From 870a6103ee7f37687f30fde0d129a72663fd89c2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 20:31:27 +0800 Subject: [PATCH 001/214] docs: add anonymous telemetry consent dialog design Co-authored-by: Cursor --- ...8-15-anonymous-telemetry-consent-design.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md diff --git a/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md b/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md new file mode 100644 index 00000000..72e865f9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md @@ -0,0 +1,143 @@ +# Anonymous Telemetry Consent Dialog + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +> **Status:** Draft (2026-08-15) + +## 1. Background + +SMM currently has no analytics / telemetry pipeline and no first-run privacy consent UX. Before any future anonymous usage reporting lands, the app should ask the user once and persist the choice. + +**Scope for this feature (locked):** + +- **In scope:** first-launch consent dialog; persist agree / disagree; General Settings toggle to change the choice later; i18n copy describing anonymous collection at a high level. +- **Out of scope:** actually collecting or uploading anonymous data; privacy-policy page; crash reporters; network calls gated on the consent flag. + +**Decisions (locked):** + +- Approach: store consent on `UserConfig` (not localStorage-only, not a blocking full-screen onboarding wizard). +- Dismiss (overlay click / Esc) is treated as **disagree**. +- Settings: a General Settings switch can change the preference anytime after the first choice. +- Copy style: general description (app version, OS, crash/usage stats; no personal identity; no media file contents) — not a field-by-field inventory and not a privacy-policy link (none exists yet). + +## 2. Architecture + +## 2.1 Project Level Architecture + +Primary work is in `apps/ui`. The consent preference is typed on shared `UserConfig` in `packages/core` so Electron / CLI / OHOS share the same `smm.json` field. + +| Package / App | Change | +|---------------|--------| +| `packages/core` | Add optional `anonymousTelemetryConsent?: boolean` on `UserConfig` | +| `apps/ui` | Consent dialog, gate component, General Settings toggle, locales | +| `apps/cli` / routes | No new API; existing read/write userConfig persists the field as JSON | +| Reporting / telemetry clients | **Not** introduced in this feature | + +i18n keys go under `apps/ui/public/locales/{en,zh-CN,zh-HK,zh-TW}/` (dialog + settings namespaces as appropriate). + +## 2.2 App Level Architecture + +### Config field + +```ts +// packages/core/types.ts — UserConfig +anonymousTelemetryConsent?: boolean +``` + +| Value | Meaning | +|-------|---------| +| `undefined` | Never answered → show consent dialog after `userConfig` is loaded | +| `true` | User agreed to anonymous telemetry (future use) | +| `false` | User declined (including dismiss) | + +`DEFAULT_USER_CONFIG` / `defaultUserConfig` **must omit** this property (leave it `undefined`) so first install and upgrades both trigger the dialog once. + +### Consent dialog — `AnonymousTelemetryConsentDialog` + +- Location: `apps/ui/src/components/dialogs/` +- Uses existing shadcn `Dialog` +- Title + short description (general anonymous-info wording) +- Primary action: Agree → save `true` +- Secondary action: Disagree → save `false` +- `onOpenChange(false)` (mask / Esc) → same as Disagree → save `false` + +### Consent gate — mount after config load + +- Small component (e.g. under `apps/ui/src/components/initialization/` or next to dialog consumers) that: + 1. Waits until `userConfig` is loaded + 2. If `anonymousTelemetryConsent === undefined`, opens the dialog + 3. On choice, persists via existing `setAndSaveUserConfig` +- Does not block `AppInitializer` bootstrap / Welcome / folder init; dialog overlays the main UI +- Does not auto-open again once the field is `true` or `false` + +### General Settings + +- In `GeneralSettings`, add a switch labeled along the lines of “Allow anonymous usage information” +- Bound to the same `anonymousTelemetryConsent` field +- Follow the existing General Settings pattern: local form state + **Save** button (same as MCP / language controls). Turning the switch alone does not write `smm.json` until Save. + +## 2.3 Key Design + +- **Tri-state via optional boolean:** `undefined` means “ask”; never default the field to `false` in defaults, or upgrades would silently skip consent. +- **Dismiss = disagree:** avoids trapping the user and still records a durable choice. +- **Consent is preference only:** no code path in this feature may send telemetry; the flag is a forward-looking gate. +- **Optimistic UI:** follow app convention — update UI/config cache first, save async, toast + rollback on failure. + +## 3. User Stories + +### 3.1 First launch shows consent dialog + +* **Given** - `userConfig` has loaded and `anonymousTelemetryConsent` is `undefined` +* **When** - the consent gate runs +* **Then** - the anonymous telemetry consent dialog is open + +```mermaid +sequenceDiagram + participant Gate as ConsentGate + participant Config as UserConfig + participant Dialog as ConsentDialog + + Gate->>Config: read anonymousTelemetryConsent + alt undefined + Gate->>Dialog: open + else true or false + Gate-->>Gate: no dialog + end +``` + +### 3.2 Agree persists true + +* **Given** - the consent dialog is open +* **When** - the user clicks Agree +* **Then** - `anonymousTelemetryConsent` is saved as `true`, dialog closes, and it does not auto-open on later startups + +### 3.3 Disagree or dismiss persists false + +* **Given** - the consent dialog is open +* **When** - the user clicks Disagree, or dismisses via overlay / Esc +* **Then** - `anonymousTelemetryConsent` is saved as `false`, dialog closes, and it does not auto-open on later startups + +### 3.4 Settings can change the choice later + +* **Given** - the user previously chose agree or disagree +* **When** - they change the General Settings switch +* **Then** - `anonymousTelemetryConsent` updates to `true` or `false` and no automatic dialog is shown + +### 3.5 No telemetry network activity in this feature + +* **Given** - consent is `true` or `false` +* **When** - the app runs after this feature ships +* **Then** - no new anonymous telemetry upload is performed (collection remains unimplemented) + +## 4. Testing notes + +- **Exploration phase:** manual verification is enough (first run with unset field; agree; disagree; dismiss; settings toggle; restart). +- **Delivery phase (later):** optional unit tests for gate open/close conditions; optional e2e that seeds `smm.json` without the field and asserts dialog `data-testid`. + +## 5. Non-goals + +- Implementing PostHog / Sentry / custom analytics endpoints +- Privacy policy URL or legal document hosting +- Re-prompting after a choice unless the field is cleared from config +- Blocking the entire app behind a mandatory wizard From 36447c9f4bd3a71f8c854f49e1ff36adac579c66 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 20:35:16 +0800 Subject: [PATCH 002/214] docs: add anonymous telemetry consent implementation plan Co-authored-by: Cursor --- .../2026-08-15-anonymous-telemetry-consent.md | 962 ++++++++++++++++++ 1 file changed, 962 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-anonymous-telemetry-consent.md diff --git a/docs/superpowers/plans/2026-08-15-anonymous-telemetry-consent.md b/docs/superpowers/plans/2026-08-15-anonymous-telemetry-consent.md new file mode 100644 index 00000000..d60fe2c1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-anonymous-telemetry-consent.md @@ -0,0 +1,962 @@ +# Anonymous Telemetry Consent Dialog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On first launch (when `anonymousTelemetryConsent` is unset), show a dismissible consent dialog; persist agree/disagree on `UserConfig`; allow changing the choice later in General Settings. No actual telemetry upload. + +**Architecture:** Optional boolean on shared `UserConfig`. A small `AnonymousTelemetryConsentGate` mounts next to `AppInitializer`, opens `AnonymousTelemetryConsentDialog` when the field is `undefined`, and persists via `setAndSaveUserConfig`. Dismiss (mask/Esc) equals disagree. General Settings adds a checkbox following the existing form + Save pattern. + +**Tech Stack:** React 19, TypeScript, Vitest + Testing Library, react-i18next, shadcn Dialog, existing `useConfig` / `UserConfig`. + +**Spec:** `docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md` + +## Global Constraints + +- Do **not** implement telemetry collection or network upload in any task. +- Do **not** set `anonymousTelemetryConsent` on any `DEFAULT_USER_CONFIG` / `defaultUserConfig` (must stay `undefined` so first launch and upgrades prompt once). +- Dismiss (overlay / Esc / close) MUST persist `false`, same as Disagree. +- Agree must not also persist `false` when the dialog closes after a successful agree. +- Settings toggle uses General Settings local form state + Save (not immediate write). +- i18n for `en`, `zh-CN`, `zh-HK`, `zh-TW`. + +--- + +## File Map + +| File | Responsibility | +|------|----------------| +| `packages/core/types.ts` | Add `anonymousTelemetryConsent?: boolean` on `UserConfig` | +| `apps/ui/src/lib/anonymousTelemetryConsent.ts` | Pure `shouldShowAnonymousTelemetryConsent` helper | +| `apps/ui/src/lib/anonymousTelemetryConsent.test.ts` | Helper unit tests | +| `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx` | Consent dialog UI | +| `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx` | Dialog interaction tests | +| `apps/ui/src/components/dialogs/index.ts` | Re-export dialog | +| `apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx` | Open dialog when unset; persist choice | +| `apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx` | Gate open/persist tests | +| `apps/ui/src/main.tsx` | Mount gate under `DialogProvider` | +| `apps/ui/src/components/ui/settings/GeneralSettings.tsx` | Checkbox + save field | +| `apps/ui/src/components/ui/settings/GeneralSettings.test.tsx` | Assert checkbox present / save includes field | +| `apps/ui/public/locales/{en,zh-CN,zh-HK,zh-TW}/components.json` | Dialog copy | +| `apps/ui/public/locales/{en,zh-CN,zh-HK,zh-TW}/settings.json` | Settings checkbox copy | +| `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts` | Locale regression | + +Defaults in `apps/ui/src/api/readUserConfig.ts`, `apps/cli/src/utils/config.ts`, and `packages/core-routes/src/userConfig.ts` are **not** modified (field stays omitted). + +--- + +### Task 1: Add `UserConfig.anonymousTelemetryConsent` + +**Files:** +- Modify: `packages/core/types.ts` (near other optional preference fields, e.g. after `mcpPort`) +- Test: `apps/ui/src/lib/anonymousTelemetryConsent.test.ts` (created in Task 2; this task only adds the type — verify with typecheck if desired) + +**Interfaces:** +- Produces: `UserConfig.anonymousTelemetryConsent?: boolean` + +- [ ] **Step 1: Add the optional field to `UserConfig`** + +In `packages/core/types.ts`, after the `mcpPort` field block, add: + +```ts + /** + * Whether the user consented to anonymous telemetry / usage information. + * - `undefined`: never asked — UI should show the consent dialog once + * - `true`: agreed + * - `false`: declined (including dismiss) + * + * Must remain unset in default configs so first launch and upgrades prompt. + */ + anonymousTelemetryConsent?: boolean +``` + +- [ ] **Step 2: Confirm defaults omit the field** + +Open and confirm these objects do **not** set `anonymousTelemetryConsent`: + +- `apps/ui/src/api/readUserConfig.ts` → `defaultUserConfig` +- `apps/cli/src/utils/config.ts` → `DEFAULT_USER_CONFIG` +- `packages/core-routes/src/userConfig.ts` → `DEFAULT_USER_CONFIG` + +Do not add the property to them. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/types.ts +git commit -m "$(cat <<'EOF' +feat(core): add anonymousTelemetryConsent to UserConfig + +EOF +)" +``` + +--- + +### Task 2: Pure helper + i18n keys + +**Files:** +- Create: `apps/ui/src/lib/anonymousTelemetryConsent.ts` +- Create: `apps/ui/src/lib/anonymousTelemetryConsent.test.ts` +- Create: `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts` +- Modify: `apps/ui/public/locales/en/components.json` +- Modify: `apps/ui/public/locales/zh-CN/components.json` +- Modify: `apps/ui/public/locales/zh-HK/components.json` +- Modify: `apps/ui/public/locales/zh-TW/components.json` +- Modify: `apps/ui/public/locales/en/settings.json` +- Modify: `apps/ui/public/locales/zh-CN/settings.json` +- Modify: `apps/ui/public/locales/zh-HK/settings.json` +- Modify: `apps/ui/public/locales/zh-TW/settings.json` + +**Interfaces:** +- Consumes: `UserConfig.anonymousTelemetryConsent` +- Produces: `shouldShowAnonymousTelemetryConsent(consent: boolean | undefined): boolean` + +- [ ] **Step 1: Write the failing helper test** + +Create `apps/ui/src/lib/anonymousTelemetryConsent.test.ts`: + +```ts +import { describe, it, expect } from "vitest" +import { shouldShowAnonymousTelemetryConsent } from "./anonymousTelemetryConsent" + +describe("shouldShowAnonymousTelemetryConsent", () => { + it("returns true when consent is undefined", () => { + expect(shouldShowAnonymousTelemetryConsent(undefined)).toBe(true) + }) + + it("returns false when consent is true", () => { + expect(shouldShowAnonymousTelemetryConsent(true)).toBe(false) + }) + + it("returns false when consent is false", () => { + expect(shouldShowAnonymousTelemetryConsent(false)).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/ui && pnpm exec vitest run src/lib/anonymousTelemetryConsent.test.ts` + +Expected: FAIL (module / export not found) + +- [ ] **Step 3: Implement the helper** + +Create `apps/ui/src/lib/anonymousTelemetryConsent.ts`: + +```ts +/** + * Show the first-run consent dialog only when the user has never answered. + */ +export function shouldShowAnonymousTelemetryConsent( + consent: boolean | undefined, +): boolean { + return consent === undefined +} +``` + +- [ ] **Step 4: Run helper test to verify it passes** + +Run: `cd apps/ui && pnpm exec vitest run src/lib/anonymousTelemetryConsent.test.ts` + +Expected: PASS + +- [ ] **Step 5: Write the failing locale catalog test** + +Create `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts`: + +```ts +import { describe, it, expect } from "vitest" +import enComponents from "../../../public/locales/en/components.json" +import zhCNComponents from "../../../public/locales/zh-CN/components.json" +import zhHKComponents from "../../../public/locales/zh-HK/components.json" +import zhTWComponents from "../../../public/locales/zh-TW/components.json" +import enSettings from "../../../public/locales/en/settings.json" +import zhCNSettings from "../../../public/locales/zh-CN/settings.json" +import zhHKSettings from "../../../public/locales/zh-HK/settings.json" +import zhTWSettings from "../../../public/locales/zh-TW/settings.json" + +interface ConsentDialogCatalog { + anonymousTelemetryConsent: { + title: string + description: string + agree: string + disagree: string + } +} + +interface SettingsCatalog { + general: { + anonymousTelemetryConsent: string + anonymousTelemetryConsentDescription: string + } +} + +const componentLocales: { name: string; data: ConsentDialogCatalog }[] = [ + { name: "en", data: enComponents as unknown as ConsentDialogCatalog }, + { name: "zh-CN", data: zhCNComponents as unknown as ConsentDialogCatalog }, + { name: "zh-HK", data: zhHKComponents as unknown as ConsentDialogCatalog }, + { name: "zh-TW", data: zhTWComponents as unknown as ConsentDialogCatalog }, +] + +const settingsLocales: { name: string; data: SettingsCatalog }[] = [ + { name: "en", data: enSettings as unknown as SettingsCatalog }, + { name: "zh-CN", data: zhCNSettings as unknown as SettingsCatalog }, + { name: "zh-HK", data: zhHKSettings as unknown as SettingsCatalog }, + { name: "zh-TW", data: zhTWSettings as unknown as SettingsCatalog }, +] + +describe("anonymous telemetry consent locale catalog", () => { + for (const { name, data } of componentLocales) { + describe(`components ${name}`, () => { + it("defines anonymousTelemetryConsent dialog strings", () => { + const block = data.anonymousTelemetryConsent + expect(typeof block.title).toBe("string") + expect(block.title.length).toBeGreaterThan(0) + expect(typeof block.description).toBe("string") + expect(block.description.length).toBeGreaterThan(0) + expect(typeof block.agree).toBe("string") + expect(block.agree.length).toBeGreaterThan(0) + expect(typeof block.disagree).toBe("string") + expect(block.disagree.length).toBeGreaterThan(0) + }) + }) + } + + for (const { name, data } of settingsLocales) { + describe(`settings ${name}`, () => { + it("defines general.anonymousTelemetryConsent strings", () => { + expect(typeof data.general.anonymousTelemetryConsent).toBe("string") + expect(data.general.anonymousTelemetryConsent.length).toBeGreaterThan(0) + expect(typeof data.general.anonymousTelemetryConsentDescription).toBe("string") + expect(data.general.anonymousTelemetryConsentDescription.length).toBeGreaterThan(0) + }) + }) + } +}) +``` + +- [ ] **Step 6: Run locale test to verify it fails** + +Run: `cd apps/ui && pnpm exec vitest run src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts` + +Expected: FAIL (missing keys) + +- [ ] **Step 7: Add locale strings** + +Add to each `apps/ui/public/locales/*/components.json` (top-level key alongside other feature blocks): + +**en:** + +```json + "anonymousTelemetryConsent": { + "title": "Help improve SMM", + "description": "We would like to collect anonymous usage information such as app version, operating system, and crash or usage statistics. This does not include personal identity or the contents of your media files.", + "agree": "Agree", + "disagree": "Disagree" + } +``` + +**zh-CN:** + +```json + "anonymousTelemetryConsent": { + "title": "帮助改进 SMM", + "description": "我们希望收集匿名使用信息,例如应用版本、操作系统,以及崩溃与使用统计。不会包含个人身份信息,也不会包含您的媒体文件内容。", + "agree": "同意", + "disagree": "不同意" + } +``` + +**zh-HK:** + +```json + "anonymousTelemetryConsent": { + "title": "協助改進 SMM", + "description": "我們希望收集匿名使用資訊,例如應用程式版本、作業系統,以及當機與使用統計。不會包含個人身分資訊,亦不會包含您的媒體檔案內容。", + "agree": "同意", + "disagree": "不同意" + } +``` + +**zh-TW:** + +```json + "anonymousTelemetryConsent": { + "title": "協助改進 SMM", + "description": "我們希望收集匿名使用資訊,例如應用程式版本、作業系統,以及當機與使用統計。不會包含個人身分資訊,也不會包含您的媒體檔案內容。", + "agree": "同意", + "disagree": "不同意" + } +``` + +Add to each `apps/ui/public/locales/*/settings.json` under `"general"`: + +**en:** + +```json + "anonymousTelemetryConsent": "Allow anonymous usage information", + "anonymousTelemetryConsentDescription": "Share anonymous app version, OS, and usage/crash statistics. Never includes personal identity or media file contents." +``` + +**zh-CN:** + +```json + "anonymousTelemetryConsent": "允许收集匿名使用信息", + "anonymousTelemetryConsentDescription": "分享匿名的应用版本、操作系统以及使用/崩溃统计。不会包含个人身份或媒体文件内容。" +``` + +**zh-HK:** + +```json + "anonymousTelemetryConsent": "允許收集匿名使用資訊", + "anonymousTelemetryConsentDescription": "分享匿名的應用程式版本、作業系統以及使用/當機統計。不會包含個人身分或媒體檔案內容。" +``` + +**zh-TW:** + +```json + "anonymousTelemetryConsent": "允許收集匿名使用資訊", + "anonymousTelemetryConsentDescription": "分享匿名的應用程式版本、作業系統以及使用/當機統計。不會包含個人身分或媒體檔案內容。" +``` + +- [ ] **Step 8: Run locale test to verify it passes** + +Run: `cd apps/ui && pnpm exec vitest run src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts src/lib/anonymousTelemetryConsent.test.ts` + +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add apps/ui/src/lib/anonymousTelemetryConsent.ts \ + apps/ui/src/lib/anonymousTelemetryConsent.test.ts \ + apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts \ + apps/ui/public/locales/en/components.json \ + apps/ui/public/locales/zh-CN/components.json \ + apps/ui/public/locales/zh-HK/components.json \ + apps/ui/public/locales/zh-TW/components.json \ + apps/ui/public/locales/en/settings.json \ + apps/ui/public/locales/zh-CN/settings.json \ + apps/ui/public/locales/zh-HK/settings.json \ + apps/ui/public/locales/zh-TW/settings.json +git commit -m "$(cat <<'EOF' +feat(ui): add consent helper and i18n for anonymous telemetry + +EOF +)" +``` + +--- + +### Task 3: Consent dialog component + +**Files:** +- Create: `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx` +- Create: `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx` +- Modify: `apps/ui/src/components/dialogs/index.ts` + +**Interfaces:** +- Consumes: i18n keys `anonymousTelemetryConsent.*` from `components` namespace +- Produces: + +```ts +export interface AnonymousTelemetryConsentDialogProps { + isOpen: boolean + onAgree: () => void + onDisagree: () => void +} +``` + +- [ ] **Step 1: Write the failing dialog tests** + +Create `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx`: + +```tsx +/** @vitest-environment jsdom */ +import React from "react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { AnonymousTelemetryConsentDialog } from "./AnonymousTelemetryConsentDialog" + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("AnonymousTelemetryConsentDialog", () => { + const onAgree = vi.fn() + const onDisagree = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders title and actions when open", () => { + render( + , + ) + expect(screen.getByTestId("anonymous-telemetry-consent-dialog")).toBeInTheDocument() + expect(screen.getByTestId("anonymous-telemetry-consent-agree")).toBeInTheDocument() + expect(screen.getByTestId("anonymous-telemetry-consent-disagree")).toBeInTheDocument() + }) + + it("calls onAgree when Agree is clicked", () => { + render( + , + ) + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-agree")) + expect(onAgree).toHaveBeenCalledTimes(1) + expect(onDisagree).not.toHaveBeenCalled() + }) + + it("calls onDisagree when Disagree is clicked", () => { + render( + , + ) + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-disagree")) + expect(onDisagree).toHaveBeenCalledTimes(1) + expect(onAgree).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run dialog tests to verify they fail** + +Run: `cd apps/ui && pnpm exec vitest run src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx` + +Expected: FAIL (component missing) + +- [ ] **Step 3: Implement the dialog** + +Create `apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx`: + +```tsx +import { useRef } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { useTranslation } from "@/lib/i18n" + +export interface AnonymousTelemetryConsentDialogProps { + isOpen: boolean + onAgree: () => void + onDisagree: () => void +} + +/** + * First-run consent for anonymous usage information. + * Dismiss (overlay / Esc / close) is treated as disagree. + * Agree uses a ref guard so closing after agree does not also fire disagree. + */ +export function AnonymousTelemetryConsentDialog({ + isOpen, + onAgree, + onDisagree, +}: AnonymousTelemetryConsentDialogProps) { + const { t } = useTranslation("components", { + keyPrefix: "anonymousTelemetryConsent", + }) + const decidedRef = useRef(false) + + const handleAgree = () => { + decidedRef.current = true + onAgree() + } + + const handleDisagree = () => { + decidedRef.current = true + onDisagree() + } + + return ( + { + if (!open && !decidedRef.current) { + onDisagree() + } + if (open) { + decidedRef.current = false + } + }} + > + + + {t("title")} + {t("description")} + + + + + + + + ) +} +``` + +Export from `apps/ui/src/components/dialogs/index.ts`: + +```ts +export { AnonymousTelemetryConsentDialog } from "./AnonymousTelemetryConsentDialog" +export type { AnonymousTelemetryConsentDialogProps } from "./AnonymousTelemetryConsentDialog" +``` + +- [ ] **Step 4: Run dialog tests to verify they pass** + +Run: `cd apps/ui && pnpm exec vitest run src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx \ + apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx \ + apps/ui/src/components/dialogs/index.ts +git commit -m "$(cat <<'EOF' +feat(ui): add AnonymousTelemetryConsentDialog + +EOF +)" +``` + +--- + +### Task 4: Consent gate + mount in `main.tsx` + +**Files:** +- Create: `apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx` +- Create: `apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx` +- Modify: `apps/ui/src/main.tsx` + +**Interfaces:** +- Consumes: `useConfig()` (`userConfig`, `isUserConfigLoaded`, `isLoading`, `setAndSaveUserConfig`), `shouldShowAnonymousTelemetryConsent`, `AnonymousTelemetryConsentDialog` +- Produces: `AnonymousTelemetryConsentGate` React component (renders dialog; returns null otherwise) + +- [ ] **Step 1: Write the failing gate tests** + +Create `apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx`: + +```tsx +/** @vitest-environment jsdom */ +import React from "react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { AnonymousTelemetryConsentGate } from "./AnonymousTelemetryConsentGate" + +const setAndSaveUserConfig = vi.fn(async () => {}) + +const mockUseConfig = vi.fn() + +vi.mock("@/hooks/userConfig", () => ({ + useConfig: () => mockUseConfig(), +})) + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +describe("AnonymousTelemetryConsentGate", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("does not open dialog while userConfig is loading", () => { + mockUseConfig.mockReturnValue({ + userConfig: { folders: [] }, + isLoading: true, + isUserConfigLoaded: false, + setAndSaveUserConfig, + }) + render() + expect(screen.queryByTestId("anonymous-telemetry-consent-dialog")).not.toBeInTheDocument() + }) + + it("opens dialog when consent is undefined after load", () => { + mockUseConfig.mockReturnValue({ + userConfig: { folders: [], anonymousTelemetryConsent: undefined }, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + expect(screen.getByTestId("anonymous-telemetry-consent-dialog")).toBeInTheDocument() + }) + + it("does not open dialog when consent is already true", () => { + mockUseConfig.mockReturnValue({ + userConfig: { folders: [], anonymousTelemetryConsent: true }, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + expect(screen.queryByTestId("anonymous-telemetry-consent-dialog")).not.toBeInTheDocument() + }) + + it("persists true on Agree", async () => { + const userConfig = { folders: [], anonymousTelemetryConsent: undefined as boolean | undefined } + mockUseConfig.mockReturnValue({ + userConfig, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-agree")) + await waitFor(() => { + expect(setAndSaveUserConfig).toHaveBeenCalled() + }) + const [, saved] = setAndSaveUserConfig.mock.calls[0]! + expect(saved.anonymousTelemetryConsent).toBe(true) + }) + + it("persists false on Disagree", async () => { + const userConfig = { folders: [], anonymousTelemetryConsent: undefined as boolean | undefined } + mockUseConfig.mockReturnValue({ + userConfig, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-disagree")) + await waitFor(() => { + expect(setAndSaveUserConfig).toHaveBeenCalled() + }) + const [, saved] = setAndSaveUserConfig.mock.calls[0]! + expect(saved.anonymousTelemetryConsent).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run gate tests to verify they fail** + +Run: `cd apps/ui && pnpm exec vitest run src/components/initialization/AnonymousTelemetryConsentGate.test.tsx` + +Expected: FAIL (component missing) + +- [ ] **Step 3: Implement the gate** + +Create `apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx`: + +```tsx +import { useCallback, useEffect, useState } from "react" +import { toast } from "sonner" +import { useConfig } from "@/hooks/userConfig" +import { nextTraceId } from "@/lib/utils" +import { shouldShowAnonymousTelemetryConsent } from "@/lib/anonymousTelemetryConsent" +import { AnonymousTelemetryConsentDialog } from "@/components/dialogs/AnonymousTelemetryConsentDialog" + +/** + * Shows the anonymous telemetry consent dialog once when + * `userConfig.anonymousTelemetryConsent` is still undefined. + */ +export function AnonymousTelemetryConsentGate() { + const { + userConfig, + isLoading, + isUserConfigLoaded, + setAndSaveUserConfig, + } = useConfig() + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + if (isLoading || !isUserConfigLoaded) return + if (shouldShowAnonymousTelemetryConsent(userConfig.anonymousTelemetryConsent)) { + setIsOpen(true) + } else { + setIsOpen(false) + } + }, [ + isLoading, + isUserConfigLoaded, + userConfig.anonymousTelemetryConsent, + ]) + + const persist = useCallback( + async (value: boolean) => { + const previous = userConfig.anonymousTelemetryConsent + const next = { ...userConfig, anonymousTelemetryConsent: value } + const traceId = `AnonymousTelemetryConsent-${nextTraceId()}` + setIsOpen(false) + try { + await setAndSaveUserConfig(traceId, next) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(message) + // Re-open if still unset after failure so the user can retry + if (previous === undefined) { + setIsOpen(true) + } + } + }, + [setAndSaveUserConfig, userConfig], + ) + + const onAgree = useCallback(() => { + void persist(true) + }, [persist]) + + const onDisagree = useCallback(() => { + void persist(false) + }, [persist]) + + return ( + + ) +} +``` + +- [ ] **Step 4: Mount the gate in `main.tsx`** + +Inside the `DialogProvider` tree, next to `AppInitializer`: + +```tsx +import { AnonymousTelemetryConsentGate } from '@/components/initialization/AnonymousTelemetryConsentGate' +``` + +```tsx + + + + + +``` + +- [ ] **Step 5: Run gate tests to verify they pass** + +Run: `cd apps/ui && pnpm exec vitest run src/components/initialization/AnonymousTelemetryConsentGate.test.tsx` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx \ + apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx \ + apps/ui/src/main.tsx +git commit -m "$(cat <<'EOF' +feat(ui): show anonymous telemetry consent on first launch + +EOF +)" +``` + +--- + +### Task 5: General Settings checkbox + +**Files:** +- Modify: `apps/ui/src/components/ui/settings/GeneralSettings.tsx` +- Modify: `apps/ui/src/components/ui/settings/GeneralSettings.test.tsx` + +**Interfaces:** +- Consumes: `userConfig.anonymousTelemetryConsent`, settings i18n keys `general.anonymousTelemetryConsent*` +- Produces: form field saved as `boolean` (`true`/`false`); checkbox unchecked when `undefined` or `false` + +- [ ] **Step 1: Extend the failing / existing GeneralSettings tests** + +Add to `apps/ui/src/components/ui/settings/GeneralSettings.test.tsx`: + +```ts + it("renders anonymous telemetry consent checkbox", () => { + render(); + expect( + screen.getByTestId("setting-anonymous-telemetry-consent"), + ).toBeInTheDocument(); + }); + + it("checkbox is unchecked when consent is undefined", () => { + mockUseConfig.mockReturnValue({ + userConfig: { ...defaultUserConfig, anonymousTelemetryConsent: undefined }, + setAndSaveUserConfig: vi.fn(), + }); + render(); + expect( + screen.getByTestId("setting-anonymous-telemetry-consent"), + ).not.toBeChecked(); + }); + + it("checkbox is checked when consent is true", () => { + mockUseConfig.mockReturnValue({ + userConfig: { ...defaultUserConfig, anonymousTelemetryConsent: true }, + setAndSaveUserConfig: vi.fn(), + }); + render(); + expect( + screen.getByTestId("setting-anonymous-telemetry-consent"), + ).toBeChecked(); + }); +``` + +- [ ] **Step 2: Run GeneralSettings tests to verify new ones fail** + +Run: `cd apps/ui && pnpm exec vitest run src/components/ui/settings/GeneralSettings.test.tsx` + +Expected: FAIL on new assertions (testid missing) + +- [ ] **Step 3: Wire the checkbox into GeneralSettings** + +Follow the MCP checkbox pattern: + +1. Extend `initialValues` with: + `anonymousTelemetryConsent: userConfig.anonymousTelemetryConsent ?? false` +2. Add state: + `const [anonymousTelemetryConsent, setAnonymousTelemetryConsent] = useState(initialValues.anonymousTelemetryConsent)` +3. Reset it in the `useEffect` that syncs from `initialValues` +4. Include it in `hasChanges` +5. In `handleSave`, write: + `anonymousTelemetryConsent` onto `updatedConfig` (boolean `true`/`false`, never leave `undefined` after an explicit save from Settings) +6. UI block (place above the MCP section border, or in the same bordered section before MCP): + +```tsx +
+
+
+ setAnonymousTelemetryConsent(e.target.checked)} + className="h-4 w-4 rounded border-input" + data-testid="setting-anonymous-telemetry-consent" + /> + +
+

+ {t("general.anonymousTelemetryConsentDescription")} +

+
+
+``` + +Keep the existing MCP block as its own bordered section after this (or merge carefully without breaking MCP testids). + +- [ ] **Step 4: Run GeneralSettings tests to verify they pass** + +Run: `cd apps/ui && pnpm exec vitest run src/components/ui/settings/GeneralSettings.test.tsx` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/ui/src/components/ui/settings/GeneralSettings.tsx \ + apps/ui/src/components/ui/settings/GeneralSettings.test.tsx +git commit -m "$(cat <<'EOF' +feat(ui): add anonymous telemetry toggle in General Settings + +EOF +)" +``` + +--- + +### Task 6: Manual verification (exploration phase) + +No code changes required unless bugs are found. + +- [ ] **Step 1: Fresh profile** + +1. Point `USER_DATA_DIR` at an empty temp folder (or delete `anonymousTelemetryConsent` from existing `smm.json`). +2. Start `pnpm dev` (or Electron). +3. Confirm consent dialog appears after UI loads. +4. Click **Agree** → restart → dialog must **not** reappear; `smm.json` has `"anonymousTelemetryConsent": true`. + +- [ ] **Step 2: Disagree / dismiss** + +1. Reset field to unset again. +2. Restart → dialog appears → click **Disagree** → field is `false`. +3. Reset again → dismiss via overlay/Esc → field is `false`. + +- [ ] **Step 3: Settings** + +1. Open Settings → General. +2. Toggle “Allow anonymous usage information”, click Save. +3. Confirm `smm.json` updates; no auto dialog on next launch. + +- [ ] **Step 4: Final test sweep** + +Run: + +```bash +cd apps/ui && pnpm exec vitest run \ + src/lib/anonymousTelemetryConsent.test.ts \ + src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts \ + src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx \ + src/components/initialization/AnonymousTelemetryConsentGate.test.tsx \ + src/components/ui/settings/GeneralSettings.test.tsx +``` + +Expected: all PASS + +- [ ] **Step 5: Update design status (optional)** + +In `docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md`, set Status to Implemented and note the commit range when done. + +--- + +## Spec coverage self-check + +| Spec requirement | Task | +|------------------|------| +| `anonymousTelemetryConsent?: boolean` on `UserConfig` | Task 1 | +| Defaults omit field | Task 1 Step 2 | +| First-launch dialog when `undefined` | Task 4 | +| Agree → `true` | Task 3 + 4 | +| Disagree / dismiss → `false` | Task 3 + 4 | +| Settings toggle, form + Save | Task 5 | +| General anonymous copy (4 locales) | Task 2 | +| No telemetry upload | Global Constraints (no task adds upload) | +| Overlay does not block AppInitializer | Task 4 mount beside initializer | +| Manual verification | Task 6 | From ddf6699b1153933778ca4fed8836b3cc90391d5e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 20:57:07 +0800 Subject: [PATCH 003/214] feat(core): add anonymousTelemetryConsent to UserConfig Co-authored-by: Cursor --- packages/core/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/types.ts b/packages/core/types.ts index 16d1b6a7..da9c4e94 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -115,6 +115,16 @@ export interface UserConfig { */ mcpPort?: number + /** + * Whether the user consented to anonymous telemetry / usage information. + * - `undefined`: never asked — UI should show the consent dialog once + * - `true`: agreed + * - `false`: declined (including dismiss) + * + * Must remain unset in default configs so first launch and upgrades prompt. + */ + anonymousTelemetryConsent?: boolean + /** * Path to the yt-dlp executable file. */ From 4d3e6d5d7fd3f4cd97bf8b1319eaf1dee8c37584 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 20:58:55 +0800 Subject: [PATCH 004/214] feat(ui): add consent helper and i18n for anonymous telemetry Co-authored-by: Cursor --- apps/ui/public/locales/en/components.json | 6 ++ apps/ui/public/locales/en/settings.json | 4 +- apps/ui/public/locales/zh-CN/components.json | 6 ++ apps/ui/public/locales/zh-CN/settings.json | 4 +- apps/ui/public/locales/zh-HK/components.json | 6 ++ apps/ui/public/locales/zh-HK/settings.json | 4 +- apps/ui/public/locales/zh-TW/components.json | 6 ++ apps/ui/public/locales/zh-TW/settings.json | 4 +- ...ymousTelemetryConsentDialog.locale.test.ts | 68 +++++++++++++++++++ .../src/lib/anonymousTelemetryConsent.test.ts | 16 +++++ apps/ui/src/lib/anonymousTelemetryConsent.ts | 8 +++ 11 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts create mode 100644 apps/ui/src/lib/anonymousTelemetryConsent.test.ts create mode 100644 apps/ui/src/lib/anonymousTelemetryConsent.ts diff --git a/apps/ui/public/locales/en/components.json b/apps/ui/public/locales/en/components.json index a7057f72..183ac04c 100644 --- a/apps/ui/public/locales/en/components.json +++ b/apps/ui/public/locales/en/components.json @@ -589,6 +589,12 @@ "recognizeFailed": "Recognition failed. Please try again.", "renameFailed": "Rename failed. Please try again.", "noRenameFiles": "Unable to generate a rename plan. Check that episodes have video files." + }, + "anonymousTelemetryConsent": { + "title": "Help improve SMM", + "description": "We would like to collect anonymous usage information such as app version, operating system, and crash or usage statistics. This does not include personal identity or the contents of your media files.", + "agree": "Agree", + "disagree": "Disagree" } } diff --git a/apps/ui/public/locales/en/settings.json b/apps/ui/public/locales/en/settings.json index 57e88349..626ff29b 100644 --- a/apps/ui/public/locales/en/settings.json +++ b/apps/ui/public/locales/en/settings.json @@ -21,7 +21,9 @@ "mcpHost": "MCP host", "mcpHostPlaceholder": "127.0.0.1", "mcpPort": "MCP port", - "mcpPortPlaceholder": "30001" + "mcpPortPlaceholder": "30001", + "anonymousTelemetryConsent": "Allow anonymous usage information", + "anonymousTelemetryConsentDescription": "Share anonymous app version, OS, and usage/crash statistics. Never includes personal identity or media file contents." }, "mediaDatabases": { "title": "Media Databases", diff --git a/apps/ui/public/locales/zh-CN/components.json b/apps/ui/public/locales/zh-CN/components.json index 179a8466..2e81c11b 100644 --- a/apps/ui/public/locales/zh-CN/components.json +++ b/apps/ui/public/locales/zh-CN/components.json @@ -587,5 +587,11 @@ "recognizeFailed": "识别失败,请重试。", "renameFailed": "重命名失败,请重试。", "noRenameFiles": "无法生成重命名计划,请确认剧集已关联视频文件。" + }, + "anonymousTelemetryConsent": { + "title": "帮助改进 SMM", + "description": "我们希望收集匿名使用信息,例如应用版本、操作系统,以及崩溃与使用统计。不会包含个人身份信息,也不会包含您的媒体文件内容。", + "agree": "同意", + "disagree": "不同意" } } diff --git a/apps/ui/public/locales/zh-CN/settings.json b/apps/ui/public/locales/zh-CN/settings.json index 446c694b..871bc2cf 100644 --- a/apps/ui/public/locales/zh-CN/settings.json +++ b/apps/ui/public/locales/zh-CN/settings.json @@ -21,7 +21,9 @@ "mcpHost": "MCP 主机", "mcpHostPlaceholder": "127.0.0.1", "mcpPort": "MCP 端口", - "mcpPortPlaceholder": "30001" + "mcpPortPlaceholder": "30001", + "anonymousTelemetryConsent": "允许收集匿名使用信息", + "anonymousTelemetryConsentDescription": "分享匿名的应用版本、操作系统以及使用/崩溃统计。不会包含个人身份或媒体文件内容。" }, "mediaDatabases": { "title": "媒体数据库", diff --git a/apps/ui/public/locales/zh-HK/components.json b/apps/ui/public/locales/zh-HK/components.json index 73a0b4c8..ba216579 100644 --- a/apps/ui/public/locales/zh-HK/components.json +++ b/apps/ui/public/locales/zh-HK/components.json @@ -584,5 +584,11 @@ "recognizeFailed": "辨識失敗,請重試。", "renameFailed": "重新命名失敗,請重試。", "noRenameFiles": "無法產生重新命名計劃,請確認劇集已關聯影片檔案。" + }, + "anonymousTelemetryConsent": { + "title": "協助改進 SMM", + "description": "我們希望收集匿名使用資訊,例如應用程式版本、作業系統,以及當機與使用統計。不會包含個人身分資訊,亦不會包含您的媒體檔案內容。", + "agree": "同意", + "disagree": "不同意" } } diff --git a/apps/ui/public/locales/zh-HK/settings.json b/apps/ui/public/locales/zh-HK/settings.json index 2d0b21dd..b882abaf 100644 --- a/apps/ui/public/locales/zh-HK/settings.json +++ b/apps/ui/public/locales/zh-HK/settings.json @@ -21,7 +21,9 @@ "mcpHost": "MCP 主機", "mcpHostPlaceholder": "127.0.0.1", "mcpPort": "MCP 端口", - "mcpPortPlaceholder": "30001" + "mcpPortPlaceholder": "30001", + "anonymousTelemetryConsent": "允許收集匿名使用資訊", + "anonymousTelemetryConsentDescription": "分享匿名的應用程式版本、作業系統以及使用/當機統計。不會包含個人身分或媒體檔案內容。" }, "mediaDatabases": { "title": "媒體資料庫", diff --git a/apps/ui/public/locales/zh-TW/components.json b/apps/ui/public/locales/zh-TW/components.json index 3c071cc7..f69401aa 100644 --- a/apps/ui/public/locales/zh-TW/components.json +++ b/apps/ui/public/locales/zh-TW/components.json @@ -584,5 +584,11 @@ "recognizeFailed": "辨識失敗,請重試。", "renameFailed": "重新命名失敗,請重試。", "noRenameFiles": "無法產生重新命名計畫,請確認集數已關聯影片檔案。" + }, + "anonymousTelemetryConsent": { + "title": "協助改進 SMM", + "description": "我們希望收集匿名使用資訊,例如應用程式版本、作業系統,以及當機與使用統計。不會包含個人身分資訊,也不會包含您的媒體檔案內容。", + "agree": "同意", + "disagree": "不同意" } } diff --git a/apps/ui/public/locales/zh-TW/settings.json b/apps/ui/public/locales/zh-TW/settings.json index 2ef12795..df766f35 100644 --- a/apps/ui/public/locales/zh-TW/settings.json +++ b/apps/ui/public/locales/zh-TW/settings.json @@ -21,7 +21,9 @@ "mcpHost": "MCP 主機", "mcpHostPlaceholder": "127.0.0.1", "mcpPort": "MCP 端口", - "mcpPortPlaceholder": "30001" + "mcpPortPlaceholder": "30001", + "anonymousTelemetryConsent": "允許收集匿名使用資訊", + "anonymousTelemetryConsentDescription": "分享匿名的應用程式版本、作業系統以及使用/當機統計。不會包含個人身分或媒體檔案內容。" }, "mediaDatabases": { "title": "媒體資料庫", diff --git a/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts b/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts new file mode 100644 index 00000000..566df596 --- /dev/null +++ b/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.locale.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest" +import enComponents from "../../../public/locales/en/components.json" +import zhCNComponents from "../../../public/locales/zh-CN/components.json" +import zhHKComponents from "../../../public/locales/zh-HK/components.json" +import zhTWComponents from "../../../public/locales/zh-TW/components.json" +import enSettings from "../../../public/locales/en/settings.json" +import zhCNSettings from "../../../public/locales/zh-CN/settings.json" +import zhHKSettings from "../../../public/locales/zh-HK/settings.json" +import zhTWSettings from "../../../public/locales/zh-TW/settings.json" + +interface ConsentDialogCatalog { + anonymousTelemetryConsent: { + title: string + description: string + agree: string + disagree: string + } +} + +interface SettingsCatalog { + general: { + anonymousTelemetryConsent: string + anonymousTelemetryConsentDescription: string + } +} + +const componentLocales: { name: string; data: ConsentDialogCatalog }[] = [ + { name: "en", data: enComponents as unknown as ConsentDialogCatalog }, + { name: "zh-CN", data: zhCNComponents as unknown as ConsentDialogCatalog }, + { name: "zh-HK", data: zhHKComponents as unknown as ConsentDialogCatalog }, + { name: "zh-TW", data: zhTWComponents as unknown as ConsentDialogCatalog }, +] + +const settingsLocales: { name: string; data: SettingsCatalog }[] = [ + { name: "en", data: enSettings as unknown as SettingsCatalog }, + { name: "zh-CN", data: zhCNSettings as unknown as SettingsCatalog }, + { name: "zh-HK", data: zhHKSettings as unknown as SettingsCatalog }, + { name: "zh-TW", data: zhTWSettings as unknown as SettingsCatalog }, +] + +describe("anonymous telemetry consent locale catalog", () => { + for (const { name, data } of componentLocales) { + describe(`components ${name}`, () => { + it("defines anonymousTelemetryConsent dialog strings", () => { + const block = data.anonymousTelemetryConsent + expect(typeof block.title).toBe("string") + expect(block.title.length).toBeGreaterThan(0) + expect(typeof block.description).toBe("string") + expect(block.description.length).toBeGreaterThan(0) + expect(typeof block.agree).toBe("string") + expect(block.agree.length).toBeGreaterThan(0) + expect(typeof block.disagree).toBe("string") + expect(block.disagree.length).toBeGreaterThan(0) + }) + }) + } + + for (const { name, data } of settingsLocales) { + describe(`settings ${name}`, () => { + it("defines general.anonymousTelemetryConsent strings", () => { + expect(typeof data.general.anonymousTelemetryConsent).toBe("string") + expect(data.general.anonymousTelemetryConsent.length).toBeGreaterThan(0) + expect(typeof data.general.anonymousTelemetryConsentDescription).toBe("string") + expect(data.general.anonymousTelemetryConsentDescription.length).toBeGreaterThan(0) + }) + }) + } +}) diff --git a/apps/ui/src/lib/anonymousTelemetryConsent.test.ts b/apps/ui/src/lib/anonymousTelemetryConsent.test.ts new file mode 100644 index 00000000..d6f91d10 --- /dev/null +++ b/apps/ui/src/lib/anonymousTelemetryConsent.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest" +import { shouldShowAnonymousTelemetryConsent } from "./anonymousTelemetryConsent" + +describe("shouldShowAnonymousTelemetryConsent", () => { + it("returns true when consent is undefined", () => { + expect(shouldShowAnonymousTelemetryConsent(undefined)).toBe(true) + }) + + it("returns false when consent is true", () => { + expect(shouldShowAnonymousTelemetryConsent(true)).toBe(false) + }) + + it("returns false when consent is false", () => { + expect(shouldShowAnonymousTelemetryConsent(false)).toBe(false) + }) +}) diff --git a/apps/ui/src/lib/anonymousTelemetryConsent.ts b/apps/ui/src/lib/anonymousTelemetryConsent.ts new file mode 100644 index 00000000..1b9f1cf0 --- /dev/null +++ b/apps/ui/src/lib/anonymousTelemetryConsent.ts @@ -0,0 +1,8 @@ +/** + * Show the first-run consent dialog only when the user has never answered. + */ +export function shouldShowAnonymousTelemetryConsent( + consent: boolean | undefined, +): boolean { + return consent === undefined +} From 2bf70d0f746af18e42d174ca9492edb421ba08fc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 21:00:17 +0800 Subject: [PATCH 005/214] feat(ui): add AnonymousTelemetryConsentDialog Co-authored-by: Cursor --- .../AnonymousTelemetryConsentDialog.test.tsx | 59 +++++++++++++ .../AnonymousTelemetryConsentDialog.tsx | 84 +++++++++++++++++++ apps/ui/src/components/dialogs/index.ts | 2 + 3 files changed, 145 insertions(+) create mode 100644 apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx create mode 100644 apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx diff --git a/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx b/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx new file mode 100644 index 00000000..0ad71978 --- /dev/null +++ b/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.test.tsx @@ -0,0 +1,59 @@ +/** @vitest-environment jsdom */ +import React from "react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { AnonymousTelemetryConsentDialog } from "./AnonymousTelemetryConsentDialog" + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("AnonymousTelemetryConsentDialog", () => { + const onAgree = vi.fn() + const onDisagree = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders title and actions when open", () => { + render( + , + ) + expect(screen.getByTestId("anonymous-telemetry-consent-dialog")).toBeInTheDocument() + expect(screen.getByTestId("anonymous-telemetry-consent-agree")).toBeInTheDocument() + expect(screen.getByTestId("anonymous-telemetry-consent-disagree")).toBeInTheDocument() + }) + + it("calls onAgree when Agree is clicked", () => { + render( + , + ) + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-agree")) + expect(onAgree).toHaveBeenCalledTimes(1) + expect(onDisagree).not.toHaveBeenCalled() + }) + + it("calls onDisagree when Disagree is clicked", () => { + render( + , + ) + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-disagree")) + expect(onDisagree).toHaveBeenCalledTimes(1) + expect(onAgree).not.toHaveBeenCalled() + }) +}) diff --git a/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx b/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx new file mode 100644 index 00000000..042374ba --- /dev/null +++ b/apps/ui/src/components/dialogs/AnonymousTelemetryConsentDialog.tsx @@ -0,0 +1,84 @@ +import { useRef } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { useTranslation } from "@/lib/i18n" + +export interface AnonymousTelemetryConsentDialogProps { + isOpen: boolean + onAgree: () => void + onDisagree: () => void +} + +/** + * First-run consent for anonymous usage information. + * Dismiss (overlay / Esc / close) is treated as disagree. + * Agree uses a ref guard so closing after agree does not also fire disagree. + */ +export function AnonymousTelemetryConsentDialog({ + isOpen, + onAgree, + onDisagree, +}: AnonymousTelemetryConsentDialogProps) { + const { t } = useTranslation("components", { + keyPrefix: "anonymousTelemetryConsent", + }) + const decidedRef = useRef(false) + + const handleAgree = () => { + decidedRef.current = true + onAgree() + } + + const handleDisagree = () => { + decidedRef.current = true + onDisagree() + } + + return ( + { + if (!open && !decidedRef.current) { + onDisagree() + } + if (open) { + decidedRef.current = false + } + }} + > + + + {t("title")} + {t("description")} + + + + + + + + ) +} diff --git a/apps/ui/src/components/dialogs/index.ts b/apps/ui/src/components/dialogs/index.ts index dd395fa9..7ffae32c 100644 --- a/apps/ui/src/components/dialogs/index.ts +++ b/apps/ui/src/components/dialogs/index.ts @@ -1,3 +1,4 @@ +export { AnonymousTelemetryConsentDialog } from "./AnonymousTelemetryConsentDialog" export { ConfirmationDialog } from "./confirmation-dialog" export { SpinnerDialog } from "./spinner-dialog" export { ConfigDialog } from "./config-dialog" @@ -29,6 +30,7 @@ export { AddTestBackgroundJobDialog } from "./AddTestBackgroundJobDialog" export { FunctionCheckDialog } from "./FunctionCheckDialog" export { LogDialog } from "./LogDialog" +export type { AnonymousTelemetryConsentDialogProps } from "./AnonymousTelemetryConsentDialog" export type { DialogConfig, FolderType, From b9c5f3c3bde7a9832618ad278b34649ea2f3c740 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 21:02:20 +0800 Subject: [PATCH 006/214] feat(ui): show anonymous telemetry consent on first launch Co-authored-by: Cursor --- .../AnonymousTelemetryConsentGate.test.tsx | 96 +++++++++++++++++++ .../AnonymousTelemetryConsentGate.tsx | 69 +++++++++++++ apps/ui/src/main.tsx | 2 + 3 files changed, 167 insertions(+) create mode 100644 apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx create mode 100644 apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx diff --git a/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx b/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx new file mode 100644 index 00000000..6ad1f837 --- /dev/null +++ b/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.test.tsx @@ -0,0 +1,96 @@ +/** @vitest-environment jsdom */ +import React from "react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { AnonymousTelemetryConsentGate } from "./AnonymousTelemetryConsentGate" + +const setAndSaveUserConfig = vi.fn(async () => {}) + +const mockUseConfig = vi.fn() + +vi.mock("@/hooks/userConfig", () => ({ + useConfig: () => mockUseConfig(), +})) + +vi.mock("@/lib/i18n", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +describe("AnonymousTelemetryConsentGate", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("does not open dialog while userConfig is loading", () => { + mockUseConfig.mockReturnValue({ + userConfig: { folders: [] }, + isLoading: true, + isUserConfigLoaded: false, + setAndSaveUserConfig, + }) + render() + expect(screen.queryByTestId("anonymous-telemetry-consent-dialog")).not.toBeInTheDocument() + }) + + it("opens dialog when consent is undefined after load", () => { + mockUseConfig.mockReturnValue({ + userConfig: { folders: [], anonymousTelemetryConsent: undefined }, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + expect(screen.getByTestId("anonymous-telemetry-consent-dialog")).toBeInTheDocument() + }) + + it("does not open dialog when consent is already true", () => { + mockUseConfig.mockReturnValue({ + userConfig: { folders: [], anonymousTelemetryConsent: true }, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + expect(screen.queryByTestId("anonymous-telemetry-consent-dialog")).not.toBeInTheDocument() + }) + + it("persists true on Agree", async () => { + const userConfig = { folders: [], anonymousTelemetryConsent: undefined as boolean | undefined } + mockUseConfig.mockReturnValue({ + userConfig, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-agree")) + await waitFor(() => { + expect(setAndSaveUserConfig).toHaveBeenCalled() + }) + const [, saved] = setAndSaveUserConfig.mock.calls[0]! + expect(saved.anonymousTelemetryConsent).toBe(true) + }) + + it("persists false on Disagree", async () => { + const userConfig = { folders: [], anonymousTelemetryConsent: undefined as boolean | undefined } + mockUseConfig.mockReturnValue({ + userConfig, + isLoading: false, + isUserConfigLoaded: true, + setAndSaveUserConfig, + }) + render() + fireEvent.click(screen.getByTestId("anonymous-telemetry-consent-disagree")) + await waitFor(() => { + expect(setAndSaveUserConfig).toHaveBeenCalled() + }) + const [, saved] = setAndSaveUserConfig.mock.calls[0]! + expect(saved.anonymousTelemetryConsent).toBe(false) + }) +}) diff --git a/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx b/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx new file mode 100644 index 00000000..91886d24 --- /dev/null +++ b/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from "react" +import { toast } from "sonner" +import { useConfig } from "@/hooks/userConfig" +import { nextTraceId } from "@/lib/utils" +import { shouldShowAnonymousTelemetryConsent } from "@/lib/anonymousTelemetryConsent" +import { AnonymousTelemetryConsentDialog } from "@/components/dialogs/AnonymousTelemetryConsentDialog" + +/** + * Shows the anonymous telemetry consent dialog once when + * `userConfig.anonymousTelemetryConsent` is still undefined. + */ +export function AnonymousTelemetryConsentGate() { + const { + userConfig, + isLoading, + isUserConfigLoaded, + setAndSaveUserConfig, + } = useConfig() + const [isOpen, setIsOpen] = useState(false) + + useEffect(() => { + if (isLoading || !isUserConfigLoaded) return + if (shouldShowAnonymousTelemetryConsent(userConfig.anonymousTelemetryConsent)) { + setIsOpen(true) + } else { + setIsOpen(false) + } + }, [ + isLoading, + isUserConfigLoaded, + userConfig.anonymousTelemetryConsent, + ]) + + const persist = useCallback( + async (value: boolean) => { + const previous = userConfig.anonymousTelemetryConsent + const next = { ...userConfig, anonymousTelemetryConsent: value } + const traceId = `AnonymousTelemetryConsent-${nextTraceId()}` + setIsOpen(false) + try { + await setAndSaveUserConfig(traceId, next) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast.error(message) + // Re-open if still unset after failure so the user can retry + if (previous === undefined) { + setIsOpen(true) + } + } + }, + [setAndSaveUserConfig, userConfig], + ) + + const onAgree = useCallback(() => { + void persist(true) + }, [persist]) + + const onDisagree = useCallback(() => { + void persist(false) + }, [persist]) + + return ( + + ) +} diff --git a/apps/ui/src/main.tsx b/apps/ui/src/main.tsx index 7bdb8d26..5245bb83 100644 --- a/apps/ui/src/main.tsx +++ b/apps/ui/src/main.tsx @@ -12,6 +12,7 @@ import { DialogProvider, useDialogs } from './providers/dialog-provider' import { useWebSocket, useWebSocketEvent, sendAcknowledgement } from './hooks/useWebSocket' import { Button } from './components/ui/button' import { AppInitializer } from './AppInitializer' +import { AnonymousTelemetryConsentGate } from '@/components/initialization/AnonymousTelemetryConsentGate' import { SocketIoUserConfigFolderRenamedEventListener } from './components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx' import { PingEventListener } from './components/eventlisteners/PingEventListener.tsx' import { MediaFolderImportedEventHandler } from './components/eventlisteners/MediaFolderImportedEventHandler.tsx' @@ -220,6 +221,7 @@ async function bootstrap() { + From 92e2689901d43db950966f93c8088671e152d042 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 21:04:22 +0800 Subject: [PATCH 007/214] feat(ui): add anonymous telemetry toggle in General Settings Co-authored-by: Cursor --- .../ui/settings/GeneralSettings.test.tsx | 29 +++++++++++++++++++ .../ui/settings/GeneralSettings.tsx | 27 +++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx b/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx index ae9d033e..4aab3aef 100644 --- a/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx +++ b/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx @@ -77,4 +77,33 @@ describe("GeneralSettings", () => { "general.applicationLanguageUnset", ); }); + + it("renders anonymous telemetry consent checkbox", () => { + render(); + expect( + screen.getByTestId("setting-anonymous-telemetry-consent"), + ).toBeInTheDocument(); + }); + + it("checkbox is unchecked when consent is undefined", () => { + mockUseConfig.mockReturnValue({ + userConfig: { ...defaultUserConfig, anonymousTelemetryConsent: undefined }, + setAndSaveUserConfig: vi.fn(), + }); + render(); + expect( + screen.getByTestId("setting-anonymous-telemetry-consent"), + ).not.toBeChecked(); + }); + + it("checkbox is checked when consent is true", () => { + mockUseConfig.mockReturnValue({ + userConfig: { ...defaultUserConfig, anonymousTelemetryConsent: true }, + setAndSaveUserConfig: vi.fn(), + }); + render(); + expect( + screen.getByTestId("setting-anonymous-telemetry-consent"), + ).toBeChecked(); + }); }); diff --git a/apps/ui/src/components/ui/settings/GeneralSettings.tsx b/apps/ui/src/components/ui/settings/GeneralSettings.tsx index 43a14160..52e6c715 100644 --- a/apps/ui/src/components/ui/settings/GeneralSettings.tsx +++ b/apps/ui/src/components/ui/settings/GeneralSettings.tsx @@ -56,6 +56,7 @@ export function GeneralSettings() { applicationLanguage: (userConfig.applicationLanguage ?? APPLICATION_LANGUAGE_UNSET) as ApplicationLanguageFormValue, preferMediaLanguage: userConfig.preferMediaLanguage || PREFER_MEDIA_LANGUAGE_UNSET, + anonymousTelemetryConsent: userConfig.anonymousTelemetryConsent ?? false, enableMcpServer: userConfig.enableMcpServer ?? false, mcpHost: userConfig.mcpHost ?? '127.0.0.1', mcpPort: userConfig.mcpPort ?? 30001, @@ -66,6 +67,7 @@ export function GeneralSettings() { initialValues.applicationLanguage, ) const [preferMediaLanguage, setPreferMediaLanguage] = useState(initialValues.preferMediaLanguage as PreferMediaLanguage | typeof PREFER_MEDIA_LANGUAGE_UNSET) + const [anonymousTelemetryConsent, setAnonymousTelemetryConsent] = useState(initialValues.anonymousTelemetryConsent) const [enableMcpServer, setEnableMcpServer] = useState(initialValues.enableMcpServer) const [mcpHost, setMcpHost] = useState(initialValues.mcpHost) const [mcpPort, setMcpPort] = useState(String(initialValues.mcpPort)) @@ -75,6 +77,7 @@ export function GeneralSettings() { /* eslint-disable react-hooks/set-state-in-effect */ setApplicationLanguage(initialValues.applicationLanguage) setPreferMediaLanguage(initialValues.preferMediaLanguage as PreferMediaLanguage | typeof PREFER_MEDIA_LANGUAGE_UNSET) + setAnonymousTelemetryConsent(initialValues.anonymousTelemetryConsent) setEnableMcpServer(initialValues.enableMcpServer) setMcpHost(initialValues.mcpHost) setMcpPort(String(initialValues.mcpPort)) @@ -86,6 +89,7 @@ export function GeneralSettings() { return ( applicationLanguage !== initialValues.applicationLanguage || preferMediaLanguage !== initialValues.preferMediaLanguage || + anonymousTelemetryConsent !== initialValues.anonymousTelemetryConsent || enableMcpServer !== initialValues.enableMcpServer || mcpHost !== initialValues.mcpHost || mcpPort !== String(initialValues.mcpPort) @@ -93,6 +97,7 @@ export function GeneralSettings() { }, [ applicationLanguage, preferMediaLanguage, + anonymousTelemetryConsent, enableMcpServer, mcpHost, mcpPort, @@ -123,6 +128,7 @@ export function GeneralSettings() { ...userConfig, applicationLanguage: savedApplicationLanguage, preferMediaLanguage: preferMediaLanguage === PREFER_MEDIA_LANGUAGE_UNSET ? undefined : preferMediaLanguage, + anonymousTelemetryConsent, enableMcpServer, mcpHost: mcpHost || undefined, mcpPort: Number.isNaN(parsedMcpPort) || parsedMcpPort <= 0 ? 30001 : parsedMcpPort, @@ -237,6 +243,27 @@ export function GeneralSettings() {

{t('general.preferMediaLanguageDescription')}

+
+
+
+ setAnonymousTelemetryConsent(e.target.checked)} + className="h-4 w-4 rounded border-input" + data-testid="setting-anonymous-telemetry-consent" + /> + +
+

+ {t("general.anonymousTelemetryConsentDescription")} +

+
+
+
From 12f3d5c20e3d1b259403c018585d6623e026de9e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 15 Aug 2026 21:05:36 +0800 Subject: [PATCH 008/214] docs: mark anonymous telemetry consent design as implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note commit range 36447c9f..92e26899 after Tasks 1–5 and automated verification. Co-authored-by: Cursor --- .../specs/2026-08-15-anonymous-telemetry-consent-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md b/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md index 72e865f9..cbfedb18 100644 --- a/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md +++ b/docs/superpowers/specs/2026-08-15-anonymous-telemetry-consent-design.md @@ -3,7 +3,7 @@ This design document describe the high level design of a feature. The design document is golden source and reference by one or more features. -> **Status:** Draft (2026-08-15) +> **Status:** Implemented (2026-08-15). Commit range: `36447c9f`..`92e26899`. ## 1. Background From 836ab187efca2ea5ac73b5d4f1723d3c44de77c5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 07:11:30 +0800 Subject: [PATCH 009/214] docs: add apps/core importFolder design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for the Layer-2 Core foundation: apps/core package with a runtime-agnostic importFolder pipeline (config → metadata → recognize → episodes → persist), DI ports (fs/network/logger), and a queryable Job model. Co-Authored-By: Claude Opus 4.7 --- .../2026-08-17-core-importfolder-design.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-17-core-importfolder-design.md diff --git a/docs/superpowers/specs/2026-08-17-core-importfolder-design.md b/docs/superpowers/specs/2026-08-17-core-importfolder-design.md new file mode 100644 index 00000000..0f9afd3b --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-core-importfolder-design.md @@ -0,0 +1,231 @@ +# apps/core importFolder 核心层设计 + +本设计文档描述 3 层重构中「Layer 2 Core」的第一块基石:`apps/core` 包的媒体目录导入功能。 +它是后续 HTTP / UI / MCP 接入的统一业务入口,也是把业务逻辑从 UI 层剥离的第一步。 + +## 1. Background + +当前媒体目录初始化的整套流程(更新用户配置、识别媒体文件夹、识别剧集、持久化元数据、任务状态管理)全部实现在 UI 侧 +(`apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts` 及 `apps/ui/src/lib/*`),导致: +- 该流程无法被外部 HTTP API 或 MCP 调用; +- 无法脱离 React 环境进行单元测试; +- UI 与业务逻辑强耦合,难以扩展。 + +按 [refactoring.md](../../../refactoring.md) 的目标架构,业务逻辑应下沉到 **Layer 2 Core**(headless TypeScript, +运行时无关,通过适配器访问 fs / 网络 / 日志),表现层只负责交互与状态同步。 + +本设计创建 `apps/core`,提供 `core.importFolder(path, type)` 函数接口,在核心层完整实现「媒体目录初始化」流水线。 + +## 2. Architecture + +### 2.1 Project Level Architecture + +`apps/core` 是 monorepo 中的一个纯 TypeScript 库式应用,运行时无关,不 import 任何平台 API(`node:fs`、`window` 等)。 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Layer 1 (表现层): apps/ui / MCP / External HTTP API │ ← 未来接入 +└───────────────────────────────┬─────────────────────────────┘ + │ 函数调用 (core.importFolder) +┌───────────────────────────────▼─────────────────────────────┐ +│ Layer 2 (Core): apps/core │ +│ Core 类 · importFolder 流水线 · 识别 · 剧集匹配 · Job 状态 │ +└───────────────┬──────────────────────────────┬──────────────┘ + │ 依赖注入 │ + ┌───────▼───────┐ ┌────────▼───────┐ ┌▼────────────┐ + │ FsPort │ │ NetworkPort │ │ LoggerPort │ + │ (适配器实现) │ │ (薄 fetch) │ │ │ + └───────┬───────┘ └────────┬───────┘ └─────────────┘ + ┌────────────┼─────────────┐ │ + │ NodejsFsAdapter │ │ + │ (node 运行时) │ │ + │ NetworkFsAdapter │ │ + │ (浏览器运行时 → 内部HTTP) │ │ + └───────────────────────────┘ └── TmdbClient / TvdbClient +``` + +**依赖关系**: +- `apps/core` 依赖 `@smm/core`(领域类型:`MediaMetadata`、`UserConfig`、`FolderType` 等)与 `@smm/utils` +- core 逻辑只依赖 Ports 接口,不感知具体适配器 +- TMDB / TVDB 客户端是 core 内部模块,构建在 NetworkPort(薄 fetch)之上,负责 URL 构造、鉴权、响应解析 +- 适配器与 core 逻辑解耦,各自隔离平台依赖 + +**命名澄清**:`NetworkPort` 的适配器(`NodeNetworkAdapter` / `BrowserNetworkAdapter`)包装 fetch,是「网络能力适配器」; +`NetworkFsAdapter` 是 `FsPort` 的一种实现,指「通过内部 HTTP API 访问文件系统」,二者针对不同端口,不要混淆。 + +### 2.2 App Level Architecture + +``` +apps/core/src/ + index.ts // 导出 Core、Ports 接口、适配器 + Core.ts // Core 类:job store + importFolder 编排 + ports/ + FsPort.ts // readTextFile / writeTextFile / exists / listFiles + NetworkPort.ts // fetch(input, init): Promise (薄层) + LoggerPort.ts // info / warn / error + adapters/ + node/NodejsFsAdapter.ts // 基于 node:fs/promises,仅 Node 宿主 import + network/NetworkFsAdapter.ts // 浏览器运行时:映射到内部 HTTP API 的 fs 端点 + ConsoleLoggerAdapter.ts // 默认 console 实现 + clients/ + TmdbClient.ts // 基于 NetworkPort,search/getTvShow/getMovie + TvdbClient.ts // 基于 NetworkPort,search/getTvShow/getMovie + pipeline/ + importFolderPipeline.ts // importFolder 阶段编排 + recognizeMediaFolder.ts // 识别方法:NFO → tmdbid= → tvdbid= → 按名搜索 + recognizeEpisodes.ts // 剧集文件名模式匹配 + nfo.ts // NFO (XML) 解析 + paths.ts // smm.json 与 metadata 缓存路径解析 + jobs/ + jobStore.ts // 内存 job 存储 + types.ts // ImportJob / JobStatus / JobStage + __tests__/ // vitest +``` + +**本版适配器实现范围**: +- `NodejsFsAdapter` 完整实现(用于开发、测试与 Node 运行时) +- `NetworkFsAdapter` 完整实现为薄映射,指向 core-routes 现有的 fs 端点(`readFile` / `writeFile` / `listFiles`),实现时以实际端点契约为准 +- `NodeNetworkAdapter` / `BrowserNetworkAdapter` 完整实现(包装全局 / `window` fetch) +- `ConsoleLoggerAdapter` 完整实现 + +### 2.3 Key Design + +#### Ports(依赖注入) + +```ts +interface FsPort { + readTextFile(path: string): Promise + writeTextFile(path: string, content: string): Promise + exists(path: string): Promise + listFiles(dir: string): Promise // 递归列出目录下所有文件 +} + +interface NetworkPort { + fetch(input: string, init?: FetchInit): Promise // 只做 HTTP,不含业务解析 +} + +interface LoggerPort { + info(obj: unknown, msg: string): void + warn(obj: unknown, msg: string): void + error(obj: unknown, msg: string): void +} +``` + +#### Core 类 + +```ts +const core = new Core({ + fs: new NodejsFsAdapter(), // node 运行时 + // fs: new NetworkFsAdapter(), // 浏览器运行时:通过内部 HTTP API 访问 fs + network: new NodeNetworkAdapter({ fetch }), // 薄 fetch 包装 + logger: new ConsoleLoggerAdapter(), // 可选,默认 noop + appDataDir: "/path/to/smm-data", // smm.json 与 metadata/ 的根目录 +}) + +const job = core.importFolder(path, type) // type: FolderType +const status = core.getJob(job.id) +``` + +core 内部路径解析(写死规则,通过 FsPort 访问): +- userConfig → `/smm.json` +- metadata 缓存 → `/metadata/.json`(与现有 `metadataCacheFilePath` 一致) + +#### importFolder 流水线 + +``` +importFolder(path, type: FolderType) + [1] config → 把 path 加入 userConfig.folders(去重),写 smm.json + [2] metadata → 创建空白 MediaMetadata(type → "tvshow-folder"|"movie-folder"|"music-folder",mediaFolderPath=posix) + [3] listFiles → 经 FsPort 递归列出目录全部文件 → mm.files + [4] recognize → 仅 tvshow/movie: + 依次尝试 NFO → "tmdbid=" 文件夹名 → "tvdbid=" 文件夹名 → 按文件夹名搜索 + (搜索顺序按 userConfig.primaryDatabase:TMDB→TVDB 或 TVDB→TMDB) + music 跳过 + [5] episodes → tvshow:视频文件名模式匹配 SXXEYY / 第X季第Y集 / 第XX季第YY集 / <分隔符>N.ext + movie:取第一个视频文件作为 mediaFiles[0] + music:跳过 + [6] persist → 写 metadata 缓存 /metadata/.json + done → job.status = "succeeded" +``` + +识别语言:`userConfig.preferMediaLanguage ?? 'en-US'`(headless core 无浏览器/OS locale,该解析留给上层注入)。 + +#### Job 模型 + +```ts +type JobStatus = "pending" | "running" | "succeeded" | "failed" | "aborted" +type JobStage = "config" | "metadata" | "listFiles" | "recognize" | "episodes" | "persist" | null + +interface ImportJob { + id: string + folderPath: string // posix + type: FolderType + status: JobStatus + stage: JobStage + progress: number // 0–100,阶段边界更新 + error?: string + createdAt: number + updatedAt: number +} +``` + +- Core 持有内存 `Map` +- `importFolder(path, type)` 立即创建 `status: "running"` 的 job,后台推进流水线,返回 `{ id }` +- `getJob(id)` 返回 job 快照(或 undefined) +- 失败 → `status: "failed"` + `error` +- 本版不持久化 job、不提供取消/超时 + +## 3. User Stories + +### 3.1 导入并初始化电视剧文件夹 + +* **Given** - 一个包含电视剧视频文件的本地目录,`type="tvshow"`,网络可用(TMDB/TVDB 可访问) +* **When** - 调用 `core.importFolder(path, "tvshow")` +* **Then** - 目录加入 userConfig.folders;生成并识别 MediaMetadata(识别出 tvShow);视频文件匹配到剧集(mediaFiles);metadata 持久化;job 最终状态为 `succeeded`;`core.getJob(id)` 可查询到各阶段状态 + +```mermaid +sequenceDiagram + participant C as Caller + participant Core as Core + participant Fs as FsPort + participant Net as NetworkPort (fetch) + participant Cl as TmdbClient/TvdbClient + + C->>Core: importFolder(path, "tvshow") + Core->>Core: 创建 job (running) + Core->>Fs: readTextFile(/smm.json) + Core->>Fs: writeTextFile(smm.json + path) + Core->>Fs: listFiles(path) → mm.files + loop 识别 (NFO → tmdbid= → tvdbid= → 按名搜索) + Core->>Cl: search/getTvShow/getMovie + Cl->>Net: fetch(url) + end + Core->>Core: 剧集文件名模式匹配 → mediaFiles + Core->>Fs: writeTextFile(/metadata/.json) + Core->>Core: job.status = succeeded + C->>Core: getJob(id) → { status: "succeeded", ... } +``` + +### 3.2 导入并初始化电影文件夹 + +* **Given** - 一个包含单个电影的本地目录,`type="movie"` +* **When** - 调用 `core.importFolder(path, "movie")` +* **Then** - 目录加入 userConfig.folders;识别出 movie;取第一个视频文件作为 mediaFiles[0];metadata 持久化;job 状态为 `succeeded` + +### 3.3 初始化失败时 job 进入 failed + +* **Given** - 网络不可用或文件夹不可访问 +* **When** - 调用 `core.importFolder(path, "tvshow")` 且某阶段抛错 +* **Then** - job 状态为 `failed`,`error` 包含错误信息,其余已完成阶段不产生副作用回滚(本版不实现回滚) + +### 3.4 重复导入同一目录 + +* **Given** - 目录已在 userConfig.folders 中 +* **When** - 再次调用 `core.importFolder(path, "tvshow")` +* **Then** - `userConfig.folders` 去重,不产生重复条目 + +### 3.5 音乐文件夹跳过识别 + +* **Given** - `type="music"` +* **When** - 调用 `core.importFolder(path, "music")` +* **Then** - 更新 config、创建 metadata、列出文件、持久化,跳过媒体识别与剧集匹配;job 状态为 `succeeded` From e345b00378ea93b590c5b7abe28b81887c5c0af7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 07:38:08 +0800 Subject: [PATCH 010/214] feat(core-app): scaffold apps/core (package.json, tsconfig, vitest) --- apps/core/package.json | 18 ++++++++++++++++++ apps/core/src/hello.test.ts | 7 +++++++ apps/core/tsconfig.json | 32 ++++++++++++++++++++++++++++++++ apps/core/vitest.config.ts | 17 +++++++++++++++++ pnpm-lock.yaml | 13 +++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 apps/core/package.json create mode 100644 apps/core/src/hello.test.ts create mode 100644 apps/core/tsconfig.json create mode 100644 apps/core/vitest.config.ts diff --git a/apps/core/package.json b/apps/core/package.json new file mode 100644 index 00000000..d139a1ec --- /dev/null +++ b/apps/core/package.json @@ -0,0 +1,18 @@ +{ + "name": "core-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest --watch" + }, + "dependencies": { + "@smm/tvdb4": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/apps/core/src/hello.test.ts b/apps/core/src/hello.test.ts new file mode 100644 index 00000000..e1e776a6 --- /dev/null +++ b/apps/core/src/hello.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("apps/core scaffold", () => { + it("runs vitest", () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/apps/core/tsconfig.json b/apps/core/tsconfig.json new file mode 100644 index 00000000..d53fe381 --- /dev/null +++ b/apps/core/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@core/*": ["../../packages/core/*"], + "@smm/core": ["../../packages/core/types.ts"], + "@smm/tvdb4": ["../../packages/tvdb4/src/index.ts"], + "@smm/tvdb4/*": ["../../packages/tvdb4/src/*"] + } + } +} diff --git a/apps/core/vitest.config.ts b/apps/core/vitest.config.ts new file mode 100644 index 00000000..73f29301 --- /dev/null +++ b/apps/core/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "@core": resolve(__dirname, "../../packages/core"), + "@smm/core": resolve(__dirname, "../../packages/core/types.ts"), + "@smm/tvdb4/types": resolve(__dirname, "../../packages/tvdb4/src/types.ts"), + "@smm/tvdb4": resolve(__dirname, "../../packages/tvdb4/src/index.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 853621e2..e76b4b16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,19 @@ importers: specifier: ^1.1.19 version: 1.39.1(react@19.2.4) + apps/core: + dependencies: + '@smm/tvdb4': + specifier: workspace:* + version: link:../../packages/tvdb4 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + apps/docker: devDependencies: cli: From 2306798284e962cb89dcc9701195320e97f758dc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 07:43:04 +0800 Subject: [PATCH 011/214] feat(core-app): add Ports interfaces and network/logger adapters --- .../src/adapters/ConsoleLoggerAdapter.test.ts | 20 ++++++++ .../core/src/adapters/ConsoleLoggerAdapter.ts | 19 ++++++++ .../src/adapters/FetchNetworkAdapter.test.ts | 47 +++++++++++++++++++ apps/core/src/adapters/FetchNetworkAdapter.ts | 22 +++++++++ apps/core/src/ports/FsPort.ts | 8 ++++ apps/core/src/ports/LoggerPort.ts | 5 ++ apps/core/src/ports/NetworkPort.ts | 21 +++++++++ 7 files changed, 142 insertions(+) create mode 100644 apps/core/src/adapters/ConsoleLoggerAdapter.test.ts create mode 100644 apps/core/src/adapters/ConsoleLoggerAdapter.ts create mode 100644 apps/core/src/adapters/FetchNetworkAdapter.test.ts create mode 100644 apps/core/src/adapters/FetchNetworkAdapter.ts create mode 100644 apps/core/src/ports/FsPort.ts create mode 100644 apps/core/src/ports/LoggerPort.ts create mode 100644 apps/core/src/ports/NetworkPort.ts diff --git a/apps/core/src/adapters/ConsoleLoggerAdapter.test.ts b/apps/core/src/adapters/ConsoleLoggerAdapter.test.ts new file mode 100644 index 00000000..d2194a9f --- /dev/null +++ b/apps/core/src/adapters/ConsoleLoggerAdapter.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./ConsoleLoggerAdapter"; + +describe("ConsoleLoggerAdapter", () => { + it("forwards to console", () => { + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const adapter = new ConsoleLoggerAdapter(); + adapter.warn({ a: 1 }, "msg"); + expect(spy).toHaveBeenCalledWith("msg", { a: 1 }); + spy.mockRestore(); + }); +}); + +describe("NoopLoggerAdapter", () => { + it("does nothing without throwing", () => { + const adapter = new NoopLoggerAdapter(); + expect(() => adapter.info({}, "x")).not.toThrow(); + expect(() => adapter.error({}, "x")).not.toThrow(); + }); +}); diff --git a/apps/core/src/adapters/ConsoleLoggerAdapter.ts b/apps/core/src/adapters/ConsoleLoggerAdapter.ts new file mode 100644 index 00000000..700aa604 --- /dev/null +++ b/apps/core/src/adapters/ConsoleLoggerAdapter.ts @@ -0,0 +1,19 @@ +import type { LoggerPort } from "../ports/LoggerPort"; + +export class ConsoleLoggerAdapter implements LoggerPort { + info(obj: unknown, msg: string): void { + console.info(msg, obj); + } + warn(obj: unknown, msg: string): void { + console.warn(msg, obj); + } + error(obj: unknown, msg: string): void { + console.error(msg, obj); + } +} + +export class NoopLoggerAdapter implements LoggerPort { + info(_obj: unknown, _msg: string): void {} + warn(_obj: unknown, _msg: string): void {} + error(_obj: unknown, _msg: string): void {} +} diff --git a/apps/core/src/adapters/FetchNetworkAdapter.test.ts b/apps/core/src/adapters/FetchNetworkAdapter.test.ts new file mode 100644 index 00000000..01f5f9b9 --- /dev/null +++ b/apps/core/src/adapters/FetchNetworkAdapter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import type { FetchInit, HttpResponse } from "../ports/NetworkPort"; +import { FetchNetworkAdapter } from "./FetchNetworkAdapter"; + +function fakeResponse(overrides: Partial = {}): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve("body-text"), + json: () => Promise.resolve({} as T), + ...overrides, + }; +} + +describe("FetchNetworkAdapter", () => { + it("delegates to an injected fetch and passes init through", async () => { + const calls: Array<{ input: string; init?: FetchInit }> = []; + const injected = async (input: string, init?: FetchInit): Promise => { + calls.push({ input, init }); + return fakeResponse(); + }; + + const adapter = new FetchNetworkAdapter(injected); + const resp = await adapter.fetch("https://example.com/api", { method: "POST", headers: { "X-A": "1" }, body: "{}" }); + + expect(resp.ok).toBe(true); + expect(resp.status).toBe(200); + expect(await resp.json<{ ok: boolean }>()).toEqual({}); + expect(calls).toEqual([ + { input: "https://example.com/api", init: { method: "POST", headers: { "X-A": "1" }, body: "{}" } }, + ]); + }); + + it("uses globalThis.fetch when no fetch is injected", async () => { + const original = globalThis.fetch; + (globalThis as unknown as { fetch: unknown }).fetch = async () => fakeResponse({ status: 201 }); + try { + const adapter = new FetchNetworkAdapter(); + const resp = await adapter.fetch("https://example.com"); + expect(resp.status).toBe(201); + } finally { + (globalThis as unknown as { fetch: unknown }).fetch = original; + } + }); +}); diff --git a/apps/core/src/adapters/FetchNetworkAdapter.ts b/apps/core/src/adapters/FetchNetworkAdapter.ts new file mode 100644 index 00000000..ca0c4246 --- /dev/null +++ b/apps/core/src/adapters/FetchNetworkAdapter.ts @@ -0,0 +1,22 @@ +import type { FetchInit, HttpResponse, NetworkPort } from "../ports/NetworkPort"; + +export class FetchNetworkAdapter implements NetworkPort { + private readonly fetchImpl: (input: string, init?: FetchInit) => Promise; + + constructor(fetchImpl?: (input: string, init?: FetchInit) => Promise) { + this.fetchImpl = + fetchImpl ?? + ((input, init) => { + // Node 18+ / modern browsers expose globalThis.fetch returning a Response + // whose runtime shape satisfies HttpResponse. + const g = globalThis as unknown as { + fetch: (i: string, o?: FetchInit) => Promise; + }; + return g.fetch(input, init); + }); + } + + fetch(input: string, init?: FetchInit): Promise { + return this.fetchImpl(input, init); + } +} diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts new file mode 100644 index 00000000..dc28ef80 --- /dev/null +++ b/apps/core/src/ports/FsPort.ts @@ -0,0 +1,8 @@ +/** Runtime-agnostic file system. Paths are POSIX. Adapters convert at the boundary. */ +export interface FsPort { + readTextFile(path: string): Promise; + writeTextFile(path: string, content: string): Promise; + exists(path: string): Promise; + /** Recursively list all files under `dir` (not directories). */ + listFiles(dir: string): Promise; +} diff --git a/apps/core/src/ports/LoggerPort.ts b/apps/core/src/ports/LoggerPort.ts new file mode 100644 index 00000000..33fc4e3b --- /dev/null +++ b/apps/core/src/ports/LoggerPort.ts @@ -0,0 +1,5 @@ +export interface LoggerPort { + info(obj: unknown, msg: string): void; + warn(obj: unknown, msg: string): void; + error(obj: unknown, msg: string): void; +} diff --git a/apps/core/src/ports/NetworkPort.ts b/apps/core/src/ports/NetworkPort.ts new file mode 100644 index 00000000..af2c492a --- /dev/null +++ b/apps/core/src/ports/NetworkPort.ts @@ -0,0 +1,21 @@ +export interface FetchInit { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; +} + +/** Minimal HTTP response shape; runtime-agnostic (Node/browser). */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers: Record; + text(): Promise; + json(): Promise; +} + +/** Thin HTTP capability only. No business parsing here. */ +export interface NetworkPort { + fetch(input: string, init?: FetchInit): Promise; +} From 15f54c4f213d1aff477e06e189ed80294bcc5311 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 07:47:30 +0800 Subject: [PATCH 012/214] feat(core-app): add NodejsFsAdapter and path helpers Co-Authored-By: Claude Opus 4.7 --- .../src/adapters/node/NodejsFsAdapter.test.ts | 49 +++++++++++++++++++ .../core/src/adapters/node/NodejsFsAdapter.ts | 47 ++++++++++++++++++ apps/core/src/pipeline/paths.ts | 14 ++++++ 3 files changed, 110 insertions(+) create mode 100644 apps/core/src/adapters/node/NodejsFsAdapter.test.ts create mode 100644 apps/core/src/adapters/node/NodejsFsAdapter.ts create mode 100644 apps/core/src/pipeline/paths.ts diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts new file mode 100644 index 00000000..040d854f --- /dev/null +++ b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts @@ -0,0 +1,49 @@ +import { promises as fsp } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Path } from "@core/path"; +import { NodejsFsAdapter } from "./NodejsFsAdapter"; +import { joinPosix } from "../../pipeline/paths"; + +describe("NodejsFsAdapter", () => { + let tmpPosix: string; + + beforeEach(async () => { + const dir = await fsp.mkdtemp(join(os.tmpdir(), "smm-core-")); + tmpPosix = Path.posix(dir); + }); + + afterEach(async () => { + await fsp.rm(Path.toPlatformPath(tmpPosix), { recursive: true, force: true }); + }); + + it("writes and reads a file (POSIX in, platform on disk)", async () => { + const adapter = new NodejsFsAdapter(); + const file = joinPosix(tmpPosix, "hello.txt"); + + await adapter.writeTextFile(file, "hi"); + const content = await adapter.readTextFile(file); + + expect(content).toBe("hi"); + expect(await adapter.exists(file)).toBe(true); + }); + + it("recursively lists files, not directories", async () => { + const adapter = new NodejsFsAdapter(); + await adapter.writeTextFile(joinPosix(tmpPosix, "a.mkv"), ""); + await adapter.writeTextFile(joinPosix(tmpPosix, "sub", "b.srt"), ""); + + const files = await adapter.listFiles(tmpPosix); + + expect(files.map((f) => Path.posix(f)).sort()).toEqual([ + joinPosix(tmpPosix, "a.mkv"), + joinPosix(tmpPosix, "sub", "b.srt"), + ]); + }); + + it("exists returns false for a missing file", async () => { + const adapter = new NodejsFsAdapter(); + expect(await adapter.exists(joinPosix(tmpPosix, "nope.txt"))).toBe(false); + }); +}); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts new file mode 100644 index 00000000..ced9e107 --- /dev/null +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -0,0 +1,47 @@ +import { promises as fsp } from "node:fs"; +import { dirname, join } from "node:path"; +import { Path } from "@core/path"; +import type { FsPort } from "../../ports/FsPort"; + +/** + * Node host adapter. Core passes POSIX paths; this adapter converts to the + * host's platform format before touching node:fs. Node hosts only. + */ +export class NodejsFsAdapter implements FsPort { + async readTextFile(path: string): Promise { + return fsp.readFile(Path.toPlatformPath(path), "utf-8"); + } + + async writeTextFile(path: string, content: string): Promise { + const platform = Path.toPlatformPath(path); + await fsp.mkdir(dirname(platform), { recursive: true }); + await fsp.writeFile(platform, content, "utf-8"); + } + + async exists(path: string): Promise { + try { + await fsp.access(Path.toPlatformPath(path)); + return true; + } catch { + return false; + } + } + + async listFiles(dir: string): Promise { + const root = Path.toPlatformPath(dir); + const out: string[] = []; + const walk = async (current: string): Promise => { + const entries = await fsp.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile()) { + out.push(full); + } + } + }; + await walk(root); + return out; + } +} diff --git a/apps/core/src/pipeline/paths.ts b/apps/core/src/pipeline/paths.ts new file mode 100644 index 00000000..cd00ce32 --- /dev/null +++ b/apps/core/src/pipeline/paths.ts @@ -0,0 +1,14 @@ +export function joinPosix(...parts: string[]): string { + return parts.join("/"); +} + +export function basename(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] ?? ""; +} + +export function extname(path: string): string { + const base = basename(path); + const idx = base.lastIndexOf("."); + return idx < 0 ? "" : base.slice(idx); +} From 9c431574cbb5e5c910c68d324316a248fc04699b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 07:52:53 +0800 Subject: [PATCH 013/214] feat(core-app): add NetworkFsAdapter mapping FsPort to internal HTTP fs endpoints --- .../adapters/network/NetworkFsAdapter.test.ts | 85 +++++++++++++++++++ .../src/adapters/network/NetworkFsAdapter.ts | 59 +++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 apps/core/src/adapters/network/NetworkFsAdapter.test.ts create mode 100644 apps/core/src/adapters/network/NetworkFsAdapter.ts diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts new file mode 100644 index 00000000..39a3def5 --- /dev/null +++ b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../../ports/NetworkPort"; +import { NetworkFsAdapter } from "./NetworkFsAdapter"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function mockNetwork(): { network: NetworkPort; calls: Array<{ url: string; body: unknown }> } { + const calls: Array<{ url: string; body: unknown }> = []; + const network: NetworkPort = { + fetch: async (url, init) => { + const body = JSON.parse(init?.body ?? "{}"); + calls.push({ url, body }); + if (url.endsWith("/api/readFile")) { + if (String(body.path).includes("missing")) { + return jsonResponse({ error: `File Not Found: ${body.path}` }); + } + return jsonResponse({ data: "file-content" }); + } + if (url.endsWith("/api/writeFile")) { + return jsonResponse({}); + } + if (url.endsWith("/api/listFiles")) { + return jsonResponse({ + data: { path: body.path, items: [{ path: "/m/a.mkv" }, { path: "/m/b.srt" }], size: 0 }, + }); + } + throw new Error("unexpected url: " + url); + }, + }; + return { network, calls }; +} + +describe("NetworkFsAdapter", () => { + it("reads a file via POST /api/readFile", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + const content = await adapter.readTextFile("/m/file.txt"); + + expect(content).toBe("file-content"); + expect(calls[0]).toEqual({ url: "http://127.0.0.1:30000/api/readFile", body: { path: "/m/file.txt" } }); + }); + + it("writes a file via POST /api/writeFile", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await adapter.writeTextFile("/m/file.txt", "hello"); + + expect(calls[0]).toEqual({ + url: "http://127.0.0.1:30000/api/writeFile", + body: { path: "/m/file.txt", mode: "overwrite", data: "hello" }, + }); + }); + + it("lists files recursively via POST /api/listFiles", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + const files = await adapter.listFiles("/m"); + + expect(files).toEqual(["/m/a.mkv", "/m/b.srt"]); + expect(calls[0]).toEqual({ + url: "http://127.0.0.1:30000/api/listFiles", + body: { path: "/m", recursively: true, onlyFiles: true }, + }); + }); + + it("exists() is true when readFile succeeds and false on File Not Found", async () => { + const { network } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + expect(await adapter.exists("/m/file.txt")).toBe(true); + expect(await adapter.exists("/m/missing.txt")).toBe(false); + }); +}); diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts new file mode 100644 index 00000000..ddfab30d --- /dev/null +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -0,0 +1,59 @@ +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; + +export interface NetworkFsAdapterOptions { + network: NetworkPort; + /** Base URL of the internal HTTP API, e.g. "http://127.0.0.1:30000". */ + baseUrl: string; +} + +/** + * Browser-runtime FsPort: maps file operations to the internal HTTP fs + * endpoints exposed by Layer 3 (core-routes). Paths are POSIX; the endpoints + * accept both POSIX and platform formats. + */ +export class NetworkFsAdapter implements FsPort { + constructor(private readonly options: NetworkFsAdapterOptions) {} + + private async post(path: string, body: Record): Promise { + const resp = await this.options.network.fetch(`${this.options.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} from ${path}`); + } + return resp.json(); + } + + async readTextFile(path: string): Promise { + const json = await this.post<{ data?: string; error?: string }>("/api/readFile", { path }); + if (json.error !== undefined) throw new Error(json.error); + if (json.data === undefined) throw new Error(`readFile returned no data for ${path}`); + return json.data; + } + + async writeTextFile(path: string, content: string): Promise { + const json = await this.post<{ error?: string }>("/api/writeFile", { + path, + mode: "overwrite", + data: content, + }); + if (json.error !== undefined) throw new Error(json.error); + } + + async exists(path: string): Promise { + const json = await this.post<{ error?: string }>("/api/readFile", { path }); + return !(json.error !== undefined && json.error.startsWith("File Not Found")); + } + + async listFiles(dir: string): Promise { + const json = await this.post<{ + error?: string; + data?: { items: Array<{ path: string }> }; + }>("/api/listFiles", { path: dir, recursively: true, onlyFiles: true }); + if (json.error !== undefined) throw new Error(json.error); + return (json.data?.items ?? []).map((i) => i.path); + } +} From e7158127ad7f877b63b37cbb8bdfa3c2a251107f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 07:57:14 +0800 Subject: [PATCH 014/214] feat(core-app): add userConfig and metadata cache path resolution Co-Authored-By: Claude Opus 4.7 --- apps/core/src/pipeline/paths.test.ts | 28 ++++++++++++++++++++++++++++ apps/core/src/pipeline/paths.ts | 16 ++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 apps/core/src/pipeline/paths.test.ts diff --git a/apps/core/src/pipeline/paths.test.ts b/apps/core/src/pipeline/paths.test.ts new file mode 100644 index 00000000..232c35f8 --- /dev/null +++ b/apps/core/src/pipeline/paths.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { basename, extname, joinPosix, metadataCachePath, userConfigPath } from "./paths"; + +describe("paths", () => { + it("basename returns the last path segment", () => { + expect(basename("/C:/media/My.Show/S01E01.mkv")).toBe("S01E01.mkv"); + expect(basename("no-separator")).toBe("no-separator"); + }); + + it("extname returns the extension with leading dot", () => { + expect(extname("/C:/media/a.mkv")).toBe(".mkv"); + expect(extname("noext")).toBe(""); + }); + + it("joinPosix joins with forward slashes", () => { + expect(joinPosix("/data/smm", "metadata", "x.json")).toBe("/data/smm/metadata/x.json"); + }); + + it("userConfigPath points at /smm.json", () => { + expect(userConfigPath("/data/smm")).toBe("/data/smm/smm.json"); + }); + + it("metadataCachePath sanitizes the folder path into a cache filename", () => { + expect(metadataCachePath("/data/smm", "/C:/media/My Show")).toBe( + "/data/smm/metadata/_C__media_My Show.json", + ); + }); +}); diff --git a/apps/core/src/pipeline/paths.ts b/apps/core/src/pipeline/paths.ts index cd00ce32..8e8606db 100644 --- a/apps/core/src/pipeline/paths.ts +++ b/apps/core/src/pipeline/paths.ts @@ -1,3 +1,5 @@ +import { Path } from "@core/path"; + export function joinPosix(...parts: string[]): string { return parts.join("/"); } @@ -12,3 +14,17 @@ export function extname(path: string): string { const idx = base.lastIndexOf("."); return idx < 0 ? "" : base.slice(idx); } + +/** `/smm.json`, in POSIX form. */ +export function userConfigPath(appDataDir: string): string { + return joinPosix(Path.posix(appDataDir), "smm.json"); +} + +/** + * Metadata cache file for a media folder, mirroring `metadataCacheFilePath` + * in `apps/ui/src/api/readMediaMetadataV2.ts`. POSIX form. + */ +export function metadataCachePath(appDataDir: string, folderPathInPosix: string): string { + const filename = folderPathInPosix.replace(/[/\\:?*|<>"]/g, "_"); + return joinPosix(Path.posix(appDataDir), "metadata", `${filename}.json`); +} From b2b9ec594a5a38ead2da0d0b09937f2e99f73edb Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:03:48 +0800 Subject: [PATCH 015/214] feat(core-app): add ImportJob model and in-memory JobStore Co-Authored-By: Claude Opus 4.7 --- apps/core/src/jobs/jobStore.test.ts | 47 +++++++++++++++++++++++++++++ apps/core/src/jobs/jobStore.ts | 30 ++++++++++++++++++ apps/core/src/jobs/types.ts | 16 ++++++++++ 3 files changed, 93 insertions(+) create mode 100644 apps/core/src/jobs/jobStore.test.ts create mode 100644 apps/core/src/jobs/jobStore.ts create mode 100644 apps/core/src/jobs/types.ts diff --git a/apps/core/src/jobs/jobStore.test.ts b/apps/core/src/jobs/jobStore.test.ts new file mode 100644 index 00000000..c005b520 --- /dev/null +++ b/apps/core/src/jobs/jobStore.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { JobStore } from "./jobStore"; + +describe("JobStore", () => { + it("creates a job with id and timestamps", () => { + const store = new JobStore(); + const job = store.create({ + folderPath: "/m/My.Show", + type: "tvshow", + status: "running", + stage: "config", + progress: 0, + }); + + expect(job.id).toBeTruthy(); + expect(job.createdAt).toBeGreaterThan(0); + expect(job.updatedAt).toBeGreaterThanOrEqual(job.createdAt); + expect(store.get(job.id)?.folderPath).toBe("/m/My.Show"); + }); + + it("update patches fields and bumps updatedAt", async () => { + const store = new JobStore(); + const job = store.create({ folderPath: "/m", type: "movie", status: "running", stage: null, progress: 0 }); + const firstUpdatedAt = job.updatedAt; + + await new Promise((r) => setTimeout(r, 5)); + store.update(job.id, { status: "succeeded", stage: null, progress: 100 }); + + const updated = store.get(job.id); + expect(updated?.status).toBe("succeeded"); + expect(updated?.progress).toBe(100); + expect(updated?.updatedAt).toBeGreaterThan(firstUpdatedAt); + }); + + it("update on unknown id is a no-op", () => { + const store = new JobStore(); + expect(() => store.update("nope", { status: "failed" })).not.toThrow(); + }); + + it("get returns a snapshot (mutating it does not affect the store)", () => { + const store = new JobStore(); + const job = store.create({ folderPath: "/m", type: "music", status: "running", stage: null, progress: 0 }); + const snapshot = store.get(job.id); + snapshot!.status = "failed"; + expect(store.get(job.id)?.status).toBe("running"); + }); +}); diff --git a/apps/core/src/jobs/jobStore.ts b/apps/core/src/jobs/jobStore.ts new file mode 100644 index 00000000..dda08b4a --- /dev/null +++ b/apps/core/src/jobs/jobStore.ts @@ -0,0 +1,30 @@ +import type { ImportJob } from "./types"; + +let seq = 0; + +/** Runtime-agnostic id: base-36 timestamp + monotonic counter. */ +export function nextJobId(): string { + return `${Date.now().toString(36)}-${(seq++).toString(36)}`; +} + +export class JobStore { + private readonly jobs = new Map(); + + create(init: Omit): ImportJob { + const now = Date.now(); + const job: ImportJob = { id: nextJobId(), createdAt: now, updatedAt: now, ...init }; + this.jobs.set(job.id, job); + return job; + } + + update(id: string, patch: Partial): void { + const job = this.jobs.get(id); + if (job === undefined) return; + Object.assign(job, patch, { updatedAt: Date.now() }); + } + + get(id: string): ImportJob | undefined { + const job = this.jobs.get(id); + return job === undefined ? undefined : { ...job }; + } +} diff --git a/apps/core/src/jobs/types.ts b/apps/core/src/jobs/types.ts new file mode 100644 index 00000000..8c279f04 --- /dev/null +++ b/apps/core/src/jobs/types.ts @@ -0,0 +1,16 @@ +import type { FolderType } from "@smm/core"; + +export type JobStatus = "pending" | "running" | "succeeded" | "failed" | "aborted"; +export type JobStage = "config" | "metadata" | "listFiles" | "recognize" | "episodes" | "persist" | null; + +export interface ImportJob { + id: string; + folderPath: string; + type: FolderType; + status: JobStatus; + stage: JobStage; + progress: number; + error?: string; + createdAt: number; + updatedAt: number; +} From b5c3485285580daa1751d0c6b9a0706c1ad6bfdb Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:06:20 +0800 Subject: [PATCH 016/214] feat(core-app): add runtime-agnostic NFO parser --- apps/core/src/pipeline/nfo.test.ts | 23 +++++++++++++++++++++++ apps/core/src/pipeline/nfo.ts | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 apps/core/src/pipeline/nfo.test.ts create mode 100644 apps/core/src/pipeline/nfo.ts diff --git a/apps/core/src/pipeline/nfo.test.ts b/apps/core/src/pipeline/nfo.test.ts new file mode 100644 index 00000000..78f01a79 --- /dev/null +++ b/apps/core/src/pipeline/nfo.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { parseNfo } from "./nfo"; + +describe("parseNfo", () => { + it("extracts title, tmdbid and tvdbid from a tvshow.nfo", () => { + const xml = "My Show123456"; + expect(parseNfo(xml)).toEqual({ title: "My Show", tmdbid: "123", tvdbid: "456" }); + }); + + it("extracts ids from a movie.nfo", () => { + const xml = "My Film7"; + expect(parseNfo(xml)).toEqual({ title: "My Film", tmdbid: "7", tvdbid: undefined }); + }); + + it("handles self-closing and missing fields", () => { + expect(parseNfo("5")).toEqual({ + title: undefined, + tmdbid: "5", + tvdbid: undefined, + }); + expect(parseNfo("")).toEqual({ title: undefined, tmdbid: undefined, tvdbid: undefined }); + }); +}); diff --git a/apps/core/src/pipeline/nfo.ts b/apps/core/src/pipeline/nfo.ts new file mode 100644 index 00000000..4e80bc63 --- /dev/null +++ b/apps/core/src/pipeline/nfo.ts @@ -0,0 +1,24 @@ +export interface ParsedNfo { + title?: string; + tmdbid?: string; + tvdbid?: string; +} + +function textOf(xml: string, tag: string): string | undefined { + const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i")); + if (match === null) return undefined; + const value = match[1]?.trim(); + return value === "" ? undefined : value; +} + +/** + * Runtime-agnostic regex NFO parser. The existing UI parser uses DOMParser + * (browser-only); core needs the ids (and title) without a DOM. + */ +export function parseNfo(xml: string): ParsedNfo { + return { + title: textOf(xml, "title"), + tmdbid: textOf(xml, "tmdbid"), + tvdbid: textOf(xml, "tvdbid"), + }; +} From 750a046bfd44ac1dfbfbeb06fa8d61f90127b225 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:18:22 +0800 Subject: [PATCH 017/214] feat(core-app): add TmdbClient on NetworkPort --- apps/core/src/clients/TmdbClient.test.ts | 101 ++++++++++++++++++++ apps/core/src/clients/TmdbClient.ts | 116 +++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 apps/core/src/clients/TmdbClient.test.ts create mode 100644 apps/core/src/clients/TmdbClient.ts diff --git a/apps/core/src/clients/TmdbClient.test.ts b/apps/core/src/clients/TmdbClient.test.ts new file mode 100644 index 00000000..deb53813 --- /dev/null +++ b/apps/core/src/clients/TmdbClient.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { buildTvShowMediaMetadata, TmdbClient } from "./TmdbClient"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function networkMock(routes: Record): { network: NetworkPort; urls: string[] } { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url); + const found = Object.entries(routes).find(([pattern]) => url.includes(pattern)); + if (found === undefined) throw new Error("unexpected url: " + url); + return jsonResponse(found[1]); + }, + }; + return { network, urls }; +} + +describe("TmdbClient", () => { + it("search sends query/language and Authorization header", async () => { + const { network, urls } = networkMock({ "/search/tv" : { results: [{ id: 1, name: "S" }], page: 1, total_pages: 1, total_results: 1 } }); + const client = new TmdbClient(network, { host: "https://tmdb.example", apiKey: "abc" }); + + const body = await client.search("My Show", "tv", "en-US"); + + expect(body.results[0]?.id).toBe(1); + const url = urls[0]!; + expect(url).toContain("https://tmdb.example/search/tv"); + expect(url).toContain("query=My%20Show"); + expect(url).toContain("language=en-US"); + // auth header verified below via a route capture + }); + + it("getTvShowMediaMetadata fetches series + each season and builds TvShowMediaMetadata", async () => { + const series = { + id: 1, + name: "My Show", + first_air_date: "2020-01-01", + seasons: [ + { id: 11, name: "Season 1", season_number: 1, air_date: "2020-01-01", episode_count: 1 }, + ], + }; + const season = { + id: 11, + name: "Season 1", + season_number: 1, + air_date: "2020-01-01", + episode_count: 1, + episodes: [ + { id: 1, name: "Pilot", episode_number: 1, season_number: 1, air_date: "2020-01-01", overview: "", still_path: null, vote_average: 0, vote_count: 0, runtime: 45 }, + ], + }; + const { network, urls } = networkMock({ + "/tv/1?": series, + "/tv/1/season/1?": season, + }); + const client = new TmdbClient(network, {}); + + const tvShow = await client.getTvShowMediaMetadata(1, "en-US"); + + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TMDB", + airDate: "2020-01-01", + seasons: [ + { season: 1, name: "Season 1", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }, + ], + }); + expect(urls[0]).toContain("/tv/1?"); + expect(urls[1]).toContain("/tv/1/season/1?"); + }); + + it("getMovieMediaMetadata maps a movie detail", async () => { + const movie = { id: 2, title: "My Film", release_date: "2019-05-01" }; + const { network } = networkMock({ "/movie/2?" : movie }); + const client = new TmdbClient(network, {}); + + const mm = await client.getMovieMediaMetadata(2, "en-US"); + + expect(mm).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TMDB" }); + }); + + it("buildTvShowMediaMetadata maps series + season details to TvShowMediaMetadata", () => { + const result = buildTvShowMediaMetadata( + { id: 5, name: "S", first_air_date: "2020-01-01", seasons: [{ season_number: 1, name: "S1" }] } as never, + [{ season_number: 1, name: "S1", episodes: [{ episode_number: 1, season_number: 1, name: "E1" }] }] as never, + ); + expect(result.seasons).toEqual([{ season: 1, name: "S1", episodes: [{ season: 1, episode: 1, name: "E1" }] }]); + }); +}); diff --git a/apps/core/src/clients/TmdbClient.ts b/apps/core/src/clients/TmdbClient.ts new file mode 100644 index 00000000..81ccf1d4 --- /dev/null +++ b/apps/core/src/clients/TmdbClient.ts @@ -0,0 +1,116 @@ +import type { + MovieMediaMetadata, + TmdbMovieDetails, + TmdbSearchResponseBody, + TmdbSeasonDetails, + TmdbSeriesDetails, + TMDBMovie, + TMDBTVShow, + TvShowEpisodeMetadata, + TvShowMediaMetadata, + TvShowSeasonMetadata, +} from "@smm/core"; +import type { NetworkPort } from "../ports/NetworkPort"; + +export const SMM_TMDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tmdb"; + +export interface TmdbClientOptions { + host?: string; + apiKey?: string; +} + +export class TmdbClient { + private readonly host: string; + private readonly apiKey?: string; + + constructor( + private readonly network: NetworkPort, + options: TmdbClientOptions = {}, + ) { + this.host = (options.host?.trim() || SMM_TMDB_DEFAULT_UPSTREAM).replace(/\/+$/, ""); + this.apiKey = options.apiKey?.trim() || undefined; + } + + private async request(urlPath: string): Promise { + const headers: Record = {}; + if (this.apiKey !== undefined) headers.Authorization = `Bearer ${this.apiKey}`; + const resp = await this.network.fetch(`${this.host}${urlPath}`, { method: "GET", headers }); + if (!resp.ok) { + throw new Error(`TMDB request failed: ${resp.status} ${resp.statusText}`); + } + return resp.json(); + } + + /** + * Build a query string using application/x-www-form-urlencoded encoding + * (spaces → `%20`) so values embed cleanly in a URL path segment. + */ + private queryString(params: Record): string { + return new URLSearchParams(params).toString().replace(/\+/g, "%20"); + } + + search(keyword: string, type: "movie" | "tv", language: string): Promise { + return this.request( + `/search/${type}?${this.queryString({ query: keyword, language })}`, + ); + } + + getTvShowById(id: number, language: string): Promise { + return this.request(`/tv/${id}?${this.queryString({ language })}`); + } + + getTvSeasonById(seriesId: number, seasonNumber: number, language: string): Promise { + return this.request( + `/tv/${seriesId}/season/${seasonNumber}?${this.queryString({ language })}`, + ); + } + + getMovieById(id: number, language: string): Promise { + return this.request(`/movie/${id}?${this.queryString({ language })}`); + } + + /** Series details + per-season episode lists, mapped to {@link TvShowMediaMetadata}. */ + async getTvShowMediaMetadata(id: number, language: string): Promise { + const series = await this.getTvShowById(id, language); + const seasonDetails: TmdbSeasonDetails[] = []; + for (const season of series.seasons) { + seasonDetails.push(await this.getTvSeasonById(id, season.season_number, language)); + } + return buildTvShowMediaMetadata(series, seasonDetails); + } + + async getMovieMediaMetadata(id: number, language: string): Promise { + const details = await this.getMovieById(id, language); + return movieMediaMetadataFromTmdbSearch(details); + } +} + +/** TMDB details → unified `tvShow` shape (same as `tvShowMediaMetadataFromTmdbDetails` in apps/ui). */ +export function buildTvShowMediaMetadata( + series: TmdbSeriesDetails, + seasonDetails: TmdbSeasonDetails[], +): TvShowMediaMetadata { + const seasons: TvShowSeasonMetadata[] = (seasonDetails ?? []).map((season) => { + const episodes: TvShowEpisodeMetadata[] = (season.episodes ?? []).map((ep) => ({ + season: ep.season_number, + episode: ep.episode_number, + name: ep.name ?? "", + })); + return { season: season.season_number, name: season.name ?? "", episodes }; + }); + + return { + id: String(series.id), + name: series.name, + database: "TMDB", + airDate: series.first_air_date, + seasons, + }; +} + +/** TMDB movie (search result or detail) → unified `movie` shape. */ +export function movieMediaMetadataFromTmdbSearch(item: TMDBMovie): MovieMediaMetadata { + return { id: String(item.id), name: item.title, airDate: item.release_date, database: "TMDB" }; +} + +export type { TMDBTVShow }; From 7108971eb3ac28a377d7f78c149c723a7e5fa722 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:24:56 +0800 Subject: [PATCH 018/214] test(core-app): cover TmdbClient Authorization header and guard seasons Co-Authored-By: Claude Opus 4.7 --- apps/core/src/clients/TmdbClient.test.ts | 14 ++++++++++---- apps/core/src/clients/TmdbClient.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/core/src/clients/TmdbClient.test.ts b/apps/core/src/clients/TmdbClient.test.ts index deb53813..2411c9ca 100644 --- a/apps/core/src/clients/TmdbClient.test.ts +++ b/apps/core/src/clients/TmdbClient.test.ts @@ -13,22 +13,28 @@ function jsonResponse(body: unknown): HttpResponse { }; } -function networkMock(routes: Record): { network: NetworkPort; urls: string[] } { +function networkMock(routes: Record): { + network: NetworkPort; + urls: string[]; + headers: Record[]; +} { const urls: string[] = []; + const headers: Record[] = []; const network: NetworkPort = { fetch: async (url, init) => { urls.push(url); + headers.push(init?.headers ?? {}); const found = Object.entries(routes).find(([pattern]) => url.includes(pattern)); if (found === undefined) throw new Error("unexpected url: " + url); return jsonResponse(found[1]); }, }; - return { network, urls }; + return { network, urls, headers }; } describe("TmdbClient", () => { it("search sends query/language and Authorization header", async () => { - const { network, urls } = networkMock({ "/search/tv" : { results: [{ id: 1, name: "S" }], page: 1, total_pages: 1, total_results: 1 } }); + const { network, urls, headers } = networkMock({ "/search/tv" : { results: [{ id: 1, name: "S" }], page: 1, total_pages: 1, total_results: 1 } }); const client = new TmdbClient(network, { host: "https://tmdb.example", apiKey: "abc" }); const body = await client.search("My Show", "tv", "en-US"); @@ -38,7 +44,7 @@ describe("TmdbClient", () => { expect(url).toContain("https://tmdb.example/search/tv"); expect(url).toContain("query=My%20Show"); expect(url).toContain("language=en-US"); - // auth header verified below via a route capture + expect(headers[0]?.Authorization).toBe("Bearer abc"); }); it("getTvShowMediaMetadata fetches series + each season and builds TvShowMediaMetadata", async () => { diff --git a/apps/core/src/clients/TmdbClient.ts b/apps/core/src/clients/TmdbClient.ts index 81ccf1d4..6254af25 100644 --- a/apps/core/src/clients/TmdbClient.ts +++ b/apps/core/src/clients/TmdbClient.ts @@ -73,7 +73,7 @@ export class TmdbClient { async getTvShowMediaMetadata(id: number, language: string): Promise { const series = await this.getTvShowById(id, language); const seasonDetails: TmdbSeasonDetails[] = []; - for (const season of series.seasons) { + for (const season of series.seasons ?? []) { seasonDetails.push(await this.getTvSeasonById(id, season.season_number, language)); } return buildTvShowMediaMetadata(series, seasonDetails); From 054f2c2eafa8e40e31185a764ab20843f048c6b0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:26:32 +0800 Subject: [PATCH 019/214] feat(core-app): add TvdbClient wrapping @smm/tvdb4 on NetworkPort Co-Authored-By: Claude Opus 4.7 --- apps/core/src/clients/TvdbClient.test.ts | 103 +++++++++++++++++++++++ apps/core/src/clients/TvdbClient.ts | 101 ++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 apps/core/src/clients/TvdbClient.test.ts create mode 100644 apps/core/src/clients/TvdbClient.ts diff --git a/apps/core/src/clients/TvdbClient.test.ts b/apps/core/src/clients/TvdbClient.test.ts new file mode 100644 index 00000000..72f2b228 --- /dev/null +++ b/apps/core/src/clients/TvdbClient.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { mapToTvdbLangCode, TvdbClient } from "./TvdbClient"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function envelope(data: unknown) { + return { status: "success", data }; +} + +function tvdbNetwork(): NetworkPort { + return { + fetch: async (url) => { + if (url.includes("/search")) { + const type = url.includes("type=series") ? "series" : "movie"; + return jsonResponse( + envelope( + type === "series" + ? [{ id: "series-1", objectID: "series-1", name: "My Show", tvdb_id: "1" }] + : [{ id: "movie-2", objectID: "movie-2", name: "My Film", tvdb_id: "2" }], + ), + ); + } + if (url.includes("/series/1/translations/eng")) { + return jsonResponse(envelope({ name: "My Show" })); + } + if (url.includes("/series/1/extended")) { + return jsonResponse( + envelope({ + id: 1, + name: "My Show", + firstAired: "2020-01-01", + seasons: [{ id: 11, number: 1, type: { name: "Aired Order" } }], + }), + ); + } + if (url.includes("/seasons/11/extended")) { + return jsonResponse( + envelope({ + id: 11, + episodes: [{ id: 1, number: 1, seasonNumber: 1, name: "Pilot" }], + }), + ); + } + if (url.includes("/movies/2/translations/eng")) { + return jsonResponse(envelope({ name: "My Film" })); + } + if (url.includes("/movies/2/extended")) { + return jsonResponse(envelope({ id: 2, name: "My Film", first_release: { first: "2019-05-01" } })); + } + throw new Error("unexpected url: " + url); + }, + }; +} + +describe("TvdbClient", () => { + it("searches series", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const items = await client.searchSeries("My Show", "en-US"); + expect(items?.[0]?.tvdb_id).toBe("1"); + }); + + it("searches movies", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const items = await client.searchMovie("My Film", "en-US"); + expect(items?.[0]?.tvdb_id).toBe("2"); + }); + + it("getTvShowMediaMetadata builds seasons + episodes", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const tvShow = await client.getTvShowMediaMetadata(1, "en-US"); + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TVDB", + airDate: "2020-01-01", + seasons: [ + { season: 1, name: "", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }, + ], + }); + }); + + it("getMovieMediaMetadata maps a movie", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const movie = await client.getMovieMediaMetadata(2, "en-US"); + expect(movie).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TVDB" }); + }); + + it("maps IETF media language to TVDB ISO 639-3", () => { + expect(mapToTvdbLangCode("zh-CN")).toBe("zho"); + expect(mapToTvdbLangCode("en-US")).toBe("eng"); + expect(mapToTvdbLangCode("ja-JP")).toBe("jpn"); + }); +}); diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts new file mode 100644 index 00000000..cc0ef026 --- /dev/null +++ b/apps/core/src/clients/TvdbClient.ts @@ -0,0 +1,101 @@ +import { TVDBv4, type TVDBv4SearchResult } from "@smm/tvdb4"; +import type { + TVDBv4Season, + TVDBv4SeriesExtendedResponse, + TVDBv4SeriesSeasonsExtendedResponse, +} from "@smm/tvdb4/types"; +import type { MovieMediaMetadata, PreferMediaLanguage, TvShowMediaMetadata } from "@smm/core"; +import type { NetworkPort } from "../ports/NetworkPort"; + +export const SMM_TVDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tvdb"; + +/** IETF BCP 47 media language → TVDB ISO 639-3 code. */ +export function mapToTvdbLangCode(lang: "zh-CN" | "en-US" | "ja-JP"): string { + switch (lang) { + case "zh-CN": + return "zho"; + case "en-US": + return "eng"; + case "ja-JP": + return "jpn"; + default: + return "eng"; + } +} + +export interface TvdbClientOptions { + host?: string; + apiKey?: string; +} + +export class TvdbClient { + private readonly client: TVDBv4; + + constructor(network: NetworkPort, options: TvdbClientOptions = {}) { + const host = (options.host?.trim() || SMM_TVDB_DEFAULT_UPSTREAM).replace(/\/+$/, ""); + this.client = new TVDBv4({ + baseUrl: host, + apiKey: options.apiKey ?? "", + disableAuth: true, + fetchImpl: (input, init) => network.fetch(input, init), + }); + } + + async searchSeries(query: string, language: string): Promise { + const resp = await this.client.search({ query, type: "series", language }); + return resp.status === "success" ? resp.data : undefined; + } + + async searchMovie(query: string, language: string): Promise { + const resp = await this.client.search({ query, type: "movie", language }); + return resp.status === "success" ? resp.data : undefined; + } + + async getTvShowMediaMetadata(seriesId: number, language: string): Promise { + const langCode = mapToTvdbLangCode(language as PreferMediaLanguage); + const m: TvShowMediaMetadata = { id: seriesId.toString(), name: "", database: "TVDB", seasons: [] }; + + const translation = await this.client.seriesTranslationByLangCode(seriesId, langCode); + if (translation.status === "success") m.name = translation.data?.name ?? ""; + + const seriesResp = await this.client.seriesExtendedById(seriesId); + if (seriesResp.status !== "success") return undefined; + const series = seriesResp.data as TVDBv4SeriesExtendedResponse; + m.airDate = series.firstAired; + + const seasons = series.seasons.filter((s) => s.type.name === "Aired Order"); + for (const season of seasons) { + const seasonResp = await this.client.seasonExtendedById(season.id); + const episodes = + seasonResp.status === "success" + ? (seasonResp.data as TVDBv4SeriesSeasonsExtendedResponse).episodes + : []; + m.seasons.push({ + season: season.number, + name: "", + episodes: episodes.map((ep) => ({ season: ep.seasonNumber, episode: ep.number, name: ep.name ?? "" })), + }); + } + return m; + } + + async getMovieMediaMetadata(movieId: number, language: string): Promise { + const langCode = mapToTvdbLangCode(language as PreferMediaLanguage); + const m: MovieMediaMetadata = { id: movieId.toString(), name: "", database: "TVDB" }; + + const translation = await this.client.movieTranslationByLangCode(movieId, langCode); + if (translation.status === "success") m.name = translation.data?.name ?? ""; + + const movieResp = await this.client.movieExtendedById(movieId); + if (movieResp.status !== "success") return undefined; + const data = movieResp.data as Record; + if (m.name === "") m.name = typeof data.name === "string" ? data.name : ""; + const firstRelease = data.first_release as Record | undefined; + if (firstRelease !== undefined && typeof firstRelease.first === "string") { + m.airDate = firstRelease.first; + } + return m; + } +} + +export type { TVDBv4Season }; From 22d6adbb821ac339563cdbdb0920e30690a49930 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:31:16 +0800 Subject: [PATCH 020/214] feat(core-app): port episode filename pattern matching to core --- .../src/pipeline/recognizeEpisodes.test.ts | 99 +++++++++++++++ apps/core/src/pipeline/recognizeEpisodes.ts | 115 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 apps/core/src/pipeline/recognizeEpisodes.test.ts create mode 100644 apps/core/src/pipeline/recognizeEpisodes.ts diff --git a/apps/core/src/pipeline/recognizeEpisodes.test.ts b/apps/core/src/pipeline/recognizeEpisodes.test.ts new file mode 100644 index 00000000..46387e88 --- /dev/null +++ b/apps/core/src/pipeline/recognizeEpisodes.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { MediaMetadata } from "@smm/core"; +import { + excludeFiles, + isVideoFile, + pattern1, + pattern2, + pattern3, + pattern4, + recognizeEpisodes, +} from "./recognizeEpisodes"; + +const S01E01 = "S01E01.mkv"; +const CN_RAW = "第1季第5集.mkv"; +const CN_PADDED = "第01季第05集.mkv"; +const DIVIDER = "Show - 1.mkv"; + +const episodes = [ + { season: 1, episode: 1 }, + { season: 1, episode: 5 }, +]; + +describe("isVideoFile", () => { + it("matches known video extensions", () => { + expect(isVideoFile("/m/a.mkv")).toBe(true); + expect(isVideoFile("/m/a.srt")).toBe(false); + }); +}); + +describe("pattern1", () => { + it("matches SXXEYY", () => { + expect(pattern1(episodes, ["/m/" + S01E01, "/m/other.mkv"])).toEqual([ + { season: 1, episode: 1, file: "/m/" + S01E01 }, + ]); + }); +}); + +describe("pattern2", () => { + it("matches 第X季第Y集", () => { + expect(pattern2(episodes, ["/m/" + CN_RAW])).toEqual([{ season: 1, episode: 5, file: "/m/" + CN_RAW }]); + }); +}); + +describe("pattern3", () => { + it("matches 第XX季第YY集", () => { + expect(pattern3(episodes, ["/m/" + CN_PADDED])).toEqual([{ season: 1, episode: 5, file: "/m/" + CN_PADDED }]); + }); +}); + +describe("pattern4", () => { + it("matches N.ext for a single-season list", () => { + expect(pattern4(episodes, ["/m/" + DIVIDER])).toEqual([{ season: 1, episode: 1, file: "/m/" + DIVIDER }]); + }); + + it("refuses to disambiguate multi-season lists", () => { + expect(pattern4([{ season: 1, episode: 1 }, { season: 2, episode: 1 }], ["/m/" + DIVIDER])).toEqual([]); + }); +}); + +describe("excludeFiles", () => { + it("drops Extras and Subtitles", () => { + expect( + excludeFiles(["/m/S01E01.mkv", "/m/Extras/interview.mkv", "/m/Subtitles/en.srt"]), + ).toEqual(["/m/S01E01.mkv"]); + }); +}); + +describe("recognizeEpisodes", () => { + it("matches video files to tvShow seasons via SXXEYY", () => { + const mm: MediaMetadata = { + mediaFolderPath: "/m/My.Show", + files: ["/m/My.Show/S01E01.mkv", "/m/My.Show/S01E02.mkv", "/m/My.Show/poster.jpg"], + tvShow: { + database: "TMDB", + id: "1", + name: "My Show", + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [ + { season: 1, episode: 1, name: "E1" }, + { season: 1, episode: 2, name: "E2" }, + ], + }, + ], + }, + }; + expect(recognizeEpisodes(mm)).toEqual([ + { season: 1, episode: 1, file: "/m/My.Show/S01E01.mkv" }, + { season: 1, episode: 2, file: "/m/My.Show/S01E02.mkv" }, + ]); + }); + + it("returns [] when there is no tvShow", () => { + const mm: MediaMetadata = { mediaFolderPath: "/m", files: ["/m/S01E01.mkv"] }; + expect(recognizeEpisodes(mm)).toEqual([]); + }); +}); diff --git a/apps/core/src/pipeline/recognizeEpisodes.ts b/apps/core/src/pipeline/recognizeEpisodes.ts new file mode 100644 index 00000000..7b753da1 --- /dev/null +++ b/apps/core/src/pipeline/recognizeEpisodes.ts @@ -0,0 +1,115 @@ +import { videoFileExtensions } from "@core/utils"; +import type { MediaMetadata } from "@smm/core"; +import { basename, extname } from "./paths"; + +export interface RecognizedEpisode { + season: number; + episode: number; + file: string; +} + +export function isVideoFile(file: string): boolean { + return videoFileExtensions.includes(extname(file).toLowerCase()); +} + +const EXCLUDED_FOLDERS = ["/Extras/", "/EXTRAS/", "/Subtitles/"]; + +export function excludeFiles(files: string[]): string[] { + return files.filter((file) => !EXCLUDED_FOLDERS.some((folder) => file.includes(folder))); +} + +export function pattern1( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const pattern = `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + const target = videoFiles.find((file) => file.includes(pattern)); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function pattern2( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const pattern = `第${season}季第${episode}集`; + const target = videoFiles.find((file) => file.includes(pattern)); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function pattern3( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const pattern = `第${String(season).padStart(2, "0")}季第${String(episode).padStart(2, "0")}集`; + const target = videoFiles.find((file) => file.includes(pattern)); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function pattern4( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const numberOfSeasons = [...new Set(episodes.map((i) => i.season))]; + if (numberOfSeasons.length !== 1) return []; + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const regex = new RegExp(`[\\s.\\-_]+${episode}\\.\\w+$`, "i"); + const target = videoFiles.find((file) => regex.test(basename(file))); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function buildEpisodes(mm: MediaMetadata): { season: number; episode: number }[] { + const ret: { season: number; episode: number }[] = []; + for (const season of mm.tvShow?.seasons ?? []) { + for (const episode of season.episodes ?? []) { + ret.push({ season: episode.season, episode: episode.episode }); + } + } + return ret; +} + +export function preciselyRecognizeEpisodes( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + let ret = pattern1(episodes, videoFiles); + if (ret.length > 0) return ret; + ret = pattern2(episodes, videoFiles); + if (ret.length > 0) return ret; + ret = pattern3(episodes, videoFiles); + if (ret.length > 0) return ret; + return pattern4(episodes, videoFiles); +} + +export function recognizeEpisodes(mm: MediaMetadata): RecognizedEpisode[] { + if ( + mm.files === undefined || + mm.files === null || + mm.files.length === 0 || + mm.tvShow === undefined || + mm.tvShow.seasons === undefined || + mm.tvShow.seasons.length === 0 + ) { + return []; + } + let videoFiles = mm.files.filter(isVideoFile); + videoFiles = excludeFiles(videoFiles); + if (videoFiles.length === 0) return []; + const episodes = buildEpisodes(mm); + if (episodes.length === 0) return []; + return preciselyRecognizeEpisodes(episodes, videoFiles); +} From 1833af1cbb24c4f38fc2076f6c9027343986f4a4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:33:50 +0800 Subject: [PATCH 021/214] test(core-app): cover recognizeEpisodes fallthrough ordering Co-Authored-By: Claude Opus 4.7 --- apps/core/src/pipeline/recognizeEpisodes.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/core/src/pipeline/recognizeEpisodes.test.ts b/apps/core/src/pipeline/recognizeEpisodes.test.ts index 46387e88..490d33f9 100644 --- a/apps/core/src/pipeline/recognizeEpisodes.test.ts +++ b/apps/core/src/pipeline/recognizeEpisodes.test.ts @@ -7,6 +7,7 @@ import { pattern2, pattern3, pattern4, + preciselyRecognizeEpisodes, recognizeEpisodes, } from "./recognizeEpisodes"; @@ -97,3 +98,16 @@ describe("recognizeEpisodes", () => { expect(recognizeEpisodes(mm)).toEqual([]); }); }); + +describe("preciselyRecognizeEpisodes", () => { + it("falls through to pattern2 when pattern1 misses", () => { + const target = "/m/第1季第5集.mkv"; + expect(preciselyRecognizeEpisodes(episodes, [target])).toEqual([ + { season: 1, episode: 5, file: target }, + ]); + }); + + it("returns [] when every pattern misses", () => { + expect(preciselyRecognizeEpisodes(episodes, ["/m/unrelated.mkv"])).toEqual([]); + }); +}); From 51b2cc75b0f55a5e15b2b317892d4d71739ca764 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:37:21 +0800 Subject: [PATCH 022/214] =?UTF-8?q?feat(core-app):=20add=20media=20folder?= =?UTF-8?q?=20recognition=20orchestrator=20(NFO=20=E2=86=92=20id=20?= =?UTF-8?q?=E2=86=92=20search)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../src/pipeline/recognizeMediaFolder.test.ts | 134 ++++++++++ .../core/src/pipeline/recognizeMediaFolder.ts | 235 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 apps/core/src/pipeline/recognizeMediaFolder.test.ts create mode 100644 apps/core/src/pipeline/recognizeMediaFolder.ts diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts new file mode 100644 index 00000000..0e88cf3e --- /dev/null +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata, TvShowMediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import type { TvdbClient } from "../clients/TvdbClient"; +import type { TmdbClient } from "../clients/TmdbClient"; +import { + getTmdbIdFromFolderName, + getTvdbIdFromFolderName, + recognizeMediaFolder, + type RecognitionDeps, +} from "./recognizeMediaFolder"; + +const tvShow: TvShowMediaMetadata = { + database: "TMDB", + id: "1", + name: "My Show", + seasons: [{ season: 1, name: "S1", episodes: [] }], +}; + +function fsPort(files: Record): FsPort { + return { + readTextFile: vi.fn(async (path: string) => { + const content = files[path]; + if (content === undefined) throw new Error("ENOENT: " + path); + return content; + }), + writeTextFile: vi.fn(async () => {}), + exists: vi.fn(async () => true), + listFiles: vi.fn(async () => []), + }; +} + +function deps(overrides: Partial = {}): RecognitionDeps { + return { + fs: fsPort({}), + tmdb: { + search: vi.fn(), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + } as unknown as TmdbClient, + tvdb: { + searchSeries: vi.fn(), + searchMovie: vi.fn(), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + } as unknown as TvdbClient, + language: "en-US", + primaryDatabase: "TMDB", + ...overrides, + }; +} + +describe("getTmdbIdFromFolderName / getTvdbIdFromFolderName", () => { + it("parses (tmdbid=123) and (tvdbid=456)", () => { + expect(getTmdbIdFromFolderName("Show (tmdbid=123)")).toBe("123"); + expect(getTvdbIdFromFolderName("Show [tvdbid=456]")).toBe("456"); + expect(getTmdbIdFromFolderName("Plain Name")).toBeNull(); + }); +}); + +describe("recognizeMediaFolder", () => { + it("recognizes via tmdbid in folder name (tvshow)", async () => { + const d = deps(); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show (tmdbid=1)", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow).toEqual(tvShow); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(1, "en-US"); + }); + + it("recognizes via tvshow.nfo (tvshow)", async () => { + const d = deps({ fs: fsPort({ "/m/My.Show/tvshow.nfo": "7" }) }); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show", type: "tvshow-folder", files: ["/m/My.Show/tvshow.nfo", "/m/My.Show/S01E01.mkv"] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(7, "en-US"); + }); + + it("searches TMDB by folder name when no id present", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ results: [{ id: 9, name: "My Show" }] }); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tmdb.search).toHaveBeenCalledWith("My Show", "tv", "en-US"); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(9, "en-US"); + }); + + it("searches TVDB before TMDB when primaryDatabase is TVDB", async () => { + const d = deps({ primaryDatabase: "TVDB" }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue([ + { objectID: "series-5", name: "My Show", tvdb_id: "5" }, + ]); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(5, "en-US"); + expect(d.tmdb.search).not.toHaveBeenCalled(); + }); + + it("recognizes a movie by exact TMDB title match", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ + results: [{ id: 2, title: "My Film" }], + }); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Film", type: "movie-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie).toEqual({ id: "2", name: "My Film", database: "TMDB" }); + }); + + it("returns an empty result when nothing matches", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ results: [] }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue(undefined); + + const mm: MediaMetadata = { mediaFolderPath: "/m/Unknown", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result).toEqual({}); + }); +}); diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts new file mode 100644 index 00000000..e4426cbf --- /dev/null +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -0,0 +1,235 @@ +import type { + MediaMetadata, + MovieMediaMetadata, + PrimaryDatabase, + TMDBMovie, + TMDBTVShow, + TvShowMediaMetadata, +} from "@smm/core"; +import type { TVDBv4SearchResult } from "@smm/tvdb4"; +import type { TmdbClient } from "../clients/TmdbClient"; +import { movieMediaMetadataFromTmdbSearch } from "../clients/TmdbClient"; +import type { TvdbClient } from "../clients/TvdbClient"; +import type { FsPort } from "../ports/FsPort"; +import { parseNfo } from "./nfo"; +import { basename } from "./paths"; + +export interface RecognitionDeps { + fs: FsPort; + tmdb: TmdbClient; + tvdb: TvdbClient; + language: string; + primaryDatabase?: PrimaryDatabase; +} + +export interface RecognitionResult { + tvShow?: TvShowMediaMetadata; + movie?: MovieMediaMetadata; +} + +const TMDB_ID_IN_FOLDER_RE = /(?:[[({])\s*tmdbid\s*=\s*(\d+)\s*[\])}]/i; +const TVDB_ID_IN_FOLDER_RE = /(?:[[({])\s*tvdbid\s*=\s*(\d+)\s*[\])}]/i; + +export function getTmdbIdFromFolderName(folderName: string): string | null { + const match = folderName.match(TMDB_ID_IN_FOLDER_RE); + return match === null ? null : match[1]!; +} + +export function getTvdbIdFromFolderName(folderName: string): string | null { + const match = folderName.match(TVDB_ID_IN_FOLDER_RE); + return match === null ? null : match[1]!; +} + +export function resolveTvdbSeriesId(item: TVDBv4SearchResult): number | undefined { + const oid = item.objectID ?? item.id; + if (oid.startsWith("series-")) { + const n = parseInt(oid.slice("series-".length), 10); + if (Number.isFinite(n) && n > 0) return n; + } + const raw = item.tvdb_id; + if (raw !== undefined) { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n > 0) return n; + } + return undefined; +} + +export function resolveTvdbMovieId(item: TVDBv4SearchResult): number | undefined { + const oid = item.objectID ?? item.id; + if (/^movie-/i.test(oid)) { + const n = parseInt(oid.replace(/^movie-/i, ""), 10); + if (Number.isFinite(n) && n > 0) return n; + } + const raw = item.tvdb_id; + if (raw !== undefined) { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n > 0) return n; + } + return undefined; +} + +function folderNameOf(mm: MediaMetadata): string { + return basename(mm.mediaFolderPath ?? ""); +} + +async function recognizeByNfo( + mm: MediaMetadata, + deps: RecognitionDeps, + result: RecognitionResult, + isTvShow: boolean, +): Promise { + const nfoName = isTvShow ? "tvshow.nfo" : "movie.nfo"; + const nfoPath = (mm.files ?? []).find((f) => f.endsWith(`/${nfoName}`)); + if (nfoPath === undefined) return; + + let xml: string; + try { + xml = await deps.fs.readTextFile(nfoPath); + } catch { + return; + } + const nfo = parseNfo(xml); + + if (nfo.tmdbid !== undefined) { + const n = parseInt(nfo.tmdbid, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + return; + } + + if (nfo.tvdbid !== undefined) { + const n = parseInt(nfo.tvdbid, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + } +} + +async function searchInTmdb( + folderName: string, + isTvShow: boolean, + deps: RecognitionDeps, + result: RecognitionResult, +): Promise { + try { + if (isTvShow) { + const body = await deps.tmdb.search(folderName, "tv", deps.language); + const first = body.results[0] as TMDBTVShow | undefined; + if (first !== undefined) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(first.id, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } + } else { + const body = await deps.tmdb.search(folderName, "movie", deps.language); + for (const item of body.results) { + const movie = item as TMDBMovie; + if (movie.title === folderName) { + result.movie = movieMediaMetadataFromTmdbSearch(movie); + return; + } + } + } + } catch { + // recognition is best-effort; fall through to the next phase + } +} + +async function searchInTvdb( + folderName: string, + isTvShow: boolean, + deps: RecognitionDeps, + result: RecognitionResult, +): Promise { + try { + if (isTvShow) { + const items = await deps.tvdb.searchSeries(folderName, deps.language); + for (const item of items ?? []) { + const id = resolveTvdbSeriesId(item); + if (id === undefined) continue; + const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, deps.language); + if (tvShow !== undefined) { + result.tvShow = tvShow; + return; + } + } + } else { + const items = await deps.tvdb.searchMovie(folderName, deps.language); + for (const item of items ?? []) { + if (item.name === folderName) { + const id = resolveTvdbMovieId(item); + if (id === undefined) continue; + const movie = await deps.tvdb.getMovieMediaMetadata(id, deps.language); + if (movie !== undefined) { + result.movie = movie; + return; + } + } + } + } + } catch { + // best-effort + } +} + +/** + * Mirrors `recognizeMediaFolder` in `apps/ui/src/lib/recognizeMediaFolder.ts`: + * NFO → tmdbid in folder name → tvdbid in folder name → search by folder name + * (ordered by primaryDatabase). Only reached for tvshow / movie folders. + */ +export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionDeps): Promise { + const result: RecognitionResult = {}; + const folderName = folderNameOf(mm); + const isTvShow = mm.type === "tvshow-folder"; + + await recognizeByNfo(mm, deps, result, isTvShow); + + const tmdbId = getTmdbIdFromFolderName(folderName); + if (tmdbId !== null && result.tvShow === undefined && result.movie === undefined) { + const n = parseInt(tmdbId, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + } + + const tvdbId = getTvdbIdFromFolderName(folderName); + if (tvdbId !== null && result.tvShow === undefined && result.movie === undefined) { + const n = parseInt(tvdbId, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + } + + const order: Array<"TMDB" | "TVDB"> = deps.primaryDatabase === "TVDB" ? ["TVDB", "TMDB"] : ["TMDB", "TVDB"]; + for (const db of order) { + if (result.tvShow !== undefined || result.movie !== undefined) break; + if (db === "TMDB") await searchInTmdb(folderName, isTvShow, deps, result); + else await searchInTvdb(folderName, isTvShow, deps, result); + } + + return result; +} From 6b56ce638668f9fd4a0e1af30d8be8d4093897d5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:41:11 +0800 Subject: [PATCH 023/214] fix(core-app): swallow id-fetch errors so recognition falls through phases Wrap NFO/tmdbid-name/tvdbid-name metadata fetches in try/catch so a network failure in one phase falls through to the next, matching the UI orchestrator which try/catches every phase. Co-Authored-By: Claude Opus 4.7 --- .../src/pipeline/recognizeMediaFolder.test.ts | 15 +++++ .../core/src/pipeline/recognizeMediaFolder.ts | 64 ++++++++++++------- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 0e88cf3e..1fd7a50b 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -131,4 +131,19 @@ describe("recognizeMediaFolder", () => { expect(result).toEqual({}); }); + + it("falls through to the search phases when the tmdbid fetch throws", async () => { + const d = deps(); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockImplementation(async (id: number) => { + if (id === 1) throw new Error("network down"); + return tvShow; + }); + (d.tmdb.search as ReturnType).mockResolvedValue({ results: [{ id: 9, name: "My Show" }] }); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show (tmdbid=1)", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tmdb.search).toHaveBeenCalled(); + }); }); diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index e4426cbf..96bb54fa 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -93,12 +93,16 @@ async function recognizeByNfo( if (nfo.tmdbid !== undefined) { const n = parseInt(nfo.tmdbid, 10); if (n > 0) { - if (isTvShow) { - const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); - if (tvShow !== undefined) result.tvShow = tvShow; - } else { - const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); - if (movie !== undefined) result.movie = movie; + try { + if (isTvShow) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } catch { + // best-effort; fall through to later phases } } return; @@ -107,12 +111,16 @@ async function recognizeByNfo( if (nfo.tvdbid !== undefined) { const n = parseInt(nfo.tvdbid, 10); if (n > 0) { - if (isTvShow) { - const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); - if (tvShow !== undefined) result.tvShow = tvShow; - } else { - const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); - if (movie !== undefined) result.movie = movie; + try { + if (isTvShow) { + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } catch { + // best-effort; fall through to later phases } } } @@ -200,12 +208,16 @@ export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionD if (tmdbId !== null && result.tvShow === undefined && result.movie === undefined) { const n = parseInt(tmdbId, 10); if (n > 0) { - if (isTvShow) { - const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); - if (tvShow !== undefined) result.tvShow = tvShow; - } else { - const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); - if (movie !== undefined) result.movie = movie; + try { + if (isTvShow) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } catch { + // best-effort; fall through to the tvdbid phase } } } @@ -214,12 +226,16 @@ export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionD if (tvdbId !== null && result.tvShow === undefined && result.movie === undefined) { const n = parseInt(tvdbId, 10); if (n > 0) { - if (isTvShow) { - const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); - if (tvShow !== undefined) result.tvShow = tvShow; - } else { - const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); - if (movie !== undefined) result.movie = movie; + try { + if (isTvShow) { + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } catch { + // best-effort; fall through to the search phases } } } From 5f0ea6d69a8eddafacee2b753aae3dd292f3d00b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:44:28 +0800 Subject: [PATCH 024/214] =?UTF-8?q?feat(core-app):=20add=20importFolder=20?= =?UTF-8?q?pipeline=20orchestration=20(config=E2=86=92persist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../src/pipeline/importFolderPipeline.test.ts | 117 ++++++++++++++++++ .../core/src/pipeline/importFolderPipeline.ts | 101 +++++++++++++++ apps/core/src/pipeline/userConfig.ts | 23 ++++ 3 files changed, 241 insertions(+) create mode 100644 apps/core/src/pipeline/importFolderPipeline.test.ts create mode 100644 apps/core/src/pipeline/importFolderPipeline.ts create mode 100644 apps/core/src/pipeline/userConfig.ts diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts new file mode 100644 index 00000000..f280e85b --- /dev/null +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import type { NetworkPort } from "../ports/NetworkPort"; +import { NoopLoggerAdapter } from "../adapters/ConsoleLoggerAdapter"; +import { ImportFolderPipeline } from "./importFolderPipeline"; +import { userConfigPath, metadataCachePath } from "./paths"; +import { recognizeMediaFolder } from "./recognizeMediaFolder"; +const mockRecognizeMediaFolder = recognizeMediaFolder as ReturnType; + +vi.mock("./recognizeMediaFolder", () => ({ + recognizeMediaFolder: vi.fn(async () => { + return { tvShow: undefined, movie: undefined }; + }), +})); + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const out: string[] = []; + for (const key of files.keys()) { + if (key.startsWith(dir + "/") && !key.endsWith("/")) out.push(key); + } + return out; + }), + }; +} + +function emptyNetwork(): NetworkPort { + return { fetch: vi.fn() as never }; +} + +function makePipeline(options: { seed?: Record; appDataDir?: string } = {}) { + const appDataDir = options.appDataDir ?? "/data/smm"; + const fs = inMemoryFs(options.seed); + const network = emptyNetwork(); + const logger = new NoopLoggerAdapter(); + return { + fs, + network, + pipeline: new ImportFolderPipeline({ fs, network, logger, appDataDir }), + appDataDir, + }; +} + +describe("ImportFolderPipeline", () => { + it("adds the folder to userConfig, lists files, persists metadata", async () => { + const mediaDir = "/m/My.Show"; + const { fs, pipeline, appDataDir } = makePipeline({ + seed: { + "/m/My.Show/S01E01.mkv": "", + "/m/My.Show/poster.jpg": "", + }, + }); + + const mm = await pipeline.run(mediaDir, "music"); + + expect(mm.type).toBe("music-folder"); + expect(mm.mediaFolderPath).toBe(mediaDir); + expect(mm.files?.sort()).toEqual(["/m/My.Show/S01E01.mkv", "/m/My.Show/poster.jpg"]); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath(appDataDir))) as string); + expect(savedConfig.folders).toContain(mediaDir); + + const cached = JSON.parse((await fs.readTextFile(metadataCachePath(appDataDir, mediaDir))) as string); + expect(cached.type).toBe("music-folder"); + }); + + it("dedupes an already-present folder in userConfig", async () => { + const mediaDir = "/m/My.Show"; + const { fs, pipeline, appDataDir } = makePipeline({ + seed: { [userConfigPath("/data/smm")]: JSON.stringify({ folders: [mediaDir] }) }, + }); + + await pipeline.run(mediaDir, "music"); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath(appDataDir))) as string); + expect(savedConfig.folders).toEqual([mediaDir]); + }); + + it("recognizes a tvshow and matches episodes via SXXEYY", async () => { + const mediaDir = "/m/My.Show"; + const { pipeline } = makePipeline({ + seed: { + "/m/My.Show/S01E01.mkv": "", + "/m/My.Show/S01E02.mkv": "", + "/m/My.Show/tvshow.nfo": "1", + }, + }); + + mockRecognizeMediaFolder.mockResolvedValue({ + tvShow: { + database: "TMDB", + id: "1", + name: "My Show", + seasons: [{ season: 1, name: "Season 1", episodes: [{ season: 1, episode: 1, name: "E1" }, { season: 1, episode: 2, name: "E2" }] }], + }, + }); + + const mm = await pipeline.run(mediaDir, "tvshow"); + + expect(mm.tvShow?.database).toBe("TMDB"); + expect(mm.mediaFiles).toEqual([ + { absolutePath: "/m/My.Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: "/m/My.Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + ]); + }); +}); diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts new file mode 100644 index 00000000..8f1a2392 --- /dev/null +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -0,0 +1,101 @@ +import { Path } from "@core/path"; +import type { FolderType, MediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import type { NetworkPort } from "../ports/NetworkPort"; +import type { LoggerPort } from "../ports/LoggerPort"; +import type { JobStage } from "../jobs/types"; +import { TmdbClient } from "../clients/TmdbClient"; +import { TvdbClient } from "../clients/TvdbClient"; +import { isVideoFile, recognizeEpisodes } from "./recognizeEpisodes"; +import { recognizeMediaFolder } from "./recognizeMediaFolder"; +import { metadataCachePath } from "./paths"; +import { readUserConfig, writeUserConfig } from "./userConfig"; + +export interface ImportFolderPipelineOptions { + fs: FsPort; + network: NetworkPort; + logger: LoggerPort; + appDataDir: string; +} + +export interface ImportFolderPipelineCallbacks { + onStage?: (stage: JobStage, progress: number) => void; +} + +function mediaMetadataType(type: FolderType): MediaMetadata["type"] { + return type === "tvshow" ? "tvshow-folder" : type === "movie" ? "movie-folder" : "music-folder"; +} + +export class ImportFolderPipeline { + constructor(private readonly options: ImportFolderPipelineOptions) {} + + async run(folderPath: string, type: FolderType, cb: ImportFolderPipelineCallbacks = {}): Promise { + const { fs, logger, appDataDir, network } = this.options; + const posixPath = Path.posix(folderPath); + const stages: JobStage[] = []; + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=config"); + const userConfig = await readUserConfig(fs, appDataDir); + const folders = [...new Set([...userConfig.folders, folderPath])]; + await writeUserConfig(fs, appDataDir, { ...userConfig, folders }); + stages.push("config"); + cb.onStage?.("config", 10); + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=metadata"); + const mm: MediaMetadata = { + mediaFolderPath: posixPath, + type: mediaMetadataType(type), + files: [], + mediaFiles: [], + }; + stages.push("metadata"); + cb.onStage?.("metadata", 25); + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=listFiles"); + const listed = await fs.listFiles(posixPath); + mm.files = listed.map((f) => Path.posix(f)); + stages.push("listFiles"); + cb.onStage?.("listFiles", 40); + + if (type === "tvshow" || type === "movie") { + const language = userConfig.preferMediaLanguage ?? "en-US"; + const tmdb = new TmdbClient(network, userConfig.tmdb); + const tvdb = new TvdbClient(network, userConfig.tvdb); + + logger.info({ folderPath: posixPath, language }, "importFolder: stage=recognize"); + const result = await recognizeMediaFolder(mm, { + fs, + tmdb, + tvdb, + language, + primaryDatabase: userConfig.primaryDatabase, + }); + if (result.tvShow !== undefined) mm.tvShow = result.tvShow; + if (result.movie !== undefined) mm.movie = result.movie; + stages.push("recognize"); + cb.onStage?.("recognize", 60); + + logger.info({ folderPath: posixPath }, "importFolder: stage=episodes"); + if (type === "tvshow" && mm.tvShow !== undefined) { + mm.mediaFiles = recognizeEpisodes(mm).map((i) => ({ + absolutePath: i.file, + seasonNumber: i.season, + episodeNumber: i.episode, + })); + } else if (type === "movie" && mm.movie !== undefined) { + const firstVideo = (mm.files ?? []).find(isVideoFile); + mm.mediaFiles = firstVideo === undefined ? [] : [{ absolutePath: firstVideo }]; + } + stages.push("episodes"); + cb.onStage?.("episodes", 80); + } + + logger.info({ folderPath: posixPath }, "importFolder: stage=persist"); + const { files: _files, ...mmToPersist } = mm; + await fs.writeTextFile(metadataCachePath(appDataDir, posixPath), JSON.stringify(mmToPersist, null, 2)); + stages.push("persist"); + cb.onStage?.("persist", 95); + + return mm; + } +} diff --git a/apps/core/src/pipeline/userConfig.ts b/apps/core/src/pipeline/userConfig.ts new file mode 100644 index 00000000..4b78a4b7 --- /dev/null +++ b/apps/core/src/pipeline/userConfig.ts @@ -0,0 +1,23 @@ +import type { UserConfig } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { userConfigPath } from "./paths"; + +export const DEFAULT_USER_CONFIG: UserConfig = { + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", +}; + +export async function readUserConfig(fs: FsPort, appDataDir: string): Promise { + const path = userConfigPath(appDataDir); + if (!(await fs.exists(path))) return { ...DEFAULT_USER_CONFIG }; + const content = await fs.readTextFile(path); + return { ...DEFAULT_USER_CONFIG, ...(JSON.parse(content) as Partial) }; +} + +export async function writeUserConfig(fs: FsPort, appDataDir: string, config: UserConfig): Promise { + await fs.writeTextFile(userConfigPath(appDataDir), JSON.stringify(config, null, 2)); +} From 4a2a554398eabcbe2aa3dcc4c745758b9a556b22 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:48:57 +0800 Subject: [PATCH 025/214] feat(core-app): add Core class with importFolder API and job lifecycle Co-Authored-By: Claude Opus 4.7 --- apps/core/src/Core.test.ts | 114 +++++++++++++++++++++++++++++++++++++ apps/core/src/Core.ts | 75 ++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 apps/core/src/Core.test.ts create mode 100644 apps/core/src/Core.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts new file mode 100644 index 00000000..a374dcf8 --- /dev/null +++ b/apps/core/src/Core.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "./ports/FsPort"; +import type { NetworkPort } from "./ports/NetworkPort"; +import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +import { Core } from "./Core"; +import { userConfigPath } from "./pipeline/paths"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const out: string[] = []; + for (const key of files.keys()) { + if (key.startsWith(dir + "/") && !key.endsWith("/")) out.push(key); + } + return out; + }), + }; +} + +/** Network that satisfies the empty-seed recognition path (returns no results). */ +function emptyNetwork(): NetworkPort { + return { + fetch: vi.fn(async (url: string) => { + const body = + url.includes("/api/tmdb/") || url.includes("tmdb") + ? { results: [], page: 1, total_pages: 1, total_results: 0 } + : { status: "success", data: [] }; + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; + }) as never, + }; +} + +async function waitForStatus(core: Core, id: string, status: string): Promise { + const started = Date.now(); + for (;;) { + const job = core.getJob(id); + if (job?.status === status || job?.status === "failed" || job?.status === "aborted") return; + if (Date.now() - started > 5000) throw new Error(`timeout waiting for ${status}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("Core", () => { + it("importFolder runs the pipeline and succeeds", async () => { + const fs = inMemoryFs({ "/m/My.Music/a.mp3": "" }); + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importFolder("/m/My.Music", "music"); + expect(core.getJob(id)).toBeDefined(); + + await waitForStatus(core, id, "succeeded"); + + const job = core.getJob(id); + expect(job?.status).toBe("succeeded"); + expect(job?.progress).toBe(100); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); + expect(savedConfig.folders).toContain("/m/My.Music"); + }); + + it("marks the job failed when the pipeline throws", async () => { + const fs = inMemoryFs(); + const failingFs: FsPort = { + ...fs, + listFiles: vi.fn(async () => { + throw new Error("boom"); + }), + }; + const core = new Core({ + fs: failingFs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importFolder("/m/Broken", "tvshow"); + await waitForStatus(core, id, "failed"); + + const job = core.getJob(id); + expect(job?.status).toBe("failed"); + expect(job?.error).toContain("boom"); + }); + + it("getJob returns undefined for unknown id", () => { + const core = new Core({ + fs: inMemoryFs(), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + expect(core.getJob("nope")).toBeUndefined(); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts new file mode 100644 index 00000000..01a27c60 --- /dev/null +++ b/apps/core/src/Core.ts @@ -0,0 +1,75 @@ +import { Path } from "@core/path"; +import type { FolderType } from "@smm/core"; +import type { FsPort } from "./ports/FsPort"; +import type { NetworkPort } from "./ports/NetworkPort"; +import type { LoggerPort } from "./ports/LoggerPort"; +import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; +import { JobStore } from "./jobs/jobStore"; +import type { ImportJob } from "./jobs/types"; + +export interface CoreOptions { + fs: FsPort; + network: NetworkPort; + logger?: LoggerPort; + /** Root directory holding smm.json and metadata/. */ + appDataDir: string; +} + +export interface ImportFolderHandle { + id: string; +} + +export class Core { + private readonly jobs = new JobStore(); + private readonly fs: FsPort; + private readonly network: NetworkPort; + private readonly logger: LoggerPort; + private readonly appDataDir: string; + + constructor(options: CoreOptions) { + this.fs = options.fs; + this.network = options.network; + this.logger = options.logger ?? new NoopLoggerAdapter(); + this.appDataDir = options.appDataDir; + } + + /** Starts the import pipeline in the background; returns a job handle immediately. */ + importFolder(path: string, type: FolderType): ImportFolderHandle { + const job = this.jobs.create({ + folderPath: Path.posix(path), + type, + status: "running", + stage: "config", + progress: 0, + }); + void this.runImport(job, path, type); + return { id: job.id }; + } + + getJob(id: string): ImportJob | undefined { + return this.jobs.get(id); + } + + private async runImport(job: ImportJob, folderPath: string, type: FolderType): Promise { + try { + const pipeline = new ImportFolderPipeline({ + fs: this.fs, + network: this.network, + logger: this.logger, + appDataDir: this.appDataDir, + }); + await pipeline.run(folderPath, type, { + onStage: (stage, progress) => { + this.jobs.update(job.id, { stage, progress }); + }, + }); + this.jobs.update(job.id, { status: "succeeded", stage: null, progress: 100 }); + } catch (error) { + this.jobs.update(job.id, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } +} From 0f92c92a15fc6bbb9f82da8952da014c321af097 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 12:53:36 +0800 Subject: [PATCH 026/214] feat(core-app): export public API and register apps/core scripts at repo root Co-Authored-By: Claude Opus 4.7 --- apps/core/src/index.ts | 14 ++++++++++++++ package.json | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 apps/core/src/index.ts diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts new file mode 100644 index 00000000..d78857bf --- /dev/null +++ b/apps/core/src/index.ts @@ -0,0 +1,14 @@ +export { Core, type CoreOptions, type ImportFolderHandle } from "./Core"; +export type { FsPort } from "./ports/FsPort"; +export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; +export type { LoggerPort } from "./ports/LoggerPort"; +export { NodejsFsAdapter } from "./adapters/node/NodejsFsAdapter"; +export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/network/NetworkFsAdapter"; +export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; +export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +export { ImportFolderPipeline, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; +export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; +export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; +export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; +export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; +export type { FolderType } from "@smm/core"; diff --git a/package.json b/package.json index e6a9390e..3c05c2b4 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:core": "cd packages/core && pnpm test", "test:core-routes": "cd packages/core-routes && pnpm test", "test:electron-common": "cd packages/electron-common && pnpm test", + "test:core-app": "cd apps/core && pnpm test", "test:cli": "cd apps/cli && pnpm test", "test:ui": "cd apps/ui && pnpm test", "test:e2e": "cd apps/e2e && pnpm run test", @@ -48,7 +49,7 @@ "e2e:failover": "cross-env EXTERNAL_CONFIG_FILE_URL=\"http://localhost:8000/config.json\" bun ci/run-e2e-test.ts --spec ./common/tv/TmdbHostFailover.e2e.ts", "e2e:scrape-failover": "bun apps/cicd/run.ts -f apps/e2e/scenarios/scrape-failover-tmdb.test.json", "e2e:http-proxy": "bun apps/cicd/run.ts -f apps/e2e/scenarios/http-proxy-reverse-proxy.test.json", - "typecheck": "pnpm run typecheck:core && pnpm run typecheck:core-routes && pnpm run typecheck:electron-common && pnpm run typecheck:cli && pnpm run typecheck:ui && pnpm run typecheck:e2e && pnpm run typecheck:electron", + "typecheck": "pnpm run typecheck:core && pnpm run typecheck:core-routes && pnpm run typecheck:electron-common && pnpm run typecheck:cli && pnpm run typecheck:ui && pnpm run typecheck:e2e && pnpm run typecheck:electron && pnpm run typecheck:core-app", "typecheck:core": "cd packages/core && pnpm run typecheck", "typecheck:core-routes": "cd packages/core-routes && pnpm run typecheck", "typecheck:electron-common": "cd packages/electron-common && pnpm run typecheck", @@ -56,6 +57,7 @@ "typecheck:ui": "cd apps/ui && pnpm run typecheck", "typecheck:e2e": "cd apps/e2e && pnpm run typecheck", "typecheck:electron": "cd apps/electron && pnpm run typecheck", + "typecheck:core-app": "cd apps/core && pnpm run typecheck", "typecheck:ohos-main": "pnpm --filter @smm/ohos-electron-main typecheck", "ci": "pnpm run build && pnpm run test && pnpm run typecheck", "ci:cli": "cd apps/cli && pnpm run test && pnpm run typecheck", From 1f6cb3d7ec0ca75d726914728620d1e1fedefd3e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 13:08:32 +0800 Subject: [PATCH 027/214] fix(core-app): apply final review findings (TVDB search lang, per-item catch, structural deps, invalid-path jobs, integration test) Co-Authored-By: Claude Opus 4.7 --- apps/core/src/Core.test.ts | 9 +++ apps/core/src/Core.ts | 8 +- apps/core/src/clients/TvdbClient.test.ts | 14 +++- apps/core/src/clients/TvdbClient.ts | 12 ++- apps/core/src/hello.test.ts | 7 -- apps/core/src/index.ts | 2 +- .../importFolderPipeline.integration.test.ts | 73 +++++++++++++++++++ .../src/pipeline/recognizeMediaFolder.test.ts | 26 ++++++- .../core/src/pipeline/recognizeMediaFolder.ts | 58 ++++++++++----- 9 files changed, 175 insertions(+), 34 deletions(-) delete mode 100644 apps/core/src/hello.test.ts create mode 100644 apps/core/src/pipeline/importFolderPipeline.integration.test.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index a374dcf8..2c2a0884 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -103,6 +103,15 @@ describe("Core", () => { expect(job?.error).toContain("boom"); }); + it("invalid path produces a failed job, not a synchronous throw", async () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); + const { id } = core.importFolder("relative/path", "music"); + expect(id).toBeDefined(); + await waitForStatus(core, id, "failed"); + const job = core.getJob(id); + expect(job?.status).toBe("failed"); + }); + it("getJob returns undefined for unknown id", () => { const core = new Core({ fs: inMemoryFs(), diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 01a27c60..0b252c66 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -36,8 +36,14 @@ export class Core { /** Starts the import pipeline in the background; returns a job handle immediately. */ importFolder(path: string, type: FolderType): ImportFolderHandle { + let folderPath: string; + try { + folderPath = Path.posix(path); + } catch { + folderPath = path; + } const job = this.jobs.create({ - folderPath: Path.posix(path), + folderPath, type, status: "running", stage: "config", diff --git a/apps/core/src/clients/TvdbClient.test.ts b/apps/core/src/clients/TvdbClient.test.ts index 72f2b228..83142e73 100644 --- a/apps/core/src/clients/TvdbClient.test.ts +++ b/apps/core/src/clients/TvdbClient.test.ts @@ -17,10 +17,11 @@ function envelope(data: unknown) { return { status: "success", data }; } -function tvdbNetwork(): NetworkPort { +function tvdbNetwork(searchUrls: string[] = []): NetworkPort { return { fetch: async (url) => { if (url.includes("/search")) { + searchUrls.push(url); const type = url.includes("type=series") ? "series" : "movie"; return jsonResponse( envelope( @@ -75,6 +76,17 @@ describe("TvdbClient", () => { expect(items?.[0]?.tvdb_id).toBe("2"); }); + it("maps the preferred language to TVDB ISO 639-3 in search", async () => { + const searchUrls: string[] = []; + const client = new TvdbClient(tvdbNetwork(searchUrls), {}); + await client.searchSeries("My Show", "en-US"); + await client.searchMovie("My Film", "ja-JP"); + await client.searchSeries("My Show", "zh-CN"); + expect(searchUrls[0]).toContain("language=eng"); + expect(searchUrls[1]).toContain("language=jpn"); + expect(searchUrls[2]).toContain("language=zho"); + }); + it("getTvShowMediaMetadata builds seasons + episodes", async () => { const client = new TvdbClient(tvdbNetwork(), {}); const tvShow = await client.getTvShowMediaMetadata(1, "en-US"); diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts index cc0ef026..b5993c24 100644 --- a/apps/core/src/clients/TvdbClient.ts +++ b/apps/core/src/clients/TvdbClient.ts @@ -42,12 +42,20 @@ export class TvdbClient { } async searchSeries(query: string, language: string): Promise { - const resp = await this.client.search({ query, type: "series", language }); + const resp = await this.client.search({ + query, + type: "series", + language: mapToTvdbLangCode(language as PreferMediaLanguage), + }); return resp.status === "success" ? resp.data : undefined; } async searchMovie(query: string, language: string): Promise { - const resp = await this.client.search({ query, type: "movie", language }); + const resp = await this.client.search({ + query, + type: "movie", + language: mapToTvdbLangCode(language as PreferMediaLanguage), + }); return resp.status === "success" ? resp.data : undefined; } diff --git a/apps/core/src/hello.test.ts b/apps/core/src/hello.test.ts deleted file mode 100644 index e1e776a6..00000000 --- a/apps/core/src/hello.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, expect, it } from "vitest"; - -describe("apps/core scaffold", () => { - it("runs vitest", () => { - expect(1 + 1).toBe(2); - }); -}); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index d78857bf..946dfd21 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -6,7 +6,7 @@ export { NodejsFsAdapter } from "./adapters/node/NodejsFsAdapter"; export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/network/NetworkFsAdapter"; export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; -export { ImportFolderPipeline, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; +export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts new file mode 100644 index 00000000..3257bed8 --- /dev/null +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { NoopLoggerAdapter } from "../adapters/ConsoleLoggerAdapter"; +import { ImportFolderPipeline } from "./importFolderPipeline"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const out: string[] = []; + for (const key of files.keys()) { + if (key.startsWith(dir + "/") && !key.endsWith("/")) out.push(key); + } + return out; + }), + }; +} + +describe("ImportFolderPipeline integration", () => { + it("runs the real recognizeMediaFolder: preferMediaLanguage + primaryDatabase flow into recognition, and the movie branch sets mediaFiles", async () => { + const fs = inMemoryFs({ + "/data/smm/smm.json": JSON.stringify({ preferMediaLanguage: "en-US", primaryDatabase: "TMDB" }), + "/m/My Film/my.video.mkv": "", + "/m/My Film/cover.jpg": "", + }); + const network: NetworkPort = { + fetch: async (url: string) => { + if (url.includes("/search/movie")) { + if (!url.includes("language=en-US")) throw new Error("wrong language: " + url); + return jsonResponse({ + results: [{ id: 2, title: "My Film" }], + page: 1, + total_pages: 1, + total_results: 1, + }); + } + throw new Error("unexpected url: " + url); + }, + }; + const pipeline = new ImportFolderPipeline({ + fs, + network, + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const result = await pipeline.run("/m/My Film", "movie"); + + expect(result.movie).toMatchObject({ id: "2", name: "My Film", database: "TMDB" }); + expect(result.mediaFiles).toEqual([{ absolutePath: "/m/My Film/my.video.mkv" }]); + }); +}); diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 1fd7a50b..8246a2eb 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import type { MediaMetadata, TvShowMediaMetadata } from "@smm/core"; import type { FsPort } from "../ports/FsPort"; -import type { TvdbClient } from "../clients/TvdbClient"; -import type { TmdbClient } from "../clients/TmdbClient"; import { getTmdbIdFromFolderName, getTvdbIdFromFolderName, recognizeMediaFolder, type RecognitionDeps, + type TmdbRecognitionClient, + type TvdbRecognitionClient, } from "./recognizeMediaFolder"; const tvShow: TvShowMediaMetadata = { @@ -37,13 +37,13 @@ function deps(overrides: Partial = {}): RecognitionDeps { search: vi.fn(), getTvShowMediaMetadata: vi.fn(), getMovieMediaMetadata: vi.fn(), - } as unknown as TmdbClient, + } as unknown as TmdbRecognitionClient, tvdb: { searchSeries: vi.fn(), searchMovie: vi.fn(), getTvShowMediaMetadata: vi.fn(), getMovieMediaMetadata: vi.fn(), - } as unknown as TvdbClient, + } as unknown as TvdbRecognitionClient, language: "en-US", primaryDatabase: "TMDB", ...overrides, @@ -121,6 +121,24 @@ describe("recognizeMediaFolder", () => { expect(result.movie).toEqual({ id: "2", name: "My Film", database: "TMDB" }); }); + it("continues the TVDB search loop when one result's metadata throws", async () => { + const d = deps({ primaryDatabase: "TVDB" }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue([ + { objectID: "series-1", name: "My Show", tvdb_id: "1" }, + { objectID: "series-2", name: "My Show", tvdb_id: "2" }, + ]); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockImplementation(async (id: number) => { + if (id === 1) throw new Error("no translation in preferred language"); + return tvShow; + }); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledTimes(2); + }); + it("returns an empty result when nothing matches", async () => { const d = deps(); (d.tmdb.search as ReturnType).mockResolvedValue({ results: [] }); diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index 96bb54fa..3085b59e 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -4,20 +4,34 @@ import type { PrimaryDatabase, TMDBMovie, TMDBTVShow, + TmdbSearchResponseBody, TvShowMediaMetadata, } from "@smm/core"; import type { TVDBv4SearchResult } from "@smm/tvdb4"; -import type { TmdbClient } from "../clients/TmdbClient"; import { movieMediaMetadataFromTmdbSearch } from "../clients/TmdbClient"; -import type { TvdbClient } from "../clients/TvdbClient"; import type { FsPort } from "../ports/FsPort"; import { parseNfo } from "./nfo"; import { basename } from "./paths"; +/** Structural subset of TmdbClient used by recognition, so consumers can supply their own. */ +export interface TmdbRecognitionClient { + search(keyword: string, type: "movie" | "tv", language: string): Promise; + getTvShowMediaMetadata(id: number, language: string): Promise; + getMovieMediaMetadata(id: number, language: string): Promise; +} + +/** Structural subset of TvdbClient used by recognition. */ +export interface TvdbRecognitionClient { + searchSeries(query: string, language: string): Promise; + searchMovie(query: string, language: string): Promise; + getTvShowMediaMetadata(id: number, language: string): Promise; + getMovieMediaMetadata(id: number, language: string): Promise; +} + export interface RecognitionDeps { fs: FsPort; - tmdb: TmdbClient; - tvdb: TvdbClient; + tmdb: TmdbRecognitionClient; + tvdb: TvdbRecognitionClient; language: string; primaryDatabase?: PrimaryDatabase; } @@ -165,30 +179,38 @@ async function searchInTvdb( if (isTvShow) { const items = await deps.tvdb.searchSeries(folderName, deps.language); for (const item of items ?? []) { - const id = resolveTvdbSeriesId(item); - if (id === undefined) continue; - const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, deps.language); - if (tvShow !== undefined) { - result.tvShow = tvShow; - return; + try { + const id = resolveTvdbSeriesId(item); + if (id === undefined) continue; + const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, deps.language); + if (tvShow !== undefined) { + result.tvShow = tvShow; + return; + } + } catch { + // best-effort per search result } } } else { const items = await deps.tvdb.searchMovie(folderName, deps.language); for (const item of items ?? []) { - if (item.name === folderName) { - const id = resolveTvdbMovieId(item); - if (id === undefined) continue; - const movie = await deps.tvdb.getMovieMediaMetadata(id, deps.language); - if (movie !== undefined) { - result.movie = movie; - return; + try { + if (item.name === folderName) { + const id = resolveTvdbMovieId(item); + if (id === undefined) continue; + const movie = await deps.tvdb.getMovieMediaMetadata(id, deps.language); + if (movie !== undefined) { + result.movie = movie; + return; + } } + } catch { + // best-effort per search result } } } } catch { - // best-effort + // recognition is best-effort } } From 202bb08fe2d3b9a61b9ef2adab37ef455118edb5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 13:12:47 +0800 Subject: [PATCH 028/214] docs: mark apps/core importFolder implementation plan as implemented All 14 tasks landed incrementally (e345b003..1f6cb3d7) plus a final-review fix commit; add a Status banner so readers know the plan is done. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-08-17-core-importfolder.md | 2858 +++++++++++++++++ 1 file changed, 2858 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-core-importfolder.md diff --git a/docs/superpowers/plans/2026-08-17-core-importfolder.md b/docs/superpowers/plans/2026-08-17-core-importfolder.md new file mode 100644 index 00000000..a460f403 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-core-importfolder.md @@ -0,0 +1,2858 @@ +# apps/core importFolder Implementation Plan + +> **Status:** Implemented (2026-08-17). All 14 tasks were followed via subagent-driven-development and committed incrementally, plus one final-review fix commit. Commits: `e345b003` (Task 1 scaffold) through `1f6cb3d7` (final review fixes). Do not re-run. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create the `apps/core` app — Layer 2 of the 3-layer refactor — exposing `core.importFolder(path, type)` that runs the full media-folder initialization pipeline (config → metadata → listFiles → recognize → episodes → persist) through injected Ports (FsPort / NetworkPort / LoggerPort), with a queryable in-memory Job model. + +**Architecture:** Pure TypeScript, runtime-agnostic, no platform API imports. Core logic depends only on Port interfaces. Node/browser adapters live under `adapters/` and are only imported by hosts. TMDB/TVDB clients are core-internal modules built on `NetworkPort` (thin fetch). `@smm/core` (types/path/utils) and `@smm/tvdb4` (TVDBv4 client with injectable `fetchImpl`) are reused via source aliases. Behavior mirrors the existing UI pipeline in `apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts` and `apps/ui/src/lib/*`. + +**Tech Stack:** TypeScript 5 (strict), vitest 4 (node env), pnpm workspaces. Cross-package resolution via tsconfig `paths` + vitest `resolve.alias` (same pattern as `apps/cli`). + +**Spec:** [docs/superpowers/specs/2026-08-17-core-importfolder-design.md](../../superpowers/specs/2026-08-17-core-importfolder-design.md) + +**Naming decision:** package name is `core-app` (directory `apps/core`) because `pnpm --filter core` already targets `packages/core` in the root `build:electron` script. + +**Path convention (applies to every task):** +- Core logic and Ports operate on **POSIX paths** only. +- `NodejsFsAdapter` converts POSIX → platform via `Path.toPlatformPath()` at the boundary. +- `NetworkFsAdapter` forwards POSIX paths to the internal HTTP fs endpoints (which accept both). +- `importFolder(path, type)` receives a platform path; the pipeline converts once via `Path.posix(path)`. +- `userConfig.folders` persists the **platform** input path (matches the documented `UserConfig.folders` contract); everything else in Core is POSIX. + +--- + +## File Structure + +``` +apps/core/ + package.json // name: core-app, type: module, vitest/tsc scripts + tsconfig.json // @core/*, @smm/core, @smm/tvdb4 paths (mirrors apps/cli) + vitest.config.ts // resolve.alias → packages source files + src/ + index.ts // public exports (Core, Ports, adapters, pipeline, job types) + Core.ts // Core class: JobStore + importFolder orchestration + ports/ + FsPort.ts // readTextFile / writeTextFile / exists / listFiles + NetworkPort.ts // thin fetch(input, init) → Promise + LoggerPort.ts // info / warn / error + adapters/ + FetchNetworkAdapter.ts // wraps globalThis.fetch (or injected fetch) + ConsoleLoggerAdapter.ts // ConsoleLoggerAdapter + NoopLoggerAdapter + node/NodejsFsAdapter.ts // node:fs/promises, Node hosts only + network/NetworkFsAdapter.ts // browser: maps to internal HTTP fs endpoints + clients/ + TmdbClient.ts // search / getTvShowById / getTvSeasonById / getMovieById + builders + TvdbClient.ts // wraps @smm/tvdb4; searchSeries / searchMovie / getTvShowMediaMetadata / getMovieMediaMetadata + pipeline/ + paths.ts // joinPosix / basename / extname / userConfigPath / metadataCachePath + userConfig.ts // DEFAULT_USER_CONFIG + read/write + nfo.ts // runtime-agnostic regex NFO parser (title/tmdbid/tvdbid) + recognizeEpisodes.ts // pattern1-4 episode matching (ported from apps/ui) + recognizeMediaFolder.ts // NFO → tmdbid= → tvdbid= → search orchestrator + importFolderPipeline.ts // stage orchestration: config→metadata→listFiles→recognize→episodes→persist + jobs/ + types.ts // ImportJob / JobStatus / JobStage + jobStore.ts // JobStore + nextJobId +``` + +Each source file has a co-located `*.test.ts` (vitest `include: ["src/**/*.test.ts"]`). + +--- + +## Task 1: Scaffold apps/core + +**Files:** +- Create: `apps/core/package.json` +- Create: `apps/core/tsconfig.json` +- Create: `apps/core/vitest.config.ts` +- Create: `apps/core/src/hello.test.ts` + +- [ ] **Step 1: Create `apps/core/package.json`** + +```json +{ + "name": "core-app", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest --watch" + }, + "dependencies": { + "@smm/tvdb4": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} +``` + +- [ ] **Step 2: Create `apps/core/tsconfig.json`** + +```json +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@core/*": ["../../packages/core/*"], + "@smm/core": ["../../packages/core/types.ts"], + "@smm/tvdb4": ["../../packages/tvdb4/src/index.ts"], + "@smm/tvdb4/*": ["../../packages/tvdb4/src/*"] + } + } +} +``` + +- [ ] **Step 3: Create `apps/core/vitest.config.ts`** + +```ts +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "@core": resolve(__dirname, "../../packages/core"), + "@smm/core": resolve(__dirname, "../../packages/core/types.ts"), + "@smm/tvdb4/types": resolve(__dirname, "../../packages/tvdb4/src/types.ts"), + "@smm/tvdb4": resolve(__dirname, "../../packages/tvdb4/src/index.ts"), + }, + }, +}); +``` + +- [ ] **Step 4: Create `apps/core/src/hello.test.ts`** + +```ts +import { describe, expect, it } from "vitest"; + +describe("apps/core scaffold", () => { + it("runs vitest", () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 5: Install workspace link and verify the scaffold** + +Run: +```bash +cd C:/Users/lawrence/workspace/smm_github && pnpm install +cd apps/core && pnpm test +``` + +Expected: 1 test passes. `@smm/tvdb4` becomes linked as a workspace dependency. + +- [ ] **Step 6: Verify typecheck runs** + +Run: +```bash +cd apps/core && pnpm run typecheck +``` + +Expected: no output (exit 0). The empty `src/` typechecks cleanly. + +- [ ] **Step 7: Commit** + +```bash +git add apps/core +git commit -m "feat(core-app): scaffold apps/core (package.json, tsconfig, vitest)" +``` + +--- + +## Task 2: Ports + network adapter + +**Files:** +- Create: `apps/core/src/ports/FsPort.ts` +- Create: `apps/core/src/ports/NetworkPort.ts` +- Create: `apps/core/src/ports/LoggerPort.ts` +- Create: `apps/core/src/adapters/FetchNetworkAdapter.ts` +- Create: `apps/core/src/adapters/ConsoleLoggerAdapter.ts` +- Test: `apps/core/src/adapters/FetchNetworkAdapter.test.ts` +- Test: `apps/core/src/adapters/ConsoleLoggerAdapter.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`apps/core/src/adapters/FetchNetworkAdapter.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { FetchInit, HttpResponse } from "../ports/NetworkPort"; +import { FetchNetworkAdapter } from "./FetchNetworkAdapter"; + +function fakeResponse(overrides: Partial = {}): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve("body-text"), + json: () => Promise.resolve({} as T), + ...overrides, + }; +} + +describe("FetchNetworkAdapter", () => { + it("delegates to an injected fetch and passes init through", async () => { + const calls: Array<{ input: string; init?: FetchInit }> = []; + const injected = async (input: string, init?: FetchInit): Promise => { + calls.push({ input, init }); + return fakeResponse(); + }; + + const adapter = new FetchNetworkAdapter(injected); + const resp = await adapter.fetch("https://example.com/api", { method: "POST", headers: { "X-A": "1" }, body: "{}" }); + + expect(resp.ok).toBe(true); + expect(resp.status).toBe(200); + expect(await resp.json<{ ok: boolean }>()).toEqual({}); + expect(calls).toEqual([ + { input: "https://example.com/api", init: { method: "POST", headers: { "X-A": "1" }, body: "{}" } }, + ]); + }); + + it("uses globalThis.fetch when no fetch is injected", async () => { + const original = globalThis.fetch; + (globalThis as unknown as { fetch: unknown }).fetch = async () => fakeResponse({ status: 201 }); + try { + const adapter = new FetchNetworkAdapter(); + const resp = await adapter.fetch("https://example.com"); + expect(resp.status).toBe(201); + } finally { + (globalThis as unknown as { fetch: unknown }).fetch = original; + } + }); +}); +``` + +`apps/core/src/adapters/ConsoleLoggerAdapter.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./ConsoleLoggerAdapter"; + +describe("ConsoleLoggerAdapter", () => { + it("forwards to console", () => { + const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const adapter = new ConsoleLoggerAdapter(); + adapter.warn({ a: 1 }, "msg"); + expect(spy).toHaveBeenCalledWith("msg", { a: 1 }); + spy.mockRestore(); + }); +}); + +describe("NoopLoggerAdapter", () => { + it("does nothing without throwing", () => { + const adapter = new NoopLoggerAdapter(); + expect(() => adapter.info({}, "x")).not.toThrow(); + expect(() => adapter.error({}, "x")).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — module `../ports/NetworkPort` and `./FetchNetworkAdapter` cannot be resolved. + +- [ ] **Step 3: Create the Port interfaces** + +`apps/core/src/ports/FsPort.ts`: + +```ts +/** Runtime-agnostic file system. Paths are POSIX. Adapters convert at the boundary. */ +export interface FsPort { + readTextFile(path: string): Promise; + writeTextFile(path: string, content: string): Promise; + exists(path: string): Promise; + /** Recursively list all files under `dir` (not directories). */ + listFiles(dir: string): Promise; +} +``` + +`apps/core/src/ports/NetworkPort.ts`: + +```ts +export interface FetchInit { + method?: string; + headers?: Record; + body?: string; + signal?: AbortSignal; +} + +/** Minimal HTTP response shape; runtime-agnostic (Node/browser). */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers: Record; + text(): Promise; + json(): Promise; +} + +/** Thin HTTP capability only. No business parsing here. */ +export interface NetworkPort { + fetch(input: string, init?: FetchInit): Promise; +} +``` + +`apps/core/src/ports/LoggerPort.ts`: + +```ts +export interface LoggerPort { + info(obj: unknown, msg: string): void; + warn(obj: unknown, msg: string): void; + error(obj: unknown, msg: string): void; +} +``` + +- [ ] **Step 4: Create the adapters** + +`apps/core/src/adapters/FetchNetworkAdapter.ts`: + +```ts +import type { FetchInit, HttpResponse, NetworkPort } from "../ports/NetworkPort"; + +export class FetchNetworkAdapter implements NetworkPort { + private readonly fetchImpl: (input: string, init?: FetchInit) => Promise; + + constructor(fetchImpl?: (input: string, init?: FetchInit) => Promise) { + this.fetchImpl = + fetchImpl ?? + ((input, init) => { + // Node 18+ / modern browsers expose globalThis.fetch returning a Response + // whose runtime shape satisfies HttpResponse. + const g = globalThis as unknown as { + fetch: (i: string, o?: FetchInit) => Promise; + }; + return g.fetch(input, init); + }); + } + + fetch(input: string, init?: FetchInit): Promise { + return this.fetchImpl(input, init); + } +} +``` + +`apps/core/src/adapters/ConsoleLoggerAdapter.ts`: + +```ts +import type { LoggerPort } from "../ports/LoggerPort"; + +export class ConsoleLoggerAdapter implements LoggerPort { + info(obj: unknown, msg: string): void { + console.info(msg, obj); + } + warn(obj: unknown, msg: string): void { + console.warn(msg, obj); + } + error(obj: unknown, msg: string): void { + console.error(msg, obj); + } +} + +export class NoopLoggerAdapter implements LoggerPort { + info(): void {} + warn(): void {} + error(): void {} +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS — all adapter tests green. + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/src/ports apps/core/src/adapters/FetchNetworkAdapter.ts apps/core/src/adapters/ConsoleLoggerAdapter.ts +git commit -m "feat(core-app): add Ports interfaces and network/logger adapters" +``` + +--- + +## Task 3: NodejsFsAdapter + +**Files:** +- Create: `apps/core/src/adapters/node/NodejsFsAdapter.ts` +- Test: `apps/core/src/adapters/node/NodejsFsAdapter.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/adapters/node/NodejsFsAdapter.test.ts`: + +```ts +import { promises as fsp } from "node:fs"; +import os from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Path } from "@core/path"; +import { NodejsFsAdapter } from "./NodejsFsAdapter"; +import { joinPosix } from "../../pipeline/paths"; + +describe("NodejsFsAdapter", () => { + let tmpPosix: string; + + beforeEach(async () => { + const dir = await fsp.mkdtemp(join(os.tmpdir(), "smm-core-")); + tmpPosix = Path.posix(dir); + }); + + afterEach(async () => { + await fsp.rm(Path.toPlatformPath(tmpPosix), { recursive: true, force: true }); + }); + + it("writes and reads a file (POSIX in, platform on disk)", async () => { + const adapter = new NodejsFsAdapter(); + const file = joinPosix(tmpPosix, "hello.txt"); + + await adapter.writeTextFile(file, "hi"); + const content = await adapter.readTextFile(file); + + expect(content).toBe("hi"); + expect(await adapter.exists(file)).toBe(true); + }); + + it("recursively lists files, not directories", async () => { + const adapter = new NodejsFsAdapter(); + await adapter.writeTextFile(joinPosix(tmpPosix, "a.mkv"), ""); + await adapter.writeTextFile(joinPosix(tmpPosix, "sub", "b.srt"), ""); + + const files = await adapter.listFiles(tmpPosix); + + expect(files.map((f) => Path.posix(f)).sort()).toEqual([ + joinPosix(tmpPosix, "a.mkv"), + joinPosix(tmpPosix, "sub", "b.srt"), + ]); + }); + + it("exists returns false for a missing file", async () => { + const adapter = new NodejsFsAdapter(); + expect(await adapter.exists(joinPosix(tmpPosix, "nope.txt"))).toBe(false); + }); +}); +``` + +Note: `joinPosix` and `Path` round-trip on both Windows and POSIX hosts, so this test is host-independent. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./NodejsFsAdapter` and `../../pipeline/paths` cannot be resolved. + +- [ ] **Step 3: Implement `NodejsFsAdapter`** + +`apps/core/src/adapters/node/NodejsFsAdapter.ts`: + +```ts +import { promises as fsp } from "node:fs"; +import { dirname, join } from "node:path"; +import { Path } from "@core/path"; +import type { FsPort } from "../../ports/FsPort"; + +/** + * Node host adapter. Core passes POSIX paths; this adapter converts to the + * host's platform format before touching node:fs. Node hosts only. + */ +export class NodejsFsAdapter implements FsPort { + async readTextFile(path: string): Promise { + return fsp.readFile(Path.toPlatformPath(path), "utf-8"); + } + + async writeTextFile(path: string, content: string): Promise { + const platform = Path.toPlatformPath(path); + await fsp.mkdir(dirname(platform), { recursive: true }); + await fsp.writeFile(platform, content, "utf-8"); + } + + async exists(path: string): Promise { + try { + await fsp.access(Path.toPlatformPath(path)); + return true; + } catch { + return false; + } + } + + async listFiles(dir: string): Promise { + const root = Path.toPlatformPath(dir); + const out: string[] = []; + const walk = async (current: string): Promise => { + const entries = await fsp.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const full = join(current, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile()) { + out.push(full); + } + } + }; + await walk(root); + return out; + } +} +``` + +- [ ] **Step 4: Implement the minimal `paths.ts` needed by the test** + +`apps/core/src/pipeline/paths.ts`: + +```ts +export function joinPosix(...parts: string[]): string { + return parts.join("/"); +} + +export function basename(path: string): string { + const parts = path.split("/"); + return parts[parts.length - 1] ?? ""; +} + +export function extname(path: string): string { + const base = basename(path); + const idx = base.lastIndexOf("."); + return idx < 0 ? "" : base.slice(idx); +} +``` + +(The full `paths.ts` — `userConfigPath` / `metadataCachePath` — is completed in Task 5.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/src/adapters/node apps/core/src/pipeline/paths.ts +git commit -m "feat(core-app): add NodejsFsAdapter and path helpers" +``` + +--- + +## Task 4: NetworkFsAdapter + +**Files:** +- Create: `apps/core/src/adapters/network/NetworkFsAdapter.ts` +- Test: `apps/core/src/adapters/network/NetworkFsAdapter.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/adapters/network/NetworkFsAdapter.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../../ports/NetworkPort"; +import { NetworkFsAdapter } from "./NetworkFsAdapter"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function mockNetwork(): NetworkPort & { calls: Array<{ url: string; body: unknown }> } { + const calls: Array<{ url: string; body: unknown }> = []; + const network: NetworkPort = { + fetch: async (url, init) => { + const body = JSON.parse(init?.body ?? "{}"); + calls.push({ url, body }); + if (url.endsWith("/api/readFile")) { + if (String(body.path).includes("missing")) { + return jsonResponse({ error: `File Not Found: ${body.path}` }); + } + return jsonResponse({ data: "file-content" }); + } + if (url.endsWith("/api/writeFile")) { + return jsonResponse({}); + } + if (url.endsWith("/api/listFiles")) { + return jsonResponse({ + data: { path: body.path, items: [{ path: "/m/a.mkv" }, { path: "/m/b.srt" }], size: 0 }, + }); + } + throw new Error("unexpected url: " + url); + }, + }; + return { ...network, calls }; +} + +describe("NetworkFsAdapter", () => { + it("reads a file via POST /api/readFile", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + const content = await adapter.readTextFile("/m/file.txt"); + + expect(content).toBe("file-content"); + expect(calls[0]).toEqual({ url: "http://127.0.0.1:30000/api/readFile", body: { path: "/m/file.txt" } }); + }); + + it("writes a file via POST /api/writeFile", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await adapter.writeTextFile("/m/file.txt", "hello"); + + expect(calls[0]).toEqual({ + url: "http://127.0.0.1:30000/api/writeFile", + body: { path: "/m/file.txt", mode: "overwrite", data: "hello" }, + }); + }); + + it("lists files recursively via POST /api/listFiles", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + const files = await adapter.listFiles("/m"); + + expect(files).toEqual(["/m/a.mkv", "/m/b.srt"]); + expect(calls[0]).toEqual({ + url: "http://127.0.0.1:30000/api/listFiles", + body: { path: "/m", recursively: true, onlyFiles: true }, + }); + }); + + it("exists() is true when readFile succeeds and false on File Not Found", async () => { + const { network } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + expect(await adapter.exists("/m/file.txt")).toBe(true); + expect(await adapter.exists("/m/missing.txt")).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./NetworkFsAdapter` cannot be resolved. + +- [ ] **Step 3: Implement `NetworkFsAdapter`** + +`apps/core/src/adapters/network/NetworkFsAdapter.ts`: + +```ts +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; + +export interface NetworkFsAdapterOptions { + network: NetworkPort; + /** Base URL of the internal HTTP API, e.g. "http://127.0.0.1:30000". */ + baseUrl: string; +} + +/** + * Browser-runtime FsPort: maps file operations to the internal HTTP fs + * endpoints exposed by Layer 3 (core-routes). Paths are POSIX; the endpoints + * accept both POSIX and platform formats. + */ +export class NetworkFsAdapter implements FsPort { + constructor(private readonly options: NetworkFsAdapterOptions) {} + + private async post(path: string, body: Record): Promise { + const resp = await this.options.network.fetch(`${this.options.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status} from ${path}`); + } + return resp.json(); + } + + async readTextFile(path: string): Promise { + const json = await this.post<{ data?: string; error?: string }>("/api/readFile", { path }); + if (json.error !== undefined) throw new Error(json.error); + if (json.data === undefined) throw new Error(`readFile returned no data for ${path}`); + return json.data; + } + + async writeTextFile(path: string, content: string): Promise { + const json = await this.post<{ error?: string }>("/api/writeFile", { + path, + mode: "overwrite", + data: content, + }); + if (json.error !== undefined) throw new Error(json.error); + } + + async exists(path: string): Promise { + const json = await this.post<{ error?: string }>("/api/readFile", { path }); + return !(json.error !== undefined && json.error.startsWith("File Not Found")); + } + + async listFiles(dir: string): Promise { + const json = await this.post<{ + error?: string; + data?: { items: Array<{ path: string }> }; + }>("/api/listFiles", { path: dir, recursively: true, onlyFiles: true }); + if (json.error !== undefined) throw new Error(json.error); + return (json.data?.items ?? []).map((i) => i.path); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/adapters/network +git commit -m "feat(core-app): add NetworkFsAdapter mapping FsPort to internal HTTP fs endpoints" +``` + +--- + +## Task 5: Path resolution (userConfigPath, metadataCachePath) + +**Files:** +- Modify: `apps/core/src/pipeline/paths.ts` (append `userConfigPath` / `metadataCachePath`) +- Test: `apps/core/src/pipeline/paths.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/pipeline/paths.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { basename, extname, joinPosix, metadataCachePath, userConfigPath } from "./paths"; + +describe("paths", () => { + it("basename returns the last path segment", () => { + expect(basename("/C:/media/My.Show/S01E01.mkv")).toBe("S01E01.mkv"); + expect(basename("no-separator")).toBe("no-separator"); + }); + + it("extname returns the extension with leading dot", () => { + expect(extname("/C:/media/a.mkv")).toBe(".mkv"); + expect(extname("noext")).toBe(""); + }); + + it("joinPosix joins with forward slashes", () => { + expect(joinPosix("/data/smm", "metadata", "x.json")).toBe("/data/smm/metadata/x.json"); + }); + + it("userConfigPath points at /smm.json", () => { + expect(userConfigPath("/data/smm")).toBe("/data/smm/smm.json"); + }); + + it("metadataCachePath sanitizes the folder path into a cache filename", () => { + expect(metadataCachePath("/data/smm", "/C:/media/My Show")).toBe( + "/data/smm/metadata/_C__media_My Show.json", + ); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `userConfigPath` / `metadataCachePath` are not exported. + +- [ ] **Step 3: Extend `paths.ts`** + +Append to `apps/core/src/pipeline/paths.ts`: + +```ts +import { Path } from "@core/path"; + +/** `/smm.json`, in POSIX form. */ +export function userConfigPath(appDataDir: string): string { + return joinPosix(Path.posix(appDataDir), "smm.json"); +} + +/** + * Metadata cache file for a media folder, mirroring `metadataCacheFilePath` + * in `apps/ui/src/api/readMediaMetadataV2.ts`. POSIX form. + */ +export function metadataCachePath(appDataDir: string, folderPathInPosix: string): string { + const filename = folderPathInPosix.replace(/[/\\:?*|<>"]/g, "_"); + return joinPosix(Path.posix(appDataDir), "metadata", `${filename}.json`); +} +``` + +(Keep the existing `joinPosix` / `basename` / `extname` definitions above.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/paths.ts +git commit -m "feat(core-app): add userConfig and metadata cache path resolution" +``` + +--- + +## Task 6: Job model + JobStore + +**Files:** +- Create: `apps/core/src/jobs/types.ts` +- Create: `apps/core/src/jobs/jobStore.ts` +- Test: `apps/core/src/jobs/jobStore.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/jobs/jobStore.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { JobStore } from "./jobStore"; + +describe("JobStore", () => { + it("creates a job with id and timestamps", () => { + const store = new JobStore(); + const job = store.create({ + folderPath: "/m/My.Show", + type: "tvshow", + status: "running", + stage: "config", + progress: 0, + }); + + expect(job.id).toBeTruthy(); + expect(job.createdAt).toBeGreaterThan(0); + expect(job.updatedAt).toBeGreaterThanOrEqual(job.createdAt); + expect(store.get(job.id)?.folderPath).toBe("/m/My.Show"); + }); + + it("update patches fields and bumps updatedAt", async () => { + const store = new JobStore(); + const job = store.create({ folderPath: "/m", type: "movie", status: "running", stage: null, progress: 0 }); + const firstUpdatedAt = job.updatedAt; + + await new Promise((r) => setTimeout(r, 5)); + store.update(job.id, { status: "succeeded", stage: null, progress: 100 }); + + const updated = store.get(job.id); + expect(updated?.status).toBe("succeeded"); + expect(updated?.progress).toBe(100); + expect(updated?.updatedAt).toBeGreaterThan(firstUpdatedAt); + }); + + it("update on unknown id is a no-op", () => { + const store = new JobStore(); + expect(() => store.update("nope", { status: "failed" })).not.toThrow(); + }); + + it("get returns a snapshot (mutating it does not affect the store)", () => { + const store = new JobStore(); + const job = store.create({ folderPath: "/m", type: "music", status: "running", stage: null, progress: 0 }); + const snapshot = store.get(job.id); + snapshot!.status = "failed"; + expect(store.get(job.id)?.status).toBe("running"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./jobStore` cannot be resolved. + +- [ ] **Step 3: Implement `types.ts`** + +`apps/core/src/jobs/types.ts`: + +```ts +import type { FolderType } from "@smm/core"; + +export type JobStatus = "pending" | "running" | "succeeded" | "failed" | "aborted"; +export type JobStage = "config" | "metadata" | "listFiles" | "recognize" | "episodes" | "persist" | null; + +export interface ImportJob { + id: string; + folderPath: string; + type: FolderType; + status: JobStatus; + stage: JobStage; + progress: number; + error?: string; + createdAt: number; + updatedAt: number; +} +``` + +- [ ] **Step 4: Implement `jobStore.ts`** + +`apps/core/src/jobs/jobStore.ts`: + +```ts +import type { ImportJob } from "./types"; + +let seq = 0; + +/** Runtime-agnostic id: base-36 timestamp + monotonic counter. */ +export function nextJobId(): string { + return `${Date.now().toString(36)}-${(seq++).toString(36)}`; +} + +export class JobStore { + private readonly jobs = new Map(); + + create(init: Omit): ImportJob { + const now = Date.now(); + const job: ImportJob = { id: nextJobId(), createdAt: now, updatedAt: now, ...init }; + this.jobs.set(job.id, job); + return job; + } + + update(id: string, patch: Partial): void { + const job = this.jobs.get(id); + if (job === undefined) return; + Object.assign(job, patch, { updatedAt: Date.now() }); + } + + get(id: string): ImportJob | undefined { + const job = this.jobs.get(id); + return job === undefined ? undefined : { ...job }; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/src/jobs +git commit -m "feat(core-app): add ImportJob model and in-memory JobStore" +``` + +--- + +## Task 7: NFO parser + +**Files:** +- Create: `apps/core/src/pipeline/nfo.ts` +- Test: `apps/core/src/pipeline/nfo.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/pipeline/nfo.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { parseNfo } from "./nfo"; + +describe("parseNfo", () => { + it("extracts title, tmdbid and tvdbid from a tvshow.nfo", () => { + const xml = "My Show123456"; + expect(parseNfo(xml)).toEqual({ title: "My Show", tmdbid: "123", tvdbid: "456" }); + }); + + it("extracts ids from a movie.nfo", () => { + const xml = "My Film7"; + expect(parseNfo(xml)).toEqual({ title: "My Film", tmdbid: "7", tvdbid: undefined }); + }); + + it("handles self-closing and missing fields", () => { + expect(parseNfo("5")).toEqual({ + title: undefined, + tmdbid: "5", + tvdbid: undefined, + }); + expect(parseNfo("")).toEqual({ title: undefined, tmdbid: undefined, tvdbid: undefined }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./nfo` cannot be resolved. + +- [ ] **Step 3: Implement `nfo.ts`** + +`apps/core/src/pipeline/nfo.ts`: + +```ts +export interface ParsedNfo { + title?: string; + tmdbid?: string; + tvdbid?: string; +} + +function textOf(xml: string, tag: string): string | undefined { + const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i")); + if (match === null) return undefined; + const value = match[1]?.trim(); + return value === "" ? undefined : value; +} + +/** + * Runtime-agnostic regex NFO parser. The existing UI parser uses DOMParser + * (browser-only); core needs the ids (and title) without a DOM. + */ +export function parseNfo(xml: string): ParsedNfo { + return { + title: textOf(xml, "title"), + tmdbid: textOf(xml, "tmdbid"), + tvdbid: textOf(xml, "tvdbid"), + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/nfo.ts +git commit -m "feat(core-app): add runtime-agnostic NFO parser" +``` + +--- + +## Task 8: TmdbClient + +**Files:** +- Create: `apps/core/src/clients/TmdbClient.ts` +- Test: `apps/core/src/clients/TmdbClient.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/clients/TmdbClient.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { buildTvShowMediaMetadata, TmdbClient } from "./TmdbClient"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function networkMock(routes: Record): NetworkPort & { urls: string[] } { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url); + const found = Object.entries(routes).find(([pattern]) => url.includes(pattern)); + if (found === undefined) throw new Error("unexpected url: " + url); + return jsonResponse(found[1]); + }, + }; + return { ...network, urls }; +} + +describe("TmdbClient", () => { + it("search sends query/language and Authorization header", async () => { + const { network, urls } = networkMock({ "/search/tv" : { results: [{ id: 1, name: "S" }], page: 1, total_pages: 1, total_results: 1 } }); + const client = new TmdbClient(network, { host: "https://tmdb.example", apiKey: "abc" }); + + const body = await client.search("My Show", "tv", "en-US"); + + expect(body.results[0]?.id).toBe(1); + const url = urls[0]!; + expect(url).toContain("https://tmdb.example/search/tv"); + expect(url).toContain("query=My%20Show"); + expect(url).toContain("language=en-US"); + // auth header verified below via a route capture + }); + + it("getTvShowMediaMetadata fetches series + each season and builds TvShowMediaMetadata", async () => { + const series = { + id: 1, + name: "My Show", + first_air_date: "2020-01-01", + seasons: [ + { id: 11, name: "Season 1", season_number: 1, air_date: "2020-01-01", episode_count: 1 }, + ], + }; + const season = { + id: 11, + name: "Season 1", + season_number: 1, + air_date: "2020-01-01", + episode_count: 1, + episodes: [ + { id: 1, name: "Pilot", episode_number: 1, season_number: 1, air_date: "2020-01-01", overview: "", still_path: null, vote_average: 0, vote_count: 0, runtime: 45 }, + ], + }; + const { network, urls } = networkMock({ + "/tv/1?": series, + "/tv/1/season/1?": season, + }); + const client = new TmdbClient(network, {}); + + const tvShow = await client.getTvShowMediaMetadata(1, "en-US"); + + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TMDB", + airDate: "2020-01-01", + seasons: [ + { season: 1, name: "Season 1", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }, + ], + }); + expect(urls[0]).toContain("/tv/1?"); + expect(urls[1]).toContain("/tv/1/season/1?"); + }); + + it("getMovieMediaMetadata maps a movie detail", async () => { + const movie = { id: 2, title: "My Film", release_date: "2019-05-01" }; + const { network } = networkMock({ "/movie/2?" : movie }); + const client = new TmdbClient(network, {}); + + const mm = await client.getMovieMediaMetadata(2, "en-US"); + + expect(mm).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TMDB" }); + }); + + it("buildTvShowMediaMetadata maps series + season details to TvShowMediaMetadata", () => { + const result = buildTvShowMediaMetadata( + { id: 5, name: "S", first_air_date: "2020-01-01", seasons: [{ season_number: 1, name: "S1" }] } as never, + [{ season_number: 1, name: "S1", episodes: [{ episode_number: 1, season_number: 1, name: "E1" }] }] as never, + ); + expect(result.seasons).toEqual([{ season: 1, name: "S1", episodes: [{ season: 1, episode: 1, name: "E1" }] }]); + }); +}); +``` + +Note: The network mock does not assert the `Authorization` header. To verify it, add to the first test after the client call: + +```ts +const authCall = networkMock2; // optional +``` + +Keep the test simple: header behavior is covered in Task 14's pipeline test via a header-capturing mock. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./TmdbClient` cannot be resolved. + +- [ ] **Step 3: Implement `TmdbClient`** + +`apps/core/src/clients/TmdbClient.ts`: + +```ts +import type { + MovieMediaMetadata, + TmdbMovieDetails, + TmdbSearchResponseBody, + TmdbSeasonDetails, + TmdbSeriesDetails, + TMDBMovie, + TMDBTVShow, + TvShowEpisodeMetadata, + TvShowMediaMetadata, + TvShowSeasonMetadata, +} from "@smm/core"; +import type { NetworkPort } from "../ports/NetworkPort"; + +export const SMM_TMDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tmdb"; + +export interface TmdbClientOptions { + host?: string; + apiKey?: string; +} + +export class TmdbClient { + private readonly host: string; + private readonly apiKey?: string; + + constructor( + private readonly network: NetworkPort, + options: TmdbClientOptions = {}, + ) { + this.host = (options.host?.trim() || SMM_TMDB_DEFAULT_UPSTREAM).replace(/\/+$/, ""); + this.apiKey = options.apiKey?.trim() || undefined; + } + + private async request(urlPath: string): Promise { + const headers: Record = {}; + if (this.apiKey !== undefined) headers.Authorization = `Bearer ${this.apiKey}`; + const resp = await this.network.fetch(`${this.host}${urlPath}`, { method: "GET", headers }); + if (!resp.ok) { + throw new Error(`TMDB request failed: ${resp.status} ${resp.statusText}`); + } + return resp.json(); + } + + search(keyword: string, type: "movie" | "tv", language: string): Promise { + const qs = new URLSearchParams({ query: keyword, language }); + return this.request(`/search/${type}?${qs.toString()}`); + } + + getTvShowById(id: number, language: string): Promise { + const qs = new URLSearchParams({ language }); + return this.request(`/tv/${id}?${qs.toString()}`); + } + + getTvSeasonById(seriesId: number, seasonNumber: number, language: string): Promise { + const qs = new URLSearchParams({ language }); + return this.request(`/tv/${seriesId}/season/${seasonNumber}?${qs.toString()}`); + } + + getMovieById(id: number, language: string): Promise { + const qs = new URLSearchParams({ language }); + return this.request(`/movie/${id}?${qs.toString()}`); + } + + /** Series details + per-season episode lists, mapped to {@link TvShowMediaMetadata}. */ + async getTvShowMediaMetadata(id: number, language: string): Promise { + const series = await this.getTvShowById(id, language); + const seasonDetails: TmdbSeasonDetails[] = []; + for (const season of series.seasons) { + seasonDetails.push(await this.getTvSeasonById(id, season.season_number, language)); + } + return buildTvShowMediaMetadata(series, seasonDetails); + } + + async getMovieMediaMetadata(id: number, language: string): Promise { + const details = await this.getMovieById(id, language); + return movieMediaMetadataFromTmdbSearch(details); + } +} + +/** TMDB details → unified `tvShow` shape (same as `tvShowMediaMetadataFromTmdbDetails` in apps/ui). */ +export function buildTvShowMediaMetadata( + series: TmdbSeriesDetails, + seasonDetails: TmdbSeasonDetails[], +): TvShowMediaMetadata { + const seasons: TvShowSeasonMetadata[] = (seasonDetails ?? []).map((season) => { + const episodes: TvShowEpisodeMetadata[] = (season.episodes ?? []).map((ep) => ({ + season: ep.season_number, + episode: ep.episode_number, + name: ep.name ?? "", + })); + return { season: season.season_number, name: season.name ?? "", episodes }; + }); + + return { + id: String(series.id), + name: series.name, + database: "TMDB", + airDate: series.first_air_date, + seasons, + }; +} + +/** TMDB movie (search result or detail) → unified `movie` shape. */ +export function movieMediaMetadataFromTmdbSearch(item: TMDBMovie): MovieMediaMetadata { + return { id: String(item.id), name: item.title, airDate: item.release_date, database: "TMDB" }; +} + +export type { TMDBTVShow }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/clients/TmdbClient.ts +git commit -m "feat(core-app): add TmdbClient on NetworkPort" +``` + +--- + +## Task 9: TvdbClient + +**Files:** +- Create: `apps/core/src/clients/TvdbClient.ts` +- Test: `apps/core/src/clients/TvdbClient.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/clients/TvdbClient.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { mapToTvdbLangCode, TvdbClient } from "./TvdbClient"; + +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function envelope(data: unknown) { + return { status: "success", data }; +} + +function tvdbNetwork(): NetworkPort { + return { + fetch: async (url) => { + if (url.includes("/search")) { + const type = url.includes("type=series") ? "series" : "movie"; + return jsonResponse( + envelope( + type === "series" + ? [{ id: "series-1", objectID: "series-1", name: "My Show", tvdb_id: "1" }] + : [{ id: "movie-2", objectID: "movie-2", name: "My Film", tvdb_id: "2" }], + ), + ); + } + if (url.includes("/series/1/translations/eng")) { + return jsonResponse(envelope({ name: "My Show" })); + } + if (url.includes("/series/1/extended")) { + return jsonResponse( + envelope({ + id: 1, + name: "My Show", + firstAired: "2020-01-01", + seasons: [{ id: 11, number: 1, type: { name: "Aired Order" } }], + }), + ); + } + if (url.includes("/seasons/11/extended")) { + return jsonResponse( + envelope({ + id: 11, + episodes: [{ id: 1, number: 1, seasonNumber: 1, name: "Pilot" }], + }), + ); + } + if (url.includes("/movies/2/translations/eng")) { + return jsonResponse(envelope({ name: "My Film" })); + } + if (url.includes("/movies/2/extended")) { + return jsonResponse(envelope({ id: 2, name: "My Film", first_release: { first: "2019-05-01" } })); + } + throw new Error("unexpected url: " + url); + }, + }; +} + +describe("TvdbClient", () => { + it("searches series", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const items = await client.searchSeries("My Show", "en-US"); + expect(items?.[0]?.tvdb_id).toBe("1"); + }); + + it("searches movies", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const items = await client.searchMovie("My Film", "en-US"); + expect(items?.[0]?.tvdb_id).toBe("2"); + }); + + it("getTvShowMediaMetadata builds seasons + episodes", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const tvShow = await client.getTvShowMediaMetadata(1, "en-US"); + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TVDB", + airDate: "2020-01-01", + seasons: [ + { season: 1, name: "", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }, + ], + }); + }); + + it("getMovieMediaMetadata maps a movie", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const movie = await client.getMovieMediaMetadata(2, "en-US"); + expect(movie).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TVDB" }); + }); + + it("maps IETF media language to TVDB ISO 639-3", () => { + expect(mapToTvdbLangCode("zh-CN")).toBe("zho"); + expect(mapToTvdbLangCode("en-US")).toBe("eng"); + expect(mapToTvdbLangCode("ja-JP")).toBe("jpn"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./TvdbClient` cannot be resolved. + +- [ ] **Step 3: Implement `TvdbClient`** + +`apps/core/src/clients/TvdbClient.ts`: + +```ts +import { TVDBv4, type TVDBv4SearchResult } from "@smm/tvdb4"; +import type { + TVDBv4Season, + TVDBv4SeriesExtendedResponse, + TVDBv4SeriesSeasonsExtendedResponse, +} from "@smm/tvdb4/types"; +import type { MovieMediaMetadata, PreferMediaLanguage, TvShowMediaMetadata } from "@smm/core"; +import type { NetworkPort } from "../ports/NetworkPort"; + +export const SMM_TVDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tvdb"; + +/** IETF BCP 47 media language → TVDB ISO 639-3 code. */ +export function mapToTvdbLangCode(lang: "zh-CN" | "en-US" | "ja-JP"): string { + switch (lang) { + case "zh-CN": + return "zho"; + case "en-US": + return "eng"; + case "ja-JP": + return "jpn"; + default: + return "eng"; + } +} + +export interface TvdbClientOptions { + host?: string; + apiKey?: string; +} + +export class TvdbClient { + private readonly client: TVDBv4; + + constructor(network: NetworkPort, options: TvdbClientOptions = {}) { + const host = (options.host?.trim() || SMM_TVDB_DEFAULT_UPSTREAM).replace(/\/+$/, ""); + this.client = new TVDBv4({ + baseUrl: host, + apiKey: options.apiKey ?? "", + disableAuth: true, + fetchImpl: (input, init) => network.fetch(input, init), + }); + } + + async searchSeries(query: string, language: string): Promise { + const resp = await this.client.search({ query, type: "series", language }); + return resp.status === "success" ? resp.data : undefined; + } + + async searchMovie(query: string, language: string): Promise { + const resp = await this.client.search({ query, type: "movie", language }); + return resp.status === "success" ? resp.data : undefined; + } + + async getTvShowMediaMetadata(seriesId: number, language: string): Promise { + const langCode = mapToTvdbLangCode(language as PreferMediaLanguage); + const m: TvShowMediaMetadata = { id: seriesId.toString(), name: "", database: "TVDB", seasons: [] }; + + const translation = await this.client.seriesTranslationByLangCode(seriesId, langCode); + if (translation.status === "success") m.name = translation.data?.name ?? ""; + + const seriesResp = await this.client.seriesExtendedById(seriesId); + if (seriesResp.status !== "success") return undefined; + const series = seriesResp.data as TVDBv4SeriesExtendedResponse; + m.airDate = series.firstAired; + + const seasons = series.seasons.filter((s) => s.type.name === "Aired Order"); + for (const season of seasons) { + const seasonResp = await this.client.seasonExtendedById(season.id); + const episodes = + seasonResp.status === "success" + ? (seasonResp.data as TVDBv4SeriesSeasonsExtendedResponse).episodes + : []; + m.seasons.push({ + season: season.number, + name: "", + episodes: episodes.map((ep) => ({ season: ep.seasonNumber, episode: ep.number, name: ep.name ?? "" })), + }); + } + return m; + } + + async getMovieMediaMetadata(movieId: number, language: string): Promise { + const langCode = mapToTvdbLangCode(language as PreferMediaLanguage); + const m: MovieMediaMetadata = { id: movieId.toString(), name: "", database: "TVDB" }; + + const translation = await this.client.movieTranslationByLangCode(movieId, langCode); + if (translation.status === "success") m.name = translation.data?.name ?? ""; + + const movieResp = await this.client.movieExtendedById(movieId); + if (movieResp.status !== "success") return undefined; + const data = movieResp.data as Record; + if (m.name === "") m.name = typeof data.name === "string" ? data.name : ""; + const firstRelease = data.first_release as Record | undefined; + if (firstRelease !== undefined && typeof firstRelease.first === "string") { + m.airDate = firstRelease.first; + } + return m; + } +} + +export type { TVDBv4Season }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/clients/TvdbClient.ts +git commit -m "feat(core-app): add TvdbClient wrapping @smm/tvdb4 on NetworkPort" +``` + +--- + +## Task 10: recognizeEpisodes + +**Files:** +- Create: `apps/core/src/pipeline/recognizeEpisodes.ts` +- Test: `apps/core/src/pipeline/recognizeEpisodes.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/pipeline/recognizeEpisodes.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { MediaMetadata } from "@smm/core"; +import { + excludeFiles, + isVideoFile, + pattern1, + pattern2, + pattern3, + pattern4, + recognizeEpisodes, +} from "./recognizeEpisodes"; + +const S01E01 = "S01E01.mkv"; +const CN_RAW = "第1季第5集.mkv"; +const CN_PADDED = "第01季第05集.mkv"; +const DIVIDER = "Show - 1.mkv"; + +const episodes = [ + { season: 1, episode: 1 }, + { season: 1, episode: 5 }, +]; + +describe("isVideoFile", () => { + it("matches known video extensions", () => { + expect(isVideoFile("/m/a.mkv")).toBe(true); + expect(isVideoFile("/m/a.srt")).toBe(false); + }); +}); + +describe("pattern1", () => { + it("matches SXXEYY", () => { + expect(pattern1(episodes, ["/m/" + S01E01, "/m/other.mkv"])).toEqual([ + { season: 1, episode: 1, file: "/m/" + S01E01 }, + ]); + }); +}); + +describe("pattern2", () => { + it("matches 第X季第Y集", () => { + expect(pattern2(episodes, ["/m/" + CN_RAW])).toEqual([{ season: 1, episode: 5, file: "/m/" + CN_RAW }]); + }); +}); + +describe("pattern3", () => { + it("matches 第XX季第YY集", () => { + expect(pattern3(episodes, ["/m/" + CN_PADDED])).toEqual([{ season: 1, episode: 5, file: "/m/" + CN_PADDED }]); + }); +}); + +describe("pattern4", () => { + it("matches N.ext for a single-season list", () => { + expect(pattern4(episodes, ["/m/" + DIVIDER])).toEqual([{ season: 1, episode: 1, file: "/m/" + DIVIDER }]); + }); + + it("refuses to disambiguate multi-season lists", () => { + expect(pattern4([{ season: 1, episode: 1 }, { season: 2, episode: 1 }], ["/m/" + DIVIDER])).toEqual([]); + }); +}); + +describe("excludeFiles", () => { + it("drops Extras and Subtitles", () => { + expect( + excludeFiles(["/m/S01E01.mkv", "/m/Extras/interview.mkv", "/m/Subtitles/en.srt"]), + ).toEqual(["/m/S01E01.mkv"]); + }); +}); + +describe("recognizeEpisodes", () => { + it("matches video files to tvShow seasons via SXXEYY", () => { + const mm: MediaMetadata = { + mediaFolderPath: "/m/My.Show", + files: ["/m/My.Show/S01E01.mkv", "/m/My.Show/S01E02.mkv", "/m/My.Show/poster.jpg"], + tvShow: { + database: "TMDB", + id: "1", + name: "My Show", + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [ + { season: 1, episode: 1, name: "E1" }, + { season: 1, episode: 2, name: "E2" }, + ], + }, + ], + }, + }; + expect(recognizeEpisodes(mm)).toEqual([ + { season: 1, episode: 1, file: "/m/My.Show/S01E01.mkv" }, + { season: 1, episode: 2, file: "/m/My.Show/S01E02.mkv" }, + ]); + }); + + it("returns [] when there is no tvShow", () => { + const mm: MediaMetadata = { mediaFolderPath: "/m", files: ["/m/S01E01.mkv"] }; + expect(recognizeEpisodes(mm)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./recognizeEpisodes` cannot be resolved. + +- [ ] **Step 3: Implement `recognizeEpisodes`** + +`apps/core/src/pipeline/recognizeEpisodes.ts`: + +```ts +import { videoFileExtensions } from "@core/utils"; +import type { MediaMetadata } from "@smm/core"; +import { basename, extname } from "./paths"; + +export interface RecognizedEpisode { + season: number; + episode: number; + file: string; +} + +export function isVideoFile(file: string): boolean { + return videoFileExtensions.includes(extname(file).toLowerCase()); +} + +const EXCLUDED_FOLDERS = ["/Extras/", "/EXTRAS/", "/Subtitles/"]; + +export function excludeFiles(files: string[]): string[] { + return files.filter((file) => !EXCLUDED_FOLDERS.some((folder) => file.includes(folder))); +} + +export function pattern1( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const pattern = `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; + const target = videoFiles.find((file) => file.includes(pattern)); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function pattern2( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const pattern = `第${season}季第${episode}集`; + const target = videoFiles.find((file) => file.includes(pattern)); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function pattern3( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const pattern = `第${String(season).padStart(2, "0")}季第${String(episode).padStart(2, "0")}集`; + const target = videoFiles.find((file) => file.includes(pattern)); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function pattern4( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + const numberOfSeasons = [...new Set(episodes.map((i) => i.season))]; + if (numberOfSeasons.length !== 1) return []; + const ret: RecognizedEpisode[] = []; + for (const { season, episode } of episodes) { + const regex = new RegExp(`[\\s.\\-_]+${episode}\\.\\w+$`, "i"); + const target = videoFiles.find((file) => regex.test(basename(file))); + if (target !== undefined) ret.push({ season, episode, file: target }); + } + return ret; +} + +export function buildEpisodes(mm: MediaMetadata): { season: number; episode: number }[] { + const ret: { season: number; episode: number }[] = []; + for (const season of mm.tvShow?.seasons ?? []) { + for (const episode of season.episodes ?? []) { + ret.push({ season: episode.season, episode: episode.episode }); + } + } + return ret; +} + +export function preciselyRecognizeEpisodes( + episodes: { season: number; episode: number }[], + videoFiles: string[], +): RecognizedEpisode[] { + let ret = pattern1(episodes, videoFiles); + if (ret.length > 0) return ret; + ret = pattern2(episodes, videoFiles); + if (ret.length > 0) return ret; + ret = pattern3(episodes, videoFiles); + if (ret.length > 0) return ret; + return pattern4(episodes, videoFiles); +} + +export function recognizeEpisodes(mm: MediaMetadata): RecognizedEpisode[] { + if ( + mm.files === undefined || + mm.files === null || + mm.files.length === 0 || + mm.tvShow === undefined || + mm.tvShow.seasons === undefined || + mm.tvShow.seasons.length === 0 + ) { + return []; + } + let videoFiles = mm.files.filter(isVideoFile); + videoFiles = excludeFiles(videoFiles); + if (videoFiles.length === 0) return []; + const episodes = buildEpisodes(mm); + if (episodes.length === 0) return []; + return preciselyRecognizeEpisodes(episodes, videoFiles); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/recognizeEpisodes.ts +git commit -m "feat(core-app): port episode filename pattern matching to core" +``` + +--- + +## Task 11: recognizeMediaFolder + +**Files:** +- Create: `apps/core/src/pipeline/recognizeMediaFolder.ts` +- Test: `apps/core/src/pipeline/recognizeMediaFolder.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/pipeline/recognizeMediaFolder.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata, TvShowMediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import type { TvdbClient } from "../clients/TvdbClient"; +import type { TmdbClient } from "../clients/TmdbClient"; +import { + getTmdbIdFromFolderName, + getTvdbIdFromFolderName, + recognizeMediaFolder, + type RecognitionDeps, +} from "./recognizeMediaFolder"; + +const tvShow: TvShowMediaMetadata = { + database: "TMDB", + id: "1", + name: "My Show", + seasons: [{ season: 1, name: "S1", episodes: [] }], +}; + +function fsPort(files: Record): FsPort { + return { + readTextFile: vi.fn(async (path: string) => { + const content = files[path]; + if (content === undefined) throw new Error("ENOENT: " + path); + return content; + }), + writeTextFile: vi.fn(async () => {}), + exists: vi.fn(async () => true), + listFiles: vi.fn(async () => []), + }; +} + +function deps(overrides: Partial = {}): RecognitionDeps { + return { + fs: fsPort({}), + tmdb: { + search: vi.fn(), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + } as unknown as TmdbClient, + tvdb: { + searchSeries: vi.fn(), + searchMovie: vi.fn(), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + } as unknown as TvdbClient, + language: "en-US", + primaryDatabase: "TMDB", + ...overrides, + }; +} + +describe("getTmdbIdFromFolderName / getTvdbIdFromFolderName", () => { + it("parses (tmdbid=123) and (tvdbid=456)", () => { + expect(getTmdbIdFromFolderName("Show (tmdbid=123)")).toBe("123"); + expect(getTvdbIdFromFolderName("Show [tvdbid=456]")).toBe("456"); + expect(getTmdbIdFromFolderName("Plain Name")).toBeNull(); + }); +}); + +describe("recognizeMediaFolder", () => { + it("recognizes via tmdbid in folder name (tvshow)", async () => { + const d = deps(); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show (tmdbid=1)", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow).toEqual(tvShow); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(1, "en-US"); + }); + + it("recognizes via tvshow.nfo (tvshow)", async () => { + const d = deps({ fs: fsPort({ "/m/My.Show/tvshow.nfo": "7" }) }); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show", type: "tvshow-folder", files: ["/m/My.Show/tvshow.nfo", "/m/My.Show/S01E01.mkv"] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(7, "en-US"); + }); + + it("searches TMDB by folder name when no id present", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ results: [{ id: 9, name: "My Show" }] }); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tmdb.search).toHaveBeenCalledWith("My Show", "tv", "en-US"); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(9, "en-US"); + }); + + it("searches TVDB before TMDB when primaryDatabase is TVDB", async () => { + const d = deps({ primaryDatabase: "TVDB" }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue([ + { objectID: "series-5", name: "My Show", tvdb_id: "5" }, + ]); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("1"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(5, "en-US"); + expect(d.tmdb.search).not.toHaveBeenCalled(); + }); + + it("recognizes a movie by exact TMDB title match", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ + results: [{ id: 2, title: "My Film" }], + }); + + const mm: MediaMetadata = { mediaFolderPath: "/m/My Film", type: "movie-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie).toEqual({ id: "2", name: "My Film", database: "TMDB" }); + }); + + it("returns an empty result when nothing matches", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ results: [] }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue(undefined); + + const mm: MediaMetadata = { mediaFolderPath: "/m/Unknown", type: "tvshow-folder", files: [] }; + const result = await recognizeMediaFolder(mm, d); + + expect(result).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./recognizeMediaFolder` cannot be resolved. + +- [ ] **Step 3: Implement `recognizeMediaFolder`** + +`apps/core/src/pipeline/recognizeMediaFolder.ts`: + +```ts +import type { + MediaMetadata, + MovieMediaMetadata, + PrimaryDatabase, + TMDBMovie, + TMDBTVShow, + TvShowMediaMetadata, +} from "@smm/core"; +import type { TVDBv4SearchResult } from "@smm/tvdb4"; +import type { TmdbClient } from "../clients/TmdbClient"; +import { movieMediaMetadataFromTmdbSearch } from "../clients/TmdbClient"; +import type { TvdbClient } from "../clients/TvdbClient"; +import type { FsPort } from "../ports/FsPort"; +import { parseNfo } from "./nfo"; +import { basename } from "./paths"; + +export interface RecognitionDeps { + fs: FsPort; + tmdb: TmdbClient; + tvdb: TvdbClient; + language: string; + primaryDatabase?: PrimaryDatabase; +} + +export interface RecognitionResult { + tvShow?: TvShowMediaMetadata; + movie?: MovieMediaMetadata; +} + +const TMDB_ID_IN_FOLDER_RE = /(?:[[({])\s*tmdbid\s*=\s*(\d+)\s*[\])}]/i; +const TVDB_ID_IN_FOLDER_RE = /(?:[[({])\s*tvdbid\s*=\s*(\d+)\s*[\])}]/i; + +export function getTmdbIdFromFolderName(folderName: string): string | null { + const match = folderName.match(TMDB_ID_IN_FOLDER_RE); + return match === null ? null : match[1]!; +} + +export function getTvdbIdFromFolderName(folderName: string): string | null { + const match = folderName.match(TVDB_ID_IN_FOLDER_RE); + return match === null ? null : match[1]!; +} + +export function resolveTvdbSeriesId(item: TVDBv4SearchResult): number | undefined { + const oid = item.objectID ?? item.id; + if (oid.startsWith("series-")) { + const n = parseInt(oid.slice("series-".length), 10); + if (Number.isFinite(n) && n > 0) return n; + } + const raw = item.tvdb_id; + if (raw !== undefined) { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n > 0) return n; + } + return undefined; +} + +export function resolveTvdbMovieId(item: TVDBv4SearchResult): number | undefined { + const oid = item.objectID ?? item.id; + if (/^movie-/i.test(oid)) { + const n = parseInt(oid.replace(/^movie-/i, ""), 10); + if (Number.isFinite(n) && n > 0) return n; + } + const raw = item.tvdb_id; + if (raw !== undefined) { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n > 0) return n; + } + return undefined; +} + +function folderNameOf(mm: MediaMetadata): string { + return basename(mm.mediaFolderPath ?? ""); +} + +async function recognizeByNfo( + mm: MediaMetadata, + deps: RecognitionDeps, + result: RecognitionResult, + isTvShow: boolean, +): Promise { + const nfoName = isTvShow ? "tvshow.nfo" : "movie.nfo"; + const nfoPath = (mm.files ?? []).find((f) => f.endsWith(`/${nfoName}`)); + if (nfoPath === undefined) return; + + let xml: string; + try { + xml = await deps.fs.readTextFile(nfoPath); + } catch { + return; + } + const nfo = parseNfo(xml); + + if (nfo.tmdbid !== undefined) { + const n = parseInt(nfo.tmdbid, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + return; + } + + if (nfo.tvdbid !== undefined) { + const n = parseInt(nfo.tvdbid, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + } +} + +async function searchInTmdb( + folderName: string, + isTvShow: boolean, + deps: RecognitionDeps, + result: RecognitionResult, +): Promise { + try { + if (isTvShow) { + const body = await deps.tmdb.search(folderName, "tv", deps.language); + const first = body.results[0] as TMDBTVShow | undefined; + if (first !== undefined) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(first.id, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } + } else { + const body = await deps.tmdb.search(folderName, "movie", deps.language); + for (const item of body.results) { + const movie = item as TMDBMovie; + if (movie.title === folderName) { + // Build from the search item, matching the UI reference + // (`tryToRecognizeMediaFolderBySearchingFolderNameInTMDB` does not + // do a second metadata fetch for movies). + result.movie = movieMediaMetadataFromTmdbSearch(movie); + return; + } + } + } + } catch { + // recognition is best-effort; fall through to the next phase + } +} + +async function searchInTvdb( + folderName: string, + isTvShow: boolean, + deps: RecognitionDeps, + result: RecognitionResult, +): Promise { + try { + if (isTvShow) { + const items = await deps.tvdb.searchSeries(folderName, deps.language); + for (const item of items ?? []) { + const id = resolveTvdbSeriesId(item); + if (id === undefined) continue; + const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, deps.language); + if (tvShow !== undefined) { + result.tvShow = tvShow; + return; + } + } + } else { + const items = await deps.tvdb.searchMovie(folderName, deps.language); + for (const item of items ?? []) { + if (item.name === folderName) { + const id = resolveTvdbMovieId(item); + if (id === undefined) continue; + const movie = await deps.tvdb.getMovieMediaMetadata(id, deps.language); + if (movie !== undefined) { + result.movie = movie; + return; + } + } + } + } + } catch { + // best-effort + } +} + +/** + * Mirrors `recognizeMediaFolder` in `apps/ui/src/lib/recognizeMediaFolder.ts`: + * NFO → tmdbid in folder name → tvdbid in folder name → search by folder name + * (ordered by primaryDatabase). Only reached for tvshow / movie folders. + */ +export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionDeps): Promise { + const result: RecognitionResult = {}; + const folderName = folderNameOf(mm); + const isTvShow = mm.type === "tvshow-folder"; + + await recognizeByNfo(mm, deps, result, isTvShow); + + const tmdbId = getTmdbIdFromFolderName(folderName); + if (tmdbId !== null && result.tvShow === undefined && result.movie === undefined) { + const n = parseInt(tmdbId, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tmdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tmdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + } + + const tvdbId = getTvdbIdFromFolderName(folderName); + if (tvdbId !== null && result.tvShow === undefined && result.movie === undefined) { + const n = parseInt(tvdbId, 10); + if (n > 0) { + if (isTvShow) { + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + if (tvShow !== undefined) result.tvShow = tvShow; + } else { + const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + if (movie !== undefined) result.movie = movie; + } + } + } + + const order: Array<"TMDB" | "TVDB"> = deps.primaryDatabase === "TVDB" ? ["TVDB", "TMDB"] : ["TMDB", "TVDB"]; + for (const db of order) { + if (result.tvShow !== undefined || result.movie !== undefined) break; + if (db === "TMDB") await searchInTmdb(folderName, isTvShow, deps, result); + else await searchInTvdb(folderName, isTvShow, deps, result); + } + + return result; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/recognizeMediaFolder.ts +git commit -m "feat(core-app): add media folder recognition orchestrator (NFO → id → search)" +``` + +--- + +## Task 12: userConfig read/write + importFolderPipeline + +**Files:** +- Create: `apps/core/src/pipeline/userConfig.ts` +- Create: `apps/core/src/pipeline/importFolderPipeline.ts` +- Test: `apps/core/src/pipeline/importFolderPipeline.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/pipeline/importFolderPipeline.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import type { NetworkPort } from "../ports/NetworkPort"; +import { NoopLoggerAdapter } from "../adapters/ConsoleLoggerAdapter"; +import { ImportFolderPipeline } from "./importFolderPipeline"; +import { userConfigPath, metadataCachePath } from "./paths"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const out: string[] = []; + for (const key of files.keys()) { + if (key.startsWith(dir + "/") && !key.endsWith("/")) out.push(key); + } + return out; + }), + }; +} + +function emptyNetwork(): NetworkPort { + return { fetch: vi.fn() as never }; +} + +function makePipeline(options: { seed?: Record; appDataDir?: string } = {}) { + const appDataDir = options.appDataDir ?? "/data/smm"; + const fs = inMemoryFs(options.seed); + const network = emptyNetwork(); + const logger = new NoopLoggerAdapter(); + return { + fs, + network, + pipeline: new ImportFolderPipeline({ fs, network, logger, appDataDir }), + appDataDir, + }; +} + +describe("ImportFolderPipeline", () => { + it("adds the folder to userConfig, lists files, persists metadata", async () => { + const mediaDir = "/m/My.Show"; + const { fs, pipeline, appDataDir } = makePipeline({ + seed: { + "/m/My.Show/S01E01.mkv": "", + "/m/My.Show/poster.jpg": "", + }, + }); + + const mm = await pipeline.run(mediaDir, "music"); + + expect(mm.type).toBe("music-folder"); + expect(mm.mediaFolderPath).toBe(mediaDir); + expect(mm.files?.sort()).toEqual(["/m/My.Show/S01E01.mkv", "/m/My.Show/poster.jpg"]); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath(appDataDir))) as string); + expect(savedConfig.folders).toContain(mediaDir); + + const cached = JSON.parse((await fs.readTextFile(metadataCachePath(appDataDir, mediaDir))) as string); + expect(cached.type).toBe("music-folder"); + }); + + it("dedupes an already-present folder in userConfig", async () => { + const mediaDir = "/m/My.Show"; + const { fs, pipeline, appDataDir } = makePipeline({ + seed: { [userConfigPath("/data/smm")]: JSON.stringify({ folders: [mediaDir] }) }, + }); + + await pipeline.run(mediaDir, "music"); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath(appDataDir))) as string); + expect(savedConfig.folders).toEqual([mediaDir]); + }); + + it("recognizes a tvshow and matches episodes via SXXEYY", async () => { + const mediaDir = "/m/My.Show"; + const { pipeline } = makePipeline({ + seed: { + "/m/My.Show/S01E01.mkv": "", + "/m/My.Show/S01E02.mkv": "", + "/m/My.Show/tvshow.nfo": "1", + }, + }); + + const mm = await pipeline.run(mediaDir, "tvshow"); + + expect(mm.tvShow?.database).toBe("TMDB"); + expect(mm.mediaFiles).toEqual([ + { absolutePath: "/m/My.Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: "/m/My.Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + ]); + }); +}); +``` + +Note: the third test's tvshow recognition requires a real TmdbClient network call. This test relies on the `recognizeMediaFolder` module being mocked — handled in Step 3 by `vi.mock("./recognizeMediaFolder")`. If the mock is not used, the test will fail because the empty `NetworkPort` cannot serve TMDB. The mock is defined in the test file's Step 3 implementation. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./userConfig` and `./importFolderPipeline` cannot be resolved. + +- [ ] **Step 3: Implement `userConfig.ts`, `importFolderPipeline.ts`, and add the mock to the test** + +`apps/core/src/pipeline/userConfig.ts`: + +```ts +import type { UserConfig } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { userConfigPath } from "./paths"; + +export const DEFAULT_USER_CONFIG: UserConfig = { + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", +}; + +export async function readUserConfig(fs: FsPort, appDataDir: string): Promise { + const path = userConfigPath(appDataDir); + if (!(await fs.exists(path))) return { ...DEFAULT_USER_CONFIG }; + const content = await fs.readTextFile(path); + return { ...DEFAULT_USER_CONFIG, ...(JSON.parse(content) as Partial) }; +} + +export async function writeUserConfig(fs: FsPort, appDataDir: string, config: UserConfig): Promise { + await fs.writeTextFile(userConfigPath(appDataDir), JSON.stringify(config, null, 2)); +} +``` + +`apps/core/src/pipeline/importFolderPipeline.ts`: + +```ts +import { Path } from "@core/path"; +import type { FolderType, MediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import type { NetworkPort } from "../ports/NetworkPort"; +import type { LoggerPort } from "../ports/LoggerPort"; +import type { JobStage } from "../jobs/types"; +import { TmdbClient } from "../clients/TmdbClient"; +import { TvdbClient } from "../clients/TvdbClient"; +import { isVideoFile, recognizeEpisodes } from "./recognizeEpisodes"; +import { recognizeMediaFolder } from "./recognizeMediaFolder"; +import { metadataCachePath } from "./paths"; +import { readUserConfig, writeUserConfig } from "./userConfig"; + +export interface ImportFolderPipelineOptions { + fs: FsPort; + network: NetworkPort; + logger: LoggerPort; + appDataDir: string; +} + +export interface ImportFolderPipelineCallbacks { + onStage?: (stage: JobStage, progress: number) => void; +} + +function mediaMetadataType(type: FolderType): MediaMetadata["type"] { + return type === "tvshow" ? "tvshow-folder" : type === "movie" ? "movie-folder" : "music-folder"; +} + +export class ImportFolderPipeline { + constructor(private readonly options: ImportFolderPipelineOptions) {} + + async run(folderPath: string, type: FolderType, cb: ImportFolderPipelineCallbacks = {}): Promise { + const { fs, logger, appDataDir, network } = this.options; + const posixPath = Path.posix(folderPath); + const stages: JobStage[] = []; + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=config"); + const userConfig = await readUserConfig(fs, appDataDir); + const folders = [...new Set([...userConfig.folders, folderPath])]; + await writeUserConfig(fs, appDataDir, { ...userConfig, folders }); + stages.push("config"); + cb.onStage?.("config", 10); + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=metadata"); + const mm: MediaMetadata = { + mediaFolderPath: posixPath, + type: mediaMetadataType(type), + files: [], + mediaFiles: [], + }; + stages.push("metadata"); + cb.onStage?.("metadata", 25); + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=listFiles"); + const listed = await fs.listFiles(posixPath); + mm.files = listed.map((f) => Path.posix(f)); + stages.push("listFiles"); + cb.onStage?.("listFiles", 40); + + if (type === "tvshow" || type === "movie") { + const language = userConfig.preferMediaLanguage ?? "en-US"; + const tmdb = new TmdbClient(network, userConfig.tmdb); + const tvdb = new TvdbClient(network, userConfig.tvdb); + + logger.info({ folderPath: posixPath, language }, "importFolder: stage=recognize"); + const result = await recognizeMediaFolder(mm, { + fs, + tmdb, + tvdb, + language, + primaryDatabase: userConfig.primaryDatabase, + }); + if (result.tvShow !== undefined) mm.tvShow = result.tvShow; + if (result.movie !== undefined) mm.movie = result.movie; + stages.push("recognize"); + cb.onStage?.("recognize", 60); + + logger.info({ folderPath: posixPath }, "importFolder: stage=episodes"); + if (type === "tvshow" && mm.tvShow !== undefined) { + mm.mediaFiles = recognizeEpisodes(mm).map((i) => ({ + absolutePath: i.file, + seasonNumber: i.season, + episodeNumber: i.episode, + })); + } else if (type === "movie" && mm.movie !== undefined) { + const firstVideo = (mm.files ?? []).find(isVideoFile); + mm.mediaFiles = firstVideo === undefined ? [] : [{ absolutePath: firstVideo }]; + } + stages.push("episodes"); + cb.onStage?.("episodes", 80); + } + + logger.info({ folderPath: posixPath }, "importFolder: stage=persist"); + const { files: _files, ...mmToPersist } = mm; + await fs.writeTextFile(metadataCachePath(appDataDir, posixPath), JSON.stringify(mmToPersist, null, 2)); + stages.push("persist"); + cb.onStage?.("persist", 95); + + return mm; + } +} +``` + +Update `apps/core/src/pipeline/importFolderPipeline.test.ts` — add at the top of the file (before the `inMemoryFs` helper), a module mock so the recognize stage is deterministic: + +```ts +vi.mock("./recognizeMediaFolder", () => ({ + recognizeMediaFolder: vi.fn(async () => { + return { tvShow: undefined, movie: undefined }; + }), +})); +``` + +and import the mocked module to control it in the tvshow test: + +```ts +import { recognizeMediaFolder } from "./recognizeMediaFolder"; +const mockRecognizeMediaFolder = recognizeMediaFolder as ReturnType; +``` + +In the tvshow test, before `pipeline.run`, add: + +```ts +mockRecognizeMediaFolder.mockResolvedValue({ + tvShow: { + database: "TMDB", + id: "1", + name: "My Show", + seasons: [{ season: 1, name: "Season 1", episodes: [{ season: 1, episode: 1, name: "E1" }, { season: 1, episode: 2, name: "E2" }] }], + }, +}); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS — all three pipeline tests green. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/userConfig.ts apps/core/src/pipeline/importFolderPipeline.ts +git commit -m "feat(core-app): add importFolder pipeline orchestration (config→persist)" +``` + +--- + +## Task 13: Core class + +**Files:** +- Create: `apps/core/src/Core.ts` +- Test: `apps/core/src/Core.test.ts` + +- [ ] **Step 1: Write the failing test** + +`apps/core/src/Core.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "./ports/FsPort"; +import type { NetworkPort } from "./ports/NetworkPort"; +import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +import { Core } from "./Core"; +import { userConfigPath } from "./pipeline/paths"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async (dir: string) => { + const out: string[] = []; + for (const key of files.keys()) { + if (key.startsWith(dir + "/") && !key.endsWith("/")) out.push(key); + } + return out; + }), + }; +} + +/** Network that satisfies the empty-seed recognition path (returns no results). */ +function emptyNetwork(): NetworkPort { + return { + fetch: vi.fn(async (url: string) => { + const body = + url.includes("/api/tmdb/") || url.includes("tmdb") + ? { results: [], page: 1, total_pages: 1, total_results: 0 } + : { status: "success", data: [] }; + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; + }) as never, + }; +} + +async function waitForStatus(core: Core, id: string, status: string): Promise { + const started = Date.now(); + for (;;) { + const job = core.getJob(id); + if (job?.status === status || job?.status === "failed" || job?.status === "aborted") return; + if (Date.now() - started > 5000) throw new Error(`timeout waiting for ${status}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("Core", () => { + it("importFolder runs the pipeline and succeeds", async () => { + const fs = inMemoryFs({ "/m/My.Music/a.mp3": "" }); + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importFolder("/m/My.Music", "music"); + expect(core.getJob(id)).toBeDefined(); + + await waitForStatus(core, id, "succeeded"); + + const job = core.getJob(id); + expect(job?.status).toBe("succeeded"); + expect(job?.progress).toBe(100); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); + expect(savedConfig.folders).toContain("/m/My.Music"); + }); + + it("marks the job failed when the pipeline throws", async () => { + const fs = inMemoryFs(); + const failingFs: FsPort = { + ...fs, + listFiles: vi.fn(async () => { + throw new Error("boom"); + }), + }; + const core = new Core({ + fs: failingFs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importFolder("/m/Broken", "tvshow"); + await waitForStatus(core, id, "failed"); + + const job = core.getJob(id); + expect(job?.status).toBe("failed"); + expect(job?.error).toContain("boom"); + }); + + it("getJob returns undefined for unknown id", () => { + const core = new Core({ + fs: inMemoryFs(), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + expect(core.getJob("nope")).toBeUndefined(); + }); +}); +``` + +Note: the `emptyNetwork` must satisfy BOTH TMDB (`/api/tmdb/search/...`) and TVDB (`/api/tvdb/search?...`) URLs. The mock branches on the presence of `tmdb` in the URL. In the failed-job test, the pipeline throws in `listFiles` before any network call, so the network shape is irrelevant. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm test` +Expected: FAIL — `./Core` cannot be resolved. + +- [ ] **Step 3: Implement `Core.ts`** + +`apps/core/src/Core.ts`: + +```ts +import { Path } from "@core/path"; +import type { FolderType } from "@smm/core"; +import type { FsPort } from "./ports/FsPort"; +import type { NetworkPort } from "./ports/NetworkPort"; +import type { LoggerPort } from "./ports/LoggerPort"; +import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; +import { JobStore } from "./jobs/jobStore"; +import type { ImportJob } from "./jobs/types"; + +export interface CoreOptions { + fs: FsPort; + network: NetworkPort; + logger?: LoggerPort; + /** Root directory holding smm.json and metadata/. */ + appDataDir: string; +} + +export interface ImportFolderHandle { + id: string; +} + +export class Core { + private readonly jobs = new JobStore(); + private readonly fs: FsPort; + private readonly network: NetworkPort; + private readonly logger: LoggerPort; + private readonly appDataDir: string; + + constructor(options: CoreOptions) { + this.fs = options.fs; + this.network = options.network; + this.logger = options.logger ?? new NoopLoggerAdapter(); + this.appDataDir = options.appDataDir; + } + + /** Starts the import pipeline in the background; returns a job handle immediately. */ + importFolder(path: string, type: FolderType): ImportFolderHandle { + const job = this.jobs.create({ + folderPath: Path.posix(path), + type, + status: "running", + stage: "config", + progress: 0, + }); + void this.runImport(job, path, type); + return { id: job.id }; + } + + getJob(id: string): ImportJob | undefined { + return this.jobs.get(id); + } + + private async runImport(job: ImportJob, folderPath: string, type: FolderType): Promise { + try { + const pipeline = new ImportFolderPipeline({ + fs: this.fs, + network: this.network, + logger: this.logger, + appDataDir: this.appDataDir, + }); + await pipeline.run(folderPath, type, { + onStage: (stage, progress) => { + this.jobs.update(job.id, { stage, progress }); + }, + }); + this.jobs.update(job.id, { status: "succeeded", stage: null, progress: 100 }); + } catch (error) { + this.jobs.update(job.id, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/Core.ts +git commit -m "feat(core-app): add Core class with importFolder API and job lifecycle" +``` + +--- + +## Task 14: Public exports + repo integration + +**Files:** +- Create: `apps/core/src/index.ts` +- Modify: `package.json` (root scripts) + +- [ ] **Step 1: Create `apps/core/src/index.ts`** + +```ts +export { Core, type CoreOptions, type ImportFolderHandle } from "./Core"; +export type { FsPort } from "./ports/FsPort"; +export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; +export type { LoggerPort } from "./ports/LoggerPort"; +export { NodejsFsAdapter } from "./adapters/node/NodejsFsAdapter"; +export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/network/NetworkFsAdapter"; +export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; +export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +export { ImportFolderPipeline, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; +export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; +export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; +export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; +export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; +export type { FolderType } from "@smm/core"; +``` + +- [ ] **Step 2: Verify typecheck + full test suite** + +Run: +```bash +cd apps/core && pnpm run typecheck +cd apps/core && pnpm test +``` + +Expected: typecheck clean (exit 0); all tests pass. + +- [ ] **Step 3: Register apps/core in the root package.json scripts** + +In `C:/Users/lawrence/workspace/smm_github/package.json`, add two scripts and append `typecheck:core-app` to the `typecheck` chain: + +```json +"typecheck:core-app": "cd apps/core && pnpm run typecheck", +"test:core-app": "cd apps/core && pnpm test", +``` + +and change the `typecheck` script from: + +```json +"typecheck": "pnpm run typecheck:core && pnpm run typecheck:core-routes && pnpm run typecheck:electron-common && pnpm run typecheck:cli && pnpm run typecheck:ui && pnpm run typecheck:e2e && pnpm run typecheck:electron", +``` + +to: + +```json +"typecheck": "pnpm run typecheck:core && pnpm run typecheck:core-routes && pnpm run typecheck:electron-common && pnpm run typecheck:cli && pnpm run typecheck:ui && pnpm run typecheck:e2e && pnpm run typecheck:electron && pnpm run typecheck:core-app", +``` + +Note: the root `test` script is `pnpm -r test`, which already runs `apps/core`'s `vitest run` because it is a workspace member with a `test` script — no change needed there. + +- [ ] **Step 4: Verify the root scripts** + +Run: +```bash +cd C:/Users/lawrence/workspace/smm_github && pnpm run test:core-app +cd C:/Users/lawrence/workspace/smm_github && pnpm run typecheck:core-app +``` + +Expected: tests pass; typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/index.ts package.json +git commit -m "feat(core-app): export public API and register apps/core scripts at repo root" +``` + +--- + +## Self-Review + +**Spec coverage:** +- §2.1 Project-level architecture (Ports + adapters, core internal clients) → Tasks 2–4, 8–9, 14. +- §2.2 App-level file layout → Task 1 + all file paths match the tree. +- §2.3 Ports interfaces → Task 2 (FsPort/NetworkPort/LoggerPort signatures match the spec). +- §2.3 Core class (`importFolder` returns `{id}`, `getJob`) → Task 13. +- §2.3 importFolder pipeline stages 1–6 → Task 12 (config → metadata → listFiles → recognize → episodes → persist, with the spec's progress boundaries). +- §2.3 Job model (`ImportJob` fields, in-memory Map, no cancel/timeout/persistence) → Tasks 6, 13. +- §3.1 tvshow import → Tasks 12 (mock-driven) + 11 (real recognition). +- §3.2 movie import → Task 11 (`movieMediaMetadataFromTmdbSearch`, exact title match) + Task 12 movie branch (first video as `mediaFiles[0]`). +- §3.3 failure → failed job with `error` → Task 13 test 2. +- §3.4 dedupe → Task 12 test 2. +- §3.5 music skip → Task 12 test 1 (config/metadata/listFiles/persist, no recognize). +- `preferMediaLanguage ?? 'en-US'` → Task 12 pipeline (language default). + +**Placeholder scan:** every task has complete code, exact file paths, and exact commands. No TBD/TODO. The one cross-task mock (`vi.mock("./recognizeMediaFolder")` in Task 12) is spelled out with the exact import lines and the mock body. + +**Type consistency:** +- `TmdbClient` methods used by `recognizeMediaFolder`: `search`, `getTvShowMediaMetadata`, `getMovieMediaMetadata` — all defined in Task 8. +- `TvdbClient` methods used: `searchSeries`, `searchMovie`, `getTvShowMediaMetadata`, `getMovieMediaMetadata` — all defined in Task 9. +- `recognizeMediaFolder`'s `RecognitionDeps` includes `fs` (for NFO) — pipeline passes it in Task 12. +- `ImportJob.stage` type is `JobStage` (`"config"|...|null`); pipeline emits stage names that all exist in `JobStage`. `Core` sets `stage: null` on completion — allowed. +- `nextJobId`/`JobStore` used only by `Core`; `JobStore.create` signature matches the `Core` call. +- Path helpers (`joinPosix`, `basename`, `extname`) are shared by `NodejsFsAdapter` test (Task 3) and `recognizeEpisodes` (Task 10) — identical names throughout. +- `FolderType` comes from `@smm/core` everywhere. + +**Host-independence caveat (flagged, not blocking):** `NodejsFsAdapter` and the Core/Pipeline tests exercise POSIX round-trips via `Path.posix` / `Path.toPlatformPath`, so they are deterministic on both Windows and POSIX CI. The `NetworkFsAdapter` contract is implemented per the current `core-routes` endpoints (`POST /api/readFile|writeFile|listFiles`); wiring it to a live Layer 3 server is future work. From 51eb44cfa3bef87e4629677b8bf2af4877987b2c Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 15:31:25 +0800 Subject: [PATCH 029/214] docs: add apps/core read/query and unimport API design spec Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-08-17-core-read-apis-design.md | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-17-core-read-apis-design.md diff --git a/docs/superpowers/specs/2026-08-17-core-read-apis-design.md b/docs/superpowers/specs/2026-08-17-core-read-apis-design.md new file mode 100644 index 00000000..7c8a8565 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-core-read-apis-design.md @@ -0,0 +1,213 @@ +# apps/core 只读查询与移除目录 API 设计 + +本设计文档描述 `apps/core`(Layer 2 Core)新增的 5 个接口: + +`getFolders()`、`unimportFolder(path)`、`getUserConfig()`、`getAppConfig()`、`getMediaMetadata(folder)`。 + +它们让上层(Layer 1 表现层 / Layer 3 宿主)可以查询媒体库状态、读取配置、读取已导入目录的元数据,并移除已导入的目录,是 `core.importFolder(path, type)` 的配套只读 / 移除接口。 + +## 1. Background + +`apps/core` 目前只暴露 `importFolder(path, type)`(后台运行流水线)与 `getJob(id)`(查询任务状态)。上层尚无方式: + +- 查询已导入的媒体目录列表(`userConfig.folders`); +- 读取用户配置 / 应用配置; +- 读取某个已导入目录的元数据(`/metadata/*.json` 缓存); +- 移除一个已导入的目录(从配置中删除 + 清理元数据缓存)。 + +按 [refactoring.md](../../../refactoring.md) 的目标架构,这些查询与命令都应作为 Core 用例下沉到 Layer 2,表现层通过 HTTP / Socket.IO 间接调用,不直接操作文件。 + +## 2. Architecture + +新方法全部挂在 `Core` 类上,复用已有辅助函数: + +- `pipeline/userConfig.ts` 的 `readUserConfig` / `writeUserConfig`; +- `pipeline/paths.ts` 的 `metadataCachePath`。 + +新增唯一的基础能力是 `FsPort.deleteFile`(`unimportFolder` 需要删除元数据缓存文件),两个既有适配器各加一个实现。 + +``` +Core 类 + ├─ getAppConfig() → 读构造参数(同步) + ├─ getUserConfig() → readUserConfig(fs, appDataDir) + ├─ getFolders() → readUserConfig(fs, appDataDir).folders + ├─ getMediaMetadata() → fs.exists + fs.readTextFile(metadataCachePath) + └─ unimportFolder() → readUserConfig + writeUserConfig + fs.deleteFile(metadataCachePath) + │ + ▼ + FsPort(适配器实现) +``` + +依赖方向保持不变:Core 逻辑只依赖 Ports 接口,不直接访问平台 API。 + +## 3. API 设计 + +### 3.1 `CoreOptions` 扩展 + +`getAppConfig()` 返回 `AppConfig { version, userDataDir?, reverseProxyUrl }`,但 Core 目前只知道 `appDataDir`。 +`version` / `reverseProxyUrl` / `userDataDir` 是宿主(Layer 3)提供的应用级信息,因此在构造时注入: + +```ts +export interface CoreOptions { + fs: FsPort; + network: NetworkPort; + logger?: LoggerPort; + appDataDir: string; + /** App version string (e.g. "1.3.8"). getAppConfig() 缺省为 "". */ + version?: string; + /** Reverse proxy base URL. getAppConfig() 缺省为 null. */ + reverseProxyUrl?: string | null; + /** getAppConfig() 报告的 userDataDir;缺省回落到 appDataDir。 */ + userDataDir?: string; +} +``` + +### 3.2 新方法 + +| 方法 | 同步/异步 | 返回 | 说明 | +|------|-----------|------|------| +| `getAppConfig()` | 同步 | `AppConfig` | `{ version: version ?? "", userDataDir: userDataDir ?? appDataDir, reverseProxyUrl: reverseProxyUrl ?? null }`;不访问 fs | +| `getUserConfig()` | 异步 | `Promise` | `readUserConfig(fs, appDataDir)`;无文件时返回默认配置合并结果 | +| `getFolders()` | 异步 | `Promise` | `(await readUserConfig(...)).folders` | +| `getMediaMetadata(folder)` | 异步 | `Promise` | 读元数据缓存;不存在 / JSON 解析失败返回 `null` | +| `unimportFolder(path)` | 异步 | `Promise` | 从配置移除 + 删除元数据缓存;不在配置中时静默 no-op | + +#### `getMediaMetadata(folder: string)` + +```ts +async getMediaMetadata(folder: string): Promise { + const posixPath = this.normalizePosix(folder); + const cachePath = metadataCachePath(this.appDataDir, posixPath); + if (!(await this.fs.exists(cachePath))) return null; + try { + const content = await this.fs.readTextFile(cachePath); + return JSON.parse(content) as MediaMetadata; + } catch { + return null; + } +} +``` + +- 语义对齐 core-routes 的 `readMediaMetadataCache`(读 `{appDataDir}/metadata/{sanitized-folder}.json`)。 +- **只读缓存、不补 `files`**:`importFolder` 持久化时已剔除 `files` 字段,本方法返回的 `MediaMetadata.files` 保持 `undefined`。上层需要文件清单时应自行 `fs.listFiles`。 +- 不做「目录是否已导入」校验:未导入且无缓存 → 返回 `null`,调用方可先用 `getFolders()` 判断。 +- 复用 `importFolder` 已有的防御式路径规范化(非法/相对路径在 `Path.posix` 抛错时回落到原字符串)。 + +#### `unimportFolder(path: string)` + +```ts +async unimportFolder(path: string): Promise { + const posixPath = this.normalizePosix(path); + const config = await readUserConfig(this.fs, this.appDataDir); + const folders = config.folders.filter((f) => this.normalizePosix(f) !== posixPath); + if (folders.length === config.folders.length) return; // 不在配置中,no-op + await writeUserConfig(this.fs, this.appDataDir, { ...config, folders }); + await this.fs.deleteFile(metadataCachePath(this.appDataDir, posixPath)); +} +``` + +- **匹配按 POSIX 规范化比较**:`importFolder` 存进 `folders` 的是调用方传入的原始路径(平台格式或 POSIX 格式),故对存储项与被删路径两侧都做 `Path.posix` 规范化再比较,两种输入都能命中。 +- **幂等**:文件夹不在配置中时直接返回,不写配置、不删缓存(对齐核心层的防御式风格)。 +- 只有确认文件夹在配置中才会删除缓存文件,避免误删孤儿文件。 + +#### 私有辅助 `normalizePosix` + +三个方法(`getMediaMetadata`、`unimportFolder`、以及既有的 `importFolder`)都需要「`Path.posix` 失败时回落原字符串」。 +新增私有方法统一这一行为;`importFolder` 同步改为调用它(行为不变,纯内联重构): + +```ts +private normalizePosix(path: string): string { + try { + return Path.posix(path); + } catch { + return path; + } +} +``` + +### 3.3 同步/异步边界 + +- `getAppConfig()` 不访问 fs,**同步**返回。 +- 其余 4 个方法都经 fs 读取/写入,**异步**返回。 + +## 4. FsPort 扩展 + +`unimportFolder` 需要删除元数据缓存文件,而 `FsPort` 目前只有读 / 写 / 存在 / 列表,没有删除能力。新增: + +```ts +export interface FsPort { + readTextFile(path: string): Promise; + writeTextFile(path: string, content: string): Promise; + exists(path: string): Promise; + listFiles(dir: string): Promise; + /** Delete a file; missing files count as success (idempotent). */ + deleteFile(path: string): Promise; +} +``` + +`deleteFile` 定义为**幂等**(文件不存在视为成功),与 core-routes 的 `doDeleteFile` / `deleteMediaMetadataCache` 语义一致。 + +### 4.1 NodejsFsAdapter + +```ts +async deleteFile(path: string): Promise { + try { + await fsp.unlink(Path.toPlatformPath(path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} +``` + +### 4.2 NetworkFsAdapter + +映射到既有的 `POST /api/deleteFile`(请求体 `{ path }`,成功返回 `{ data }`,错误返回 `{ error }`): + +```ts +async deleteFile(path: string): Promise { + const json = await this.post<{ error?: string }>("/api/deleteFile", { path }); + if (json.error !== undefined) throw new Error(json.error); +} +``` + +## 5. 错误处理 + +- `getMediaMetadata`:缓存不存在 / 读取失败 / JSON 解析失败 → 返回 `null`(不抛错,对齐 core-routes 语义)。 +- `unimportFolder`:不在配置中 → 静默 no-op;写配置或删文件失败 → 正常向上抛错,由调用方处理。 +- `getUserConfig` / `getFolders`:读配置失败 → 向上抛错。 +- `getAppConfig`:不访问 fs,无失败路径。 + +## 6. 测试计划 + +在 `Core.test.ts` 新增 describe(`inMemoryFs` 增加 `deleteFile`:从 map 中删除,不存在时静默成功): + +- `getAppConfig`:注入值原样返回;未注入时 `version=""`、`userDataDir=appDataDir`、`reverseProxyUrl=null`。 +- `getUserConfig`:无文件返回默认配置;有文件返回合并后的配置。 +- `getFolders`:返回配置中的 folders 列表。 +- `getMediaMetadata`:返回缓存元数据;无缓存返回 `null`;缓存 JSON 损坏返回 `null`。 +- `unimportFolder`:从配置移除该路径并删除缓存文件;路径不在配置中时 no-op 且不删缓存;平台格式与 POSIX 格式输入都能命中。 +- `importFolder` 的防御式路径规范化仍通过既有测试(重构 `normalizePosix` 后行为不变)。 + +适配器测试: + +- `NodejsFsAdapter.test.ts`:`deleteFile` 删除存在文件;ENOENT 静默成功;其他错误向上抛。 +- `NetworkFsAdapter.test.ts`:`deleteFile` 调用 `/api/deleteFile`,成功不抛错;返回 `error` 时抛错。 + +其余 3 个 FsPort 型 mock(`recognizeMediaFolder.test.ts`、`importFolderPipeline.test.ts`、`importFolderPipeline.integration.test.ts`)补上 `deleteFile` 实现以免类型报错。 + +## 7. 涉及文件 + +- 改:`apps/core/src/Core.ts`(`CoreOptions` 扩展 + 5 个方法 + `normalizePosix` 私有辅助 + `importFolder` 复用辅助) +- 改:`apps/core/src/ports/FsPort.ts`(`deleteFile`) +- 改:`apps/core/src/adapters/node/NodejsFsAdapter.ts` +- 改:`apps/core/src/adapters/network/NetworkFsAdapter.ts` +- 改:`apps/core/src/Core.test.ts`、`apps/core/src/adapters/node/NodejsFsAdapter.test.ts`、`apps/core/src/adapters/network/NetworkFsAdapter.test.ts` +- 改:`apps/core/src/pipeline/recognizeMediaFolder.test.ts`、`apps/core/src/pipeline/importFolderPipeline.test.ts`、`apps/core/src/pipeline/importFolderPipeline.integration.test.ts`(机械性补 `deleteFile`) +- `apps/core/src/index.ts` 无需改动(方法挂在类上,类型均已导出) + +## 8. 不做的事(YAGNI) + +- 不在 `getMediaMetadata` 里补 `files` 实时文件清单(上层按需自取)。 +- 不做「目录是否已导入」校验(调用方用 `getFolders()` 判断)。 +- 不新增独立的服务类(方法足够简单,直接挂 `Core`)。 +- 不改动既有流水线 / 识别 / 任务逻辑,仅 `importFolder` 内联复用 `normalizePosix`。 From 146c088b33197bd2a454254a26da2aa87d905b6a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 15:33:11 +0800 Subject: [PATCH 030/214] feat(core-app): add deleteFile to FsPort and both fs adapters Co-Authored-By: Claude Opus 4.7 --- apps/core/src/Core.test.ts | 3 +++ .../adapters/network/NetworkFsAdapter.test.ts | 25 +++++++++++++++++++ .../src/adapters/network/NetworkFsAdapter.ts | 5 ++++ .../src/adapters/node/NodejsFsAdapter.test.ts | 16 ++++++++++++ .../core/src/adapters/node/NodejsFsAdapter.ts | 8 ++++++ .../importFolderPipeline.integration.test.ts | 1 + .../src/pipeline/importFolderPipeline.test.ts | 1 + .../src/pipeline/recognizeMediaFolder.test.ts | 1 + apps/core/src/ports/FsPort.ts | 2 ++ 9 files changed, 62 insertions(+) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 2c2a0884..34d6aabc 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -24,6 +24,9 @@ function inMemoryFs(seed: Record = {}): FsPort { } return out; }), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), }; } diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts index 39a3def5..44360ce3 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts @@ -33,6 +33,12 @@ function mockNetwork(): { network: NetworkPort; calls: Array<{ url: string; body data: { path: body.path, items: [{ path: "/m/a.mkv" }, { path: "/m/b.srt" }], size: 0 }, }); } + if (url.endsWith("/api/deleteFile")) { + if (String(body.path).includes("denied")) { + return jsonResponse({ error: "Permission denied: Cannot delete file" }); + } + return jsonResponse({ data: { path: body.path } }); + } throw new Error("unexpected url: " + url); }, }; @@ -82,4 +88,23 @@ describe("NetworkFsAdapter", () => { expect(await adapter.exists("/m/file.txt")).toBe(true); expect(await adapter.exists("/m/missing.txt")).toBe(false); }); + + it("deletes a file via POST /api/deleteFile", async () => { + const { network, calls } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await adapter.deleteFile("/m/file.txt"); + + expect(calls[0]).toEqual({ + url: "http://127.0.0.1:30000/api/deleteFile", + body: { path: "/m/file.txt" }, + }); + }); + + it("throws when deleteFile returns an error", async () => { + const { network } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await expect(adapter.deleteFile("/m/denied.txt")).rejects.toThrow("Permission denied"); + }); }); diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index ddfab30d..7f2be979 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -56,4 +56,9 @@ export class NetworkFsAdapter implements FsPort { if (json.error !== undefined) throw new Error(json.error); return (json.data?.items ?? []).map((i) => i.path); } + + async deleteFile(path: string): Promise { + const json = await this.post<{ error?: string }>("/api/deleteFile", { path }); + if (json.error !== undefined) throw new Error(json.error); + } } diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts index 040d854f..9194ade5 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts @@ -46,4 +46,20 @@ describe("NodejsFsAdapter", () => { const adapter = new NodejsFsAdapter(); expect(await adapter.exists(joinPosix(tmpPosix, "nope.txt"))).toBe(false); }); + + it("deletes a file", async () => { + const adapter = new NodejsFsAdapter(); + const file = joinPosix(tmpPosix, "del.txt"); + await adapter.writeTextFile(file, "hi"); + expect(await adapter.exists(file)).toBe(true); + + await adapter.deleteFile(file); + + expect(await adapter.exists(file)).toBe(false); + }); + + it("deleteFile is idempotent for a missing file", async () => { + const adapter = new NodejsFsAdapter(); + await expect(adapter.deleteFile(joinPosix(tmpPosix, "nope.txt"))).resolves.toBeUndefined(); + }); }); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts index ced9e107..cf891453 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -44,4 +44,12 @@ export class NodejsFsAdapter implements FsPort { await walk(root); return out; } + + async deleteFile(path: string): Promise { + try { + await fsp.unlink(Path.toPlatformPath(path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } } diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts index 3257bed8..2606c1f7 100644 --- a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -34,6 +34,7 @@ function inMemoryFs(seed: Record = {}): FsPort { } return out; }), + deleteFile: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index f280e85b..afc2a9c0 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -32,6 +32,7 @@ function inMemoryFs(seed: Record = {}): FsPort { } return out; }), + deleteFile: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 8246a2eb..fe3c8ec7 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -27,6 +27,7 @@ function fsPort(files: Record): FsPort { writeTextFile: vi.fn(async () => {}), exists: vi.fn(async () => true), listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async () => {}), }; } diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts index dc28ef80..67e2669b 100644 --- a/apps/core/src/ports/FsPort.ts +++ b/apps/core/src/ports/FsPort.ts @@ -5,4 +5,6 @@ export interface FsPort { exists(path: string): Promise; /** Recursively list all files under `dir` (not directories). */ listFiles(dir: string): Promise; + /** Delete a file; missing files count as success (idempotent). */ + deleteFile(path: string): Promise; } From 13cf7c56b83f1123aff84c348d2c5e4a27a4308a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 15:35:43 +0800 Subject: [PATCH 031/214] feat(core-app): add getAppConfig/getUserConfig/getFolders/getMediaMetadata/unimportFolder to Core Co-Authored-By: Claude Opus 4.7 --- apps/core/src/Core.test.ts | 148 +++++++++++++++++- apps/core/src/Core.ts | 71 ++++++++- .../specs/2026-08-17-core-read-apis-design.md | 4 +- 3 files changed, 213 insertions(+), 10 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 34d6aabc..51a3c27c 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it, vi } from "vitest"; +import { Path } from "@core/path"; import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { Core } from "./Core"; -import { userConfigPath } from "./pipeline/paths"; +import { metadataCachePath, userConfigPath } from "./pipeline/paths"; function inMemoryFs(seed: Record = {}): FsPort { const files = new Map(Object.entries(seed)); @@ -124,3 +125,148 @@ describe("Core", () => { expect(core.getJob("nope")).toBeUndefined(); }); }); + +describe("getAppConfig", () => { + it("returns the injected app config values", () => { + const core = new Core({ + fs: inMemoryFs(), + network: emptyNetwork(), + appDataDir: "/data/smm", + version: "1.3.8", + reverseProxyUrl: "http://127.0.0.1:30005", + userDataDir: "/data/ud", + }); + expect(core.getAppConfig()).toEqual({ + version: "1.3.8", + userDataDir: "/data/ud", + reverseProxyUrl: "http://127.0.0.1:30005", + }); + }); + + it("falls back to defaults when version/reverseProxyUrl/userDataDir are omitted", () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); + expect(core.getAppConfig()).toEqual({ + version: "", + userDataDir: "/data/smm", + reverseProxyUrl: null, + }); + }); +}); + +describe("getUserConfig", () => { + it("returns the default config when no smm.json exists", async () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); + const config = await core.getUserConfig(); + expect(config.folders).toEqual([]); + expect(config.tmdb).toEqual({}); + expect(config.selectedRenameRule).toBe("plex"); + }); + + it("returns the persisted config when smm.json exists", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: ["/m/Show"], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + expect((await core.getUserConfig()).folders).toEqual(["/m/Show"]); + }); +}); + +describe("getFolders", () => { + it("returns the folders from the user config", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: ["/m/A", "/m/B"], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + expect(await core.getFolders()).toEqual(["/m/A", "/m/B"]); + }); + + it("returns an empty list when no config exists", async () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); + expect(await core.getFolders()).toEqual([]); + }); +}); + +describe("getMediaMetadata", () => { + const cache = metadataCachePath("/data/smm", "/m/Show"); + + it("returns the cached metadata for a folder", async () => { + const mm = { mediaFolderPath: "/m/Show", type: "tvshow-folder" }; + const fs = inMemoryFs({ [cache]: JSON.stringify(mm) }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + expect(await core.getMediaMetadata("/m/Show")).toEqual(mm); + }); + + it("returns null when there is no cache", async () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); + expect(await core.getMediaMetadata("/m/Show")).toBeNull(); + }); + + it("returns null when the cache JSON is corrupt", async () => { + const fs = inMemoryFs({ [cache]: "{ not json" }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + expect(await core.getMediaMetadata("/m/Show")).toBeNull(); + }); +}); + +describe("unimportFolder", () => { + const cache = metadataCachePath("/data/smm", "/m/Show"); + + function configWith(folders: string[]): string { + return JSON.stringify({ folders, tmdb: {}, tvdb: {}, renameRules: [], dryRun: false, selectedRenameRule: "plex" }); + } + + it("removes the folder from config and deletes its metadata cache", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: configWith(["/m/Show", "/m/Keep"]), + [cache]: JSON.stringify({ mediaFolderPath: "/m/Show" }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.unimportFolder("/m/Show"); + + expect(JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string).folders).toEqual(["/m/Keep"]); + expect(await fs.exists(cache)).toBe(false); + }); + + it("is a no-op and keeps the cache when the folder is not imported", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: configWith(["/m/Keep"]), + [cache]: JSON.stringify({ mediaFolderPath: "/m/Show" }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.unimportFolder("/m/Show"); + + expect(JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string).folders).toEqual(["/m/Keep"]); + expect(await fs.exists(cache)).toBe(true); + }); + + it("unimports a folder stored in Windows backslash form (same format as import)", async () => { + const stored = "C:\\Movies\\Show"; + const cache = metadataCachePath("/data/smm", Path.posix(stored)); + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: configWith([stored]), + [cache]: JSON.stringify({ mediaFolderPath: stored }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.unimportFolder(stored); + + expect(JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string).folders).toEqual([]); + expect(await fs.exists(cache)).toBe(false); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 0b252c66..eab4dde1 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -1,10 +1,12 @@ import { Path } from "@core/path"; -import type { FolderType } from "@smm/core"; +import type { AppConfig, FolderType, MediaMetadata, UserConfig } from "@smm/core"; import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; +import { metadataCachePath } from "./pipeline/paths"; +import { readUserConfig, writeUserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; @@ -14,6 +16,12 @@ export interface CoreOptions { logger?: LoggerPort; /** Root directory holding smm.json and metadata/. */ appDataDir: string; + /** App version string (e.g. "1.3.8"); getAppConfig() falls back to "". */ + version?: string; + /** Reverse proxy base URL; getAppConfig() falls back to null. */ + reverseProxyUrl?: string | null; + /** userDataDir reported by getAppConfig(); falls back to appDataDir. */ + userDataDir?: string; } export interface ImportFolderHandle { @@ -26,22 +34,23 @@ export class Core { private readonly network: NetworkPort; private readonly logger: LoggerPort; private readonly appDataDir: string; + private readonly version: string; + private readonly reverseProxyUrl: string | null; + private readonly userDataDir: string; constructor(options: CoreOptions) { this.fs = options.fs; this.network = options.network; this.logger = options.logger ?? new NoopLoggerAdapter(); this.appDataDir = options.appDataDir; + this.version = options.version ?? ""; + this.reverseProxyUrl = options.reverseProxyUrl ?? null; + this.userDataDir = options.userDataDir ?? options.appDataDir; } /** Starts the import pipeline in the background; returns a job handle immediately. */ importFolder(path: string, type: FolderType): ImportFolderHandle { - let folderPath: string; - try { - folderPath = Path.posix(path); - } catch { - folderPath = path; - } + const folderPath = this.normalizePosix(path); const job = this.jobs.create({ folderPath, type, @@ -57,6 +66,54 @@ export class Core { return this.jobs.get(id); } + /** Application-level config (version / userDataDir / reverseProxyUrl); never touches fs. */ + getAppConfig(): AppConfig { + return { + version: this.version, + userDataDir: this.userDataDir, + reverseProxyUrl: this.reverseProxyUrl, + }; + } + + getUserConfig(): Promise { + return readUserConfig(this.fs, this.appDataDir); + } + + async getFolders(): Promise { + return (await readUserConfig(this.fs, this.appDataDir)).folders; + } + + /** Reads the persisted metadata cache for a folder; null when absent or corrupt. */ + async getMediaMetadata(folder: string): Promise { + const posixPath = this.normalizePosix(folder); + const cachePath = metadataCachePath(this.appDataDir, posixPath); + if (!(await this.fs.exists(cachePath))) return null; + try { + const content = await this.fs.readTextFile(cachePath); + return JSON.parse(content) as MediaMetadata; + } catch { + return null; + } + } + + /** Removes a folder from the user config and deletes its metadata cache. Idempotent. */ + async unimportFolder(path: string): Promise { + const posixPath = this.normalizePosix(path); + const config = await readUserConfig(this.fs, this.appDataDir); + const folders = config.folders.filter((f) => this.normalizePosix(f) !== posixPath); + if (folders.length === config.folders.length) return; + await writeUserConfig(this.fs, this.appDataDir, { ...config, folders }); + await this.fs.deleteFile(metadataCachePath(this.appDataDir, posixPath)); + } + + private normalizePosix(path: string): string { + try { + return Path.posix(path); + } catch { + return path; + } + } + private async runImport(job: ImportJob, folderPath: string, type: FolderType): Promise { try { const pipeline = new ImportFolderPipeline({ diff --git a/docs/superpowers/specs/2026-08-17-core-read-apis-design.md b/docs/superpowers/specs/2026-08-17-core-read-apis-design.md index 7c8a8565..ef19594a 100644 --- a/docs/superpowers/specs/2026-08-17-core-read-apis-design.md +++ b/docs/superpowers/specs/2026-08-17-core-read-apis-design.md @@ -106,7 +106,7 @@ async unimportFolder(path: string): Promise { } ``` -- **匹配按 POSIX 规范化比较**:`importFolder` 存进 `folders` 的是调用方传入的原始路径(平台格式或 POSIX 格式),故对存储项与被删路径两侧都做 `Path.posix` 规范化再比较,两种输入都能命中。 +- **匹配按 POSIX 规范化比较**:`importFolder` 存进 `folders` 的是调用方传入的原始路径(平台格式或 POSIX 格式),故对存储项与被删路径两侧都做 `Path.posix` 规范化再比较。注意 `Path.posix` 不统一盘符路径的反斜杠/斜杠写法(`C:\A` 与 `C:/A` 归一后不同),因此以「import 时所用格式」作为 unimport 的入参格式即可命中;缓存删除路径同样取自该格式的 `Path.posix`,与 import 写入位置一致。 - **幂等**:文件夹不在配置中时直接返回,不写配置、不删缓存(对齐核心层的防御式风格)。 - 只有确认文件夹在配置中才会删除缓存文件,避免误删孤儿文件。 @@ -185,7 +185,7 @@ async deleteFile(path: string): Promise { - `getUserConfig`:无文件返回默认配置;有文件返回合并后的配置。 - `getFolders`:返回配置中的 folders 列表。 - `getMediaMetadata`:返回缓存元数据;无缓存返回 `null`;缓存 JSON 损坏返回 `null`。 -- `unimportFolder`:从配置移除该路径并删除缓存文件;路径不在配置中时 no-op 且不删缓存;平台格式与 POSIX 格式输入都能命中。 +- `unimportFolder`:从配置移除该路径并删除缓存文件;路径不在配置中时 no-op 且不删缓存;同一格式(POSIX 或 Windows 反斜杠)的平台路径往返都能命中。 - `importFolder` 的防御式路径规范化仍通过既有测试(重构 `normalizePosix` 后行为不变)。 适配器测试: From 1b9f165cd738ca3b56a46264cc69ac4da1ce4a26 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 15:36:45 +0800 Subject: [PATCH 032/214] docs: mark apps/core read/query and unimport API design as implemented Co-Authored-By: Claude Opus 4.7 --- docs/superpowers/specs/2026-08-17-core-read-apis-design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/specs/2026-08-17-core-read-apis-design.md b/docs/superpowers/specs/2026-08-17-core-read-apis-design.md index ef19594a..fbdc6c8f 100644 --- a/docs/superpowers/specs/2026-08-17-core-read-apis-design.md +++ b/docs/superpowers/specs/2026-08-17-core-read-apis-design.md @@ -1,5 +1,7 @@ # apps/core 只读查询与移除目录 API 设计 +> **Status:** Implemented (2026-08-17). Design approved by user, then implemented directly on request (no separate plan). Commits: `146c088b` (FsPort.deleteFile + adapters), `13cf7c56` (Core read/query/unimport methods). Tests: 77 passing in apps/core, repo typecheck clean. Do not re-run. + 本设计文档描述 `apps/core`(Layer 2 Core)新增的 5 个接口: `getFolders()`、`unimportFolder(path)`、`getUserConfig()`、`getAppConfig()`、`getMediaMetadata(folder)`。 From bc3200cf16afc660b0a366d291d58dcbdc3bfbae Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:24:17 +0800 Subject: [PATCH 033/214] docs: add display-folders V3 design (get-folders + useFoldersQuery) Co-authored-by: Cursor --- .../2026-08-17-display-folders-v3-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-17-display-folders-v3-design.md diff --git a/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md b/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md new file mode 100644 index 00000000..ae4a9972 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md @@ -0,0 +1,214 @@ +# Display Folders V3(get-folders + useFoldersQuery) + +本设计描述「显示媒体文件夹列表」从 UI 直接读 `UserConfig` / Zustand,迁移到 **UI → Internal HTTP → Core** 的第一刀,并由 `localStorage["smm.v3.enabled"]` 控制新旧路径。 + +> 配套已实现:`Core.getFolders()`(见 [2026-08-17-core-read-apis-design.md](./2026-08-17-core-read-apis-design.md))。 + +## 1. Background + +现状 Sidebar 列表源: + +1. `AppInitializer` → `UIMediaFolderStoreInitializer` 从 `useConfig().userConfig.folders`(`POST /api/readFile` 读 `userDataDir/smm.json`)一次性 `setFolders` 进 Zustand; +2. `Sidebar` 读 Zustand `folders`,再用 `useQueries(mediaMetadata…)` 拼 `mediaName` / `mediaType`,`useMemo` 做 search / filter / sort。 + +问题:列表 paths 与业务配置耦合在 UI 编排里,无法走 Layer 2 Core,也难以被 MCP / 外部 API 复用同一数据源。 + +目标(本切片范围,方案 A + 落地方式 1): + +- 新增 `POST /api/get-folders` → `Core.getFolders()`; +- UI 增加 `useFoldersQuery`;V3 下 Sidebar 以 query paths 为列表源; +- **status / selection 仍用 Zustand**;**metadata 仍用现有 `useQueries`**; +- flag 关闭时行为与今天完全一致。 + +## 2. Architecture + +### 2.1 Project Level Architecture + +``` +apps/ui (Layer 1) + useFoldersQuery ──POST /api/get-folders──► apps/cli (Layer 3 宿主) + │ + ▼ + Core.getFolders() (apps/core, Layer 2) + │ + ▼ + FsPort → userDataDir/smm.json +``` + +依赖方向:`ui → cli HTTP → core-app → FsPort`。本切片不改 `packages/core` 领域类型。 + +### 2.2 App Level Architecture + +| 层 | 新增 / 改动 | +|----|-------------| +| Layer 2 | 无新方法(复用已有 `Core.getFolders`) | +| Layer 3 cli | `POST /api/get-folders` 路由;懒创建/单例 `Core`(`NodejsFsAdapter`) | +| Layer 1 ui | `localStorage` flag、`getFolders` API client、`useFoldersQuery`、Sidebar 合并逻辑、folders 变更处 `invalidateQueries` | + +### 2.3 Key Design + +**Feature flag** + +```ts +localStorage.getItem("smm.v3.enabled") === "true" +``` + +默认关闭。可挂在 `localStorages` 上作为 getter,非必须。 + +**数据职责拆分(方案 A)** + +| 数据 | 来源(V3) | 来源(旧) | +|------|------------|------------| +| 列表 paths | `useFoldersQuery` → Core | Zustand `folders`(由 Initializer / import 写入) | +| status / type / test | Zustand(按 path 合并;缺失默认 `"ok"`) | 同左 | +| selection | Zustand + `localStorages.sidebarSelectedFolder` | 同左 | +| mediaName / mediaType | 现有 metadata `useQueries` | 同左 | +| search / filter / sort | `sidebarStore` + 现有 `useMemo` | 同左 | + +**路径合并(V3 Sidebar)** + +```ts +const paths = useFoldersQuery().data ?? [] +const byPath = new Map(zustandFolders.map((f) => [Path.posix(f.path), f])) +const rows = paths.map((p) => { + const platform = Path.toPlatformPath(p) + const existing = byPath.get(Path.posix(p)) + return { + path: platform, + status: existing?.status ?? "ok", + test: existing?.test, + type: existing?.type, + } +}) +// 再接现有 metadata useQueries + filteredAndSortedFolders useMemo +``` + +**Core 构造时的目录参数(重要)** + +生产环境: + +- `smm.json` → **userDataDir** +- metadata 缓存 → **appDataDir** + +Win / macOS 上二者通常相同;Linux 上 XDG 分离。`Core` 当前把 `appDataDir` 当作「持有 `smm.json` 的根」。本切片 `getFolders` 只读配置,cli 构造 Core 时传入: + +```ts +appDataDir: getUserDataDir() +``` + +以保证读到与现 UI 相同的 `smm.json`。完整宿主拆分 `userDataDir` / `appDataDir` 不在本切片;不改 Core API。 + +## 3. HTTP 契约 + +### `POST /api/get-folders` + +- **Request**:`{}`(允许空 body) +- **Success**:`{ data: { folders: string[] } }` +- **Failure**:`{ error: "Error Reason: …" }`,HTTP **200**(对齐现有 RPC 风格) +- `folders` 与 `UserConfig.folders` 一致;本接口不做 sort / filter / search + +## 4. User Stories + +### 4.1 Flag 关闭时行为不变 + +* **Given** `smm.v3.enabled` 未设或不为 `"true"` +* **When** 打开应用并查看 Sidebar +* **Then** 不发起 `/api/get-folders`;列表仍来自 Zustand,与改前一致 + +### 4.2 Flag 开启时从 Core 拉列表 + +* **Given** `smm.v3.enabled === "true"`,且 `smm.json` 含若干 folders +* **When** Sidebar 渲染 +* **Then** `useFoldersQuery` 请求 `POST /api/get-folders`,列表 paths 与配置一致;search / filter / sort / 选中仍可用 + +```mermaid +sequenceDiagram + participant UI as Sidebar / useFoldersQuery + participant CLI as POST /api/get-folders + participant Core as Core.getFolders + participant FS as FsPort (smm.json) + + UI->>CLI: POST {} + CLI->>Core: getFolders() + Core->>FS: readUserConfig + FS-->>Core: folders[] + Core-->>CLI: string[] + CLI-->>UI: { data: { folders } } + Note over UI: merge Zustand status + metadata Query + useMemo +``` + +### 4.3 导入 / 删除后列表刷新 + +* **Given** V3 已开启 +* **When** import / delete / rename 成功修改了 `UserConfig.folders` +* **Then** 调用方 `invalidateQueries({ queryKey: ['folders'] })`(或等价 root key),Sidebar paths 更新;status 仍可由 Zustand 乐观更新 + +## 5. UI 细节 + +### 5.1 `useFoldersQuery` + +- 仿 `usePlansQuery`:`queryKey: ['folders']`(或 `foldersQueryKey` 常量) +- `enabled: isSmmV3Enabled()` +- `queryFn`:调用 API client;`resp.error` 则 throw + +### 5.2 Initializer / selection(V3) + +| 职责 | V3 行为 | +|------|---------| +| 列表 paths | 来自 query,不依赖 Initializer `setFolders` 作为列表源 | +| status seed / availability | Initializer 仍可写 Zustand;缺失 path 默认 `"ok"` | +| selection | 不变(含 `sidebarSelectedFolder` 恢复) | +| 旧路径 | Initializer / Sidebar 逻辑不动 | + +### 5.3 Invalidate 落点 + +在 V3 且成功改动 `UserConfig.folders` 的路径上失效 folders query,至少包括: + +- 单目录 import / media library import +- Sidebar / AppV2 删除 +- folder rename(Socket 或 mutation 成功后) + +默认策略:**invalidate**(不做本切片强制的 `setQueryData` 乐观 paths)。 + +## 6. cli 细节 + +- 新路由文件(例如 `apps/cli/src/route/GetFolders.ts`)`handleGetFolders(app)` +- 在现有 Hono 注册处挂上该 handler +- 懒单例 `getCore()`:`new Core({ fs: new NodejsFsAdapter(), network: …, appDataDir: getUserDataDir(), userDataDir: getUserDataDir(), … })` +- `core-app` 加入 cli 的 workspace 依赖 + +NetworkPort:本切片 `getFolders` 不触网;可注入既有/最小 noop fetch 适配器以满足构造函数。 + +## 7. 测试计划 + +| 层 | 用例 | +|----|------| +| cli 路由 | 空 folders;有 folders;Core/fs 失败 → `error` 字段、HTTP 200 | +| UI hook / 合并 | flag off 不请求;flag on 合并 status;缺 Zustand 行时 status=`ok` | +| 回归 | flag off 下既有 Sidebar / Initializer 行为不变(单元或现有测试) | + +`Core.getFolders` 单测已存在,本切片不重复。 + +## 8. 不做的事(YAGNI) + +- 不用 Socket.IO 推送文件夹列表 +- 不拆 Core 的 `userDataDir` / `appDataDir` 双根(仅构造时传 `getUserDataDir()`) +- 不移除 Zustand `folders` / 不迁移初始化流水线到 Core +- 不 enrich `get-folders` DTO(不加 status / type) +- 不改 MCP `get-media-folders` 工具(可后续改为同一 Core 调用) + +## 9. 涉及文件(预期) + +- 新:`apps/cli/src/route/GetFolders.ts`(+ 可选测试) +- 改:cli 路由注册、`apps/cli/package.json`(依赖 `core-app`) +- 新:`apps/ui/src/api/getFolders.ts`、`apps/ui/src/hooks/.../useFoldersQuery.ts`(及 query key) +- 改:`apps/ui/src/components/v2/Sidebar.tsx`(或薄 hook) +- 改:import / delete / rename 成功路径上的 invalidate +- 可选:`apps/ui/src/lib/localStorages.ts` 增加 v3 flag getter +- 文档:`docs/api/index.md` 增加条目(实现时) + +## 10. 与现状代码流对照 + +**旧路径**:`useUserConfigQuery` → Initializer `setFolders` → Zustand → Sidebar `useMemo(filter/sort)` + metadata queries。 + +**新路径(flag on)**:`useFoldersQuery` → `POST /api/get-folders` → `Core.getFolders` → 合并 Zustand status → 同一套 metadata queries + `useMemo`。 From 1ff84f3a1c191c69b9bee442dbe44d2e737bed53 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:28:18 +0800 Subject: [PATCH 034/214] docs: add display-folders V3 implementation plan Co-authored-by: Cursor --- .../plans/2026-08-17-display-folders-v3.md | 746 ++++++++++++++++++ 1 file changed, 746 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-display-folders-v3.md diff --git a/docs/superpowers/plans/2026-08-17-display-folders-v3.md b/docs/superpowers/plans/2026-08-17-display-folders-v3.md new file mode 100644 index 00000000..8a60220e --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-display-folders-v3.md @@ -0,0 +1,746 @@ +# Display Folders V3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire `POST /api/get-folders` → `Core.getFolders()` and switch Sidebar path list to `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`, keeping status/selection in Zustand and metadata queries unchanged. + +**Architecture:** Thin cli Hono route holds a lazy Core singleton (`NodejsFsAdapter`, `appDataDir: getUserDataDir()`). UI adds API client + TanStack Query; Sidebar merges query paths with Zustand status. Flag off = zero behavior change. + +**Tech Stack:** TypeScript, Hono (cli), vitest, React 19, TanStack Query, Zustand, `core-app` (`apps/core`). + +**Spec:** [docs/superpowers/specs/2026-08-17-display-folders-v3-design.md](../specs/2026-08-17-display-folders-v3-design.md) + +## Global Constraints + +- Feature flag: `localStorage.getItem("smm.v3.enabled") === "true"` only; default off. +- HTTP RPC style: success/failure both HTTP 200; errors use `error: "Error Reason: …"`. +- `Core` constructed with `appDataDir: getUserDataDir()` so `getFolders` reads the same `smm.json` as today’s UI. +- Do not enrich get-folders DTO; do not remove Zustand; do not migrate import pipeline. +- Package name for `apps/core` is `core-app` (not `core`). + +--- + +## File Structure + +``` +apps/cli/ + package.json # + dependency "core-app": "workspace:*" + tsconfig.json # + paths for core-app + vitest.config.ts # + alias core-app → ../core/src + server.ts # register handleGetFolders + src/ + core/getCore.ts # lazy Core singleton + resetCoreForTests + route/GetFolders.ts # POST /api/get-folders + route/GetFolders.test.ts + +apps/core/ + package.json # + "exports": { ".": "./src/index.ts" } + +apps/ui/ + src/lib/localStorages.ts # + isSmmV3Enabled getter (optional but preferred) + src/lib/isSmmV3Enabled.ts # OR dedicated helper if not on localStorages + src/api/getFolders.ts + src/hooks/folders/foldersQueryKeys.ts + src/hooks/folders/useFoldersQuery.ts + src/hooks/folders/useFoldersQuery.test.ts + src/lib/mergeFolderPathsWithUiStatus.ts + src/lib/mergeFolderPathsWithUiStatus.test.ts + src/components/v2/Sidebar.tsx # V3 path source branch + src/hooks/userConfig/useSaveUserConfigMutation.ts # invalidate when folders change + src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx + +docs/api/index.md # document POST /api/get-folders +``` + +--- + +### Task 1: Wire `core-app` into cli + Core singleton + +**Files:** +- Modify: `apps/core/package.json` +- Modify: `apps/cli/package.json` +- Modify: `apps/cli/tsconfig.json` +- Modify: `apps/cli/vitest.config.ts` +- Create: `apps/cli/src/core/getCore.ts` + +**Interfaces:** +- Produces: `getCore(): Core`, `resetCoreForTests(): void` + +- [ ] **Step 1: Add exports to `apps/core/package.json`** + +Add: + +```json +"exports": { + ".": "./src/index.ts" +} +``` + +- [ ] **Step 2: Add workspace dependency in `apps/cli/package.json`** + +Under `"dependencies"`: + +```json +"core-app": "workspace:*" +``` + +Run from repo root: + +```bash +pnpm install --filter cli... +``` + +Expected: lockfile updates; `cli` can resolve `core-app`. + +- [ ] **Step 3: Add tsconfig + vitest aliases for `core-app`** + +In `apps/cli/tsconfig.json` `paths`: + +```json +"core-app": ["../core/src/index.ts"], +"core-app/*": ["../core/src/*"] +``` + +In `apps/cli/vitest.config.ts` `resolve.alias`: + +```ts +'core-app': resolve(__dirname, '../core/src/index.ts'), +``` + +(Keep existing `@core` alias — Core sources import `@core/path`.) + +- [ ] **Step 4: Create `apps/cli/src/core/getCore.ts`** + +```ts +import { + Core, + FetchNetworkAdapter, + NodejsFsAdapter, + NoopLoggerAdapter, +} from 'core-app' +import { getUserDataDir } from '@/utils/config' + +let instance: Core | undefined + +/** Lazy singleton. appDataDir = userDataDir so getFolders reads production smm.json. */ +export function getCore(): Core { + if (!instance) { + const userDataDir = getUserDataDir() + instance = new Core({ + fs: new NodejsFsAdapter(), + network: new FetchNetworkAdapter(), + logger: new NoopLoggerAdapter(), + appDataDir: userDataDir, + userDataDir, + }) + } + return instance +} + +/** Test-only: drop the singleton so env/dir changes take effect. */ +export function resetCoreForTests(): void { + instance = undefined +} +``` + +- [ ] **Step 5: Smoke-check TypeScript resolves Core** + +Run: + +```bash +pnpm --filter cli exec tsc --noEmit 2>&1 | head -40 +``` + +Expected: no errors about `core-app` / `getCore` (pre-existing unrelated errors OK if any; new file must typecheck). If `core-app` import fails, fix aliases / exports before continuing. + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/package.json apps/cli/package.json apps/cli/tsconfig.json apps/cli/vitest.config.ts apps/cli/src/core/getCore.ts pnpm-lock.yaml +git commit -m "$(cat <<'EOF' +feat(cli): wire core-app and lazy Core singleton for getFolders + +EOF +)" +``` + +--- + +### Task 2: `POST /api/get-folders` route (TDD) + +**Files:** +- Create: `apps/cli/src/route/GetFolders.test.ts` +- Create: `apps/cli/src/route/GetFolders.ts` +- Modify: `apps/cli/server.ts` +- Modify: `docs/api/index.md` + +**Interfaces:** +- Consumes: `getCore()`, `resetCoreForTests()` +- Produces: `handleGetFolders(app: Hono): void` registering `POST /api/get-folders` + +- [ ] **Step 1: Write failing route tests** + +Create `apps/cli/src/route/GetFolders.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleGetFolders } from './GetFolders' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/get-folders', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-get-folders-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleGetFolders(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns empty folders when smm.json is missing', async () => { + const res = await app.request('/api/get-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { folders: [] } }) + }) + + it('returns folders from smm.json', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: ['/media/A', '/media/B'] }), + 'utf-8', + ) + resetCoreForTests() + const res = await app.request('/api/get-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { folders: ['/media/A', '/media/B'] }, + }) + }) +}) +``` + +- [ ] **Step 2: Run tests — expect FAIL (module missing)** + +```bash +pnpm --filter cli test -- src/route/GetFolders.test.ts +``` + +Expected: FAIL cannot find `./GetFolders` or `handleGetFolders`. + +- [ ] **Step 3: Implement `apps/cli/src/route/GetFolders.ts`** + +```ts +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface GetFoldersResponseBody { + data?: { folders: string[] } + error?: string +} + +export function handleGetFolders(app: Hono): void { + app.post('/api/get-folders', async (c) => { + try { + // Body optional; tolerate missing/invalid JSON + try { + await c.req.json() + } catch { + /* empty body OK */ + } + const folders = await getCore().getFolders() + const body: GetFoldersResponseBody = { data: { folders } } + return c.json(body, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-folders] route error') + const body: GetFoldersResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(body, 200) + } + }) +} +``` + +- [ ] **Step 4: Register in `apps/cli/server.ts`** + +Add import near other route imports: + +```ts +import { handleGetFolders } from './src/route/GetFolders' +``` + +In the route registration block (near `handlePlans(this.app)`): + +```ts +handleGetFolders(this.app) +``` + +- [ ] **Step 5: Run tests — expect PASS** + +```bash +pnpm --filter cli test -- src/route/GetFolders.test.ts +``` + +Expected: 2 passed. + +- [ ] **Step 6: Document in `docs/api/index.md`** + +Add a section (near Plans or SetWatchedFolder): + +```markdown +## GetFolders +Source Code: apps/cli/src/route/GetFolders.ts +HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/cli/src/route/GetFolders.ts apps/cli/src/route/GetFolders.test.ts apps/cli/server.ts docs/api/index.md +git commit -m "$(cat <<'EOF' +feat(cli): add POST /api/get-folders via Core.getFolders + +EOF +)" +``` + +--- + +### Task 3: UI flag + API client + `useFoldersQuery` + +**Files:** +- Modify: `apps/ui/src/lib/localStorages.ts` +- Create: `apps/ui/src/api/getFolders.ts` +- Create: `apps/ui/src/hooks/folders/foldersQueryKeys.ts` +- Create: `apps/ui/src/hooks/folders/useFoldersQuery.ts` +- Create: `apps/ui/src/hooks/folders/useFoldersQuery.test.ts` +- Create: `apps/ui/src/hooks/folders/index.ts` + +**Interfaces:** +- Produces: `isSmmV3Enabled(): boolean`, `getFolders(signal?)`, `FOLDERS_QUERY_KEY`, `useFoldersQuery()` + +- [ ] **Step 1: Add flag helper on `localStorages`** + +In `apps/ui/src/lib/localStorages.ts`, add constant + getter on the exported object: + +```ts +const STORAGE_KEY_SMM_V3_ENABLED = 'smm.v3.enabled' + +// inside localStorages object: +get isSmmV3Enabled(): boolean { + try { + return localStorage.getItem(STORAGE_KEY_SMM_V3_ENABLED) === 'true' + } catch { + return false + } +}, +``` + +Also export a named function for non-React call sites: + +```ts +export function isSmmV3Enabled(): boolean { + return localStorages.isSmmV3Enabled +} +``` + +- [ ] **Step 2: Create API client `apps/ui/src/api/getFolders.ts`** + +```ts +import { apiFetch } from '@/lib/apiFetch' + +export interface GetFoldersResponseBody { + data?: { folders: string[] } + error?: string +} + +export async function getFolders(signal?: AbortSignal): Promise { + const resp = await apiFetch('/api/get-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as GetFoldersResponseBody +} +``` + +- [ ] **Step 3: Create query keys + hook** + +`apps/ui/src/hooks/folders/foldersQueryKeys.ts`: + +```ts +export const FOLDERS_QUERY_ROOT = 'folders' as const +export const foldersQueryKey = [FOLDERS_QUERY_ROOT] as const +``` + +`apps/ui/src/hooks/folders/useFoldersQuery.ts`: + +```ts +import { useQuery } from '@tanstack/react-query' +import { getFolders } from '@/api/getFolders' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { foldersQueryKey } from './foldersQueryKeys' + +export function useFoldersQuery() { + const enabled = isSmmV3Enabled() + return useQuery({ + queryKey: foldersQueryKey, + enabled, + queryFn: async (): Promise => { + const resp = await getFolders() + if (resp.error) throw new Error(resp.error) + return resp.data?.folders ?? [] + }, + }) +} +``` + +`apps/ui/src/hooks/folders/index.ts`: + +```ts +export { useFoldersQuery } from './useFoldersQuery' +export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' +``` + +- [ ] **Step 4: Write hook unit test (flag gating)** + +`apps/ui/src/hooks/folders/useFoldersQuery.test.ts` — follow existing hook test patterns in `apps/ui` (QueryClientProvider + renderHook). Minimal cases: + +1. When `localStorage` key unset → `fetch` / `getFolders` not called (`isFetching` false / no network). +2. When key `"true"` → queryFn runs and returns folders from mocked `getFolders`. + +Mock `@/api/getFolders` with vitest `vi.mock`. Set/clear `localStorage` in beforeEach/afterEach. + +- [ ] **Step 5: Run UI test** + +```bash +pnpm --filter ui test -- src/hooks/folders/useFoldersQuery.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/ui/src/lib/localStorages.ts apps/ui/src/api/getFolders.ts apps/ui/src/hooks/folders +git commit -m "$(cat <<'EOF' +feat(ui): add useFoldersQuery gated by smm.v3.enabled + +EOF +)" +``` + +--- + +### Task 4: Merge helper + Sidebar V3 branch + +**Files:** +- Create: `apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts` +- Create: `apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts` +- Modify: `apps/ui/src/components/v2/Sidebar.tsx` + +**Interfaces:** +- Consumes: `useFoldersQuery()`, `UIMediaFolder[]` from Zustand +- Produces: `mergeFolderPathsWithUiStatus(paths, zustandFolders): UIMediaFolder[]` + +- [ ] **Step 1: Write failing merge tests** + +```ts +import { describe, expect, it } from 'vitest' +import { mergeFolderPathsWithUiStatus } from './mergeFolderPathsWithUiStatus' +import type { UIMediaFolder } from '@/types/UIMediaFolder' + +describe('mergeFolderPathsWithUiStatus', () => { + it('defaults status to ok when Zustand has no row', () => { + const result = mergeFolderPathsWithUiStatus(['/m/A'], []) + expect(result).toEqual([ + expect.objectContaining({ status: 'ok', path: expect.any(String) }), + ]) + }) + + it('preserves Zustand status/type/test when path matches', () => { + const existing: UIMediaFolder[] = [ + { path: '/m/A', status: 'initializing', type: 'tvshow-folder', test: true }, + ] + const result = mergeFolderPathsWithUiStatus(['/m/A'], existing) + expect(result[0]?.status).toBe('initializing') + expect(result[0]?.type).toBe('tvshow-folder') + expect(result[0]?.test).toBe(true) + }) + + it('follows query path order', () => { + const existing: UIMediaFolder[] = [ + { path: '/m/B', status: 'ok' }, + { path: '/m/A', status: 'ok' }, + ] + const result = mergeFolderPathsWithUiStatus(['/m/A', '/m/B'], existing) + expect(result.map((r) => r.path)).toEqual( + result.map((_, i) => result[i]!.path), // length 2 + ) + expect(result).toHaveLength(2) + }) +}) +``` + +(Adjust path assertions to use `Path.toPlatformPath` / `Path.posix` consistently with implementation.) + +- [ ] **Step 2: Run — expect FAIL** + +```bash +pnpm --filter ui test -- src/lib/mergeFolderPathsWithUiStatus.test.ts +``` + +- [ ] **Step 3: Implement merge helper** + +```ts +import { Path } from '@core/path' +import type { UIMediaFolder } from '@/types/UIMediaFolder' + +export function mergeFolderPathsWithUiStatus( + paths: string[], + zustandFolders: UIMediaFolder[], +): UIMediaFolder[] { + const byPosix = new Map( + zustandFolders.map((f) => [Path.posix(f.path), f] as const), + ) + return paths.map((p) => { + const posix = Path.posix(p) + const existing = byPosix.get(posix) + const platform = Path.toPlatformPath(p) + return { + path: platform, + status: existing?.status ?? 'ok', + test: existing?.test, + type: existing?.type, + } + }) +} +``` + +- [ ] **Step 4: Run merge tests — PASS** + +- [ ] **Step 5: Branch Sidebar folder source** + +In `apps/ui/src/components/v2/Sidebar.tsx`: + +1. Import `isSmmV3Enabled`, `useFoldersQuery`, `mergeFolderPathsWithUiStatus`. +2. Call `const foldersQuery = useFoldersQuery()` (hook always called; query self-disables when flag off). +3. Replace raw `folders` used for **list rows** with: + +```ts +const v3 = isSmmV3Enabled() +const listFolders = v3 + ? mergeFolderPathsWithUiStatus(foldersQuery.data ?? [], folders) + : folders +``` + +4. Use `listFolders` for `folderPaths`, `rowsWithMeta`, and list rendering. Keep selection / actions on Zustand as today. + +Do **not** change filter/sort/search `useMemo` logic beyond swapping the input array to `listFolders`-derived `rowsWithMeta`. + +- [ ] **Step 6: Manual sanity (optional in agent run)** + +With flag off, open app — Sidebar unchanged. With `localStorage.setItem('smm.v3.enabled','true')` and reload — network shows `POST /api/get-folders`, list still renders. + +- [ ] **Step 7: Commit** + +```bash +git add apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts apps/ui/src/components/v2/Sidebar.tsx +git commit -m "$(cat <<'EOF' +feat(ui): Sidebar uses useFoldersQuery paths when smm.v3.enabled + +EOF +)" +``` + +--- + +### Task 5: Invalidate folders query when config folders change + +**Files:** +- Create: `apps/ui/src/hooks/folders/invalidateFoldersQuery.ts` +- Modify: `apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts` +- Modify: `apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx` + +**Interfaces:** +- Produces: `invalidateFoldersQueryIfV3(queryClient: QueryClient): void` + +- [ ] **Step 1: Add helper** + +```ts +import type { QueryClient } from '@tanstack/react-query' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { foldersQueryKey } from './foldersQueryKeys' + +export function invalidateFoldersQueryIfV3(queryClient: QueryClient): void { + if (!isSmmV3Enabled()) return + void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) +} +``` + +Export from `apps/ui/src/hooks/folders/index.ts`. + +- [ ] **Step 2: Hook `useSaveUserConfigMutation`** + +In `onSuccess`, after `setQueryData` for userConfig: + +```ts +onSuccess: (config, { config: _requested }) => { + const helloData = queryClient.getQueryData(helloQueryKey) + const dir = helloData?.userDataDir + if (dir) { + const prev = + queryClient.getQueryData(userConfigQueryKey(dir)) ?? defaultUserConfig + // Note: setQueryData already applied `config` above — compare using mutation variables + } +} +``` + +Correct pattern — use mutation variables for “previous vs next”: + +```ts +mutationFn: async ({ traceId, config }) => { + const helloData = ... + const dir = ... + const prev = queryClient.getQueryData(userConfigQueryKey(dir)) ?? defaultUserConfig + // ... existing writeFile ... + return { config, prevFolders: prev.folders } +}, +onSuccess: ({ config, prevFolders }) => { + const dir = queryClient.getQueryData(helloQueryKey)?.userDataDir + if (dir) { + queryClient.setQueryData(userConfigQueryKey(dir), config) + } + const foldersChanged = + prevFolders.length !== config.folders.length || + prevFolders.some((p, i) => p !== config.folders[i]) + if (foldersChanged) { + invalidateFoldersQueryIfV3(queryClient) + } +}, +``` + +Keep language-change behavior intact. Adjust any callers that assumed `mutateAsync` resolved to bare `UserConfig` — grep `saveUserConfigMutation` / `mutateAsync({ traceId, config` and update types if needed. + +- [ ] **Step 3: Invalidate on socket rename** + +In `SocketIoUserConfigFolderRenamedEventListener`, after updating folders in Zustand / cache: + +```ts +import { useQueryClient } from '@tanstack/react-query' +import { invalidateFoldersQueryIfV3 } from '@/hooks/folders' + +// inside component: +const queryClient = useQueryClient() +// inside listener after setFolders: +invalidateFoldersQueryIfV3(queryClient) +``` + +- [ ] **Step 4: Run related tests** + +```bash +pnpm --filter ui test -- src/hooks/userConfig +pnpm --filter ui test -- src/hooks/folders +pnpm --filter ui test -- src/lib/mergeFolderPathsWithUiStatus.test.ts +``` + +Fix any breakage from `mutateAsync` return-type change. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ui/src/hooks/folders apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx +git commit -m "$(cat <<'EOF' +feat(ui): invalidate folders query when UserConfig.folders changes (v3) + +EOF +)" +``` + +--- + +### Task 6: Spec status + verification + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-17-display-folders-v3-design.md` (status line at top) +- Modify: `docs/superpowers/plans/2026-08-17-display-folders-v3.md` (this file — mark Implemented when done) + +- [ ] **Step 1: Run focused verification** + +```bash +pnpm --filter cli test -- src/route/GetFolders.test.ts +pnpm --filter ui test -- src/hooks/folders src/lib/mergeFolderPathsWithUiStatus.test.ts +``` + +Expected: all PASS. + +- [ ] **Step 2: Mark design implemented** + +At top of the design spec, add: + +```markdown +> **Status:** Implemented (YYYY-MM-DD). ... +``` + +- [ ] **Step 3: Final commit** + +```bash +git add docs/superpowers/specs/2026-08-17-display-folders-v3-design.md docs/superpowers/plans/2026-08-17-display-folders-v3.md +git commit -m "$(cat <<'EOF' +docs: mark display-folders V3 design/plan as implemented + +EOF +)" +``` + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| Flag `smm.v3.enabled` | Task 3 | +| `POST /api/get-folders` → `Core.getFolders` | Task 2 | +| Core `appDataDir: getUserDataDir()` | Task 1 | +| `useFoldersQuery` | Task 3 | +| Merge paths + Zustand status | Task 4 | +| Sidebar V3 branch; old path untouched | Task 4 | +| Invalidate on folders change | Task 5 | +| API docs | Task 2 | +| No Socket push / no DTO enrich / no Zustand removal | (YAGNI — not tasked) | + +## Self-review notes + +- `useSaveUserConfigMutation` return type change must be grepped — do not leave callers assuming bare `UserConfig`. +- Hook order: always call `useFoldersQuery()`; rely on `enabled`, do not conditionally call hooks. +- Path matching uses `Path.posix` on both sides when merging. From 0ea4f5e17e31c2ec481f10a9aead14e462ed69e0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:33:29 +0800 Subject: [PATCH 035/214] feat(cli): wire core-app and lazy Core singleton for getFolders Co-authored-by: Cursor --- apps/cli/package.json | 1 + apps/cli/src/core/getCore.ts | 29 +++++++++++++++++++++++++++++ apps/cli/tsconfig.json | 4 +++- apps/cli/vitest.config.ts | 1 + apps/core/package.json | 3 +++ pnpm-lock.yaml | 3 +++ 6 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/core/getCore.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 079aae59..536189c1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,6 +18,7 @@ "@assistant-ui/react-ai-sdk": "^1.3.7", "@hono/node-server": "^1.19.0", "@modelcontextprotocol/sdk": "^1.25.3", + "core-app": "workspace:*", "@smm/core-routes": "workspace:*", "@smm/tvdb4": "workspace:*", "@types/shelljs": "^0.10.0", diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts new file mode 100644 index 00000000..6e4cfe82 --- /dev/null +++ b/apps/cli/src/core/getCore.ts @@ -0,0 +1,29 @@ +import { + Core, + FetchNetworkAdapter, + NodejsFsAdapter, + NoopLoggerAdapter, +} from 'core-app' +import { getUserDataDir } from '@/utils/config' + +let instance: Core | undefined + +/** Lazy singleton. appDataDir = userDataDir so getFolders reads production smm.json. */ +export function getCore(): Core { + if (!instance) { + const userDataDir = getUserDataDir() + instance = new Core({ + fs: new NodejsFsAdapter(), + network: new FetchNetworkAdapter(), + logger: new NoopLoggerAdapter(), + appDataDir: userDataDir, + userDataDir, + }) + } + return instance +} + +/** Test-only: drop the singleton so env/dir changes take effect. */ +export function resetCoreForTests(): void { + instance = undefined +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 630e322f..a73a5c19 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -35,7 +35,9 @@ "@smm/tvdb4": ["../../packages/tvdb4/src/index.ts"], "@smm/tvdb4/*": ["../../packages/tvdb4/src/*"], "@smm/test": ["../../packages/test/src/index.ts"], - "@smm/test/*": ["../../packages/test/*"] + "@smm/test/*": ["../../packages/test/*"], + "core-app": ["../core/src/index.ts"], + "core-app/*": ["../core/src/*"] } } } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 1c2bb969..9f221f19 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ '@core': resolve(__dirname, '../../packages/core'), '@smm/core': resolve(__dirname, '../../packages/core/types.ts'), '@smm/test': resolve(__dirname, '../../packages/test/src/index.ts'), + 'core-app': resolve(__dirname, '../core/src/index.ts'), }, }, }) diff --git a/apps/core/package.json b/apps/core/package.json index d139a1ec..06dcdc94 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts" + }, "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e76b4b16..669aeae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: ai: specifier: ^6.0.36 version: 6.0.98(zod@4.3.6) + core-app: + specifier: workspace:* + version: link:../core dotenv: specifier: ^17.2.3 version: 17.3.1 From be9d6eb5c9271d06b63d533093988615bb8906c1 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:35:44 +0800 Subject: [PATCH 036/214] feat(cli): add POST /api/get-folders via Core.getFolders Co-authored-by: Cursor --- apps/cli/server.ts | 2 + apps/cli/src/route/GetFolders.test.ts | 57 +++++++++++++++++++++++++++ apps/cli/src/route/GetFolders.ts | 30 ++++++++++++++ docs/api/index.md | 4 ++ 4 files changed, 93 insertions(+) create mode 100644 apps/cli/src/route/GetFolders.test.ts create mode 100644 apps/cli/src/route/GetFolders.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index e59fed4c..d9d10ffc 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -42,6 +42,7 @@ import { handleDebugGetMediaFoldersRoute } from './src/route/debug/debugGetMedia import { handleDebugGetEpisodesToolRoute } from './src/route/debug/debugGetEpisodesTool'; import { handleDebugIsFolderExistToolRoute } from './src/route/debug/debugIsFolderExistTool'; import { handlePlans } from './src/route/Plans'; +import { handleGetFolders } from './src/route/GetFolders'; import { handleTencentAsrTranscribe } from './src/route/tencentAsr/Transcribe'; import { handleExecuteCmd } from './src/route/executeCmd'; import { handleDiscoverExecutables } from './src/route/discoverExecutables'; @@ -275,6 +276,7 @@ export class Server { handleDebugGetEpisodesToolRoute(this.app); handleDebugIsFolderExistToolRoute(this.app); handlePlans(this.app); + handleGetFolders(this.app); handleTencentAsrTranscribe(this.app); handleExecuteCmd(this.app); handleDiscoverExecutables(this.app); diff --git a/apps/cli/src/route/GetFolders.test.ts b/apps/cli/src/route/GetFolders.test.ts new file mode 100644 index 00000000..c4e56f59 --- /dev/null +++ b/apps/cli/src/route/GetFolders.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleGetFolders } from './GetFolders' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/get-folders', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-get-folders-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleGetFolders(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns empty folders when smm.json is missing', async () => { + const res = await app.request('/api/get-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { folders: [] } }) + }) + + it('returns folders from smm.json', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: ['/media/A', '/media/B'] }), + 'utf-8', + ) + resetCoreForTests() + const res = await app.request('/api/get-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { folders: ['/media/A', '/media/B'] }, + }) + }) +}) diff --git a/apps/cli/src/route/GetFolders.ts b/apps/cli/src/route/GetFolders.ts new file mode 100644 index 00000000..d82f4e79 --- /dev/null +++ b/apps/cli/src/route/GetFolders.ts @@ -0,0 +1,30 @@ +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface GetFoldersResponseBody { + data?: { folders: string[] } + error?: string +} + +export function handleGetFolders(app: Hono): void { + app.post('/api/get-folders', async (c) => { + try { + // Body optional; tolerate missing/invalid JSON + try { + await c.req.json() + } catch { + /* empty body OK */ + } + const folders = await getCore().getFolders() + const body: GetFoldersResponseBody = { data: { folders } } + return c.json(body, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-folders] route error') + const body: GetFoldersResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(body, 200) + } + }) +} diff --git a/docs/api/index.md b/docs/api/index.md index edcd3b85..ab41d2ea 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -46,6 +46,10 @@ HTTP: `POST /api/shutdown` — localhost-only graceful shutdown used by the Elec CLI also sweeps `{userDataDir}/temp/ytdlp-cookies-*.txt` on startup (fallback when the prior run was hard-killed). +## GetFolders +Source Code: apps/cli/src/route/GetFolders.ts +HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. + ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts HTTP: `POST /api/setWatchedFolder` — sets the single media folder the CLI `FolderWatcher` listens to (UI primary `selectedFolder`). Request body: `{ folderPath: string | null }` (platform absolute path, or null/empty to stop watching). Response: `{ data: { watchedFolder: string | null }, error?: string }`. Startup no longer watches all imported folders. From 66f1eac9830b8e704b57ce05d17c3a7d0687e470 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:38:37 +0800 Subject: [PATCH 037/214] feat(ui): add useFoldersQuery gated by smm.v3.enabled Co-authored-by: Cursor --- apps/ui/src/api/getFolders.ts | 21 ++++++ apps/ui/src/hooks/folders/foldersQueryKeys.ts | 2 + apps/ui/src/hooks/folders/index.ts | 2 + .../src/hooks/folders/useFoldersQuery.test.ts | 64 +++++++++++++++++++ apps/ui/src/hooks/folders/useFoldersQuery.ts | 17 +++++ apps/ui/src/lib/localStorages.ts | 12 ++++ 6 files changed, 118 insertions(+) create mode 100644 apps/ui/src/api/getFolders.ts create mode 100644 apps/ui/src/hooks/folders/foldersQueryKeys.ts create mode 100644 apps/ui/src/hooks/folders/index.ts create mode 100644 apps/ui/src/hooks/folders/useFoldersQuery.test.ts create mode 100644 apps/ui/src/hooks/folders/useFoldersQuery.ts diff --git a/apps/ui/src/api/getFolders.ts b/apps/ui/src/api/getFolders.ts new file mode 100644 index 00000000..b90553b8 --- /dev/null +++ b/apps/ui/src/api/getFolders.ts @@ -0,0 +1,21 @@ +import { apiFetch } from '@/lib/apiFetch' + +export interface GetFoldersResponseBody { + data?: { folders: string[] } + error?: string +} + +export async function getFolders(signal?: AbortSignal): Promise { + const resp = await apiFetch('/api/get-folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as GetFoldersResponseBody +} diff --git a/apps/ui/src/hooks/folders/foldersQueryKeys.ts b/apps/ui/src/hooks/folders/foldersQueryKeys.ts new file mode 100644 index 00000000..a0a6c7f6 --- /dev/null +++ b/apps/ui/src/hooks/folders/foldersQueryKeys.ts @@ -0,0 +1,2 @@ +export const FOLDERS_QUERY_ROOT = 'folders' as const +export const foldersQueryKey = [FOLDERS_QUERY_ROOT] as const diff --git a/apps/ui/src/hooks/folders/index.ts b/apps/ui/src/hooks/folders/index.ts new file mode 100644 index 00000000..93c789e6 --- /dev/null +++ b/apps/ui/src/hooks/folders/index.ts @@ -0,0 +1,2 @@ +export { useFoldersQuery } from './useFoldersQuery' +export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' diff --git a/apps/ui/src/hooks/folders/useFoldersQuery.test.ts b/apps/ui/src/hooks/folders/useFoldersQuery.test.ts new file mode 100644 index 00000000..5ffda097 --- /dev/null +++ b/apps/ui/src/hooks/folders/useFoldersQuery.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React, { type ReactNode } from 'react' +import { useFoldersQuery } from './useFoldersQuery' +import * as getFoldersApi from '@/api/getFolders' + +vi.mock('@/api/getFolders', () => ({ + getFolders: vi.fn(), +})) + +const mockedGetFolders = vi.mocked(getFoldersApi.getFolders) + +const STORAGE_KEY_SMM_V3_ENABLED = 'smm.v3.enabled' + +function createWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) +} + +describe('useFoldersQuery', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + }) + + afterEach(() => { + localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) + queryClient.clear() + }) + + it('does not call getFolders when smm.v3.enabled is unset', async () => { + const wrapper = createWrapper(queryClient) + const { result } = renderHook(() => useFoldersQuery(), { wrapper }) + + await new Promise((r) => setTimeout(r, 20)) + + expect(mockedGetFolders).not.toHaveBeenCalled() + expect(result.current.isFetching).toBe(false) + expect(result.current.fetchStatus).toBe('idle') + }) + + it('fetches folders when smm.v3.enabled is true', async () => { + localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, 'true') + mockedGetFolders.mockResolvedValue({ + data: { folders: ['/media/a', '/media/b'] }, + }) + + const wrapper = createWrapper(queryClient) + const { result } = renderHook(() => useFoldersQuery(), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(mockedGetFolders).toHaveBeenCalled() + expect(result.current.data).toEqual(['/media/a', '/media/b']) + }) +}) diff --git a/apps/ui/src/hooks/folders/useFoldersQuery.ts b/apps/ui/src/hooks/folders/useFoldersQuery.ts new file mode 100644 index 00000000..616c2e89 --- /dev/null +++ b/apps/ui/src/hooks/folders/useFoldersQuery.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { getFolders } from '@/api/getFolders' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { foldersQueryKey } from './foldersQueryKeys' + +export function useFoldersQuery() { + const enabled = isSmmV3Enabled() + return useQuery({ + queryKey: foldersQueryKey, + enabled, + queryFn: async (): Promise => { + const resp = await getFolders() + if (resp.error) throw new Error(resp.error) + return resp.data?.folders ?? [] + }, + }) +} diff --git a/apps/ui/src/lib/localStorages.ts b/apps/ui/src/lib/localStorages.ts index 7b3df814..7bd2eab2 100644 --- a/apps/ui/src/lib/localStorages.ts +++ b/apps/ui/src/lib/localStorages.ts @@ -8,6 +8,7 @@ const STORAGE_KEY_PREFER_REVERSE_PROXY_BASE_URL = 'preferReverseProxyBaseUrl'; const STORAGE_KEY_LAST_SELECTED_TMDB_LANGUAGE = 'lastSelectedTmdbLanguage'; const STORAGE_KEY_LAST_SELECTED_TVDB_LANGUAGE = 'lastSelectedTvdbLanguage'; const STORAGE_KEY_DISABLED_DOMAINS = 'disabledDomains'; +const STORAGE_KEY_SMM_V3_ENABLED = 'smm.v3.enabled'; function readDisabledDomainsSet(): Set { try { @@ -195,6 +196,17 @@ const localStorages = { set disabledDomains(domains: Set) { writeDisabledDomainsSet(domains); }, + get isSmmV3Enabled(): boolean { + try { + return localStorage.getItem(STORAGE_KEY_SMM_V3_ENABLED) === 'true' + } catch { + return false + } + }, +} + +export function isSmmV3Enabled(): boolean { + return localStorages.isSmmV3Enabled } export default localStorages; \ No newline at end of file From 1bf7aa60e4ff693a7957fc1c95c3dc7689ae884b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:41:58 +0800 Subject: [PATCH 038/214] feat(ui): Sidebar uses useFoldersQuery paths when smm.v3.enabled Co-authored-by: Cursor --- apps/ui/src/components/v2/Sidebar.test.tsx | 5 ++- apps/ui/src/components/v2/Sidebar.tsx | 15 +++++-- .../lib/mergeFolderPathsWithUiStatus.test.ts | 44 +++++++++++++++++++ .../src/lib/mergeFolderPathsWithUiStatus.ts | 22 ++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts create mode 100644 apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts diff --git a/apps/ui/src/components/v2/Sidebar.test.tsx b/apps/ui/src/components/v2/Sidebar.test.tsx index 89ea8ce6..c7dba0e5 100644 --- a/apps/ui/src/components/v2/Sidebar.test.tsx +++ b/apps/ui/src/components/v2/Sidebar.test.tsx @@ -8,12 +8,15 @@ vi.mock("@tanstack/react-query", () => ({ useQueries: vi.fn(), })) +vi.mock("@/hooks/folders", () => ({ + useFoldersQuery: vi.fn(() => ({ data: undefined, isFetching: false })), +})) + vi.mock("@/components/search-form", () => ({ SearchForm: ({ placeholder }: { placeholder?: string }) => (
{placeholder}
), })) - vi.mock("@/components/shared/MediaFolderToolbar", () => ({ MediaFolderToolbar: () =>
, })) diff --git a/apps/ui/src/components/v2/Sidebar.tsx b/apps/ui/src/components/v2/Sidebar.tsx index cfae0cd0..faeae493 100644 --- a/apps/ui/src/components/v2/Sidebar.tsx +++ b/apps/ui/src/components/v2/Sidebar.tsx @@ -20,6 +20,9 @@ import { openInFileManagerApi } from "@/api/openInFileManager" import { nextTraceId } from "@/lib/utils" import { mediaMetadataRepository } from "@/api/mediaMetadataRepository" import { useTranslation } from "@/lib/i18n" +import { isSmmV3Enabled } from "@/lib/localStorages" +import { useFoldersQuery } from "@/hooks/folders" +import { mergeFolderPathsWithUiStatus } from "@/lib/mergeFolderPathsWithUiStatus" export type { SortOrder, FilterType } @@ -40,7 +43,13 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) const primarySelectedPath = selectedMediaMetadata?.mediaFolderPath ?? selectedFolder - const folderPaths = useMemo(() => folders.map((f) => f.path), [folders]) + const foldersQuery = useFoldersQuery() + const v3 = isSmmV3Enabled() + const listFolders = v3 + ? mergeFolderPathsWithUiStatus(foldersQuery.data ?? [], folders) + : folders + + const folderPaths = useMemo(() => listFolders.map((f) => f.path), [listFolders]) const metadataQueries = useQueries({ queries: folderPaths.map((path) => ({ @@ -50,10 +59,10 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { }) const rowsWithMeta = useMemo(() => { - return folders.map((folder, i) => + return listFolders.map((folder, i) => buildMediaFolderListItemPropsFromFolderAndMetadata(folder, metadataQueries[i]?.data), ) - }, [folders, metadataQueries]) + }, [listFolders, metadataQueries]) const filteredAndSortedFolders = useMemo(() => { let result = [...rowsWithMeta] diff --git a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts new file mode 100644 index 00000000..85b9ecdc --- /dev/null +++ b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { Path } from '@core/path' +import { mergeFolderPathsWithUiStatus } from './mergeFolderPathsWithUiStatus' +import type { UIMediaFolder } from '@/types/UIMediaFolder' + +describe('mergeFolderPathsWithUiStatus', () => { + it('defaults status to ok when Zustand has no row', () => { + const result = mergeFolderPathsWithUiStatus(['/m/A'], []) + expect(result).toEqual([ + expect.objectContaining({ + status: 'ok', + path: Path.toPlatformPath('/m/A'), + }), + ]) + }) + + it('preserves Zustand status/type/test when path matches', () => { + const existing: UIMediaFolder[] = [ + { + path: Path.toPlatformPath('/m/A'), + status: 'initializing', + type: 'tvshow-folder', + test: true, + }, + ] + const result = mergeFolderPathsWithUiStatus(['/m/A'], existing) + expect(result[0]?.status).toBe('initializing') + expect(result[0]?.type).toBe('tvshow-folder') + expect(result[0]?.test).toBe(true) + }) + + it('follows query path order', () => { + const existing: UIMediaFolder[] = [ + { path: Path.toPlatformPath('/m/B'), status: 'ok' }, + { path: Path.toPlatformPath('/m/A'), status: 'ok' }, + ] + const result = mergeFolderPathsWithUiStatus(['/m/A', '/m/B'], existing) + expect(result.map((r) => r.path)).toEqual([ + Path.toPlatformPath('/m/A'), + Path.toPlatformPath('/m/B'), + ]) + expect(result).toHaveLength(2) + }) +}) diff --git a/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts new file mode 100644 index 00000000..50f96e32 --- /dev/null +++ b/apps/ui/src/lib/mergeFolderPathsWithUiStatus.ts @@ -0,0 +1,22 @@ +import { Path } from '@core/path' +import type { UIMediaFolder } from '@/types/UIMediaFolder' + +export function mergeFolderPathsWithUiStatus( + paths: string[], + zustandFolders: UIMediaFolder[], +): UIMediaFolder[] { + const byPosix = new Map( + zustandFolders.map((f) => [Path.posix(f.path), f] as const), + ) + return paths.map((p) => { + const posix = Path.posix(p) + const existing = byPosix.get(posix) + const platform = Path.toPlatformPath(p) + return { + path: platform, + status: existing?.status ?? 'ok', + test: existing?.test, + type: existing?.type, + } + }) +} From 6d28212be40a425ccbbcc4d83d3552ea72de3545 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:46:35 +0800 Subject: [PATCH 039/214] feat(ui): invalidate folders query when UserConfig.folders changes (v3) Co-authored-by: Cursor --- .../SocketIoUserConfigFolderRenamedEventListener.tsx | 4 ++++ apps/ui/src/hooks/folders/index.ts | 1 + apps/ui/src/hooks/folders/invalidateFoldersQuery.ts | 8 ++++++++ .../src/hooks/userConfig/useSaveUserConfigMutation.ts | 11 +++++++++-- 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 apps/ui/src/hooks/folders/invalidateFoldersQuery.ts diff --git a/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx b/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx index a9c9ded2..31b05b31 100644 --- a/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx +++ b/apps/ui/src/components/eventlisteners/SocketIoUserConfigFolderRenamedEventListener.tsx @@ -1,7 +1,9 @@ import { USER_CONFIG_FOLDER_RENAMED_EVENT } from "@core/event-types" import { useRef } from "react"; import { useLatest, useMount, useUnmount } from "react-use" +import { useQueryClient } from "@tanstack/react-query" import { useConfig } from "@/hooks/userConfig"; +import { invalidateFoldersQueryIfV3 } from "@/hooks/folders"; import { Path } from "@core/path"; import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata"; import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore"; @@ -20,6 +22,7 @@ export function SocketIoUserConfigFolderRenamedEventListener() { const setSelectedFolder = useUIMediaFolderStore((s) => s.setSelectedFolder); const latestFolders = useLatest(folders); const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation(); + const queryClient = useQueryClient(); useMount(() => { @@ -42,6 +45,7 @@ export function SocketIoUserConfigFolderRenamedEventListener() { } : folder)) setSelectedFolder(to) + invalidateFoldersQueryIfV3(queryClient) fetchMediaMetadata({ path: Path.posix(to), traceId }) }; diff --git a/apps/ui/src/hooks/folders/index.ts b/apps/ui/src/hooks/folders/index.ts index 93c789e6..75b2ea3b 100644 --- a/apps/ui/src/hooks/folders/index.ts +++ b/apps/ui/src/hooks/folders/index.ts @@ -1,2 +1,3 @@ export { useFoldersQuery } from './useFoldersQuery' export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' +export { invalidateFoldersQueryIfV3 } from './invalidateFoldersQuery' diff --git a/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts b/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts new file mode 100644 index 00000000..72975cbe --- /dev/null +++ b/apps/ui/src/hooks/folders/invalidateFoldersQuery.ts @@ -0,0 +1,8 @@ +import type { QueryClient } from '@tanstack/react-query' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { foldersQueryKey } from './foldersQueryKeys' + +export function invalidateFoldersQueryIfV3(queryClient: QueryClient): void { + if (!isSmmV3Enabled()) return + void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) +} diff --git a/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts b/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts index a2accc85..30f11f52 100644 --- a/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts +++ b/apps/ui/src/hooks/userConfig/useSaveUserConfigMutation.ts @@ -7,6 +7,7 @@ import { changeLanguage } from "@/lib/i18n" import { join } from "@/lib/path" import { helloQueryKey } from "@/lib/appQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" +import { invalidateFoldersQueryIfV3 } from "@/hooks/folders" export function useSaveUserConfigMutation() { const queryClient = useQueryClient() @@ -29,13 +30,19 @@ export function useSaveUserConfigMutation() { } const filePath = join(dir, "smm.json") await writeFile(filePath, JSON.stringify(config), 'overwrite', traceId) - return config + return { config, prevFolders: prev.folders } }, - onSuccess: (config) => { + onSuccess: ({ config, prevFolders }) => { const dir = queryClient.getQueryData(helloQueryKey)?.userDataDir if (dir) { queryClient.setQueryData(userConfigQueryKey(dir), config) } + const foldersChanged = + prevFolders.length !== config.folders.length || + prevFolders.some((p, i) => p !== config.folders[i]) + if (foldersChanged) { + invalidateFoldersQueryIfV3(queryClient) + } }, }) } From 77b910ba14a89f4555413a354712f58c6237ea86 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:49:41 +0800 Subject: [PATCH 040/214] docs: mark display-folders V3 design/plan as implemented Co-authored-by: Cursor --- docs/superpowers/plans/2026-08-17-display-folders-v3.md | 8 +++++--- .../specs/2026-08-17-display-folders-v3-design.md | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-17-display-folders-v3.md b/docs/superpowers/plans/2026-08-17-display-folders-v3.md index 8a60220e..00650415 100644 --- a/docs/superpowers/plans/2026-08-17-display-folders-v3.md +++ b/docs/superpowers/plans/2026-08-17-display-folders-v3.md @@ -1,5 +1,7 @@ # Display Folders V3 Implementation Plan +> **Status:** Implemented (2026-08-17). All 6 tasks completed via subagent-driven-development on branch `core-layer`. Commits: `0ea4f5e1` (Task 1) through `6d28212b` (Task 5). Verification: focused tests all PASS. Do not re-run. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Wire `POST /api/get-folders` → `Core.getFolders()` and switch Sidebar path list to `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`, keeping status/selection in Zustand and metadata queries unchanged. @@ -695,7 +697,7 @@ EOF - Modify: `docs/superpowers/specs/2026-08-17-display-folders-v3-design.md` (status line at top) - Modify: `docs/superpowers/plans/2026-08-17-display-folders-v3.md` (this file — mark Implemented when done) -- [ ] **Step 1: Run focused verification** +- [x] **Step 1: Run focused verification** ```bash pnpm --filter cli test -- src/route/GetFolders.test.ts @@ -704,7 +706,7 @@ pnpm --filter ui test -- src/hooks/folders src/lib/mergeFolderPathsWithUiStatus. Expected: all PASS. -- [ ] **Step 2: Mark design implemented** +- [x] **Step 2: Mark design implemented** At top of the design spec, add: @@ -712,7 +714,7 @@ At top of the design spec, add: > **Status:** Implemented (YYYY-MM-DD). ... ``` -- [ ] **Step 3: Final commit** +- [x] **Step 3: Final commit** ```bash git add docs/superpowers/specs/2026-08-17-display-folders-v3-design.md docs/superpowers/plans/2026-08-17-display-folders-v3.md diff --git a/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md b/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md index ae4a9972..32c912d6 100644 --- a/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md +++ b/docs/superpowers/specs/2026-08-17-display-folders-v3-design.md @@ -1,5 +1,7 @@ # Display Folders V3(get-folders + useFoldersQuery) +> **Status:** Implemented (2026-08-17). Tasks 1–6 completed on branch `core-layer`. Commits: `0ea4f5e1` (Core singleton) through `6d28212b` (folders query invalidation). Verification: GetFolders.test.ts (2), useFoldersQuery.test.ts (2), mergeFolderPathsWithUiStatus.test.ts (3). Do not re-run. + 本设计描述「显示媒体文件夹列表」从 UI 直接读 `UserConfig` / Zustand,迁移到 **UI → Internal HTTP → Core** 的第一刀,并由 `localStorage["smm.v3.enabled"]` 控制新旧路径。 > 配套已实现:`Core.getFolders()`(见 [2026-08-17-core-read-apis-design.md](./2026-08-17-core-read-apis-design.md))。 From 6e8fe156b4637bd2275ee42e7e7f10d049ff6a9f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 17 Aug 2026 21:55:14 +0800 Subject: [PATCH 041/214] fix(ui): invalidate folders query on single-folder import (v3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useAddMediaFolderMutation now returns foldersChanged and calls invalidateFoldersQueryIfV3 when a new folder is written, matching useSaveUserConfigMutation behavior for 单目录 import. Co-authored-by: Cursor --- apps/cli/src/route/GetFolders.test.ts | 2 +- .../useAddMediaFolderMutation.test.ts | 97 +++++++++++++++++++ .../userConfig/useAddMediaFolderMutation.ts | 10 +- 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts diff --git a/apps/cli/src/route/GetFolders.test.ts b/apps/cli/src/route/GetFolders.test.ts index c4e56f59..7e5901cd 100644 --- a/apps/cli/src/route/GetFolders.test.ts +++ b/apps/cli/src/route/GetFolders.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { Hono } from 'hono' diff --git a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts new file mode 100644 index 00000000..3d5f60cf --- /dev/null +++ b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts @@ -0,0 +1,97 @@ +import React from "react" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { renderHook, act } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { HelloResponseBody } from "@core/types" +import { defaultUserConfig } from "@/api/readUserConfig" +import { helloQueryKey } from "@/lib/appQueryKeys" +import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" +import { foldersQueryKey } from "@/hooks/folders/foldersQueryKeys" +import { useAddMediaFolderMutation } from "./useAddMediaFolderMutation" + +const STORAGE_KEY_SMM_V3_ENABLED = "smm.v3.enabled" +const USER_DATA_DIR = "/tmp/smm-user-data" +const NEW_FOLDER = "/media/new" + +vi.mock("@/api/writeFile", () => ({ + writeFile: vi.fn().mockResolvedValue({ data: true, error: null }), +})) + +function createWrapper(queryClient: QueryClient) { + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) +} + +function seedUserConfigCache(queryClient: QueryClient, folders: string[] = []) { + queryClient.setQueryData(helloQueryKey, { + userDataDir: USER_DATA_DIR, + } as HelloResponseBody) + queryClient.setQueryData(userConfigQueryKey(USER_DATA_DIR), { + ...defaultUserConfig, + folders, + }) +} + +describe("useAddMediaFolderMutation", () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + }) + + afterEach(() => { + localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) + queryClient.clear() + }) + + it("invalidates folders query when a new folder is added and v3 is enabled", async () => { + localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, "true") + seedUserConfigCache(queryClient) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + const { result } = renderHook(() => useAddMediaFolderMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ traceId: "t1", folder: NEW_FOLDER }) + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: foldersQueryKey }) + }) + + it("does not invalidate folders query when folder was already present", async () => { + localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, "true") + seedUserConfigCache(queryClient, [NEW_FOLDER]) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + const { result } = renderHook(() => useAddMediaFolderMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ traceId: "t1", folder: NEW_FOLDER }) + }) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it("does not invalidate folders query when v3 is disabled", async () => { + seedUserConfigCache(queryClient) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + const { result } = renderHook(() => useAddMediaFolderMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ traceId: "t1", folder: NEW_FOLDER }) + }) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts index 3a6b4635..071f15c3 100644 --- a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts +++ b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.ts @@ -6,6 +6,7 @@ import { defaultUserConfig } from "@/api/readUserConfig" import { join } from "@/lib/path" import { helloQueryKey } from "@/lib/appQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" +import { invalidateFoldersQueryIfV3 } from "@/hooks/folders" export function useAddMediaFolderMutation() { const queryClient = useQueryClient() @@ -25,7 +26,7 @@ export function useAddMediaFolderMutation() { const prev = queryClient.getQueryData(userConfigQueryKey(dir)) ?? defaultUserConfig if (prev.folders.includes(folder)) { - return prev + return { config: prev, foldersChanged: false } } const updatedConfig: UserConfig = { ...prev, @@ -33,13 +34,16 @@ export function useAddMediaFolderMutation() { } const filePath = join(dir, "smm.json") await writeFile(filePath, JSON.stringify(updatedConfig), 'overwrite', traceId) - return updatedConfig + return { config: updatedConfig, foldersChanged: true } }, - onSuccess: (config) => { + onSuccess: ({ config, foldersChanged }) => { const dir = queryClient.getQueryData(helloQueryKey)?.userDataDir if (dir) { queryClient.setQueryData(userConfigQueryKey(dir), config) } + if (foldersChanged) { + invalidateFoldersQueryIfV3(queryClient) + } }, }) } From ae9c119b8132813f24a4fe4bfabf504bcd0b094a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 12:30:12 +0800 Subject: [PATCH 042/214] feat: route folder delete through Core.unimportFolder when v3 is enabled Add POST /api/unimport-folder and a UI mutation so context-menu, Delete key, and multi-select remove folders from the library without the client writing smm.json. Co-authored-by: Cursor --- apps/cli/server.ts | 2 + apps/cli/src/route/UnimportFolder.test.ts | 81 +++++++++++ apps/cli/src/route/UnimportFolder.ts | 40 ++++++ apps/ui/src/AppV2.test.tsx | 1 + apps/ui/src/AppV2.tsx | 10 +- apps/ui/src/api/unimportFolder.ts | 26 ++++ apps/ui/src/components/v2/Sidebar.test.tsx | 37 ++++- apps/ui/src/components/v2/Sidebar.tsx | 43 +++--- apps/ui/src/hooks/folders/index.ts | 1 + .../folders/useUnimportFolderMutation.test.ts | 131 ++++++++++++++++++ .../folders/useUnimportFolderMutation.ts | 117 ++++++++++++++++ docs/api/index.md | 4 + 12 files changed, 473 insertions(+), 20 deletions(-) create mode 100644 apps/cli/src/route/UnimportFolder.test.ts create mode 100644 apps/cli/src/route/UnimportFolder.ts create mode 100644 apps/ui/src/api/unimportFolder.ts create mode 100644 apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts create mode 100644 apps/ui/src/hooks/folders/useUnimportFolderMutation.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index d9d10ffc..d1be2457 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -43,6 +43,7 @@ import { handleDebugGetEpisodesToolRoute } from './src/route/debug/debugGetEpiso import { handleDebugIsFolderExistToolRoute } from './src/route/debug/debugIsFolderExistTool'; import { handlePlans } from './src/route/Plans'; import { handleGetFolders } from './src/route/GetFolders'; +import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleTencentAsrTranscribe } from './src/route/tencentAsr/Transcribe'; import { handleExecuteCmd } from './src/route/executeCmd'; import { handleDiscoverExecutables } from './src/route/discoverExecutables'; @@ -277,6 +278,7 @@ export class Server { handleDebugIsFolderExistToolRoute(this.app); handlePlans(this.app); handleGetFolders(this.app); + handleUnimportFolder(this.app); handleTencentAsrTranscribe(this.app); handleExecuteCmd(this.app); handleDiscoverExecutables(this.app); diff --git a/apps/cli/src/route/UnimportFolder.test.ts b/apps/cli/src/route/UnimportFolder.test.ts new file mode 100644 index 00000000..f0b642f1 --- /dev/null +++ b/apps/cli/src/route/UnimportFolder.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleUnimportFolder } from './UnimportFolder' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/unimport-folder', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-unimport-folder-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleUnimportFolder(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + async function post(body: unknown) { + return app.request('/api/unimport-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('removes the folder from smm.json and deletes metadata cache', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: ['/media/A', '/media/B'] }), + 'utf-8', + ) + const cacheDir = join(userDataDir, 'metadata') + mkdirSync(cacheDir, { recursive: true }) + const cacheFile = join(cacheDir, '_media_A.json') + writeFileSync(cacheFile, JSON.stringify({ mediaFolderPath: '/media/A' }), 'utf-8') + resetCoreForTests() + + const res = await post({ path: '/media/A' }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { path: '/media/A' } }) + + const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } + expect(saved.folders).toEqual(['/media/B']) + expect(existsSync(cacheFile)).toBe(false) + }) + + it('succeeds when the folder is not imported', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: ['/media/Keep'] }), + 'utf-8', + ) + resetCoreForTests() + + const res = await post({ path: '/media/Missing' }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { path: '/media/Missing' } }) + + const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } + expect(saved.folders).toEqual(['/media/Keep']) + }) + + it('returns Error Reason when path is missing', async () => { + const res = await post({}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + }) +}) diff --git a/apps/cli/src/route/UnimportFolder.ts b/apps/cli/src/route/UnimportFolder.ts new file mode 100644 index 00000000..25074489 --- /dev/null +++ b/apps/cli/src/route/UnimportFolder.ts @@ -0,0 +1,40 @@ +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface UnimportFolderResponseBody { + data?: { path: string } + error?: string +} + +export function handleUnimportFolder(app: Hono): void { + app.post('/api/unimport-folder', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const path = + typeof body === 'object' && body !== null && 'path' in body + ? (body as { path: unknown }).path + : undefined + if (typeof path !== 'string' || path.trim() === '') { + const err: UnimportFolderResponseBody = { + error: 'Error Reason: path is required', + } + return c.json(err, 200) + } + await getCore().unimportFolder(path) + const ok: UnimportFolderResponseBody = { data: { path } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/unimport-folder] route error') + const err: UnimportFolderResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/ui/src/AppV2.test.tsx b/apps/ui/src/AppV2.test.tsx index 28a2da33..02ec7769 100644 --- a/apps/ui/src/AppV2.test.tsx +++ b/apps/ui/src/AppV2.test.tsx @@ -41,6 +41,7 @@ vi.mock("@/hooks/userConfig", () => ({ vi.mock("@/lib/localStorages", () => ({ default: mockLocalStorages, + isSmmV3Enabled: () => false, })) vi.mock("@/providers/dialog-provider", () => ({ diff --git a/apps/ui/src/AppV2.tsx b/apps/ui/src/AppV2.tsx index 3b8e8e79..7c55f017 100644 --- a/apps/ui/src/AppV2.tsx +++ b/apps/ui/src/AppV2.tsx @@ -38,7 +38,8 @@ import { type OnMediaLibraryImportedEventData, } from "./types/eventTypes" import { MusicPanel } from "./components/music/MusicPanel" -import localStorages from "@/lib/localStorages" +import localStorages, { isSmmV3Enabled } from "@/lib/localStorages" +import { useUnimportFolderMutation } from "@/hooks/folders" import { isElectron } from "@/lib/isElectron" import { openNativeFolderDialog } from "@/lib/nativeFolderDialog" import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable" @@ -50,6 +51,7 @@ function AppV2Content() { // WebSocket connection is now established at AppSwitcher level to persist across view changes // No need to call useWebSocket() here anymore const { userConfig, setAndSaveUserConfig, isUserConfigLoaded } = useConfig() + const unimportFolderMutation = useUnimportFolderMutation() const { folders: uiFolders, selectedFolder } = useUIMediaFolderStoreState() const { isAiAreaEnabled, isAiFeatureEnabled } = useFeatures() @@ -216,6 +218,11 @@ function AppV2Content() { async (paths: string[]) => { if (paths.length === 0) return + if (isSmmV3Enabled()) { + await unimportFolderMutation.mutateAsync(paths) + return + } + const traceId = `AppV2-onDeleteSelected-${nextTraceId()}` const deletedPosix = new Set(paths.map((p) => Path.posix(p))) const deletedNative = new Set(paths) @@ -293,6 +300,7 @@ function AppV2Content() { userConfig, setAndSaveUserConfig, queryClient, + unimportFolderMutation, ] ) diff --git a/apps/ui/src/api/unimportFolder.ts b/apps/ui/src/api/unimportFolder.ts new file mode 100644 index 00000000..3c480eef --- /dev/null +++ b/apps/ui/src/api/unimportFolder.ts @@ -0,0 +1,26 @@ +import { apiFetch } from '@/lib/apiFetch' + +export interface UnimportFolderResponseBody { + data?: { path: string } + error?: string +} + +export async function unimportFolder( + path: string, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/unimport-folder', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ path }), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as UnimportFolderResponseBody +} diff --git a/apps/ui/src/components/v2/Sidebar.test.tsx b/apps/ui/src/components/v2/Sidebar.test.tsx index c7dba0e5..d7420979 100644 --- a/apps/ui/src/components/v2/Sidebar.test.tsx +++ b/apps/ui/src/components/v2/Sidebar.test.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { render, screen, fireEvent } from "@testing-library/react" import type { MediaMetadata } from "@core/types" +import { Path } from "@core/path" import { basename } from "@/lib/path" import { Sidebar } from "./Sidebar" @@ -10,6 +11,7 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("@/hooks/folders", () => ({ useFoldersQuery: vi.fn(() => ({ data: undefined, isFetching: false })), + useUnimportFolderMutation: vi.fn(() => ({ mutateAsync: vi.fn() })), })) vi.mock("@/components/search-form", () => ({ @@ -97,6 +99,7 @@ import { useUIMediaFolderSelection, } from "@/stores/uiMediaFolderStore" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" +import { useUnimportFolderMutation, useFoldersQuery } from "@/hooks/folders" const mockUseQueries = useQueries as ReturnType const mockUseSidebarStore = useSidebarStore as ReturnType @@ -143,6 +146,17 @@ describe("Sidebar delete behavior", () => { }) mockUseMediaMetadataQuery.mockReturnValue({ data: { mediaFolderPath: pathA } }) mockUseQueries.mockReturnValue([{ data: null }, { data: null }]) + vi.mocked(useFoldersQuery).mockReturnValue({ + data: undefined, + isFetching: false, + } as ReturnType) + vi.mocked(useUnimportFolderMutation).mockReturnValue({ + mutateAsync: vi.fn(), + } as ReturnType) + }) + + afterEach(() => { + localStorage.removeItem("smm.v3.enabled") }) it("deletes full selected set when deleting a selected item", () => { @@ -169,6 +183,26 @@ describe("Sidebar delete behavior", () => { expect(onDeleteSelected).toHaveBeenCalledTimes(1) expect(onDeleteSelected).toHaveBeenCalledWith([pathB]) }) + + it("uses unimport mutation instead of onDeleteSelected when v3 is enabled", () => { + localStorage.setItem("smm.v3.enabled", "true") + vi.mocked(useFoldersQuery).mockReturnValue({ + data: [pathA, pathB], + isFetching: false, + } as ReturnType) + const mutateAsync = vi.fn().mockResolvedValue(undefined) + vi.mocked(useUnimportFolderMutation).mockReturnValue({ + mutateAsync, + } as ReturnType) + const onDeleteSelected = vi.fn() + render() + + fireEvent.click(screen.getByTestId(`delete-${Path.toPlatformPath(pathA)}`)) + + expect(mutateAsync).toHaveBeenCalledTimes(1) + expect(mutateAsync).toHaveBeenCalledWith(expect.arrayContaining([pathA, pathB])) + expect(onDeleteSelected).not.toHaveBeenCalled() + }) }) describe("Sidebar i18n", () => { @@ -205,6 +239,7 @@ describe("Sidebar mediaName", () => { beforeEach(() => { vi.clearAllMocks() + localStorage.removeItem("smm.v3.enabled") baseSidebarMocks() mockUseUIMediaFolderStoreState.mockReturnValue({ folders: [{ path: folderPath, status: "ok", test: false }], diff --git a/apps/ui/src/components/v2/Sidebar.tsx b/apps/ui/src/components/v2/Sidebar.tsx index faeae493..3cd02496 100644 --- a/apps/ui/src/components/v2/Sidebar.tsx +++ b/apps/ui/src/components/v2/Sidebar.tsx @@ -21,7 +21,7 @@ import { nextTraceId } from "@/lib/utils" import { mediaMetadataRepository } from "@/api/mediaMetadataRepository" import { useTranslation } from "@/lib/i18n" import { isSmmV3Enabled } from "@/lib/localStorages" -import { useFoldersQuery } from "@/hooks/folders" +import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" import { mergeFolderPathsWithUiStatus } from "@/lib/mergeFolderPathsWithUiStatus" export type { SortOrder, FilterType } @@ -38,6 +38,7 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { const { applyFolderClick, selectAllFolderPaths, removeFolder } = useUIMediaFolderStoreActions() const { selectedFolder, selectedFolderPathsSet } = useUIMediaFolderSelection() const { userConfig, setAndSaveUserConfig } = useConfig() + const unimportFolderMutation = useUnimportFolderMutation() const { renameFolderDialog } = useDialogs() const [openRenameForMediaFolder] = renameFolderDialog const { data: selectedMediaMetadata } = useMediaMetadataQuery(selectedFolder || undefined) @@ -87,20 +88,6 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { return result }, [rowsWithMeta, sortOrder, filterType, searchQuery]) - const handleListKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "a") { - e.preventDefault() - selectAllFolderPaths(filteredAndSortedFolders.map((f) => f.path)) - } - if (e.key === "Delete" && selectedFolderPathsSet.size > 0 && onDeleteSelected) { - e.preventDefault() - onDeleteSelected(Array.from(selectedFolderPathsSet)) - } - }, - [onDeleteSelected, selectAllFolderPaths, filteredAndSortedFolders, selectedFolderPathsSet], - ) - const handleOpenInExplorer = useCallback(async (path: string) => { try { const result = await openInFileManagerApi(path) @@ -125,6 +112,10 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { const handleDeletePaths = useCallback( async (paths: string[]) => { if (paths.length === 0) return + if (isSmmV3Enabled()) { + await unimportFolderMutation.mutateAsync(paths) + return + } if (onDeleteSelected) { await onDeleteSelected(paths) return @@ -141,14 +132,30 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { }) paths.forEach((path) => removeFolder(path)) }, - [onDeleteSelected, setAndSaveUserConfig, userConfig, removeFolder], + [onDeleteSelected, setAndSaveUserConfig, userConfig, removeFolder, unimportFolderMutation], + ) + + const handleListKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "a") { + e.preventDefault() + selectAllFolderPaths(filteredAndSortedFolders.map((f) => f.path)) + } + if (e.key === "Delete" && selectedFolderPathsSet.size > 0) { + e.preventDefault() + void handleDeletePaths(Array.from(selectedFolderPathsSet)) + } + }, + [handleDeletePaths, selectAllFolderPaths, filteredAndSortedFolders, selectedFolderPathsSet], ) const handleDeleteItem = useCallback( (path: string) => { + const posix = Path.posix(path) + const selectedPaths = Array.from(selectedFolderPathsSet) const shouldDeleteSelection = - selectedFolderPathsSet.size > 0 && selectedFolderPathsSet.has(path) - const paths = shouldDeleteSelection ? Array.from(selectedFolderPathsSet) : [path] + selectedPaths.length > 0 && selectedPaths.some((p) => Path.posix(p) === posix) + const paths = shouldDeleteSelection ? selectedPaths : [path] void handleDeletePaths(paths) }, [handleDeletePaths, selectedFolderPathsSet], diff --git a/apps/ui/src/hooks/folders/index.ts b/apps/ui/src/hooks/folders/index.ts index 75b2ea3b..d19fce94 100644 --- a/apps/ui/src/hooks/folders/index.ts +++ b/apps/ui/src/hooks/folders/index.ts @@ -1,3 +1,4 @@ export { useFoldersQuery } from './useFoldersQuery' export { foldersQueryKey, FOLDERS_QUERY_ROOT } from './foldersQueryKeys' export { invalidateFoldersQueryIfV3 } from './invalidateFoldersQuery' +export { useUnimportFolderMutation } from './useUnimportFolderMutation' diff --git a/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts b/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts new file mode 100644 index 00000000..73b67745 --- /dev/null +++ b/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts @@ -0,0 +1,131 @@ +import React from "react" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { renderHook, act } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { HelloResponseBody } from "@core/types" +import { defaultUserConfig } from "@/api/readUserConfig" +import { helloQueryKey } from "@/lib/appQueryKeys" +import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" +import { foldersQueryKey } from "@/hooks/folders/foldersQueryKeys" +import { mediaMetadataQueryKey } from "@/lib/mediaMetadataQueryKeys" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import { useUnimportFolderMutation } from "./useUnimportFolderMutation" + +const STORAGE_KEY_SMM_V3_ENABLED = "smm.v3.enabled" +const USER_DATA_DIR = "/tmp/smm-user-data" +const PATH_A = "/media/A" +const PATH_B = "/media/B" + +const unimportFolder = vi.fn() + +vi.mock("@/api/unimportFolder", () => ({ + unimportFolder: (...args: unknown[]) => unimportFolder(...args), +})) + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + +function createWrapper(queryClient: QueryClient) { + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) +} + +function seedCaches(queryClient: QueryClient) { + queryClient.setQueryData(helloQueryKey, { + userDataDir: USER_DATA_DIR, + } as HelloResponseBody) + queryClient.setQueryData(userConfigQueryKey(USER_DATA_DIR), { + ...defaultUserConfig, + folders: [PATH_A, PATH_B], + }) + queryClient.setQueryData(mediaMetadataQueryKey(PATH_A), { + mediaFolderPath: PATH_A, + status: "ok", + }) +} + +describe("useUnimportFolderMutation", () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) + unimportFolder.mockResolvedValue({ data: { path: PATH_A } }) + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + useUIMediaFolderStore.setState({ + folders: [ + { path: PATH_A, status: "ok" }, + { path: PATH_B, status: "ok" }, + ], + selectedFolder: PATH_A, + selectedFolders: [PATH_A], + }) + }) + + afterEach(() => { + localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) + queryClient.clear() + useUIMediaFolderStore.setState({ + folders: [], + selectedFolder: "", + selectedFolders: [], + }) + }) + + it("posts each path and invalidates folders query when v3 is enabled", async () => { + localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, "true") + seedCaches(queryClient) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + const { result } = renderHook(() => useUnimportFolderMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync([PATH_A]) + }) + + expect(unimportFolder).toHaveBeenCalledTimes(1) + expect(unimportFolder).toHaveBeenCalledWith(PATH_A) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: foldersQueryKey }) + expect(queryClient.getQueryData(userConfigQueryKey(USER_DATA_DIR))).toEqual( + expect.objectContaining({ folders: [PATH_B] }), + ) + expect(useUIMediaFolderStore.getState().folders.map((f) => f.path)).toEqual([PATH_B]) + }) + + it("does not call the API for an empty path list", async () => { + seedCaches(queryClient) + const { result } = renderHook(() => useUnimportFolderMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync([]) + }) + + expect(unimportFolder).not.toHaveBeenCalled() + }) + + it("rolls back userConfig and Zustand when the API returns an error", async () => { + seedCaches(queryClient) + unimportFolder.mockResolvedValue({ error: "Error Reason: boom" }) + + const { result } = renderHook(() => useUnimportFolderMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await expect(result.current.mutateAsync([PATH_A])).rejects.toThrow("Error Reason: boom") + }) + + expect(queryClient.getQueryData(userConfigQueryKey(USER_DATA_DIR))).toEqual( + expect.objectContaining({ folders: [PATH_A, PATH_B] }), + ) + expect(useUIMediaFolderStore.getState().folders.map((f) => f.path)).toEqual([PATH_A, PATH_B]) + expect(useUIMediaFolderStore.getState().selectedFolder).toBe(PATH_A) + }) +}) diff --git a/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts b/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts new file mode 100644 index 00000000..2777c93a --- /dev/null +++ b/apps/ui/src/hooks/folders/useUnimportFolderMutation.ts @@ -0,0 +1,117 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { isNotNil } from "es-toolkit" +import type { HelloResponseBody, UserConfig } from "@core/types" +import { Path } from "@core/path" +import { unimportFolder } from "@/api/unimportFolder" +import { defaultUserConfig } from "@/api/readUserConfig" +import { helloQueryKey } from "@/lib/appQueryKeys" +import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" +import { + mediaMetadataQueryKey, + normalizeMediaFolderPathForQuery, +} from "@/lib/mediaMetadataQueryKeys" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import type { UIMediaMetadata } from "@/types/UIMediaMetadata" +import { invalidateFoldersQueryIfV3 } from "./invalidateFoldersQuery" + +function snapshotMetadata(queryClient: ReturnType, paths: string[]) { + return paths + .map((path) => { + const normalized = normalizeMediaFolderPathForQuery(path) + if (!normalized) return undefined + return queryClient.getQueryData(mediaMetadataQueryKey(normalized)) + }) + .filter((m): m is NonNullable => m != null) +} + +function applyOptimisticRemoval( + queryClient: ReturnType, + dir: string, + prev: UserConfig, + paths: string[], +) { + const deletedPosix = new Set(paths.map((p) => Path.posix(p))) + const deletedNative = new Set(paths) + + paths.forEach((path) => { + const normalized = normalizeMediaFolderPathForQuery(path) + if (normalized) { + queryClient.removeQueries({ queryKey: mediaMetadataQueryKey(normalized), exact: true }) + } + }) + + const newFolders = prev.folders + .filter((f) => isNotNil(f)) + .filter((folder) => !deletedPosix.has(Path.posix(folder))) + queryClient.setQueryData(userConfigQueryKey(dir), { ...prev, folders: newFolders }) + + const st = useUIMediaFolderStore.getState() + const nextUiFolders = st.folders.filter((folder) => !deletedPosix.has(Path.posix(folder.path))) + const nextSelectedFolders = st.selectedFolders.filter((p) => !deletedNative.has(p)) + let nextPrimary = st.selectedFolder + if (deletedNative.has(nextPrimary)) { + nextPrimary = newFolders[0] ? Path.toPlatformPath(newFolders[0]) : "" + } + useUIMediaFolderStore.setState({ + folders: nextUiFolders, + selectedFolders: + nextSelectedFolders.length > 0 ? nextSelectedFolders : nextPrimary ? [nextPrimary] : [], + selectedFolder: nextPrimary, + }) +} + +export function useUnimportFolderMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (paths: string[]) => { + if (paths.length === 0) return + + const helloData = queryClient.getQueryData(helloQueryKey) + const dir = helloData?.userDataDir + const prev = + (dir ? queryClient.getQueryData(userConfigQueryKey(dir)) : undefined) ?? + defaultUserConfig + const removedMetadata = snapshotMetadata(queryClient, paths) + const previousUiFolders = [...useUIMediaFolderStore.getState().folders] + const prevUiSelection = { + selectedFolder: useUIMediaFolderStore.getState().selectedFolder, + selectedFolders: [...useUIMediaFolderStore.getState().selectedFolders], + } + + if (dir) { + applyOptimisticRemoval(queryClient, dir, prev, paths) + } + + try { + await Promise.all( + paths.map(async (path) => { + const resp = await unimportFolder(path) + if (resp.error) throw new Error(resp.error) + }), + ) + invalidateFoldersQueryIfV3(queryClient) + } catch (error) { + if (dir) { + queryClient.setQueryData(userConfigQueryKey(dir), prev) + } + removedMetadata.forEach((metadata) => { + const folder = normalizeMediaFolderPathForQuery(metadata.mediaFolderPath || "") + if (folder) { + queryClient.setQueryData(mediaMetadataQueryKey(folder), metadata) + } + }) + useUIMediaFolderStore.setState({ + folders: previousUiFolders, + selectedFolder: prevUiSelection.selectedFolder, + selectedFolders: prevUiSelection.selectedFolders, + }) + toast.error( + error instanceof Error ? error.message : "Failed to delete selected folders. Changes reverted.", + ) + throw error + } + }, + }) +} diff --git a/docs/api/index.md b/docs/api/index.md index ab41d2ea..0c4b2c97 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -50,6 +50,10 @@ CLI also sweeps `{userDataDir}/temp/ytdlp-cookies-*.txt` on startup (fallback wh Source Code: apps/cli/src/route/GetFolders.ts HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. +## UnimportFolder +Source Code: apps/cli/src/route/UnimportFolder.ts +HTTP: `POST /api/unimport-folder` — removes an imported media folder from `userDataDir/smm.json` and deletes its metadata cache via Layer 2 `Core.unimportFolder(path)`. Request body: `{ path: string }`. Response: `{ data: { path } }` or `{ error }`. Idempotent when the path is not in the config. Used by UI delete (context menu, Delete key, multi-select) when `localStorage["smm.v3.enabled"] === "true"`. + ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts HTTP: `POST /api/setWatchedFolder` — sets the single media folder the CLI `FolderWatcher` listens to (UI primary `selectedFolder`). Request body: `{ folderPath: string | null }` (platform absolute path, or null/empty to stop watching). Response: `{ data: { watchedFolder: string | null }, error?: string }`. Startup no longer watches all imported folders. From 2414fe0a1f42b194ad499f0ac1abb50ba08aec1f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 12:30:17 +0800 Subject: [PATCH 043/214] fix(ui): register anonymousTelemetryConsent keys in i18next types Unblocks tsc -b after the consent dialog and settings strings were added to locale JSON. Co-authored-by: Cursor --- apps/ui/src/types/i18next.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/ui/src/types/i18next.d.ts b/apps/ui/src/types/i18next.d.ts index 7fd9b976..f35b93e4 100644 --- a/apps/ui/src/types/i18next.d.ts +++ b/apps/ui/src/types/i18next.d.ts @@ -602,6 +602,12 @@ interface ComponentsResources { releaseToAdd: string readyToReceive: string } + anonymousTelemetryConsent: { + title: string + description: string + agree: string + disagree: string + } } interface DialogsResources { @@ -1058,6 +1064,8 @@ interface SettingsResources { mcpHostPlaceholder: string mcpPort: string mcpPortPlaceholder: string + anonymousTelemetryConsent: string + anonymousTelemetryConsentDescription: string externalTools: string externalToolsDescription: string ytdlpExecutablePath: string From a1ec21c62eb290bcf894cdc72f79225a0f480c7d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 13:10:36 +0800 Subject: [PATCH 044/214] docs: add design for smm list CLI command Co-authored-by: Cursor --- .../2026-08-18-smm-list-command-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-smm-list-command-design.md diff --git a/docs/superpowers/specs/2026-08-18-smm-list-command-design.md b/docs/superpowers/specs/2026-08-18-smm-list-command-design.md new file mode 100644 index 00000000..15f33d25 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-smm-list-command-design.md @@ -0,0 +1,123 @@ +# `smm list` CLI command + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +Imported media folder paths are persisted in `userDataDir/smm.json` and exposed by Layer 2 `Core.getFolders()` (`apps/core`, package name `core-app`). The HTTP surface already reuses this via `POST /api/get-folders` (`apps/cli/src/route/GetFolders.ts` → `getCore().getFolders()`). + +Operators also need a terminal command to list those paths without starting (or talking to) the HTTP server. This feature adds `smm list` to `apps/cli`, using the Commander library and the same Core API. + +**Agreed product behavior** + +- Output: path only, one path per line on stdout. +- Empty list (including missing `smm.json`): empty stdout, exit 0. +- Data path: `getCore().getFolders()` (not a parallel read of `packages/core` / raw config). +- Scope: add `list` only; when argv is not `list`, keep today’s server startup and flag parsing unchanged. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +apps/cli (entry + Commander `list`) + │ + ▼ +apps/cli/src/core/getCore.ts (lazy Core singleton) + │ + ▼ +apps/core (package `core-app`) Core.getFolders() + │ + ▼ +userDataDir/smm.json → folders: string[] +``` + +- `apps/cli` depends on workspace package `core-app` (directory `apps/core`). +- No new HTTP API. No change to `packages/core` for this feature. +- Server boot path remains the default when the first argument is not `list`. + +## 2.2 App Level Architecture + +| Piece | Location | Role | +|--------|----------|------| +| Dependency | `apps/cli/package.json` | Add `commander` | +| Entry branch | `apps/cli/index.ts` | If `process.argv[2] === 'list'`, run list CLI then exit; else existing `parseArgs()` + server | +| List command | `apps/cli/src/cli/list.ts` (new) | Commander program registering `list`; action calls `getCore().getFolders()` and prints paths | +| Core singleton | `apps/cli/src/core/getCore.ts` (existing) | Same as `/api/get-folders` | +| Tests | `apps/cli/src/cli/list.test.ts` (new) | Vitest; `USER_DATA_DIR` + `resetCoreForTests()` pattern from `GetFolders.test.ts` | + +**Entry flow** + +``` +process.argv + │ + ├─ [2] === 'list' → runListCli(argv) → process.exit(code) + │ + └─ otherwise → parseArgs() + Server.start() (unchanged) +``` + +## 2.3 Key Design + +1. **Early argv branch (Approach A)** — Introduce Commander only for the `list` subcommand path. Avoid a full migration of `--staticDir` / `--port` in this change. +2. **Reuse Layer 2** — `getCore().getFolders()` is the single source of truth for imported folder paths (same as display-folders v3 HTTP route). +3. **In-process, no server** — `smm list` must work without the HTTP server listening. +4. **Stdout contract** — Each path is one `console.log` line. No headers, no JSON in v1. +5. **Errors** — Unexpected failures from `getFolders()`: message on stderr, exit code 1. Empty folders is success, not an error. + +**Out of scope** + +- Migrating server startup flags to Commander. +- JSON / table output flags. +- `smm import` / `smm unimport` or other folder management commands. + +## 3. User Stories + +### 3.1 List imported folders + +* **Given** - `smm.json` under `USER_DATA_DIR` contains `folders: ["/media/A", "/media/B"]` +* **When** - the user runs `smm list` (or `bun run index.ts list` in `apps/cli`) +* **Then** - stdout is: + +``` +/media/A +/media/B +``` + +and the process exits with code 0; the HTTP server is not started. + +```mermaid +sequenceDiagram + participant User + participant Index as apps/cli/index.ts + participant List as src/cli/list.ts + participant Core as getCore()/Core + participant FS as smm.json + + User->>Index: smm list + Index->>List: run list via Commander + List->>Core: getFolders() + Core->>FS: read userDataDir/smm.json + FS-->>Core: folders[] + Core-->>List: string[] + List-->>User: one path per line (stdout) + List-->>Index: exit 0 +``` + +### 3.2 Empty list is silent success + +* **Given** - no `smm.json` or `folders` is `[]` +* **When** - the user runs `smm list` +* **Then** - stdout is empty, exit code is 0. + +### 3.3 Failure surfaces on stderr + +* **Given** - `getFolders()` throws (unexpected I/O or Core error) +* **When** - the user runs `smm list` +* **Then** - an error message is written to stderr and the process exits with code 1. + +### 3.4 Default entry still starts the server + +* **Given** - the user runs `smm` with no `list` subcommand (optional `--port` / `--staticDir` as today) +* **When** - the process starts +* **Then** - behavior matches the current server entry; Commander list code is not required to run. From 772fde564cf294307e1ccd454511fb87787ccc99 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 15:36:25 +0800 Subject: [PATCH 045/214] feat(cli): add smm list to print imported folder paths Co-authored-by: Cursor --- apps/cli/index.ts | 7 ++ apps/cli/package.json | 3 +- apps/cli/src/cli/list.test.ts | 76 +++++++++++++++++++ apps/cli/src/cli/list.ts | 40 ++++++++++ pnpm-lock.yaml | 136 +++++++++++++++++----------------- 5 files changed, 194 insertions(+), 68 deletions(-) create mode 100644 apps/cli/src/cli/list.test.ts create mode 100644 apps/cli/src/cli/list.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 2655efa7..f833d069 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -17,6 +17,13 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); +// `smm list` — list imported folders via Core; do not start the HTTP server. +if (process.argv[2] === 'list') { + const { runListCli } = await import('./src/cli/list'); + const code = await runListCli(process.argv); + process.exit(code); +} + interface CommandLineArguments { staticDir?: string; port?: number; diff --git a/apps/cli/package.json b/apps/cli/package.json index 536189c1..e75103d2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,11 +18,12 @@ "@assistant-ui/react-ai-sdk": "^1.3.7", "@hono/node-server": "^1.19.0", "@modelcontextprotocol/sdk": "^1.25.3", - "core-app": "workspace:*", "@smm/core-routes": "workspace:*", "@smm/tvdb4": "workspace:*", "@types/shelljs": "^0.10.0", "ai": "^6.0.36", + "commander": "^15.0.0", + "core-app": "workspace:*", "dotenv": "^17.2.3", "es-toolkit": "^1.43.0", "hono": "^4.10.8", diff --git a/apps/cli/src/cli/list.test.ts b/apps/cli/src/cli/list.test.ts new file mode 100644 index 00000000..e24eae93 --- /dev/null +++ b/apps/cli/src/cli/list.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm list', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-list-cli-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('prints folder paths one per line and exits 0', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: ['/media/A', '/media/B'] }), + 'utf-8', + ) + resetCoreForTests() + + const { runListCli } = await import('./list') + const code = await runListCli(['node', 'smm', 'list']) + + expect(code).toBe(0) + expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['/media/A', '/media/B']) + }) + + it('prints nothing and exits 0 when there are no folders', async () => { + const { runListCli } = await import('./list') + const code = await runListCli(['node', 'smm', 'list']) + + expect(code).toBe(0) + expect(logSpy).not.toHaveBeenCalled() + }) + + it('writes to stderr and exits 1 when getFolders throws', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + getFolders: async () => { + throw new Error('boom') + }, + }), + resetCoreForTests: () => {}, + })) + + const { runListCli } = await import('./list') + const code = await runListCli(['node', 'smm', 'list']) + + expect(code).toBe(1) + expect(errorSpy).toHaveBeenCalled() + const errText = String(errorSpy.mock.calls[0]?.[0] ?? '') + expect(errText).toContain('boom') + + vi.doUnmock('../core/getCore') + vi.resetModules() + }) +}) diff --git a/apps/cli/src/cli/list.ts b/apps/cli/src/cli/list.ts new file mode 100644 index 00000000..9125b283 --- /dev/null +++ b/apps/cli/src/cli/list.ts @@ -0,0 +1,40 @@ +import { Command } from 'commander' +import { getCore } from '../core/getCore' + +/** + * Run the `smm list` Commander program. + * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). + * @returns Process exit code (0 success, 1 on error). + */ +export async function runListCli(argv: string[] = process.argv): Promise { + let exitCode = 0 + + const program = new Command() + program.name('smm').exitOverride() + + program + .command('list') + .description('List imported media folder paths') + .action(async () => { + try { + const folders = await getCore().getFolders() + for (const folder of folders) { + console.log(folder) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + + try { + await program.parseAsync(argv, { from: 'node' }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + + return exitCode +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 669aeae6..bfec4969 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8(@types/node@25.3.0) + version: 2.29.8(@types/node@26.2.0) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -66,6 +66,9 @@ importers: ai: specifier: ^6.0.36 version: 6.0.98(zod@4.3.6) + commander: + specifier: ^15.0.0 + version: 15.0.0 core-app: specifier: workspace:* version: link:../core @@ -132,7 +135,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) apps/convex: dependencies: @@ -151,7 +154,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) apps/docker: devDependencies: @@ -651,7 +654,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) packages/core-routes: dependencies: @@ -745,7 +748,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) packages/utils: devDependencies: @@ -1828,33 +1831,18 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - '@floating-ui/react-dom@2.1.8': resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -3486,6 +3474,9 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -4323,6 +4314,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} @@ -8147,6 +8142,9 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.23.0: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} @@ -9044,7 +9042,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8(@types/node@25.3.0)': + '@changesets/cli@2.29.8(@types/node@26.2.0)': dependencies: '@changesets/apply-release-plan': 7.0.14 '@changesets/assemble-release-plan': 6.0.9 @@ -9060,7 +9058,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@25.3.0) + '@inquirer/external-editor': 1.0.3(@types/node@26.2.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -9701,38 +9699,21 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.4': - dependencies: - '@floating-ui/utils': 0.2.10 - '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.5': - dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/dom': 1.7.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@floating-ui/dom': 1.7.6 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@floating-ui/utils@0.2.10': {} - '@floating-ui/utils@0.2.11': {} '@hono/node-server@1.19.9(hono@4.12.2)': @@ -9809,6 +9790,13 @@ snapshots: optionalDependencies: '@types/node': 25.3.0 + '@inquirer/external-editor@1.0.3(@types/node@26.2.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 26.2.0 + '@inquirer/figures@1.0.15': {} '@inquirer/input@4.3.1(@types/node@25.3.0)': @@ -10522,7 +10510,7 @@ snapshots: '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -11390,6 +11378,11 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + optional: true + '@types/normalize-package-data@2.4.4': {} '@types/plist@3.0.5': @@ -11586,7 +11579,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -11621,13 +11614,13 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -11673,7 +11666,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -12206,7 +12199,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -12527,6 +12520,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + commander@5.1.0: {} commander@8.3.0: {} @@ -12698,6 +12693,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -13039,7 +13038,7 @@ snapshots: engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 engine.io-parser: 5.2.3 ws: 8.18.3 xmlhttprequest-ssl: 2.1.2 @@ -13058,7 +13057,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 engine.io-parser: 5.2.3 ws: 8.18.3 transitivePeerDependencies: @@ -13121,7 +13120,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-toolkit@1.44.0: {} @@ -13456,7 +13455,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -13586,7 +13585,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -13651,7 +13650,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 format@0.2.2: {} @@ -14095,7 +14094,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -14107,7 +14106,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -14492,7 +14491,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0 + ws: 8.21.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -16066,7 +16065,7 @@ snapshots: react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 react: 19.2.4 use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) @@ -16405,7 +16404,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -16482,7 +16481,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -16633,7 +16632,7 @@ snapshots: socket.io-adapter@2.5.6: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -16643,7 +16642,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 engine.io-client: 6.6.4 socket.io-parser: 4.2.5 transitivePeerDependencies: @@ -16654,7 +16653,7 @@ snapshots: socket.io-parser@4.2.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16663,7 +16662,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 engine.io: 6.6.5 socket.io-adapter: 2.5.6 socket.io-parser: 4.2.5 @@ -17157,6 +17156,9 @@ snapshots: undici-types@7.18.2: {} + undici-types@8.3.0: + optional: true + undici@6.23.0: {} undici@7.22.0: {} @@ -17360,7 +17362,7 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -17369,7 +17371,7 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 26.2.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -17456,10 +17458,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -17476,11 +17478,11 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - '@types/node': 25.3.0 + '@types/node': 26.2.0 '@vitest/ui': 4.0.18(vitest@4.0.18) jsdom: 25.0.1 transitivePeerDependencies: From fb2644f7e5620d59dec781fb80c800c352ffa94a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 17:29:37 +0800 Subject: [PATCH 046/214] fix(core): serialize smm.json reads and writes with a UserConfig mutex Concurrent unimportFolder calls were truncating smm.json; path-keyed es-toolkit Mutex keeps Core and the import pipeline on one lock per file. Co-authored-by: Cursor --- apps/core/package.json | 3 +- apps/core/src/Core.test.ts | 30 +++++++ apps/core/src/Core.ts | 27 ++++--- apps/core/src/index.ts | 1 + .../core/src/pipeline/importFolderPipeline.ts | 10 ++- apps/core/src/pipeline/userConfig.test.ts | 55 +++++++++++++ apps/core/src/pipeline/userConfig.ts | 80 ++++++++++++++++--- pnpm-lock.yaml | 33 ++++---- 8 files changed, 195 insertions(+), 44 deletions(-) create mode 100644 apps/core/src/pipeline/userConfig.test.ts diff --git a/apps/core/package.json b/apps/core/package.json index 06dcdc94..b5547757 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest --watch" }, "dependencies": { - "@smm/tvdb4": "workspace:*" + "@smm/tvdb4": "workspace:*", + "es-toolkit": "^1.42.0" }, "devDependencies": { "typescript": "^5.9.3", diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 51a3c27c..d84e2b17 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -269,4 +269,34 @@ describe("unimportFolder", () => { expect(JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string).folders).toEqual([]); expect(await fs.exists(cache)).toBe(false); }); + + it("removes every folder when unimportFolder runs concurrently", async () => { + const appDataDir = "/data/smm-concurrent-unimport"; + const folders = ["/m/A", "/m/B", "/m/C", "/m/D", "/m/E"]; + const pause = () => new Promise((r) => setTimeout(r, 15)); + const files = new Map([[userConfigPath(appDataDir), configWith(folders)]]); + const fs: FsPort = { + readTextFile: async (path: string) => { + await pause(); + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }, + writeTextFile: async (path: string, content: string) => { + await pause(); + files.set(path, content); + }, + exists: async (path: string) => files.has(path), + listFiles: async () => [], + deleteFile: async (path: string) => { + files.delete(path); + }, + }; + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + + await Promise.all(folders.map((path) => core.unimportFolder(path))); + + const saved = JSON.parse(await fs.readTextFile(userConfigPath(appDataDir))) as { folders: string[] }; + expect(saved.folders).toEqual([]); + }); }); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index eab4dde1..ceebbe3d 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -1,12 +1,12 @@ import { Path } from "@core/path"; -import type { AppConfig, FolderType, MediaMetadata, UserConfig } from "@smm/core"; +import type { AppConfig, FolderType, MediaMetadata, UserConfig as UserConfigData } from "@smm/core"; import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; -import { readUserConfig, writeUserConfig } from "./pipeline/userConfig"; +import { UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; @@ -37,6 +37,7 @@ export class Core { private readonly version: string; private readonly reverseProxyUrl: string | null; private readonly userDataDir: string; + private readonly userConfig: UserConfig; constructor(options: CoreOptions) { this.fs = options.fs; @@ -46,6 +47,7 @@ export class Core { this.version = options.version ?? ""; this.reverseProxyUrl = options.reverseProxyUrl ?? null; this.userDataDir = options.userDataDir ?? options.appDataDir; + this.userConfig = new UserConfig(this.fs, this.appDataDir); } /** Starts the import pipeline in the background; returns a job handle immediately. */ @@ -75,12 +77,12 @@ export class Core { }; } - getUserConfig(): Promise { - return readUserConfig(this.fs, this.appDataDir); + getUserConfig(): Promise { + return this.userConfig.read(); } async getFolders(): Promise { - return (await readUserConfig(this.fs, this.appDataDir)).folders; + return (await this.userConfig.read()).folders; } /** Reads the persisted metadata cache for a folder; null when absent or corrupt. */ @@ -99,11 +101,16 @@ export class Core { /** Removes a folder from the user config and deletes its metadata cache. Idempotent. */ async unimportFolder(path: string): Promise { const posixPath = this.normalizePosix(path); - const config = await readUserConfig(this.fs, this.appDataDir); - const folders = config.folders.filter((f) => this.normalizePosix(f) !== posixPath); - if (folders.length === config.folders.length) return; - await writeUserConfig(this.fs, this.appDataDir, { ...config, folders }); - await this.fs.deleteFile(metadataCachePath(this.appDataDir, posixPath)); + let removed = false; + await this.userConfig.update((config) => { + const folders = config.folders.filter((f) => this.normalizePosix(f) !== posixPath); + if (folders.length === config.folders.length) return config; + removed = true; + return { ...config, folders }; + }); + if (removed) { + await this.fs.deleteFile(metadataCachePath(this.appDataDir, posixPath)); + } } private normalizePosix(path: string): string { diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 946dfd21..fc1f084f 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -7,6 +7,7 @@ export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/netwo export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; +export { UserConfig, DEFAULT_USER_CONFIG } from "./pipeline/userConfig"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index 8f1a2392..05232098 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -9,7 +9,7 @@ import { TvdbClient } from "../clients/TvdbClient"; import { isVideoFile, recognizeEpisodes } from "./recognizeEpisodes"; import { recognizeMediaFolder } from "./recognizeMediaFolder"; import { metadataCachePath } from "./paths"; -import { readUserConfig, writeUserConfig } from "./userConfig"; +import { UserConfig } from "./userConfig"; export interface ImportFolderPipelineOptions { fs: FsPort; @@ -35,9 +35,11 @@ export class ImportFolderPipeline { const stages: JobStage[] = []; logger.info({ folderPath: posixPath, type }, "importFolder: stage=config"); - const userConfig = await readUserConfig(fs, appDataDir); - const folders = [...new Set([...userConfig.folders, folderPath])]; - await writeUserConfig(fs, appDataDir, { ...userConfig, folders }); + const userConfigStore = new UserConfig(fs, appDataDir); + const userConfig = await userConfigStore.update((config) => ({ + ...config, + folders: [...new Set([...config.folders, folderPath])], + })); stages.push("config"); cb.onStage?.("config", 10); diff --git a/apps/core/src/pipeline/userConfig.test.ts b/apps/core/src/pipeline/userConfig.test.ts new file mode 100644 index 00000000..f4c1339f --- /dev/null +++ b/apps/core/src/pipeline/userConfig.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { UserConfig } from "./userConfig"; +import { userConfigPath } from "./paths"; + +function delayedFs(seed: Record, delayMs = 15): FsPort { + const files = new Map(Object.entries(seed)); + const pause = () => new Promise((r) => setTimeout(r, delayMs)); + return { + readTextFile: async (path: string) => { + await pause(); + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }, + writeTextFile: async (path: string, content: string) => { + await pause(); + files.set(path, content); + }, + exists: async (path: string) => { + await pause(); + return files.has(path); + }, + listFiles: async () => [], + deleteFile: async (path: string) => { + files.delete(path); + }, + }; +} + +describe("UserConfig", () => { + it("keeps both folders when two instances update the same smm.json concurrently", async () => { + const appDataDir = "/data/smm-userconfig-mutex"; + const fs = delayedFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const a = new UserConfig(fs, appDataDir); + const b = new UserConfig(fs, appDataDir); + + await Promise.all([ + a.update((config) => ({ ...config, folders: [...config.folders, "/m/A"] })), + b.update((config) => ({ ...config, folders: [...config.folders, "/m/B"] })), + ]); + + const saved = await a.read(); + expect([...saved.folders].sort()).toEqual(["/m/A", "/m/B"]); + }); +}); diff --git a/apps/core/src/pipeline/userConfig.ts b/apps/core/src/pipeline/userConfig.ts index 4b78a4b7..7de705ed 100644 --- a/apps/core/src/pipeline/userConfig.ts +++ b/apps/core/src/pipeline/userConfig.ts @@ -1,23 +1,79 @@ -import type { UserConfig } from "@smm/core"; -import type { FsPort } from "../ports/FsPort"; -import { userConfigPath } from "./paths"; +import { Mutex } from "es-toolkit" +import type { UserConfig as UserConfigData } from "@smm/core" +import type { FsPort } from "../ports/FsPort" +import { userConfigPath } from "./paths" -export const DEFAULT_USER_CONFIG: UserConfig = { +export const DEFAULT_USER_CONFIG: UserConfigData = { folders: [], tmdb: {}, tvdb: {}, renameRules: [], dryRun: false, selectedRenameRule: "plex", -}; +} + +const mutexByPath = new Map() -export async function readUserConfig(fs: FsPort, appDataDir: string): Promise { - const path = userConfigPath(appDataDir); - if (!(await fs.exists(path))) return { ...DEFAULT_USER_CONFIG }; - const content = await fs.readTextFile(path); - return { ...DEFAULT_USER_CONFIG, ...(JSON.parse(content) as Partial) }; +function mutexFor(path: string): Mutex { + const existing = mutexByPath.get(path) + if (existing) return existing + const created = new Mutex() + mutexByPath.set(path, created) + return created } -export async function writeUserConfig(fs: FsPort, appDataDir: string, config: UserConfig): Promise { - await fs.writeTextFile(userConfigPath(appDataDir), JSON.stringify(config, null, 2)); +/** Locked reader/writer for `{appDataDir}/smm.json`. Instances that share a path share one mutex. */ +export class UserConfig { + private readonly path: string + private readonly mutex: Mutex + + constructor( + private readonly fs: FsPort, + appDataDir: string, + ) { + this.path = userConfigPath(appDataDir) + this.mutex = mutexFor(this.path) + } + + async read(): Promise { + await this.mutex.acquire() + try { + return await this.loadUnlocked() + } finally { + this.mutex.release() + } + } + + async write(config: UserConfigData): Promise { + await this.mutex.acquire() + try { + await this.persistUnlocked(config) + } finally { + this.mutex.release() + } + } + + async update(mutator: (config: UserConfigData) => UserConfigData): Promise { + await this.mutex.acquire() + try { + const current = await this.loadUnlocked() + const next = mutator(current) + if (next !== current) { + await this.persistUnlocked(next) + } + return next + } finally { + this.mutex.release() + } + } + + private async loadUnlocked(): Promise { + if (!(await this.fs.exists(this.path))) return { ...DEFAULT_USER_CONFIG } + const content = await this.fs.readTextFile(this.path) + return { ...DEFAULT_USER_CONFIG, ...(JSON.parse(content) as Partial) } + } + + private async persistUnlocked(config: UserConfigData): Promise { + await this.fs.writeTextFile(this.path, JSON.stringify(config, null, 2)) + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfec4969..5d20fa20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@smm/tvdb4': specifier: workspace:* version: link:../../packages/tvdb4 + es-toolkit: + specifier: ^1.42.0 + version: 1.44.0 devDependencies: typescript: specifier: ^5.9.3 @@ -12199,7 +12202,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -12693,10 +12696,6 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.4.3: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -13038,7 +13037,7 @@ snapshots: engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 xmlhttprequest-ssl: 2.1.2 @@ -13057,7 +13056,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 transitivePeerDependencies: @@ -13455,7 +13454,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -13585,7 +13584,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -14094,7 +14093,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -14106,7 +14105,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -16404,7 +16403,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -16481,7 +16480,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -16632,7 +16631,7 @@ snapshots: socket.io-adapter@2.5.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -16642,7 +16641,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.4 socket.io-parser: 4.2.5 transitivePeerDependencies: @@ -16653,7 +16652,7 @@ snapshots: socket.io-parser@4.2.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -16662,7 +16661,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.5 socket.io-adapter: 2.5.6 socket.io-parser: 4.2.5 From 70a323798c8b2df6490ec737baa8b30c9a8f0e3d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 18:10:00 +0800 Subject: [PATCH 047/214] feat(cli): add smm add to import a folder and wait for init Co-authored-by: Cursor --- apps/cli/index.ts | 8 +- apps/cli/src/cli/add.test.ts | 112 ++++++++++++++++++ apps/cli/src/cli/cliLogger.ts | 30 +++++ apps/cli/src/cli/list.ts | 41 +------ apps/cli/src/cli/runCli.ts | 103 ++++++++++++++++ apps/cli/src/core/getCore.ts | 9 +- .../2026-08-18-smm-add-command-design.md | 90 ++++++++++++++ 7 files changed, 347 insertions(+), 46 deletions(-) create mode 100644 apps/cli/src/cli/add.test.ts create mode 100644 apps/cli/src/cli/cliLogger.ts create mode 100644 apps/cli/src/cli/runCli.ts create mode 100644 docs/superpowers/specs/2026-08-18-smm-add-command-design.md diff --git a/apps/cli/index.ts b/apps/cli/index.ts index f833d069..3419f8a0 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -17,10 +17,10 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); -// `smm list` — list imported folders via Core; do not start the HTTP server. -if (process.argv[2] === 'list') { - const { runListCli } = await import('./src/cli/list'); - const code = await runListCli(process.argv); +// CLI subcommands (`list`, `add`) talk to Core in-process; do not start the HTTP server. +if (process.argv[2] === 'list' || process.argv[2] === 'add') { + const { runCli } = await import('./src/cli/runCli'); + const code = await runCli(process.argv); process.exit(code); } diff --git a/apps/cli/src/cli/add.test.ts b/apps/cli/src/cli/add.test.ts new file mode 100644 index 00000000..df625a89 --- /dev/null +++ b/apps/cli/src/cli/add.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm add', () => { + let userDataDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-add-cli-')) + mediaFolder = mkdtempSync(join(tmpdir(), 'smm-add-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaFolder, { recursive: true, force: true }) + }) + + it('imports the folder, waits until initialization succeeds, and exits 0', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music']) + + expect(code).toBe(0) + const config = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + folders: string[] + } + expect(config.folders).toContain(mediaFolder) + }) + + it('writes to stderr and exits 1 when initialization fails', async () => { + const missing = join(mediaFolder, 'does-not-exist') + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', missing, '--type', 'music']) + + expect(code).toBe(1) + expect(errorSpy).toHaveBeenCalled() + }) + + it('exits 1 when --type is missing', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', mediaFolder]) + + expect(code).toBe(1) + expect(errorSpy).toHaveBeenCalled() + }) + + it('treats --type anime as tvshow', async () => { + const { Core } = await import('core-app') + const importFolder = vi.spyOn(Core.prototype, 'importFolder').mockReturnValue({ id: 'job-1' }) + vi.spyOn(Core.prototype, 'getJob').mockReturnValue({ + id: 'job-1', + folderPath: mediaFolder, + type: 'tvshow', + status: 'succeeded', + stage: null, + progress: 100, + createdAt: 0, + updatedAt: 0, + }) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'anime']) + + expect(code).toBe(0) + expect(importFolder).toHaveBeenCalledWith(mediaFolder, 'tvshow') + }) + + it('prints necessary logs by default', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music']) + + expect(code).toBe(0) + const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')) + expect(lines.some((l) => l.includes(`Adding ${mediaFolder}`))).toBe(true) + expect(lines.some((l) => l.includes('importFolder: stage=config'))).toBe(true) + expect(lines.some((l) => l.includes(`Imported ${mediaFolder}`))).toBe(true) + expect(lines.some((l) => l.includes('"folderPath"'))).toBe(false) + }) + + it('prints detailed logs with --verbose', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music', '--verbose']) + + expect(code).toBe(0) + const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')) + expect(lines.some((l) => l.includes('importFolder: stage=config'))).toBe(true) + expect(lines.some((l) => l.includes('folderPath'))).toBe(true) + }) +}) diff --git a/apps/cli/src/cli/cliLogger.ts b/apps/cli/src/cli/cliLogger.ts new file mode 100644 index 00000000..86fcd1fe --- /dev/null +++ b/apps/cli/src/cli/cliLogger.ts @@ -0,0 +1,30 @@ +import type { LoggerPort } from 'core-app' + +/** CLI logger: necessary messages by default; structured payload only when verbose. */ +export class CliLoggerAdapter implements LoggerPort { + constructor(private readonly verbose: boolean) {} + + info(obj: unknown, msg: string): void { + this.write(console.log, obj, msg) + } + + warn(obj: unknown, msg: string): void { + this.write(console.warn, obj, msg) + } + + error(obj: unknown, msg: string): void { + this.write(console.error, obj, msg) + } + + private write( + out: (message?: unknown, ...optionalParams: unknown[]) => void, + obj: unknown, + msg: string, + ): void { + if (this.verbose) { + out(msg, JSON.stringify(obj)) + } else { + out(msg) + } + } +} diff --git a/apps/cli/src/cli/list.ts b/apps/cli/src/cli/list.ts index 9125b283..56008b24 100644 --- a/apps/cli/src/cli/list.ts +++ b/apps/cli/src/cli/list.ts @@ -1,40 +1 @@ -import { Command } from 'commander' -import { getCore } from '../core/getCore' - -/** - * Run the `smm list` Commander program. - * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). - * @returns Process exit code (0 success, 1 on error). - */ -export async function runListCli(argv: string[] = process.argv): Promise { - let exitCode = 0 - - const program = new Command() - program.name('smm').exitOverride() - - program - .command('list') - .description('List imported media folder paths') - .action(async () => { - try { - const folders = await getCore().getFolders() - for (const folder of folders) { - console.log(folder) - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(message) - exitCode = 1 - } - }) - - try { - await program.parseAsync(argv, { from: 'node' }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(message) - exitCode = 1 - } - - return exitCode -} +export { runCli as runListCli } from './runCli' diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts new file mode 100644 index 00000000..9e638602 --- /dev/null +++ b/apps/cli/src/cli/runCli.ts @@ -0,0 +1,103 @@ +import { Command, Option } from 'commander' +import type { FolderType, ImportJob } from 'core-app' +import { getCore } from '../core/getCore' +import { CliLoggerAdapter } from './cliLogger' + +const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] +const TYPE_CHOICES = [...FOLDER_TYPES, 'anime'] as const + +function resolveFolderType(value: string): FolderType { + if (value === 'anime') return 'tvshow' + if ((FOLDER_TYPES as readonly string[]).includes(value)) { + return value as FolderType + } + throw new Error(`Invalid folder type: ${value}`) +} + +const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 + +async function waitUntilImportSettled( + core: ReturnType, + id: string, +): Promise { + const deadline = Date.now() + IMPORT_WAIT_TIMEOUT_MS + for (;;) { + const job = core.getJob(id) + if (job && job.status !== 'pending' && job.status !== 'running') { + return job + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for import job ${id}`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} + +/** + * Run the `smm` Commander program (`list`, `add`). + * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). + * @returns Process exit code (0 success, 1 on error). + */ +export async function runCli(argv: string[] = process.argv): Promise { + let exitCode = 0 + + const program = new Command() + program.name('smm').exitOverride() + + program + .command('list') + .description('List imported media folder paths') + .action(async () => { + try { + const folders = await getCore().getFolders() + for (const folder of folders) { + console.log(folder) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + + program + .command('add') + .description('Import a media folder and wait until initialization succeeds') + .argument('', 'Folder path to import') + .addOption( + new Option('--type ', 'Folder type (anime is an alias for tvshow)') + .choices([...TYPE_CHOICES]) + .makeOptionMandatory(), + ) + .option('-v, --verbose', 'Print detailed logs') + .action(async (folder: string, opts: { type: string; verbose?: boolean }) => { + try { + const type = resolveFolderType(opts.type) + const verbose = Boolean(opts.verbose) + const core = getCore({ logger: new CliLoggerAdapter(verbose) }) + console.log(`Adding ${folder}`) + const { id } = core.importFolder(folder, type) + const job = await waitUntilImportSettled(core, id) + if (job.status !== 'succeeded') { + console.error(job.error ?? `Import failed with status ${job.status}`) + exitCode = 1 + return + } + console.log(`Imported ${folder}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + + try { + await program.parseAsync(argv, { from: 'node' }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + + return exitCode +} diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index 6e4cfe82..eaab7af0 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -3,19 +3,24 @@ import { FetchNetworkAdapter, NodejsFsAdapter, NoopLoggerAdapter, + type LoggerPort, } from 'core-app' import { getUserDataDir } from '@/utils/config' let instance: Core | undefined +export interface GetCoreOptions { + logger?: LoggerPort +} + /** Lazy singleton. appDataDir = userDataDir so getFolders reads production smm.json. */ -export function getCore(): Core { +export function getCore(options?: GetCoreOptions): Core { if (!instance) { const userDataDir = getUserDataDir() instance = new Core({ fs: new NodejsFsAdapter(), network: new FetchNetworkAdapter(), - logger: new NoopLoggerAdapter(), + logger: options?.logger ?? new NoopLoggerAdapter(), appDataDir: userDataDir, userDataDir, }) diff --git a/docs/superpowers/specs/2026-08-18-smm-add-command-design.md b/docs/superpowers/specs/2026-08-18-smm-add-command-design.md new file mode 100644 index 00000000..24c7ac84 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-smm-add-command-design.md @@ -0,0 +1,90 @@ +# `smm add` CLI command + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +`smm list` already lists imported folders via Layer 2 `Core.getFolders()`. Operators also need a CLI to **import** a folder. + +`Core.importFolder(path, type)` already starts the media-folder initialization pipeline in the background and exposes progress via `Core.getJob(id)` (in-memory `JobStore`). A separate `smm job` command is **not** required: in CLI mode the process stays alive until initialization finishes. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +smm add --type tvshow|movie|music + │ + ▼ +apps/cli/index.ts (argv[2] is list|add → runCli, else HTTP server) + │ + ▼ +apps/cli/src/cli/runCli.ts (Commander) + │ + ▼ +getCore().importFolder(path, type) → { id } +getCore().getJob(id) → poll until not running + │ + ▼ +apps/core ImportFolderPipeline (config → metadata → listFiles → … → persist) +``` + +Same Core singleton as `smm list` / `POST /api/get-folders`. No HTTP. No new Core API. + +## 2.2 App Level Architecture + +| Piece | Location | Role | +|--------|----------|------| +| CLI program | `apps/cli/src/cli/runCli.ts` | Commander `list` + `add` | +| Entry | `apps/cli/index.ts` | If `argv[2]` is `list` or `add`, `runCli` then `process.exit` | +| Tests | `apps/cli/src/cli/add.test.ts` | Real temp folder + `USER_DATA_DIR`; `--type music` (no TMDB) | + +## 2.3 Key Design + +1. **`--type` is required** (`tvshow` \| `movie` \| `music` \| `anime`). `anime` is an alias for `tvshow`. +2. **Wait, then exit** — on success print necessary logs and exit 0. On failure, message on stderr, exit 1. Do not print a job id. +3. **Logging** — default prints necessary lines (`Adding …`, pipeline `importFolder: stage=…`, `Imported …`). `--verbose` also prints structured logger payloads (e.g. `folderPath`). +4. **Same-process poll** — `importFolder` is async; CLI polls `getJob` until `succeeded` / `failed` / `aborted` (or timeout). In-memory JobStore is enough because the process does not exit until the job settles. +5. **No `smm job`**. + +**Out of scope:** JSON output, daemon/HTTP import, persisting JobStore to disk. + +## 3. User Stories + +### 3.1 Import a music folder and wait + +* **Given** - a local folder with files +* **When** - the user runs `smm add --type music` +* **Then** - the process does not exit until initialization succeeds; the folder is in `smm.json`; exit code is 0 + +```mermaid +sequenceDiagram + participant User + participant Cli as runCli + participant Core + participant Pipeline as ImportFolderPipeline + + User->>Cli: smm add path --type music + Cli->>Core: importFolder(path, music) + Core-->>Cli: { id } + Core->>Pipeline: run (background) + loop until settled + Cli->>Core: getJob(id) + Core-->>Cli: status + end + Pipeline-->>Core: succeeded + Cli-->>User: exit 0 +``` + +### 3.2 Initialization failure + +* **Given** - a path the pipeline cannot initialize +* **When** - the user runs `smm add --type music` +* **Then** - stderr has an error message; exit code is 1 + +### 3.3 Missing type + +* **Given** - the user omits `--type` +* **When** - they run `smm add ` +* **Then** - Commander reports the missing option; exit code is 1 From 7523e50391181d21db2d103173561c41311d2ab9 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 19:18:52 +0800 Subject: [PATCH 048/214] feat(cli): add smm show and smm metadata for folder inspection Co-authored-by: Cursor --- apps/cli/index.ts | 5 +- apps/cli/src/cli/folderDisplay.ts | 167 ++++++++++++++++++ apps/cli/src/cli/metadata.test.ts | 133 ++++++++++++++ apps/cli/src/cli/runCli.ts | 57 +++++- apps/cli/src/cli/show.test.ts | 96 ++++++++++ .../2026-08-18-smm-show-metadata-design.md | 54 ++++++ 6 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/cli/folderDisplay.ts create mode 100644 apps/cli/src/cli/metadata.test.ts create mode 100644 apps/cli/src/cli/show.test.ts create mode 100644 docs/superpowers/specs/2026-08-18-smm-show-metadata-design.md diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3419f8a0..f37d6400 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -17,8 +17,9 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); -// CLI subcommands (`list`, `add`) talk to Core in-process; do not start the HTTP server. -if (process.argv[2] === 'list' || process.argv[2] === 'add') { +// CLI subcommands talk to Core in-process; do not start the HTTP server. +const cliCommands = new Set(['list', 'add', 'show', 'metadata']) +if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); process.exit(code); diff --git a/apps/cli/src/cli/folderDisplay.ts b/apps/cli/src/cli/folderDisplay.ts new file mode 100644 index 00000000..36c535c4 --- /dev/null +++ b/apps/cli/src/cli/folderDisplay.ts @@ -0,0 +1,167 @@ +import { stat } from 'fs/promises' +import { Path } from '@core/path' +import type { MediaMetadata } from '@smm/core' +import { getCore } from '../core/getCore' + +function normalizePosixSafe(path: string): string { + try { + return Path.posix(path) + } catch { + return path + } +} + +async function folderExistsOnDisk(folder: string): Promise { + try { + const st = await stat(Path.toPlatformPath(folder)) + return st.isDirectory() + } catch { + return false + } +} + +export async function isFolderImported(folder: string): Promise { + const folders = await getCore().getFolders() + const target = normalizePosixSafe(folder) + const targetPlatform = Path.toPlatformPath(folder) + return folders.some((f) => { + if (f === folder || f === targetPlatform) return true + return normalizePosixSafe(f) === target + }) +} + +export type ShowFolderStatus = + | 'ok' + | 'folder_not_found' + | 'error_loading_metadata' + +export interface ShowFolderResult { + path: string + status: ShowFolderStatus + type?: MediaMetadata['type'] + title?: string +} + +function titleFromMetadata(mm: MediaMetadata): string | undefined { + return mm.tvShow?.name ?? mm.movie?.name +} + +export async function resolveShowFolder(folder: string): Promise< + | { ok: true; result: ShowFolderResult } + | { ok: false; error: string } +> { + if (!(await isFolderImported(folder))) { + return { ok: false, error: `Folder is not imported: ${folder}` } + } + + if (!(await folderExistsOnDisk(folder))) { + return { + ok: true, + result: { path: folder, status: 'folder_not_found' }, + } + } + + const mm = await getCore().getMediaMetadata(folder) + if (mm === null) { + return { + ok: true, + result: { path: folder, status: 'error_loading_metadata' }, + } + } + + const title = titleFromMetadata(mm) + return { + ok: true, + result: { + path: folder, + status: 'ok', + type: mm.type, + ...(title !== undefined ? { title } : {}), + }, + } +} + +export function formatShowFolder(result: ShowFolderResult): string[] { + const lines = [`Path: ${result.path}`, `Status: ${result.status}`] + if (result.type !== undefined) { + lines.push(`Type: ${result.type}`) + } + if (result.title !== undefined) { + lines.push(`Title: ${result.title}`) + } + return lines +} + +export function formatMediaMetadata(_folder: string, mm: MediaMetadata): string[] { + const lines: string[] = [] + + if (mm.mediaFolderPath !== undefined) { + lines.push(`mediaFolderPath: ${Path.toPlatformPath(mm.mediaFolderPath)}`) + } + if (mm.type !== undefined) { + lines.push(`type: ${mm.type}`) + } + + if (mm.tvShow !== undefined) { + const show = mm.tvShow + lines.push('tvShow:') + lines.push(` name: ${show.name}`) + lines.push(` database: ${show.database}`) + lines.push(` id: ${show.id}`) + if (show.airDate !== undefined) { + lines.push(` airDate: ${show.airDate}`) + } + lines.push(` seasons: ${show.seasons.length}`) + for (const season of show.seasons) { + lines.push(` S${String(season.season).padStart(2, '0')} ${season.name} (${season.episodes.length} episodes)`) + for (const ep of season.episodes) { + lines.push( + ` E${String(ep.episode).padStart(2, '0')} ${ep.name}`, + ) + } + } + } + + if (mm.movie !== undefined) { + const movie = mm.movie + lines.push('movie:') + lines.push(` name: ${movie.name}`) + lines.push(` database: ${movie.database}`) + lines.push(` id: ${movie.id}`) + if (movie.airDate !== undefined) { + lines.push(` airDate: ${movie.airDate}`) + } + } + + if (mm.mediaFiles !== undefined) { + lines.push('mediaFiles:') + if (mm.mediaFiles.length === 0) { + lines.push(' (empty)') + } else { + for (const file of mm.mediaFiles) { + const parts = [`absolutePath: ${Path.toPlatformPath(file.absolutePath)}`] + if (file.seasonNumber !== undefined) { + parts.push(`seasonNumber: ${file.seasonNumber}`) + } + if (file.episodeNumber !== undefined) { + parts.push(`episodeNumber: ${file.episodeNumber}`) + } + lines.push(` - ${parts.join(' ')}`) + if (file.subtitleFilePaths !== undefined && file.subtitleFilePaths.length > 0) { + lines.push(` subtitleFilePaths:`) + for (const p of file.subtitleFilePaths) { + lines.push(` ${Path.toPlatformPath(p)}`) + } + } + if (file.audioFilePaths !== undefined && file.audioFilePaths.length > 0) { + lines.push(` audioFilePaths:`) + for (const p of file.audioFilePaths) { + lines.push(` ${Path.toPlatformPath(p)}`) + } + } + } + } + } + + return lines +} diff --git a/apps/cli/src/cli/metadata.test.ts b/apps/cli/src/cli/metadata.test.ts new file mode 100644 index 00000000..6dd7741d --- /dev/null +++ b/apps/cli/src/cli/metadata.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm metadata', () => { + let userDataDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-meta-cli-')) + mediaFolder = mkdtempSync(join(tmpdir(), 'smm-meta-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaFolder, { recursive: true, force: true }) + }) + + function output(): string { + return logSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n') + } + + it('exits 1 when the folder is not imported', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'metadata', mediaFolder]) + + expect(code).toBe(1) + expect(errorSpy.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/not imported/i) + }) + + it('exits 1 when metadata cache is missing', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: [mediaFolder] }), + 'utf-8', + ) + resetCoreForTests() + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'metadata', mediaFolder]) + + expect(code).toBe(1) + expect(errorSpy.mock.calls.map((c) => String(c[0])).join('\n')).toMatch( + /No metadata cache/i, + ) + }) + + it('prints human-readable metadata after a successful music import', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + const { runCli } = await import('./runCli') + expect(await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music'])).toBe(0) + logSpy.mockClear() + errorSpy.mockClear() + + const code = await runCli(['node', 'smm', 'metadata', mediaFolder]) + + expect(code).toBe(0) + const text = output() + expect(text).toContain('mediaFolderPath:') + expect(text).toContain('type: music-folder') + expect(text).toContain('mediaFiles:') + expect(text).not.toContain('files:') + }) + + it('prints tvShow and mediaFiles fields when present', async () => { + const { formatMediaMetadata } = await import('./folderDisplay') + const lines = formatMediaMetadata(mediaFolder, { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + tvShow: { + database: 'TMDB', + id: '42', + name: 'Demo Show', + airDate: '2024-01-01', + seasons: [ + { + season: 1, + name: 'Season 1', + episodes: [{ season: 1, episode: 1, name: 'Pilot' }], + }, + ], + }, + mediaFiles: [ + { + absolutePath: '/media/Show/S01E01.mkv', + seasonNumber: 1, + episodeNumber: 1, + }, + ], + }) + const text = lines.join('\n') + expect(text).toContain('mediaFolderPath:') + expect(text).toContain('type: tvshow-folder') + expect(text).toContain('tvShow:') + expect(text).toContain('name: Demo Show') + expect(text).toContain('mediaFiles:') + expect(text).toContain('seasonNumber: 1') + expect(text).not.toContain('files:') + expect(text).not.toContain('(unrecognized)') + }) + + it('omits absent fields and never invents unrecognized placeholders', async () => { + const { formatMediaMetadata } = await import('./folderDisplay') + const lines = formatMediaMetadata(mediaFolder, { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + mediaFiles: [], + }) + const text = lines.join('\n') + expect(text).toContain('type: tvshow-folder') + expect(text).toContain('mediaFiles:') + expect(text).toContain('(empty)') + expect(text).not.toContain('tvShow:') + expect(text).not.toContain('movie:') + expect(text).not.toContain('(unrecognized)') + }) +}) diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 9e638602..8d7247cc 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -2,6 +2,12 @@ import { Command, Option } from 'commander' import type { FolderType, ImportJob } from 'core-app' import { getCore } from '../core/getCore' import { CliLoggerAdapter } from './cliLogger' +import { + formatMediaMetadata, + formatShowFolder, + isFolderImported, + resolveShowFolder, +} from './folderDisplay' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] const TYPE_CHOICES = [...FOLDER_TYPES, 'anime'] as const @@ -34,7 +40,7 @@ async function waitUntilImportSettled( } /** - * Run the `smm` Commander program (`list`, `add`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -91,6 +97,55 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('show') + .description('Show imported folder status (UI-aligned)') + .argument('', 'Folder path') + .action(async (folder: string) => { + try { + const resolved = await resolveShowFolder(folder) + if (!resolved.ok) { + console.error(resolved.error) + exitCode = 1 + return + } + for (const line of formatShowFolder(resolved.result)) { + console.log(line) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + + program + .command('metadata') + .description('Show human-readable media metadata for an imported folder') + .argument('', 'Folder path') + .action(async (folder: string) => { + try { + if (!(await isFolderImported(folder))) { + console.error(`Folder is not imported: ${folder}`) + exitCode = 1 + return + } + const mm = await getCore().getMediaMetadata(folder) + if (mm === null) { + console.error(`No metadata cache for folder: ${folder}`) + exitCode = 1 + return + } + for (const line of formatMediaMetadata(folder, mm)) { + console.log(line) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + try { await program.parseAsync(argv, { from: 'node' }) } catch (error) { diff --git a/apps/cli/src/cli/show.test.ts b/apps/cli/src/cli/show.test.ts new file mode 100644 index 00000000..c89b3496 --- /dev/null +++ b/apps/cli/src/cli/show.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm show', () => { + let userDataDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-show-cli-')) + mediaFolder = mkdtempSync(join(tmpdir(), 'smm-show-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaFolder, { recursive: true, force: true }) + }) + + function output(): string { + return logSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n') + } + + it('exits 1 when the folder is not imported', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'show', mediaFolder]) + + expect(code).toBe(1) + expect(errorSpy.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/not imported/i) + }) + + it('reports folder_not_found when imported path is missing on disk', async () => { + const missing = join(mediaFolder, 'gone') + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: [missing] }), + 'utf-8', + ) + resetCoreForTests() + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'show', missing]) + + expect(code).toBe(0) + const text = output() + expect(text).toContain(`Path: ${missing}`) + expect(text).toContain('Status: folder_not_found') + }) + + it('reports error_loading_metadata when cache is missing', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ folders: [mediaFolder] }), + 'utf-8', + ) + resetCoreForTests() + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'show', mediaFolder]) + + expect(code).toBe(0) + const text = output() + expect(text).toContain(`Path: ${mediaFolder}`) + expect(text).toContain('Status: error_loading_metadata') + }) + + it('reports ok with type after a successful music import', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + const { runCli } = await import('./runCli') + expect(await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music'])).toBe(0) + logSpy.mockClear() + + const code = await runCli(['node', 'smm', 'show', mediaFolder]) + + expect(code).toBe(0) + const text = output() + expect(text).toContain(`Path: ${mediaFolder}`) + expect(text).toContain('Status: ok') + expect(text).toContain('Type: music-folder') + }) +}) diff --git a/docs/superpowers/specs/2026-08-18-smm-show-metadata-design.md b/docs/superpowers/specs/2026-08-18-smm-show-metadata-design.md new file mode 100644 index 00000000..3702f855 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-smm-show-metadata-design.md @@ -0,0 +1,54 @@ +# `smm show` and `smm metadata` CLI commands + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +After `smm list` / `smm add`, operators need to inspect an imported folder: + +- **Status** aligned with UI `UIMediaFolderStatus` names (not a vague “status” dump of everything) +- **Media metadata** as a human-readable view of `Core.getMediaMetadata()` + +These are two commands. Output is user-friendly text, not JSON. + +## 2. Architecture + +``` +smm show → resolveShowFolder → formatShowFolder +smm metadata → getMediaMetadata → formatMediaMetadata + │ + ▼ +getCore() (apps/core) +``` + +## 3. Commands + +### 3.1 `smm show ` + +Path required. + +| Condition | Status printed | Exit | +|-----------|----------------|------| +| Not in `getFolders()` | stderr `Folder is not imported` | 1 | +| Imported, path missing on disk | `folder_not_found` | 0 | +| Imported, no/corrupt metadata cache | `error_loading_metadata` | 0 | +| Imported, cache readable | `ok` (+ Type, Title when present) | 0 | + +Runtime-only UI states (`initializing`, `loading`, …) are not synthesized by the CLI. + +### 3.2 `smm metadata ` + +Path required. Prints human-readable `MediaMetadata` fields (except `files`, which is not persisted): + +- `mediaFolderPath`, `type` when present +- `tvShow` / `movie` nested fields when present (omitted if absent — no invented “unrecognized”) +- `mediaFiles` list when present + +Exit 1 if not imported or no cache. + +## 4. Files + +- `apps/cli/src/cli/folderDisplay.ts` — resolve / format helpers +- `apps/cli/src/cli/runCli.ts` — Commander `show` / `metadata` +- `apps/cli/src/cli/show.test.ts`, `metadata.test.ts` From efe902c33c5635ab5cb8f13b322ab4d51c8a94f3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 19:22:57 +0800 Subject: [PATCH 049/214] feat(cli): add smm rm to unimport a media folder Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/src/cli/rm.test.ts | 59 +++++++++++++++++++++++++++++++++++++ apps/cli/src/cli/runCli.ts | 22 +++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/cli/rm.test.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index f37d6400..fdf3bc8a 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm']) if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); diff --git a/apps/cli/src/cli/rm.test.ts b/apps/cli/src/cli/rm.test.ts new file mode 100644 index 00000000..19ab123b --- /dev/null +++ b/apps/cli/src/cli/rm.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm rm', () => { + let userDataDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-rm-cli-')) + mediaFolder = mkdtempSync(join(tmpdir(), 'smm-rm-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaFolder, { recursive: true, force: true }) + }) + + it('exits 1 when the folder is not imported', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'rm', mediaFolder]) + + expect(code).toBe(1) + expect(errorSpy.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/not imported/i) + }) + + it('unimports the folder and exits 0', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + const { runCli } = await import('./runCli') + expect(await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music'])).toBe(0) + logSpy.mockClear() + errorSpy.mockClear() + + const code = await runCli(['node', 'smm', 'rm', mediaFolder]) + + expect(code).toBe(0) + expect(logSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain(mediaFolder) + const config = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + folders: string[] + } + expect(config.folders).not.toContain(mediaFolder) + }) +}) diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 8d7247cc..66926f78 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -40,7 +40,7 @@ async function waitUntilImportSettled( } /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -146,6 +146,26 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('rm') + .description('Unimport a media folder (remove from config and delete metadata cache)') + .argument('', 'Folder path to unimport') + .action(async (folder: string) => { + try { + if (!(await isFolderImported(folder))) { + console.error(`Folder is not imported: ${folder}`) + exitCode = 1 + return + } + await getCore().unimportFolder(folder) + console.log(`Removed ${folder}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + try { await program.parseAsync(argv, { from: 'node' }) } catch (error) { From 37bc4fd1960e1caa6671b1ffc50a6119d0a8b558 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 21:30:09 +0800 Subject: [PATCH 050/214] feat(core): failover TMDB/TVDB hosts and print honest smm add recognition progress Core now tries bundled discover mirrors when mediadb is unreachable, matching UI initialization. CLI add prints the recognized title or an explicit miss instead of always saying recognized. Co-authored-by: Cursor --- apps/cli/src/cli/add.test.ts | 7 +- apps/cli/src/cli/addProgress.test.ts | 109 ++++++++++ apps/cli/src/cli/addProgress.ts | 156 ++++++++++++++ apps/cli/src/cli/runCli.ts | 34 +-- apps/cli/src/core/getCore.ts | 2 + apps/core/src/Core.ts | 15 +- .../adapters/StaticDiscoverAdapter.test.ts | 17 ++ .../src/adapters/StaticDiscoverAdapter.ts | 37 ++++ apps/core/src/clients/TmdbClient.test.ts | 27 +++ apps/core/src/clients/TmdbClient.ts | 32 ++- apps/core/src/clients/TvdbClient.ts | 39 +++- .../clients/mediaDatabaseTransport.test.ts | 138 ++++++++++++ .../src/clients/mediaDatabaseTransport.ts | 197 ++++++++++++++++++ apps/core/src/index.ts | 7 + apps/core/src/jobs/types.ts | 2 + .../core/src/pipeline/importFolderPipeline.ts | 28 ++- apps/core/src/ports/DiscoverPort.ts | 26 +++ ...-18-core-media-database-failover-design.md | 152 ++++++++++++++ 18 files changed, 981 insertions(+), 44 deletions(-) create mode 100644 apps/cli/src/cli/addProgress.test.ts create mode 100644 apps/cli/src/cli/addProgress.ts create mode 100644 apps/core/src/adapters/StaticDiscoverAdapter.test.ts create mode 100644 apps/core/src/adapters/StaticDiscoverAdapter.ts create mode 100644 apps/core/src/clients/mediaDatabaseTransport.test.ts create mode 100644 apps/core/src/clients/mediaDatabaseTransport.ts create mode 100644 apps/core/src/ports/DiscoverPort.ts create mode 100644 docs/superpowers/specs/2026-08-18-core-media-database-failover-design.md diff --git a/apps/cli/src/cli/add.test.ts b/apps/cli/src/cli/add.test.ts index df625a89..644d8364 100644 --- a/apps/cli/src/cli/add.test.ts +++ b/apps/cli/src/cli/add.test.ts @@ -92,9 +92,9 @@ describe('smm add', () => { expect(code).toBe(0) const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')) - expect(lines.some((l) => l.includes(`Adding ${mediaFolder}`))).toBe(true) - expect(lines.some((l) => l.includes('importFolder: stage=config'))).toBe(true) - expect(lines.some((l) => l.includes(`Imported ${mediaFolder}`))).toBe(true) + expect(lines.some((l) => l.includes(`imported folder ${mediaFolder}`))).toBe(true) + expect(lines.some((l) => l === 'succeeded')).toBe(true) + expect(lines.some((l) => l.includes('importFolder: stage='))).toBe(false) expect(lines.some((l) => l.includes('"folderPath"'))).toBe(false) }) @@ -108,5 +108,6 @@ describe('smm add', () => { const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')) expect(lines.some((l) => l.includes('importFolder: stage=config'))).toBe(true) expect(lines.some((l) => l.includes('folderPath'))).toBe(true) + expect(lines.some((l) => l === 'succeeded')).toBe(true) }) }) diff --git a/apps/cli/src/cli/addProgress.test.ts b/apps/cli/src/cli/addProgress.test.ts new file mode 100644 index 00000000..740a051f --- /dev/null +++ b/apps/cli/src/cli/addProgress.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import type { ImportJob } from 'core-app' +import { createAddProgressState, emitAddProgress } from './addProgress' + +function job(partial: Partial): ImportJob { + return { + id: 'j1', + folderPath: '/m/Show', + type: 'tvshow', + status: 'running', + stage: 'config', + progress: 0, + createdAt: 0, + updatedAt: 0, + ...partial, + } +} + +describe('emitAddProgress', () => { + it('prints the tvshow-friendly sequence', () => { + const lines: string[] = [] + const log = (line: string) => lines.push(line) + let state = createAddProgressState() + const folder = 'C:\\media\\Show' + + state = emitAddProgress(state, job({ stage: 'config', progress: 0 }), folder, 'tvshow', log) + expect(lines).toEqual([]) + + state = emitAddProgress(state, job({ stage: 'config', progress: 10 }), folder, 'tvshow', log) + expect(lines).toEqual([`imported folder ${folder}`]) + + state = emitAddProgress(state, job({ stage: 'listFiles', progress: 40 }), folder, 'tvshow', log) + expect(lines.at(-1)).toBe('recognizing tvshow') + + state = emitAddProgress( + state, + job({ stage: 'recognize', progress: 60, recognizedTitle: 'Demo Show' }), + folder, + 'tvshow', + log, + ) + expect(lines.slice(-2)).toEqual([ + 'recognized tvshow "Demo Show"', + 'recognizing episodes', + ]) + + state = emitAddProgress(state, job({ stage: 'episodes', progress: 80 }), folder, 'tvshow', log) + expect(lines.at(-1)).toBe('recognized episodes') + + state = emitAddProgress( + state, + job({ stage: null, progress: 100, status: 'succeeded', recognizedTitle: 'Demo Show' }), + folder, + 'tvshow', + log, + ) + expect(lines.at(-1)).toBe('succeeded') + expect(lines).toEqual([ + `imported folder ${folder}`, + 'recognizing tvshow', + 'recognized tvshow "Demo Show"', + 'recognizing episodes', + 'recognized episodes', + 'succeeded', + ]) + }) + + it('when recognition finds no title, prints that clearly and skips episode lines', () => { + const lines: string[] = [] + const log = (line: string) => lines.push(line) + let state = createAddProgressState() + const folder = 'C:\\media\\Unknown' + + state = emitAddProgress(state, job({ stage: 'config', progress: 10 }), folder, 'tvshow', log) + state = emitAddProgress(state, job({ stage: 'listFiles', progress: 40 }), folder, 'tvshow', log) + state = emitAddProgress(state, job({ stage: 'recognize', progress: 60 }), folder, 'tvshow', log) + state = emitAddProgress(state, job({ stage: 'episodes', progress: 80 }), folder, 'tvshow', log) + state = emitAddProgress( + state, + job({ stage: null, progress: 100, status: 'succeeded' }), + folder, + 'tvshow', + log, + ) + + expect(lines).toEqual([ + `imported folder ${folder}`, + 'recognizing tvshow', + 'recognition completed and no tvshow was recognized.', + 'succeeded', + ]) + }) + + it('for music only prints imported folder and succeeded', () => { + const lines: string[] = [] + let state = createAddProgressState() + const folder = '/m/Music' + state = emitAddProgress(state, job({ type: 'music', stage: 'config', progress: 10 }), folder, 'music', (l) => lines.push(l)) + state = emitAddProgress(state, job({ type: 'music', stage: 'listFiles', progress: 40 }), folder, 'music', (l) => lines.push(l)) + state = emitAddProgress( + state, + job({ type: 'music', stage: null, progress: 100, status: 'succeeded' }), + folder, + 'music', + (l) => lines.push(l), + ) + expect(lines).toEqual([`imported folder ${folder}`, 'succeeded']) + }) +}) diff --git a/apps/cli/src/cli/addProgress.ts b/apps/cli/src/cli/addProgress.ts new file mode 100644 index 00000000..46603ae9 --- /dev/null +++ b/apps/cli/src/cli/addProgress.ts @@ -0,0 +1,156 @@ +import type { Core, FolderType, ImportJob, JobStage } from 'core-app' + +export type AddProgressKind = 'tvshow' | 'movie' + +function mediaKind(type: FolderType): AddProgressKind | null { + if (type === 'tvshow') return 'tvshow' + if (type === 'movie') return 'movie' + return null +} + +/** Stages at or after listFiles (recognition about to start or already running). */ +function stageAtOrAfterListFiles(stage: JobStage): boolean { + return ( + stage === 'listFiles' || + stage === 'recognize' || + stage === 'episodes' || + stage === 'persist' || + stage === null + ) +} + +function stageAtOrAfterRecognize(stage: JobStage): boolean { + return stage === 'recognize' || stage === 'episodes' || stage === 'persist' || stage === null +} + +function stageAtOrAfterEpisodes(stage: JobStage): boolean { + return stage === 'episodes' || stage === 'persist' || stage === null +} + +export interface AddProgressState { + imported: boolean + recognizingMedia: boolean + recognizedMedia: boolean + /** True only when recognize stage finished with a title (tvShow/movie found). */ + recognizedOk: boolean + recognizingEpisodes: boolean + recognizedEpisodes: boolean + succeeded: boolean +} + +export function createAddProgressState(): AddProgressState { + return { + imported: false, + recognizingMedia: false, + recognizedMedia: false, + recognizedOk: false, + recognizingEpisodes: false, + recognizedEpisodes: false, + succeeded: false, + } +} + +/** + * Print user-facing `smm add` progress lines based on job stage. + * Pipeline calls onStage after each stage completes. + */ +export function emitAddProgress( + state: AddProgressState, + job: ImportJob, + folder: string, + type: FolderType, + log: (line: string) => void = console.log, +): AddProgressState { + const next = { ...state } + const kind = mediaKind(type) + const stage = job.stage + const done = job.status === 'succeeded' + + // config completed (progress set to 10); initial job uses stage=config with progress 0 + if ( + !next.imported && + (job.progress >= 10 || + stage === 'metadata' || + stageAtOrAfterListFiles(stage) || + done) + ) { + log(`imported folder ${folder}`) + next.imported = true + } + + if (kind !== null) { + // listFiles completed → start recognition + if (!next.recognizingMedia && (stage === 'listFiles' || stageAtOrAfterRecognize(stage) || done)) { + log(`recognizing ${kind}`) + next.recognizingMedia = true + } + + // recognize completed + if ( + !next.recognizedMedia && + (stageAtOrAfterRecognize(stage) || done) && + stage !== 'listFiles' && + stage !== 'metadata' && + stage !== 'config' + ) { + if (job.recognizedTitle) { + log(`recognized ${kind} "${job.recognizedTitle}"`) + next.recognizedOk = true + } else { + log(`recognition completed and no ${kind} was recognized.`) + } + next.recognizedMedia = true + } + + // episodes only when a show/movie was actually recognized + if ( + !next.recognizingEpisodes && + next.recognizedOk && + (stageAtOrAfterRecognize(stage) || done) + ) { + log('recognizing episodes') + next.recognizingEpisodes = true + } + + if (!next.recognizedEpisodes && next.recognizingEpisodes && (stageAtOrAfterEpisodes(stage) || done)) { + log('recognized episodes') + next.recognizedEpisodes = true + } + } + + if (!next.succeeded && done) { + log('succeeded') + next.succeeded = true + } + + return next +} + +export async function waitUntilImportSettled( + core: Core, + id: string, + options: { + folder: string + type: FolderType + timeoutMs: number + log?: (line: string) => void + }, +): Promise { + const log = options.log ?? console.log + let progress = createAddProgressState() + const deadline = Date.now() + options.timeoutMs + + for (;;) { + const job = core.getJob(id) + if (job) { + progress = emitAddProgress(progress, job, options.folder, options.type, log) + if (job.status !== 'pending' && job.status !== 'running') { + return job + } + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for import job ${id}`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 66926f78..b0e4268a 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -1,6 +1,8 @@ import { Command, Option } from 'commander' -import type { FolderType, ImportJob } from 'core-app' +import type { FolderType } from 'core-app' +import { NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' +import { waitUntilImportSettled } from './addProgress' import { CliLoggerAdapter } from './cliLogger' import { formatMediaMetadata, @@ -22,23 +24,6 @@ function resolveFolderType(value: string): FolderType { const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 -async function waitUntilImportSettled( - core: ReturnType, - id: string, -): Promise { - const deadline = Date.now() + IMPORT_WAIT_TIMEOUT_MS - for (;;) { - const job = core.getJob(id) - if (job && job.status !== 'pending' && job.status !== 'running') { - return job - } - if (Date.now() > deadline) { - throw new Error(`Timed out waiting for import job ${id}`) - } - await new Promise((resolve) => setTimeout(resolve, 20)) - } -} - /** * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). @@ -80,16 +65,19 @@ export async function runCli(argv: string[] = process.argv): Promise { try { const type = resolveFolderType(opts.type) const verbose = Boolean(opts.verbose) - const core = getCore({ logger: new CliLoggerAdapter(verbose) }) - console.log(`Adding ${folder}`) + const core = getCore({ + logger: verbose ? new CliLoggerAdapter(true) : new NoopLoggerAdapter(), + }) const { id } = core.importFolder(folder, type) - const job = await waitUntilImportSettled(core, id) + const job = await waitUntilImportSettled(core, id, { + folder, + type, + timeoutMs: IMPORT_WAIT_TIMEOUT_MS, + }) if (job.status !== 'succeeded') { console.error(job.error ?? `Import failed with status ${job.status}`) exitCode = 1 - return } - console.log(`Imported ${folder}`) } catch (error) { const message = error instanceof Error ? error.message : String(error) console.error(message) diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index eaab7af0..b1a49827 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -3,6 +3,7 @@ import { FetchNetworkAdapter, NodejsFsAdapter, NoopLoggerAdapter, + StaticDiscoverAdapter, type LoggerPort, } from 'core-app' import { getUserDataDir } from '@/utils/config' @@ -23,6 +24,7 @@ export function getCore(options?: GetCoreOptions): Core { logger: options?.logger ?? new NoopLoggerAdapter(), appDataDir: userDataDir, userDataDir, + discover: new StaticDiscoverAdapter(), }) } return instance diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index ceebbe3d..813935ea 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -3,6 +3,7 @@ import type { AppConfig, FolderType, MediaMetadata, UserConfig as UserConfigData import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; +import type { DiscoverPort } from "./ports/DiscoverPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; @@ -22,6 +23,8 @@ export interface CoreOptions { reverseProxyUrl?: string | null; /** userDataDir reported by getAppConfig(); falls back to appDataDir. */ userDataDir?: string; + /** Discover hosts for TMDB/TVDB failover. */ + discover?: DiscoverPort; } export interface ImportFolderHandle { @@ -38,6 +41,7 @@ export class Core { private readonly reverseProxyUrl: string | null; private readonly userDataDir: string; private readonly userConfig: UserConfig; + private readonly discover?: DiscoverPort; constructor(options: CoreOptions) { this.fs = options.fs; @@ -48,6 +52,7 @@ export class Core { this.reverseProxyUrl = options.reverseProxyUrl ?? null; this.userDataDir = options.userDataDir ?? options.appDataDir; this.userConfig = new UserConfig(this.fs, this.appDataDir); + this.discover = options.discover; } /** Starts the import pipeline in the background; returns a job handle immediately. */ @@ -128,10 +133,16 @@ export class Core { network: this.network, logger: this.logger, appDataDir: this.appDataDir, + discover: this.discover, + reverseProxyUrl: this.reverseProxyUrl, }); await pipeline.run(folderPath, type, { - onStage: (stage, progress) => { - this.jobs.update(job.id, { stage, progress }); + onStage: (stage, progress, detail) => { + this.jobs.update(job.id, { + stage, + progress, + ...(detail?.title !== undefined ? { recognizedTitle: detail.title } : {}), + }); }, }); this.jobs.update(job.id, { status: "succeeded", stage: null, progress: 100 }); diff --git a/apps/core/src/adapters/StaticDiscoverAdapter.test.ts b/apps/core/src/adapters/StaticDiscoverAdapter.test.ts new file mode 100644 index 00000000..23f57f9e --- /dev/null +++ b/apps/core/src/adapters/StaticDiscoverAdapter.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { StaticDiscoverAdapter } from "./StaticDiscoverAdapter"; + +describe("StaticDiscoverAdapter", () => { + it("returns bundled TMDB and TVDB hosts including non-mediadb mirrors", async () => { + const discover = new StaticDiscoverAdapter(); + const config = await discover.getDiscoverConfig(); + + const tmdb = config.mediaDatabases.filter((e) => e.type === "tmdb").map((e) => e.url); + const tvdb = config.mediaDatabases.filter((e) => e.type === "tvdb").map((e) => e.url); + + expect(tmdb).toContain("https://mediadb.vercel.app/api/tmdb"); + expect(tmdb.some((u) => u.includes("tencentscf.com"))).toBe(true); + expect(tvdb).toContain("https://mediadb.vercel.app/api/tvdb"); + expect(tvdb.some((u) => u.includes("tencentscf.com"))).toBe(true); + }); +}); diff --git a/apps/core/src/adapters/StaticDiscoverAdapter.ts b/apps/core/src/adapters/StaticDiscoverAdapter.ts new file mode 100644 index 00000000..dda4a1fd --- /dev/null +++ b/apps/core/src/adapters/StaticDiscoverAdapter.ts @@ -0,0 +1,37 @@ +import type { DiscoverConfig, DiscoverPort, MediaDatabaseEntry } from "../ports/DiscoverPort"; + +/** + * Bundled discover hosts (kept in sync with packages/core-routes FALLBACK_MEDIA_DATABASES). + * Used by headless CLI when no live /api/discover is available. + */ +export const STATIC_MEDIA_DATABASES: MediaDatabaseEntry[] = [ + { + type: "tmdb", + url: "https://mediadb.vercel.app/api/tmdb", + authorizationMethod: "none", + }, + { + type: "tmdb", + url: "https://1255396852-23teay8jtp.ap-hongkong.tencentscf.com", + authorizationMethod: "none", + }, + { + type: "tvdb", + url: "https://mediadb.vercel.app/api/tvdb", + authorizationMethod: "none", + }, + { + type: "tvdb", + url: "https://1255396852-24lotax0vl.ap-hongkong.tencentscf.com", + authorizationMethod: "none", + }, +]; + +export class StaticDiscoverAdapter implements DiscoverPort { + async getDiscoverConfig(): Promise { + return { + mediaDatabases: [...STATIC_MEDIA_DATABASES], + reverseProxies: [], + }; + } +} diff --git a/apps/core/src/clients/TmdbClient.test.ts b/apps/core/src/clients/TmdbClient.test.ts index 2411c9ca..3d01492b 100644 --- a/apps/core/src/clients/TmdbClient.test.ts +++ b/apps/core/src/clients/TmdbClient.test.ts @@ -104,4 +104,31 @@ describe("TmdbClient", () => { ); expect(result.seasons).toEqual([{ season: 1, name: "S1", episodes: [{ season: 1, episode: 1, name: "E1" }] }]); }); + + it("failovers across discover TMDB hosts when the first is unreachable", async () => { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url) => { + urls.push(url); + if (url.includes("dead.example")) throw new Error("down"); + return jsonResponse({ results: [{ id: 9, name: "X" }], page: 1, total_pages: 1, total_results: 1 }); + }, + }; + const client = new TmdbClient(network, { + discover: { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { type: "tmdb", url: "https://dead.example/api/tmdb", authorizationMethod: "none" }, + { type: "tmdb", url: "https://live.example/api/tmdb", authorizationMethod: "none" }, + ], + reverseProxies: [], + }), + }, + }); + + const body = await client.search("X", "tv", "en-US"); + expect(body.results[0]?.id).toBe(9); + expect(urls[0]).toContain("dead.example"); + expect(urls[1]).toContain("live.example"); + }); }); diff --git a/apps/core/src/clients/TmdbClient.ts b/apps/core/src/clients/TmdbClient.ts index 6254af25..c67ea781 100644 --- a/apps/core/src/clients/TmdbClient.ts +++ b/apps/core/src/clients/TmdbClient.ts @@ -10,31 +10,51 @@ import type { TvShowMediaMetadata, TvShowSeasonMetadata, } from "@smm/core"; +import type { DiscoverPort } from "../ports/DiscoverPort"; import type { NetworkPort } from "../ports/NetworkPort"; +import { + fetchMediaDatabase, + SMM_TMDB_DEFAULT_UPSTREAM, +} from "./mediaDatabaseTransport"; -export const SMM_TMDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tmdb"; +export { SMM_TMDB_DEFAULT_UPSTREAM }; export interface TmdbClientOptions { host?: string; apiKey?: string; + httpProxy?: string; + reverseProxyUrl?: string | null; + discover?: DiscoverPort; } export class TmdbClient { - private readonly host: string; + private readonly host?: string; private readonly apiKey?: string; + private readonly httpProxy?: string; + private readonly reverseProxyUrl?: string | null; + private readonly discover?: DiscoverPort; constructor( private readonly network: NetworkPort, options: TmdbClientOptions = {}, ) { - this.host = (options.host?.trim() || SMM_TMDB_DEFAULT_UPSTREAM).replace(/\/+$/, ""); + this.host = options.host?.trim() || undefined; this.apiKey = options.apiKey?.trim() || undefined; + this.httpProxy = options.httpProxy?.trim() || undefined; + this.reverseProxyUrl = options.reverseProxyUrl; + this.discover = options.discover; } private async request(urlPath: string): Promise { - const headers: Record = {}; - if (this.apiKey !== undefined) headers.Authorization = `Bearer ${this.apiKey}`; - const resp = await this.network.fetch(`${this.host}${urlPath}`, { method: "GET", headers }); + const resp = await fetchMediaDatabase(this.network, { + kind: "tmdb", + path: urlPath, + configuredHost: this.host, + apiKey: this.apiKey, + httpProxy: this.httpProxy, + reverseProxyUrl: this.reverseProxyUrl, + discover: this.discover, + }); if (!resp.ok) { throw new Error(`TMDB request failed: ${resp.status} ${resp.statusText}`); } diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts index b5993c24..994d9f20 100644 --- a/apps/core/src/clients/TvdbClient.ts +++ b/apps/core/src/clients/TvdbClient.ts @@ -5,9 +5,14 @@ import type { TVDBv4SeriesSeasonsExtendedResponse, } from "@smm/tvdb4/types"; import type { MovieMediaMetadata, PreferMediaLanguage, TvShowMediaMetadata } from "@smm/core"; +import type { DiscoverPort } from "../ports/DiscoverPort"; import type { NetworkPort } from "../ports/NetworkPort"; +import { + fetchMediaDatabase, + SMM_TVDB_DEFAULT_UPSTREAM, +} from "./mediaDatabaseTransport"; -export const SMM_TVDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tvdb"; +export { SMM_TVDB_DEFAULT_UPSTREAM }; /** IETF BCP 47 media language → TVDB ISO 639-3 code. */ export function mapToTvdbLangCode(lang: "zh-CN" | "en-US" | "ja-JP"): string { @@ -26,18 +31,42 @@ export function mapToTvdbLangCode(lang: "zh-CN" | "en-US" | "ja-JP"): string { export interface TvdbClientOptions { host?: string; apiKey?: string; + httpProxy?: string; + reverseProxyUrl?: string | null; + discover?: DiscoverPort; } +/** Sentinel base URL; real routing goes through {@link fetchMediaDatabase}. */ +const TVDB_TRANSPORT_BASE = "https://smm-tvdb-transport.local"; + export class TvdbClient { private readonly client: TVDBv4; constructor(network: NetworkPort, options: TvdbClientOptions = {}) { - const host = (options.host?.trim() || SMM_TVDB_DEFAULT_UPSTREAM).replace(/\/+$/, ""); + const configuredHost = options.host?.trim() || undefined; + const apiKey = options.apiKey?.trim() || undefined; + const httpProxy = options.httpProxy?.trim() || undefined; + const reverseProxyUrl = options.reverseProxyUrl; + const discover = options.discover; + this.client = new TVDBv4({ - baseUrl: host, - apiKey: options.apiKey ?? "", + baseUrl: TVDB_TRANSPORT_BASE, + apiKey: apiKey ?? "", disableAuth: true, - fetchImpl: (input, init) => network.fetch(input, init), + fetchImpl: async (input) => { + const full = typeof input === "string" ? input : String(input); + const parsed = new URL(full); + const path = `${parsed.pathname}${parsed.search}`; + return fetchMediaDatabase(network, { + kind: "tvdb", + path, + configuredHost, + apiKey, + httpProxy, + reverseProxyUrl, + discover, + }); + }, }); } diff --git a/apps/core/src/clients/mediaDatabaseTransport.test.ts b/apps/core/src/clients/mediaDatabaseTransport.test.ts new file mode 100644 index 00000000..43e27dc4 --- /dev/null +++ b/apps/core/src/clients/mediaDatabaseTransport.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import type { DiscoverPort } from "../ports/DiscoverPort"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { fetchMediaDatabase, MediaDatabaseFailoverExhaustedError } from "./mediaDatabaseTransport"; + +function jsonOk(body: unknown = {}): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + }; +} + +function jsonFail(status: number): HttpResponse { + return { + ok: false, + status, + statusText: "ERR", + headers: {}, + text: () => Promise.resolve(""), + json: async () => { + throw new Error("no json"); + }, + }; +} + +describe("fetchMediaDatabase", () => { + it("failovers to the next discover TMDB host when the first throws", async () => { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url) => { + urls.push(url); + if (url.includes("dead.example")) throw new Error("network down"); + if (url.includes("live.example")) return jsonOk({ results: [] }); + throw new Error("unexpected: " + url); + }, + }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { type: "tmdb", url: "https://dead.example/api/tmdb", authorizationMethod: "none" }, + { type: "tmdb", url: "https://live.example/api/tmdb", authorizationMethod: "none" }, + ], + reverseProxies: [], + }), + }; + + const resp = await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + discover, + }); + + expect(resp.ok).toBe(true); + expect(urls[0]).toContain("https://dead.example/api/tmdb/search/tv"); + expect(urls[1]).toContain("https://live.example/api/tmdb/search/tv"); + }); + + it("routes custom host via local reverse proxy with X-SMM-Proxy-Upstream-BaseURL", async () => { + const urls: string[] = []; + const headers: Array> = []; + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url); + headers.push(init?.headers ?? {}); + return jsonOk({}); + }, + }; + + await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + configuredHost: "https://api.themoviedb.org/3", + reverseProxyUrl: "http://127.0.0.1:30005", + apiKey: "secret", + }); + + expect(urls[0]).toBe("http://127.0.0.1:30005/search/tv?query=x"); + expect(headers[0]?.["X-SMM-Proxy-Upstream-BaseURL"]).toBe("https://api.themoviedb.org/3"); + expect(headers[0]?.Authorization).toBe("Bearer secret"); + }); + + it("throws MediaDatabaseFailoverExhaustedError when every host fails", async () => { + const network: NetworkPort = { + fetch: async () => { + throw new Error("down"); + }, + }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { type: "tmdb", url: "https://a.example", authorizationMethod: "none" }, + { type: "tmdb", url: "https://b.example", authorizationMethod: "none" }, + ], + reverseProxies: [], + }), + }; + + await expect( + fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv", + discover, + }), + ).rejects.toBeInstanceOf(MediaDatabaseFailoverExhaustedError); + }); + + it("failovers on HTTP non-OK for default upstream hosts", async () => { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url) => { + urls.push(url); + if (url.includes("a.example")) return jsonFail(502); + return jsonOk({}); + }, + }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { type: "tmdb", url: "https://a.example", authorizationMethod: "none" }, + { type: "tmdb", url: "https://b.example", authorizationMethod: "none" }, + ], + reverseProxies: [], + }), + }; + + const resp = await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/tv/1", + discover, + }); + expect(resp.ok).toBe(true); + expect(urls).toHaveLength(2); + }); +}); diff --git a/apps/core/src/clients/mediaDatabaseTransport.ts b/apps/core/src/clients/mediaDatabaseTransport.ts new file mode 100644 index 00000000..d281f37e --- /dev/null +++ b/apps/core/src/clients/mediaDatabaseTransport.ts @@ -0,0 +1,197 @@ +import type { DiscoverPort, MediaDatabaseAuthorizationMethod, ReverseProxyEntry } from "../ports/DiscoverPort"; +import type { FetchInit, HttpResponse, NetworkPort } from "../ports/NetworkPort"; + +export const SMM_TMDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tmdb"; +export const SMM_TVDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tvdb"; + +export type MediaDatabaseKind = "tmdb" | "tvdb"; + +export class MediaDatabaseFailoverExhaustedError extends Error { + readonly attemptedUrls: string[]; + + constructor(attemptedUrls: string[]) { + super(`All media-database failover attempts failed (${attemptedUrls.length} tried)`); + this.name = "MediaDatabaseFailoverExhaustedError"; + this.attemptedUrls = attemptedUrls; + } +} + +export interface FetchMediaDatabaseOptions { + kind: MediaDatabaseKind; + /** API path including leading slash and query, e.g. `/search/tv?query=x`. */ + path: string; + /** User-configured host; empty/undefined → discover default chain. */ + configuredHost?: string; + apiKey?: string; + httpProxy?: string; + /** Local SMM reverse proxy base URL (required for reliable custom-host routing). */ + reverseProxyUrl?: string | null; + discover?: DiscoverPort; +} + +function normalizeBase(url: string): string { + return url.trim().replace(/\/+$/, ""); +} + +function joinUrl(base: string, path: string): string { + const cleanBase = normalizeBase(base); + const cleanPath = path.startsWith("/") ? path : `/${path}`; + return cleanBase + cleanPath; +} + +function isCustomHost(host: string | undefined, defaultUpstream: string): boolean { + const trimmed = host?.trim() ?? ""; + if (!trimmed) return false; + try { + // eslint-disable-next-line no-new + new URL(trimmed); + } catch { + return false; + } + return normalizeBase(trimmed) !== normalizeBase(defaultUpstream); +} + +function dateToken(now = new Date()): string { + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, "0"); + const d = String(now.getUTCDate()).padStart(2, "0"); + return `${y}${m}${d}`; +} + +function authHeaders(apiKey?: string): Record { + const key = apiKey?.trim(); + if (!key) return {}; + return { Authorization: `Bearer ${key}` }; +} + +function generalProxyHeaders( + upstreamBaseURL: string, + authorizationMethod: MediaDatabaseAuthorizationMethod, + apiKey?: string, +): Record { + const headers: Record = { + Accept: "application/json", + ...authHeaders(apiKey), + "X-Upstream-Base-Url": normalizeBase(upstreamBaseURL), + }; + if (authorizationMethod === "date-token") { + headers["X-Proxy-Authorization"] = `Bearer ${dateToken()}`; + } + return headers; +} + +function localProxyHeaders( + upstreamBaseURL: string, + apiKey?: string, + httpProxy?: string, +): Record { + const headers: Record = { + Accept: "application/json", + ...authHeaders(apiKey), + "X-SMM-Proxy-Upstream-BaseURL": normalizeBase(upstreamBaseURL), + }; + const proxy = httpProxy?.trim(); + if (proxy) headers["X-Http-Proxy"] = proxy; + return headers; +} + +async function tryFetch( + network: NetworkPort, + url: string, + headers: Record, +): Promise { + try { + const init: FetchInit = { method: "GET", headers }; + const resp = await network.fetch(url, init); + if (resp.ok) return resp; + return undefined; + } catch { + return undefined; + } +} + +/** + * Fetch a TMDB/TVDB API path with the same routing policy as UI `fetchTmdb` / `fetchTvdb`: + * - custom host → local reverse proxy (or direct if no reverseProxyUrl, for tests) + * - default → discover hosts, each tried direct then via general reverse proxies + */ +export async function fetchMediaDatabase( + network: NetworkPort, + options: FetchMediaDatabaseOptions, +): Promise { + const path = options.path.startsWith("/") ? options.path : `/${options.path}`; + const defaultUpstream = + options.kind === "tmdb" ? SMM_TMDB_DEFAULT_UPSTREAM : SMM_TVDB_DEFAULT_UPSTREAM; + const attempted: string[] = []; + + if (isCustomHost(options.configuredHost, defaultUpstream)) { + const upstream = normalizeBase(options.configuredHost!); + const localProxy = options.reverseProxyUrl?.trim(); + if (localProxy) { + const url = joinUrl(localProxy, path); + attempted.push(url); + const resp = await tryFetch( + network, + url, + localProxyHeaders(upstream, options.apiKey, options.httpProxy), + ); + if (resp !== undefined) return resp; + throw new MediaDatabaseFailoverExhaustedError(attempted); + } + // Unit-test / no-proxy fallback: hit custom host directly + const url = joinUrl(upstream, path); + attempted.push(url); + const resp = await tryFetch(network, url, { + Accept: "application/json", + ...authHeaders(options.apiKey), + }); + if (resp !== undefined) return resp; + throw new MediaDatabaseFailoverExhaustedError(attempted); + } + + const config = options.discover + ? await options.discover.getDiscoverConfig() + : { mediaDatabases: [], reverseProxies: [] as ReverseProxyEntry[] }; + + let hosts = config.mediaDatabases + .filter((e) => e.type === options.kind) + .map((e) => normalizeBase(e.url)) + .filter(Boolean); + + if (hosts.length === 0) { + hosts = [normalizeBase(defaultUpstream)]; + } + + const proxies = config.reverseProxies.filter((p) => { + try { + // eslint-disable-next-line no-new + new URL(p.url); + return true; + } catch { + return false; + } + }); + + for (const host of hosts) { + const directUrl = joinUrl(host, path); + attempted.push(directUrl); + const direct = await tryFetch(network, directUrl, { + Accept: "application/json", + ...authHeaders(options.apiKey), + }); + if (direct !== undefined) return direct; + + for (const proxy of proxies) { + const proxyUrl = joinUrl(proxy.url, path); + attempted.push(proxyUrl); + const viaProxy = await tryFetch( + network, + proxyUrl, + generalProxyHeaders(host, proxy.authorizationMethod, options.apiKey), + ); + if (viaProxy !== undefined) return viaProxy; + } + } + + throw new MediaDatabaseFailoverExhaustedError(attempted); +} diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index fc1f084f..6f96d803 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -2,10 +2,17 @@ export { Core, type CoreOptions, type ImportFolderHandle } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; export type { LoggerPort } from "./ports/LoggerPort"; +export type { + DiscoverPort, + DiscoverConfig, + MediaDatabaseEntry, + ReverseProxyEntry, +} from "./ports/DiscoverPort"; export { NodejsFsAdapter } from "./adapters/node/NodejsFsAdapter"; export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/network/NetworkFsAdapter"; export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +export { StaticDiscoverAdapter, STATIC_MEDIA_DATABASES } from "./adapters/StaticDiscoverAdapter"; export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; export { UserConfig, DEFAULT_USER_CONFIG } from "./pipeline/userConfig"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; diff --git a/apps/core/src/jobs/types.ts b/apps/core/src/jobs/types.ts index 8c279f04..b96e0759 100644 --- a/apps/core/src/jobs/types.ts +++ b/apps/core/src/jobs/types.ts @@ -10,6 +10,8 @@ export interface ImportJob { status: JobStatus; stage: JobStage; progress: number; + /** Set after the recognize stage when a TV show or movie title is known. */ + recognizedTitle?: string; error?: string; createdAt: number; updatedAt: number; diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index 05232098..a06ab02f 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -6,6 +6,7 @@ import type { LoggerPort } from "../ports/LoggerPort"; import type { JobStage } from "../jobs/types"; import { TmdbClient } from "../clients/TmdbClient"; import { TvdbClient } from "../clients/TvdbClient"; +import type { DiscoverPort } from "../ports/DiscoverPort"; import { isVideoFile, recognizeEpisodes } from "./recognizeEpisodes"; import { recognizeMediaFolder } from "./recognizeMediaFolder"; import { metadataCachePath } from "./paths"; @@ -16,10 +17,14 @@ export interface ImportFolderPipelineOptions { network: NetworkPort; logger: LoggerPort; appDataDir: string; + /** Discover config for TMDB/TVDB host failover (UI-aligned). */ + discover?: DiscoverPort; + /** Local SMM reverse proxy URL for custom media-database hosts. */ + reverseProxyUrl?: string | null; } export interface ImportFolderPipelineCallbacks { - onStage?: (stage: JobStage, progress: number) => void; + onStage?: (stage: JobStage, progress: number, detail?: { title?: string }) => void; } function mediaMetadataType(type: FolderType): MediaMetadata["type"] { @@ -30,7 +35,7 @@ export class ImportFolderPipeline { constructor(private readonly options: ImportFolderPipelineOptions) {} async run(folderPath: string, type: FolderType, cb: ImportFolderPipelineCallbacks = {}): Promise { - const { fs, logger, appDataDir, network } = this.options; + const { fs, logger, appDataDir, network, discover, reverseProxyUrl } = this.options; const posixPath = Path.posix(folderPath); const stages: JobStage[] = []; @@ -61,8 +66,16 @@ export class ImportFolderPipeline { if (type === "tvshow" || type === "movie") { const language = userConfig.preferMediaLanguage ?? "en-US"; - const tmdb = new TmdbClient(network, userConfig.tmdb); - const tvdb = new TvdbClient(network, userConfig.tvdb); + const tmdb = new TmdbClient(network, { + ...userConfig.tmdb, + discover, + reverseProxyUrl, + }); + const tvdb = new TvdbClient(network, { + ...userConfig.tvdb, + discover, + reverseProxyUrl, + }); logger.info({ folderPath: posixPath, language }, "importFolder: stage=recognize"); const result = await recognizeMediaFolder(mm, { @@ -75,7 +88,12 @@ export class ImportFolderPipeline { if (result.tvShow !== undefined) mm.tvShow = result.tvShow; if (result.movie !== undefined) mm.movie = result.movie; stages.push("recognize"); - cb.onStage?.("recognize", 60); + const title = result.tvShow?.name ?? result.movie?.name; + cb.onStage?.( + "recognize", + 60, + title !== undefined ? { title } : undefined, + ); logger.info({ folderPath: posixPath }, "importFolder: stage=episodes"); if (type === "tvshow" && mm.tvShow !== undefined) { diff --git a/apps/core/src/ports/DiscoverPort.ts b/apps/core/src/ports/DiscoverPort.ts new file mode 100644 index 00000000..32ea6e80 --- /dev/null +++ b/apps/core/src/ports/DiscoverPort.ts @@ -0,0 +1,26 @@ +export type MediaDatabaseType = "tmdb" | "tvdb" | "tmdb-asset" | "tvdb-asset"; + +export type MediaDatabaseAuthorizationMethod = "date-token" | "none"; + +export interface MediaDatabaseEntry { + type: MediaDatabaseType; + url: string; + authorizationMethod: MediaDatabaseAuthorizationMethod; +} + +export interface ReverseProxyEntry { + id: string; + type: "general"; + url: string; + authorizationMethod: MediaDatabaseAuthorizationMethod; +} + +/** Subset of UI / core-routes DiscoverConfig used by Core media-database transport. */ +export interface DiscoverConfig { + mediaDatabases: MediaDatabaseEntry[]; + reverseProxies: ReverseProxyEntry[]; +} + +export interface DiscoverPort { + getDiscoverConfig(): Promise; +} diff --git a/docs/superpowers/specs/2026-08-18-core-media-database-failover-design.md b/docs/superpowers/specs/2026-08-18-core-media-database-failover-design.md new file mode 100644 index 00000000..eecc067e --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-core-media-database-failover-design.md @@ -0,0 +1,152 @@ +# Core media-database fetch failover (align with UI) + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +Layer 2 `apps/core` runs `importFolder` recognition via `TmdbClient` / `TvdbClient` on a thin `NetworkPort`. +Today each client uses a **single** upstream: + +- default: `https://mediadb.vercel.app/api/tmdb` (or `/api/tvdb`) +- or `userConfig.tmdb.host` / `userConfig.tvdb.host` when set + +There is **no** discover-config host list, **no** multi-host failover, and **no** general reverse-proxy chain. + +The UI path (`apps/ui/src/api/tmdb.ts` → `fetchTmdb` → `fetchWithFailover`) does the opposite for the default-upstream case: + +1. Load discover config (`mediaDatabases` of type `tmdb` / `tvdb`, plus `reverseProxies`) +2. Try each host (and paired reverse proxies) until one connects +3. Only then treat recognition search as failed + +So CLI / Core `smm add` can report “import succeeded” with empty `tvShow` / `mediaFiles` while the same folder would still recognize in the UI once failover finds a reachable host. That violates the Layer 2 goal: **one business path, logically consistent with existing initialization**. + +Observed symptom (2026-08-18): folder `啾咪bady` — metadata cache has only `type` + empty `mediaFiles`; `mediadb.vercel.app` timed out from the machine; Core never tried other discover hosts. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +UI today Core today (gap) +──────── ──────────────── +fetchTmdb / fetchTvdb TmdbClient / TvdbClient + │ │ + ├─ custom host → local SMM ├─ custom host → direct NetworkPort + │ reverse proxy │ (no local proxy headers) + └─ default → discover hosts └─ default → single SMM_TMDB/TVDB_DEFAULT + + fetchWithFailover (mediadb.vercel.app only) +``` + +**Target:** Core clients use the **same routing policy** as UI for media-database HTTP: + +| Case | Behavior (must match UI) | +|------|---------------------------| +| Custom host (non-empty, parseable) | Request via local SMM reverse proxy (`X-SMM-Proxy-Upstream-BaseURL`); optional `httpProxy` as UI does | +| Default / empty host | Resolve candidate hosts from discover `mediaDatabases`; failover with `proxiableFetch` / equivalent; do not fail the whole chain on one dead node | + +Discover config source for headless Core (CLI without UI): + +- Prefer an injected **DiscoverPort** (or options on `NetworkPort` / client factory) so Layer 3 can supply the same JSON the UI gets from `/api/discover` / static bundled defaults +- Fallback list must include at least today’s `SMM_TMDB_DEFAULT_UPSTREAM` / `SMM_TVDB_DEFAULT_UPSTREAM` so unit tests and offline hosts stay deterministic + +Shared building blocks already in monorepo: + +- `packages/core/proxiableFetch.ts` — URL list + reverse-proxy failover +- UI `fetchWithFailover` / discover types — policy reference (prefer moving policy into `@smm/core` or `apps/core` so UI can later thin-wrap it) + +``` +apps/cli getCore() + │ + ▼ +apps/core ImportFolderPipeline + │ + ├─ TmdbClient / TvdbClient ──► MediaDatabaseTransport (new) + │ │ + │ ├─ DiscoverPort.resolve() + │ └─ NetworkPort.fetch (+ proxy headers) + └─ recognizeMediaFolder (unchanged phases) +``` + +## 2.2 App Level Architecture + +Proposed modules (names indicative): + +| Piece | Location | Role | +|--------|----------|------| +| Transport | `apps/core/src/clients/mediaDatabaseTransport.ts` | Resolve base URL chain; perform one API path with failover | +| DiscoverPort | `apps/core/src/ports/DiscoverPort.ts` | `getDiscoverConfig(): Promise` | +| StaticDiscoverAdapter | `apps/core/src/adapters/StaticDiscoverAdapter.ts` | Bundled / hardcoded hosts for tests + CLI without HTTP | +| HttpDiscoverAdapter (Layer 3) | `apps/cli` optional | Fetch live `/api/discover` when server or remote discover URL is available | +| TmdbClient / TvdbClient | existing | Call transport instead of raw `network.fetch(host + path)` | +| Pipeline | `importFolderPipeline.ts` | Pass `reverseProxyUrl` from `Core.getAppConfig()` into clients when custom host | + +Recognition phases (`recognizeMediaFolder`) stay unchanged; only HTTP reliability improves. + +## 2.3 Key Design + +1. **Policy parity over code copy** — Encode the same decision tree as `fetchTmdb` / `fetchTvdb` (custom vs default; direct then proxies). Prefer one shared implementation in `@smm/core` or `apps/core` rather than diverging again. +2. **Single-node failure ≠ recognition failure** — Exhaust the host/proxy chain before returning empty search results from network errors. Empty **search results** after a successful HTTP 200 remain a soft miss (folder name not found). +3. **Swallow vs surface** — Today `searchInTmdb` / `searchInTvdb` catch all errors. After failover, log exhausted failover via `LoggerPort`; keep best-effort empty result for import success, unless a later product decision marks recognition-required. +4. **CLI without reverse proxy process** — Default-upstream failover must work with **general** discover reverse proxies alone (UI already supports this). Custom-host path still needs local reverse proxy URL from Layer 3 (`Core` already accepts `reverseProxyUrl`). +5. **Folder name quality is orthogonal** — Names like `啾咪bady` may still miss TMDB after network works; users rename / embed `{tmdbid=}`. Failover fixes connectivity, not fuzzy title matching. + +## 3. User Stories + +### 3.1 Default upstream: first host down, second host works + +* **Given** - discover lists TMDB hosts `[dead.example, live.example]` and Core uses MediaDatabaseTransport +* **When** - `importFolder` recognizes a folder whose title exists on TMDB +* **Then** - Core obtains search/details from `live.example` and persists `tvShow` / `mediaFiles`; import job succeeds + +```mermaid +sequenceDiagram + participant P as ImportFolderPipeline + participant T as TmdbClient + participant M as MediaDatabaseTransport + participant D as DiscoverPort + participant N as NetworkPort + + P->>T: search(folderName) + T->>M: GET /search/tv?... + M->>D: getDiscoverConfig() + D-->>M: mediaDatabases + reverseProxies + M->>N: fetch(dead host) + N-->>M: network error + M->>N: fetch(live host) + N-->>M: 200 JSON + M-->>T: body + T-->>P: RecognitionResult with tvShow +``` + +### 3.2 Custom host uses local reverse proxy + +* **Given** - `userConfig.tmdb.host` is a custom URL and Core has `reverseProxyUrl` +* **When** - recognition calls TMDB +* **Then** - request goes to local reverse proxy with `X-SMM-Proxy-Upstream-BaseURL` set to the custom host (same contract as UI `fetchByInternalReverseProxy`) + +### 3.3 All hosts unreachable + +* **Given** - every discover host and reverse proxy fails +* **When** - recognition runs +* **Then** - Logger records failover exhaustion; `tvShow` stays unset; import still completes; CLI progress shows recognition completed with no title (existing CLI wording) + +### 3.4 UI and Core same folder, same network + +* **Given** - mediadb.vercel.app is unreachable but another discover TMDB host is reachable +* **When** - user runs UI initialize and `smm add` on the same folder +* **Then** - both paths either recognize the show or both soft-miss on title; they must not diverge solely because Core skipped failover + +## 4. Out of scope (this design) + +- Fuzzy / alias matching for nicknames (`啾咪bady` → Kill Me Baby) +- Changing CLI progress exit codes when recognition soft-misses +- Removing UI `fetchTmdb` in the first PR (can thin-wrap shared transport later) +- Full migrate of scrape image CDN failover (separate design) + +## 5. Implementation order (suggested) + +1. Extract / implement `MediaDatabaseTransport` + DiscoverPort with StaticDiscoverAdapter (tests with fake multi-host NetworkPort) +2. Wire `TmdbClient` / `TvdbClient` through transport; keep single-host behavior when DiscoverPort returns one URL +3. CLI: inject StaticDiscoverAdapter (or HttpDiscover if available); pass `reverseProxyUrl` into Core when local proxy is running +4. Optional follow-up: UI `fetchTmdb` delegates to shared transport for one code path From 8feb5e011402dd8bc765f0b92a9ed488aa1c3752 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 22:00:33 +0800 Subject: [PATCH 051/214] feat(cli): add --skip-init and CLI e2e for folder commands Core.importFolder({ skipInit }) only registers the path in UserConfig. Vitest e2e covers skip-init list/rm and full add/show/metadata. Co-authored-by: Cursor --- apps/cli/src/cli/add.test.ts | 15 +++ apps/cli/src/cli/addProgress.ts | 7 +- apps/cli/src/cli/runCli.ts | 13 +- apps/cli/test/cli-e2e.test.ts | 124 ++++++++++++++++++ apps/cli/test/helpers/smm.ts | 30 +++++ apps/cli/test/helpers/testFolders.ts | 63 +++++++++ apps/cli/vitest.config.ts | 3 +- apps/core/src/Core.test.ts | 19 +++ apps/core/src/Core.ts | 28 +++- apps/core/src/index.ts | 2 +- .../2026-08-18-smm-add-skip-init-design.md | 34 +++++ 11 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 apps/cli/test/cli-e2e.test.ts create mode 100644 apps/cli/test/helpers/smm.ts create mode 100644 apps/cli/test/helpers/testFolders.ts create mode 100644 docs/superpowers/specs/2026-08-18-smm-add-skip-init-design.md diff --git a/apps/cli/src/cli/add.test.ts b/apps/cli/src/cli/add.test.ts index 644d8364..3b379287 100644 --- a/apps/cli/src/cli/add.test.ts +++ b/apps/cli/src/cli/add.test.ts @@ -110,4 +110,19 @@ describe('smm add', () => { expect(lines.some((l) => l.includes('folderPath'))).toBe(true) expect(lines.some((l) => l === 'succeeded')).toBe(true) }) + + it('with --skip-init only registers the folder and prints imported folder', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'tvshow', '--skip-init']) + + expect(code).toBe(0) + const config = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + folders: string[] + } + expect(config.folders).toContain(mediaFolder) + const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')) + expect(lines).toEqual([`imported folder ${mediaFolder}`]) + expect(lines.some((l) => l === 'succeeded')).toBe(false) + expect(lines.some((l) => l.includes('recognizing'))).toBe(false) + }) }) diff --git a/apps/cli/src/cli/addProgress.ts b/apps/cli/src/cli/addProgress.ts index 46603ae9..16e5b909 100644 --- a/apps/cli/src/cli/addProgress.ts +++ b/apps/cli/src/cli/addProgress.ts @@ -134,16 +134,21 @@ export async function waitUntilImportSettled( type: FolderType timeoutMs: number log?: (line: string) => void + /** When false, do not print progress lines (used with --skip-init). Default true. */ + progress?: boolean }, ): Promise { const log = options.log ?? console.log + const emitProgress = options.progress !== false let progress = createAddProgressState() const deadline = Date.now() + options.timeoutMs for (;;) { const job = core.getJob(id) if (job) { - progress = emitAddProgress(progress, job, options.folder, options.type, log) + if (emitProgress) { + progress = emitAddProgress(progress, job, options.folder, options.type, log) + } if (job.status !== 'pending' && job.status !== 'running') { return job } diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index b0e4268a..6df361e3 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -61,22 +61,31 @@ export async function runCli(argv: string[] = process.argv): Promise { .makeOptionMandatory(), ) .option('-v, --verbose', 'Print detailed logs') - .action(async (folder: string, opts: { type: string; verbose?: boolean }) => { + .option('--skip-init', 'Only register the folder in UserConfig; skip recognition and metadata') + .action(async (folder: string, opts: { type: string; verbose?: boolean; skipInit?: boolean }) => { try { const type = resolveFolderType(opts.type) const verbose = Boolean(opts.verbose) + const skipInit = Boolean(opts.skipInit) const core = getCore({ logger: verbose ? new CliLoggerAdapter(true) : new NoopLoggerAdapter(), }) - const { id } = core.importFolder(folder, type) + const { id } = skipInit + ? core.importFolder(folder, type, { skipInit: true }) + : core.importFolder(folder, type) const job = await waitUntilImportSettled(core, id, { folder, type, timeoutMs: IMPORT_WAIT_TIMEOUT_MS, + progress: !skipInit, }) if (job.status !== 'succeeded') { console.error(job.error ?? `Import failed with status ${job.status}`) exitCode = 1 + return + } + if (skipInit) { + console.log(`imported folder ${folder}`) } } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/apps/cli/test/cli-e2e.test.ts b/apps/cli/test/cli-e2e.test.ts new file mode 100644 index 00000000..b1206119 --- /dev/null +++ b/apps/cli/test/cli-e2e.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './helpers/smm' +import { + createFolderInTestFolder, + movieFolder, + musicFolder, + tvShowFolder, + type TestFolder, +} from './helpers/testFolders' + +describe('smm CLI e2e', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-e2e-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-e2e-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it('add --skip-init, list, then rm', async () => { + const folder = createFolderInTestFolder(mediaDir, tvShowFolder) + const path = folder.path! + + const added = await smm(['add', path, '--type', folder.type, '--skip-init']) + expect(added.code).toBe(0) + expect(added.stdout).toBe(`imported folder ${path}`) + expect(added.stdout).not.toContain('recognizing') + expect(added.stdout).not.toContain('succeeded') + + const listed = await smm(['list']) + expect(listed.code).toBe(0) + expect(listed.stdout.split('\n').filter(Boolean)).toEqual([path]) + + const removed = await smm(['rm', path]) + expect(removed.code).toBe(0) + expect(removed.stdout).toContain(`Removed ${path}`) + + const listedAfter = await smm(['list']) + expect(listedAfter.code).toBe(0) + expect(listedAfter.stdout.trim()).toBe('') + }) + + it( + 'add tvshow, movie, and music folders, then show and metadata', + { timeout: 10 * 60 * 1000 }, + async () => { + const tv = createFolderInTestFolder(mediaDir, tvShowFolder) + const movie = createFolderInTestFolder(mediaDir, movieFolder) + const music = createFolderInTestFolder(mediaDir, musicFolder) + + await addFolder(tv) + await addFolder(movie) + await addFolder(music) + + const listed = await smm(['list']) + expect(listed.code).toBe(0) + const listedPaths = listed.stdout.split('\n').filter(Boolean) + expect(listedPaths).toEqual(expect.arrayContaining([tv.path, movie.path, music.path])) + + const tvShow = await smm(['show', tv.path!]) + expect(tvShow.code).toBe(0) + expect(tvShow.stdout).toContain('Status: ok') + expect(tvShow.stdout).toContain('Type: tvshow-folder') + expect(tvShow.stdout).toMatch(/Title:\s+.+/) + expect(tvShow.stdout).toMatch(/天使降临到我身边|WATATEN|Angel Flew Down/i) + + const tvMeta = await smm(['metadata', tv.path!]) + expect(tvMeta.code).toBe(0) + expect(tvMeta.stdout).toContain('type: tvshow-folder') + expect(tvMeta.stdout).toContain('tvShow:') + expect(tvMeta.stdout).toContain('mediaFiles:') + expect(tvMeta.stdout).not.toContain('(empty)') + expect(tvMeta.stdout).toMatch(/seasonNumber:\s*1/) + + const movieShow = await smm(['show', movie.path!]) + expect(movieShow.code).toBe(0) + expect(movieShow.stdout).toContain('Status: ok') + expect(movieShow.stdout).toContain('Type: movie-folder') + expect(movieShow.stdout).toMatch(/Title:\s+.+/) + expect(movieShow.stdout).toMatch(/Dark Knight|黑暗骑士/i) + + const movieMeta = await smm(['metadata', movie.path!]) + expect(movieMeta.code).toBe(0) + expect(movieMeta.stdout).toContain('type: movie-folder') + expect(movieMeta.stdout).toContain('movie:') + expect(movieMeta.stdout).toContain('mediaFiles:') + expect(movieMeta.stdout).not.toContain('(empty)') + + const musicShow = await smm(['show', music.path!]) + expect(musicShow.code).toBe(0) + expect(musicShow.stdout).toContain('Status: ok') + expect(musicShow.stdout).toContain('Type: music-folder') + expect(musicShow.stdout).not.toMatch(/Title:/) + + const musicMeta = await smm(['metadata', music.path!]) + expect(musicMeta.code).toBe(0) + expect(musicMeta.stdout).toContain('type: music-folder') + expect(musicMeta.stdout).not.toContain('tvShow:') + expect(musicMeta.stdout).not.toContain('movie:') + }, + ) +}) + +async function addFolder(folder: TestFolder): Promise { + const result = await smm(['add', folder.path!, '--type', folder.type]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toContain(`imported folder ${folder.path}`) + expect(result.stdout).toMatch(/succeeded/) +} diff --git a/apps/cli/test/helpers/smm.ts b/apps/cli/test/helpers/smm.ts new file mode 100644 index 00000000..f6394bd7 --- /dev/null +++ b/apps/cli/test/helpers/smm.ts @@ -0,0 +1,30 @@ +import { vi } from 'vitest' +import { resetCoreForTests } from '../../src/core/getCore' +import { runCli } from '../../src/cli/runCli' + +export interface SmmResult { + code: number + stdout: string + stderr: string +} + +/** Run one `smm` command in-process and capture console output. */ +export async function smm(args: string[]): Promise { + const logs: string[] = [] + const errors: string[] = [] + const logSpy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { + logs.push(parts.map(String).join(' ')) + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation((...parts: unknown[]) => { + errors.push(parts.map(String).join(' ')) + }) + try { + const code = await runCli(['node', 'smm', ...args]) + return { code, stdout: logs.join('\n'), stderr: errors.join('\n') } + } finally { + logSpy.mockRestore() + errorSpy.mockRestore() + } +} + +export { resetCoreForTests } diff --git a/apps/cli/test/helpers/testFolders.ts b/apps/cli/test/helpers/testFolders.ts new file mode 100644 index 00000000..c83cad9e --- /dev/null +++ b/apps/cli/test/helpers/testFolders.ts @@ -0,0 +1,63 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +/** + * Mirrors `apps/e2e/test/actions/import-folders.ts` TestFolder fixtures + * so CLI e2e uses the same folder names, files, and media ids. + */ +export interface TestFolder { + folderName: string + mediaName?: string + files: string[] + type: 'tvshow' | 'movie' | 'music' + path?: string +} + +/** Same as e2e `folder1` — TMDB id in the folder name. */ +export const tvShowFolder: TestFolder = { + folderName: '天使降临到我身边! (2019) {tmdbid=84666}', + mediaName: '天使降临到我身边!', + files: [ + 'S01E01.mkv', + 'S01E01.jpg', + 'S01E01.sc.ass', + 'S01E01.tc.ass', + 'S01E01.nfo', + 'S01E02.mkv', + 'S01E02.jpg', + 'S01E02.sc.ass', + 'S01E02.tc.ass', + 'S01E02.nfo', + 'S01E03.mkv', + 'S01E03.jpg', + 'S01E03.sc.ass', + 'S01E03.tc.ass', + 'S01E03.nfo', + ], + type: 'tvshow', +} + +/** Same as e2e `folder5` — TVDB id in the folder name. */ +export const movieFolder: TestFolder = { + folderName: 'The Dark Knight {tvdbid=116}', + mediaName: '蝙蝠侠:黑暗骑士', + files: ['The Dark Knight [1080P].mkv'], + type: 'movie', +} + +/** Music fixture (same idea as MusicPanel template `BilibiliMusic`). */ +export const musicFolder: TestFolder = { + folderName: 'BilibiliMusic', + files: ['01.mp3'], + type: 'music', +} + +/** Create empty placeholder files under `mediaDir/`. */ +export function createFolderInTestFolder(mediaDir: string, folder: TestFolder): TestFolder { + const testMediaFolder = path.join(mediaDir, folder.folderName) + fs.mkdirSync(testMediaFolder, { recursive: true }) + for (const file of folder.files) { + fs.writeFileSync(path.join(testMediaFolder, file), '') + } + return { ...folder, path: testMediaFolder } +} diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 9f221f19..7ae9e1c0 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -4,7 +4,8 @@ import { resolve } from 'path' export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + exclude: ['test/test-mcp.test.ts'], }, resolve: { alias: { diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index d84e2b17..b81aea1b 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -116,6 +116,25 @@ describe("Core", () => { expect(job?.status).toBe("failed"); }); + it("skipInit writes the folder to UserConfig and does not persist metadata", async () => { + const fs = inMemoryFs(); + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importFolder("/m/Deferred", "tvshow", { skipInit: true }); + await waitForStatus(core, id, "succeeded"); + + expect(core.getJob(id)?.status).toBe("succeeded"); + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); + expect(savedConfig.folders).toContain("/m/Deferred"); + expect(fs.listFiles).not.toHaveBeenCalled(); + expect(await fs.exists(metadataCachePath("/data/smm", Path.posix("/m/Deferred")))).toBe(false); + }); + it("getJob returns undefined for unknown id", () => { const core = new Core({ fs: inMemoryFs(), diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 813935ea..3792d6c4 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -31,6 +31,11 @@ export interface ImportFolderHandle { id: string; } +export interface ImportFolderOptions { + /** When true, only register the path in UserConfig.folders; skip recognition and metadata. */ + skipInit?: boolean; +} + export class Core { private readonly jobs = new JobStore(); private readonly fs: FsPort; @@ -56,7 +61,7 @@ export class Core { } /** Starts the import pipeline in the background; returns a job handle immediately. */ - importFolder(path: string, type: FolderType): ImportFolderHandle { + importFolder(path: string, type: FolderType, options?: ImportFolderOptions): ImportFolderHandle { const folderPath = this.normalizePosix(path); const job = this.jobs.create({ folderPath, @@ -65,7 +70,11 @@ export class Core { stage: "config", progress: 0, }); - void this.runImport(job, path, type); + if (options?.skipInit === true) { + void this.runImportSkipInit(job, path); + } else { + void this.runImport(job, path, type); + } return { id: job.id }; } @@ -126,6 +135,21 @@ export class Core { } } + private async runImportSkipInit(job: ImportJob, folderPath: string): Promise { + try { + await this.userConfig.update((config) => ({ + ...config, + folders: [...new Set([...config.folders, folderPath])], + })); + this.jobs.update(job.id, { status: "succeeded", stage: "config", progress: 100 }); + } catch (error) { + this.jobs.update(job.id, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async runImport(job: ImportJob, folderPath: string, type: FolderType): Promise { try { const pipeline = new ImportFolderPipeline({ diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 6f96d803..9c7fe4e2 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -1,4 +1,4 @@ -export { Core, type CoreOptions, type ImportFolderHandle } from "./Core"; +export { Core, type CoreOptions, type ImportFolderHandle, type ImportFolderOptions } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; export type { LoggerPort } from "./ports/LoggerPort"; diff --git a/docs/superpowers/specs/2026-08-18-smm-add-skip-init-design.md b/docs/superpowers/specs/2026-08-18-smm-add-skip-init-design.md new file mode 100644 index 00000000..0968504c --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-smm-add-skip-init-design.md @@ -0,0 +1,34 @@ +# `smm add --skip-init` / `Core.importFolder({ skipInit })` + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +`smm add` always runs the full importFolder pipeline (list files, recognize, persist metadata). Operators sometimes only need the folder registered in `UserConfig.folders` (e.g. defer recognition, or batch-register paths). + +## 2. Architecture + +### 2.1 Project Level + +``` +smm add --skip-init → Core.importFolder(path, type, { skipInit: true }) + → UserConfig.folders only + → job succeeds (no metadata / recognize) +``` + +### 2.2 Key Design + +- `Core.importFolder(path, type, options?: { skipInit?: boolean })` +- `skipInit: true`: append path to `folders` (Set union, same as pipeline config stage), mark job `succeeded`; **no** metadata cache, **no** listFiles/recognize/episodes/persist +- CLI: `--skip-init` still requires `--type`; prints only `imported folder `; exit 0 on success +- Missing-on-disk path is allowed (config-only write) +- Default (`skipInit` omitted/false): unchanged full pipeline + progress lines + +## 3. User Stories + +### 3.1 Skip init + +* **Given** - a folder path (may or may not exist on disk) +* **When** - `smm add --type music --skip-init` +* **Then** - path is in `smm.json` folders; stdout is `imported folder `; no metadata cache file; exit 0 From 203146d3e688aac8d937b7888f3eedddcaf8cc4d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 22:47:26 +0800 Subject: [PATCH 052/214] feat(cli): add HTTP APIs matching smm folder commands Expose import-folder, get-job, show-folder, and folder-metadata so HTTP clients can drive the same Core operations as smm add/show/metadata. Co-authored-by: Cursor --- apps/cli/server.ts | 8 ++ apps/cli/src/route/FolderMetadata.test.ts | 98 ++++++++++++++++++ apps/cli/src/route/FolderMetadata.ts | 53 ++++++++++ apps/cli/src/route/GetJob.test.ts | 78 +++++++++++++++ apps/cli/src/route/GetJob.ts | 43 ++++++++ apps/cli/src/route/ImportFolder.test.ts | 65 ++++++++++++ apps/cli/src/route/ImportFolder.ts | 59 +++++++++++ apps/cli/src/route/ShowFolder.test.ts | 99 +++++++++++++++++++ apps/cli/src/route/ShowFolder.ts | 43 ++++++++ docs/api/index.md | 20 +++- .../2026-08-18-cli-http-folder-apis-design.md | 23 +++++ 11 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/route/FolderMetadata.test.ts create mode 100644 apps/cli/src/route/FolderMetadata.ts create mode 100644 apps/cli/src/route/GetJob.test.ts create mode 100644 apps/cli/src/route/GetJob.ts create mode 100644 apps/cli/src/route/ImportFolder.test.ts create mode 100644 apps/cli/src/route/ImportFolder.ts create mode 100644 apps/cli/src/route/ShowFolder.test.ts create mode 100644 apps/cli/src/route/ShowFolder.ts create mode 100644 docs/superpowers/specs/2026-08-18-cli-http-folder-apis-design.md diff --git a/apps/cli/server.ts b/apps/cli/server.ts index d1be2457..a758b5ca 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -44,6 +44,10 @@ import { handleDebugIsFolderExistToolRoute } from './src/route/debug/debugIsFold import { handlePlans } from './src/route/Plans'; import { handleGetFolders } from './src/route/GetFolders'; import { handleUnimportFolder } from './src/route/UnimportFolder'; +import { handleImportFolder } from './src/route/ImportFolder'; +import { handleGetJob } from './src/route/GetJob'; +import { handleShowFolder } from './src/route/ShowFolder'; +import { handleFolderMetadata } from './src/route/FolderMetadata'; import { handleTencentAsrTranscribe } from './src/route/tencentAsr/Transcribe'; import { handleExecuteCmd } from './src/route/executeCmd'; import { handleDiscoverExecutables } from './src/route/discoverExecutables'; @@ -279,6 +283,10 @@ export class Server { handlePlans(this.app); handleGetFolders(this.app); handleUnimportFolder(this.app); + handleImportFolder(this.app); + handleGetJob(this.app); + handleShowFolder(this.app); + handleFolderMetadata(this.app); handleTencentAsrTranscribe(this.app); handleExecuteCmd(this.app); handleDiscoverExecutables(this.app); diff --git a/apps/cli/src/route/FolderMetadata.test.ts b/apps/cli/src/route/FolderMetadata.test.ts new file mode 100644 index 00000000..50a38372 --- /dev/null +++ b/apps/cli/src/route/FolderMetadata.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleFolderMetadata } from './FolderMetadata' +import { handleImportFolder } from './ImportFolder' +import { handleGetJob } from './GetJob' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/folder-metadata', () => { + let userDataDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-folder-meta-')) + mediaFolder = mkdtempSync(join(tmpdir(), 'smm-folder-meta-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleImportFolder(app) + handleGetJob(app) + handleFolderMetadata(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaFolder, { recursive: true, force: true }) + }) + + async function postMeta(body: unknown) { + return app.request('/api/folder-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + async function waitForImport(path: string, type: string) { + const res = await app.request('/api/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, type }), + }) + const json = (await res.json()) as { data?: { id: string }; error?: string } + expect(json.error).toBeUndefined() + const id = json.data!.id + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const jobRes = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + const job = (await jobRes.json()) as { data?: { status: string } } + if (job.data?.status === 'succeeded' || job.data?.status === 'failed') { + expect(job.data.status).toBe('succeeded') + return + } + await new Promise((r) => setTimeout(r, 20)) + } + throw new Error('timeout waiting for import') + } + + it('returns Error Reason when path is missing', async () => { + const res = await postMeta({}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + }) + + it('returns Error Reason when the folder is not imported', async () => { + const res = await postMeta({ path: mediaFolder }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/not imported/i) + }) + + it('returns metadata without files after a successful music import', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + await waitForImport(mediaFolder, 'music') + + const res = await postMeta({ path: mediaFolder }) + expect(res.status).toBe(200) + const json = (await res.json()) as { + data?: { type?: string; files?: unknown; mediaFiles?: unknown[] } + } + expect(json.data?.type).toBe('music-folder') + expect(json.data?.files).toBeUndefined() + expect(json.data?.mediaFiles).toEqual([]) + }) +}) diff --git a/apps/cli/src/route/FolderMetadata.ts b/apps/cli/src/route/FolderMetadata.ts new file mode 100644 index 00000000..76554fe6 --- /dev/null +++ b/apps/cli/src/route/FolderMetadata.ts @@ -0,0 +1,53 @@ +import type { Hono } from 'hono' +import type { MediaMetadata } from '@smm/core' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' +import { isFolderImported } from '../cli/folderDisplay' + +export interface FolderMetadataResponseBody { + data?: Omit + error?: string +} + +export function handleFolderMetadata(app: Hono): void { + app.post('/api/folder-metadata', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const path = + typeof body === 'object' && body !== null && 'path' in body + ? (body as { path: unknown }).path + : undefined + if (typeof path !== 'string' || path.trim() === '') { + const err: FolderMetadataResponseBody = { error: 'Error Reason: path is required' } + return c.json(err, 200) + } + if (!(await isFolderImported(path))) { + const err: FolderMetadataResponseBody = { + error: `Error Reason: Folder is not imported: ${path}`, + } + return c.json(err, 200) + } + const mm = await getCore().getMediaMetadata(path) + if (mm === null) { + const err: FolderMetadataResponseBody = { + error: `Error Reason: No metadata cache for folder: ${path}`, + } + return c.json(err, 200) + } + const { files: _files, ...rest } = mm + const ok: FolderMetadataResponseBody = { data: rest } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/folder-metadata] route error') + const err: FolderMetadataResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/src/route/GetJob.test.ts b/apps/cli/src/route/GetJob.test.ts new file mode 100644 index 00000000..7a9f67dd --- /dev/null +++ b/apps/cli/src/route/GetJob.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleGetJob } from './GetJob' +import { handleImportFolder } from './ImportFolder' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/get-job', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-get-job-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleImportFolder(app) + handleGetJob(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns Error Reason when id is missing', async () => { + const res = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: id is required/) + }) + + it('returns Error Reason when the job is unknown', async () => { + const res = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'missing' }), + }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: Job not found/) + }) + + it('returns the import job after import-folder', async () => { + const imported = await app.request('/api/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: '/media/A', type: 'music', skipInit: true }), + }) + const { data } = (await imported.json()) as { data: { id: string } } + + const deadline = Date.now() + 2000 + let job: { status?: string; id?: string } | undefined + while (Date.now() < deadline) { + const res = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: data.id }), + }) + const json = (await res.json()) as { data?: { id: string; status: string } } + job = json.data + if (job?.status === 'succeeded' || job?.status === 'failed') break + await new Promise((r) => setTimeout(r, 20)) + } + expect(job?.id).toBe(data.id) + expect(job?.status).toBe('succeeded') + }) +}) diff --git a/apps/cli/src/route/GetJob.ts b/apps/cli/src/route/GetJob.ts new file mode 100644 index 00000000..2921abac --- /dev/null +++ b/apps/cli/src/route/GetJob.ts @@ -0,0 +1,43 @@ +import type { Hono } from 'hono' +import type { ImportJob } from 'core-app' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface GetJobResponseBody { + data?: ImportJob + error?: string +} + +export function handleGetJob(app: Hono): void { + app.post('/api/get-job', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const id = + typeof body === 'object' && body !== null && 'id' in body + ? (body as { id: unknown }).id + : undefined + if (typeof id !== 'string' || id.trim() === '') { + const err: GetJobResponseBody = { error: 'Error Reason: id is required' } + return c.json(err, 200) + } + const job = getCore().getJob(id) + if (job === undefined) { + const err: GetJobResponseBody = { error: 'Error Reason: Job not found' } + return c.json(err, 200) + } + const ok: GetJobResponseBody = { data: job } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-job] route error') + const err: GetJobResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/src/route/ImportFolder.test.ts b/apps/cli/src/route/ImportFolder.test.ts new file mode 100644 index 00000000..474090fc --- /dev/null +++ b/apps/cli/src/route/ImportFolder.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleImportFolder } from './ImportFolder' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/import-folder', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-import-folder-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleImportFolder(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + async function post(body: unknown) { + return app.request('/api/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('returns Error Reason when path is missing', async () => { + const res = await post({ type: 'music' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + }) + + it('returns Error Reason when type is missing', async () => { + const res = await post({ path: '/media/A' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: type is required/) + }) + + it('with skipInit registers the folder and returns a job id', async () => { + const res = await post({ path: '/media/A', type: 'tvshow', skipInit: true }) + expect(res.status).toBe(200) + const json = (await res.json()) as { data?: { id: string }; error?: string } + expect(json.error).toBeUndefined() + expect(json.data?.id).toEqual(expect.any(String)) + + await new Promise((r) => setTimeout(r, 50)) + const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + folders: string[] + } + expect(saved.folders).toContain('/media/A') + }) +}) diff --git a/apps/cli/src/route/ImportFolder.ts b/apps/cli/src/route/ImportFolder.ts new file mode 100644 index 00000000..18e94fb5 --- /dev/null +++ b/apps/cli/src/route/ImportFolder.ts @@ -0,0 +1,59 @@ +import type { Hono } from 'hono' +import type { FolderType } from 'core-app' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] + +export interface ImportFolderResponseBody { + data?: { id: string } + error?: string +} + +function resolveFolderType(value: unknown): FolderType | undefined { + if (value === 'anime') return 'tvshow' + if (typeof value === 'string' && (FOLDER_TYPES as readonly string[]).includes(value)) { + return value as FolderType + } + return undefined +} + +export function handleImportFolder(app: Hono): void { + app.post('/api/import-folder', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const rec = typeof body === 'object' && body !== null ? (body as Record) : {} + const path = rec.path + if (typeof path !== 'string' || path.trim() === '') { + const err: ImportFolderResponseBody = { error: 'Error Reason: path is required' } + return c.json(err, 200) + } + if (rec.type === undefined || rec.type === '') { + const err: ImportFolderResponseBody = { error: 'Error Reason: type is required' } + return c.json(err, 200) + } + const type = resolveFolderType(rec.type) + if (type === undefined) { + const err: ImportFolderResponseBody = { + error: `Error Reason: Invalid folder type: ${String(rec.type)}`, + } + return c.json(err, 200) + } + const skipInit = rec.skipInit === true + const { id } = getCore().importFolder(path, type, skipInit ? { skipInit: true } : undefined) + const ok: ImportFolderResponseBody = { data: { id } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/import-folder] route error') + const err: ImportFolderResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/src/route/ShowFolder.test.ts b/apps/cli/src/route/ShowFolder.test.ts new file mode 100644 index 00000000..67574a69 --- /dev/null +++ b/apps/cli/src/route/ShowFolder.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleShowFolder } from './ShowFolder' +import { handleImportFolder } from './ImportFolder' +import { handleGetJob } from './GetJob' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/show-folder', () => { + let userDataDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-show-folder-')) + mediaFolder = mkdtempSync(join(tmpdir(), 'smm-show-folder-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleImportFolder(app) + handleGetJob(app) + handleShowFolder(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaFolder, { recursive: true, force: true }) + }) + + async function postShow(body: unknown) { + return app.request('/api/show-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + async function waitForImport(path: string, type: string) { + const res = await app.request('/api/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, type }), + }) + const json = (await res.json()) as { data?: { id: string }; error?: string } + expect(json.error).toBeUndefined() + const id = json.data!.id + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const jobRes = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + const job = (await jobRes.json()) as { data?: { status: string } } + if (job.data?.status === 'succeeded' || job.data?.status === 'failed') { + expect(job.data.status).toBe('succeeded') + return + } + await new Promise((r) => setTimeout(r, 20)) + } + throw new Error('timeout waiting for import') + } + + it('returns Error Reason when path is missing', async () => { + const res = await postShow({}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + }) + + it('returns Error Reason when the folder is not imported', async () => { + const res = await postShow({ path: mediaFolder }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/not imported/i) + }) + + it('returns ok with type after a successful music import', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + await waitForImport(mediaFolder, 'music') + + const res = await postShow({ path: mediaFolder }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { + path: mediaFolder, + status: 'ok', + type: 'music-folder', + }, + }) + }) +}) diff --git a/apps/cli/src/route/ShowFolder.ts b/apps/cli/src/route/ShowFolder.ts new file mode 100644 index 00000000..d453cf68 --- /dev/null +++ b/apps/cli/src/route/ShowFolder.ts @@ -0,0 +1,43 @@ +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' +import { resolveShowFolder, type ShowFolderResult } from '../cli/folderDisplay' + +export interface ShowFolderResponseBody { + data?: ShowFolderResult + error?: string +} + +export function handleShowFolder(app: Hono): void { + app.post('/api/show-folder', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const path = + typeof body === 'object' && body !== null && 'path' in body + ? (body as { path: unknown }).path + : undefined + if (typeof path !== 'string' || path.trim() === '') { + const err: ShowFolderResponseBody = { error: 'Error Reason: path is required' } + return c.json(err, 200) + } + const resolved = await resolveShowFolder(path) + if (!resolved.ok) { + const err: ShowFolderResponseBody = { error: `Error Reason: ${resolved.error}` } + return c.json(err, 200) + } + const ok: ShowFolderResponseBody = { data: resolved.result } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/show-folder] route error') + const err: ShowFolderResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/docs/api/index.md b/docs/api/index.md index 0c4b2c97..33164155 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -48,11 +48,27 @@ CLI also sweeps `{userDataDir}/temp/ytdlp-cookies-*.txt` on startup (fallback wh ## GetFolders Source Code: apps/cli/src/route/GetFolders.ts -HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. +HTTP: `POST /api/get-folders` — returns imported media folder paths via Layer 2 `Core.getFolders()` (reads `userDataDir/smm.json`). Request body: `{}` (optional). Response: `{ data: { folders: string[] } }` or `{ error }`. Used by UI `useFoldersQuery` when `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm list`. + +## ImportFolder +Source Code: apps/cli/src/route/ImportFolder.ts +HTTP: `POST /api/import-folder` — starts Layer 2 `Core.importFolder(path, type, { skipInit? })`. Request body: `{ path: string, type: "tvshow" | "movie" | "music" | "anime", skipInit?: boolean }` (`anime` aliases `tvshow`). Response: `{ data: { id } }` (job id) or `{ error }`. Does not wait for the pipeline; poll `POST /api/get-job`. CLI equivalent: `smm add`. + +## GetJob +Source Code: apps/cli/src/route/GetJob.ts +HTTP: `POST /api/get-job` — returns an in-memory import job from `Core.getJob(id)`. Request body: `{ id: string }`. Response: `{ data: ImportJob }` or `{ error }` (`Job not found`). CLI `smm add` polls this internally until the job settles. + +## ShowFolder +Source Code: apps/cli/src/route/ShowFolder.ts +HTTP: `POST /api/show-folder` — UI-aligned folder snapshot via the same helper as `smm show`. Request body: `{ path: string }`. Response: `{ data: { path, status, type?, title? } }` (`status`: `ok` | `folder_not_found` | `error_loading_metadata`) or `{ error }` (not imported / path missing). + +## FolderMetadata +Source Code: apps/cli/src/route/FolderMetadata.ts +HTTP: `POST /api/folder-metadata` — MediaMetadata for an imported folder via `Core.getMediaMetadata`, with `files` omitted (same fields as `smm metadata`). Request body: `{ path: string }`. Response: `{ data: MediaMetadata }` or `{ error }` (not imported / no cache). ## UnimportFolder Source Code: apps/cli/src/route/UnimportFolder.ts -HTTP: `POST /api/unimport-folder` — removes an imported media folder from `userDataDir/smm.json` and deletes its metadata cache via Layer 2 `Core.unimportFolder(path)`. Request body: `{ path: string }`. Response: `{ data: { path } }` or `{ error }`. Idempotent when the path is not in the config. Used by UI delete (context menu, Delete key, multi-select) when `localStorage["smm.v3.enabled"] === "true"`. +HTTP: `POST /api/unimport-folder` — removes an imported media folder from `userDataDir/smm.json` and deletes its metadata cache via Layer 2 `Core.unimportFolder(path)`. Request body: `{ path: string }`. Response: `{ data: { path } }` or `{ error }`. Idempotent when the path is not in the config. Used by UI delete (context menu, Delete key, multi-select) when `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm rm`. ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts diff --git a/docs/superpowers/specs/2026-08-18-cli-http-folder-apis-design.md b/docs/superpowers/specs/2026-08-18-cli-http-folder-apis-design.md new file mode 100644 index 00000000..768893c2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-cli-http-folder-apis-design.md @@ -0,0 +1,23 @@ +# CLI ↔ HTTP folder APIs + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +`smm list` / `add` / `show` / `metadata` / `rm` are Layer 3 CLI commands over Core. HTTP already had `POST /api/get-folders` and `POST /api/unimport-folder`. Import / show / metadata / job poll were missing, so HTTP clients could not drive the same Core operations. + +## 2. Mapping + +| CLI | HTTP | Core | +|-----|------|------| +| `smm list` | `POST /api/get-folders` (existing) | `getFolders()` | +| `smm add` | `POST /api/import-folder` | `importFolder(path, type, { skipInit? })` | +| (add wait) | `POST /api/get-job` | `getJob(id)` | +| `smm show` | `POST /api/show-folder` | `resolveShowFolder` | +| `smm metadata` | `POST /api/folder-metadata` | `getMediaMetadata` (omit `files`) | +| `smm rm` | `POST /api/unimport-folder` (existing) | `unimportFolder` | + +Naming may differ (`list` vs `get-folders`) because CLI and HTTP target different users. + +HTTP import returns a job id immediately; clients poll `get-job` (CLI already waits in-process). From fae88929bd0b2f6620ca86c6d4c9b5728f1d9d20 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 18 Aug 2026 23:31:46 +0800 Subject: [PATCH 053/214] feat(cli): add smm config list/get/set with Core key validation Expose Core.setUserConfigKey and CLI JSON config commands so operators can read and write smm.json without starting the HTTP server. Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/src/cli/runCli.ts | 73 +++++++++++++++++++++++++++- apps/cli/test/config-e2e.test.ts | 68 ++++++++++++++++++++++++++ apps/core/src/Core.test.ts | 24 +++++++++ apps/core/src/Core.ts | 10 +++- apps/core/src/index.ts | 2 +- apps/core/src/pipeline/userConfig.ts | 35 +++++++++++++ 7 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 apps/cli/test/config-e2e.test.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index fdf3bc8a..396518b3 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config']) if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 6df361e3..35cfdd41 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -1,6 +1,6 @@ import { Command, Option } from 'commander' import type { FolderType } from 'core-app' -import { NoopLoggerAdapter } from 'core-app' +import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' import { waitUntilImportSettled } from './addProgress' import { CliLoggerAdapter } from './cliLogger' @@ -22,10 +22,23 @@ function resolveFolderType(value: string): FolderType { throw new Error(`Invalid folder type: ${value}`) } +/** Parse CLI value as JSON when possible; otherwise keep the raw string. */ +function parseConfigValue(raw: string): unknown { + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +function printJson(value: unknown): void { + console.log(JSON.stringify(value, null, 2)) +} + const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `config`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -163,6 +176,62 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + const configCmd = program.command('config').description('Read or write user config (smm.json)') + + configCmd + .command('list') + .description('Print the full user config as JSON') + .action(async () => { + try { + printJson(await getCore().getUserConfig()) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + + configCmd + .command('get') + .description('Print one config value as JSON') + .argument('', 'Config key') + .action(async (key: string) => { + try { + if (!isUserConfigKey(key)) { + console.error(`Unknown config key: ${key}`) + exitCode = 1 + return + } + const config = await getCore().getUserConfig() + printJson(config[key] ?? null) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + + configCmd + .command('set') + .description('Set one config key (value is JSON when parseable, otherwise a string)') + .argument('', 'Config key') + .argument('', 'Config value') + .action(async (key: string, value: string) => { + try { + if (!isUserConfigKey(key)) { + console.error(`Unknown config key: ${key}`) + exitCode = 1 + return + } + const updated = await getCore().setUserConfigKey(key, parseConfigValue(value)) + printJson(updated[key] ?? null) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + try { await program.parseAsync(argv, { from: 'node' }) } catch (error) { diff --git a/apps/cli/test/config-e2e.test.ts b/apps/cli/test/config-e2e.test.ts new file mode 100644 index 00000000..760083af --- /dev/null +++ b/apps/cli/test/config-e2e.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './helpers/smm' + +describe('smm config CLI e2e', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-config-e2e-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('lists the full user config as JSON', async () => { + const listed = await smm(['config', 'list']) + expect(listed.code).toBe(0) + const config = JSON.parse(listed.stdout) as { dryRun: boolean; folders: string[] } + expect(config.dryRun).toBe(false) + expect(config.folders).toEqual([]) + }) + + it('sets a key, gets the JSON value, and shows it in list', async () => { + const set = await smm(['config', 'set', 'dryRun', 'true']) + expect(set.code, set.stderr).toBe(0) + expect(JSON.parse(set.stdout)).toBe(true) + + const got = await smm(['config', 'get', 'dryRun']) + expect(got.code, got.stderr).toBe(0) + expect(JSON.parse(got.stdout)).toBe(true) + + const listed = await smm(['config', 'list']) + expect(listed.code).toBe(0) + expect(JSON.parse(listed.stdout).dryRun).toBe(true) + }) + + it('stores a non-JSON value as a string', async () => { + const set = await smm(['config', 'set', 'selectedRenameRule', 'plex']) + expect(set.code, set.stderr).toBe(0) + expect(JSON.parse(set.stdout)).toBe('plex') + + const got = await smm(['config', 'get', 'selectedRenameRule']) + expect(got.code, got.stderr).toBe(0) + expect(JSON.parse(got.stdout)).toBe('plex') + }) + + it('exits 1 when getting an unknown key', async () => { + const got = await smm(['config', 'get', 'notAKey']) + expect(got.code).toBe(1) + expect(got.stderr).toContain('Unknown config key: notAKey') + }) + + it('exits 1 when setting an unknown key', async () => { + const set = await smm(['config', 'set', 'notAKey', '1']) + expect(set.code).toBe(1) + expect(set.stderr).toContain('Unknown config key: notAKey') + }) +}) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index b81aea1b..c1e08770 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -197,6 +197,30 @@ describe("getUserConfig", () => { }); }); +describe("setUserConfigKey", () => { + it("persists a known key and returns the updated config", async () => { + const fs = inMemoryFs(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + const updated = await core.setUserConfigKey("dryRun", true); + + expect(updated.dryRun).toBe(true); + expect((await core.getUserConfig()).dryRun).toBe(true); + const written = JSON.parse(await fs.readTextFile(userConfigPath("/data/smm"))) as { + dryRun: boolean; + }; + expect(written.dryRun).toBe(true); + }); + + it("rejects an unknown key without writing", async () => { + const fs = inMemoryFs(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.setUserConfigKey("notAKey", 1)).rejects.toThrow("Unknown config key: notAKey"); + expect(await fs.exists(userConfigPath("/data/smm"))).toBe(false); + }); +}); + describe("getFolders", () => { it("returns the folders from the user config", async () => { const fs = inMemoryFs({ diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 3792d6c4..e81c37fb 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -7,7 +7,7 @@ import type { DiscoverPort } from "./ports/DiscoverPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; -import { UserConfig } from "./pipeline/userConfig"; +import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; @@ -95,6 +95,14 @@ export class Core { return this.userConfig.read(); } + /** Updates one known UserConfig key. Rejects unknown keys without writing. */ + async setUserConfigKey(key: string, value: unknown): Promise { + if (!isUserConfigKey(key)) { + throw new Error(`Unknown config key: ${key}`); + } + return this.userConfig.update((config) => ({ ...config, [key]: value })); + } + async getFolders(): Promise { return (await this.userConfig.read()).folders; } diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 9c7fe4e2..6d3296a9 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -14,7 +14,7 @@ export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; export { StaticDiscoverAdapter, STATIC_MEDIA_DATABASES } from "./adapters/StaticDiscoverAdapter"; export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; -export { UserConfig, DEFAULT_USER_CONFIG } from "./pipeline/userConfig"; +export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } from "./pipeline/userConfig"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; diff --git a/apps/core/src/pipeline/userConfig.ts b/apps/core/src/pipeline/userConfig.ts index 7de705ed..5dec343d 100644 --- a/apps/core/src/pipeline/userConfig.ts +++ b/apps/core/src/pipeline/userConfig.ts @@ -12,6 +12,41 @@ export const DEFAULT_USER_CONFIG: UserConfigData = { selectedRenameRule: "plex", } +/** Exhaustive UserConfig keys; adding a field to UserConfig without updating this fails typecheck. */ +const USER_CONFIG_KEY_FLAGS = { + applicationLanguage: true, + tmdb: true, + tvdb: true, + primaryDatabase: true, + preferMediaLanguage: true, + folders: true, + selectedFolder: true, + renameRules: true, + dryRun: true, + ai: true, + selectedAI: true, + aiProviders: true, + selectedAIProvider: true, + selectedTMDBIntance: true, + selectedRenameRule: true, + enableMcpServer: true, + mcpHost: true, + mcpPort: true, + anonymousTelemetryConsent: true, + ytdlpExecutablePath: true, + ytdlpProxy: true, + ffmpegExecutablePath: true, + videoCaptionerExecutablePath: true, + useBundledFfmpegForVideoCaptioner: true, + quickjsExecutablePath: true, +} as const satisfies { [K in keyof UserConfigData]: true } + +export const USER_CONFIG_KEYS = Object.keys(USER_CONFIG_KEY_FLAGS) as (keyof UserConfigData)[] + +export function isUserConfigKey(key: string): key is keyof UserConfigData { + return Object.prototype.hasOwnProperty.call(USER_CONFIG_KEY_FLAGS, key) +} + const mutexByPath = new Map() function mutexFor(path: string): Mutex { From cfc5b6aa3cb04098722e47ee6f5fc4516344cf4f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 01:44:55 +0800 Subject: [PATCH 054/214] docs: add Core.renameFolder design for Sidebar rename migration Co-authored-by: Cursor --- .../2026-08-19-core-rename-folder-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-core-rename-folder-design.md diff --git a/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md b/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md new file mode 100644 index 00000000..29d4d973 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-core-rename-folder-design.md @@ -0,0 +1,115 @@ +# Core.renameFolder + +This design document describes migrating Sidebar folder rename (metadata cache + user config + on-disk rename) into Layer 2 `apps/core`, without changing existing UI or `packages/core-routes` source. + +## 1. Background + +Sidebar right-click **Rename** already renames a media folder end-to-end: + +1. UI dialog / `useRenameMediaFolderMutation` builds `to = join(dirname(from), newName)`. +2. HTTP `POST /api/renameFolder` → `packages/core-routes` `doRenameFolder`. +3. That path updates the metadata cache, updates `UserConfig.folders`, then renames the directory on disk. +4. UI refreshes local state via `refreshUiAfterFolderRename`. + +Layer 2 (`apps/core`) already owns import / unimport / metadata read. Operators and a future v3 path need the same rename semantics in-process. This change adds **`Core.renameFolder` only** — no HTTP/CLI/UI/v3 wiring yet. Existing UI and `doRenameFolder` remain the production path until a later integration. + +**Constraints (from product):** + +- Careful migration: preserve `doRenameFolder` step order and failure messages; do not “improve” behavior. +- Do not edit UI or `packages/core-routes` source while migrating. +- New callers under a v3 switch come later; out of scope here. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +apps/ui (Sidebar Rename) ──unchanged──► POST /api/renameFolder + │ +packages/core-routes doRenameFolder ──unchanged──► fs + metadata + smm.json + │ +packages/core renameFolderInMediaMetadata / renameFolderInUserConfig + ▲ +apps/core Core.renameFolder ──new──► FsPort + UserConfig + same pure helpers +``` + +- **Pure transforms** stay in `packages/core` (already used by core-routes). +- **Orchestration** is duplicated into `Core.renameFolder` for this milestone (Approach A). Shared extraction with core-routes is deferred so source stays untouched. +- **Disk rename** goes through `FsPort.rename` (new), not a direct `node:fs` import inside Core. + +## 2.2 App Level Architecture + +| Piece | Role | +|-------|------| +| `Core.renameFolder({ from, to })` | Orchestrates managed check → metadata cache move → user config → `FsPort.rename` | +| `FsPort.rename(from, to)` | Rename/move a path (POSIX in, adapter converts) | +| `NodejsFsAdapter.rename` | `fs.promises.rename` after `Path.toPlatformPath` | +| `NetworkFsAdapter.rename` | Throws Not Implemented (no browser wiring this round) | +| `renameFolderInMediaMetadata` | Existing pure helper — rewrite paths inside `MediaMetadata` | +| `renameFolderInUserConfig` | Existing pure helper — rewrite `folders` entries only | +| `metadataCachePath` | Existing Core cache path helper (same sanitization as core-routes) | + +**Step order (must match `doRenameFolder`):** + +1. `Path.posix(from)` / `Path.posix(to)` +2. Assert source is managed (`UserConfig.folders` compare platform + posix, same as `isMediaFolderManaged`) +3. Read metadata cache for `from`; fail if absent +4. `renameFolderInMediaMetadata` → write cache for new path → delete old cache +5. `renameFolderInUserConfig` → persist via `UserConfig.update` / write +6. `FsPort.rename(fromPosix, toPosix)` + +## 2.3 Key Design + +- **Signature:** `renameFolder(args: { from: string; to: string }): Promise` +- **Errors:** throw `Error` (Core style). Messages aligned with `doRenameFolder`: + - `{fromPosix} is not managed by SMM` + - `Media metadata not found: {from}` (same string shape as today: original `from` in the message) + - Other IO errors propagate +- **No behavior upgrades:** do not update `selectedFolder`; do not require destination parent to exist beyond what `fs.rename` already requires; do not reorder disk rename before config writes. +- **Tests:** unit tests in `apps/core` prove cache/config/rename side effects and rejection paths; adapter tests for `NodejsFsAdapter.rename` and Network NYI. + +## 3. User Stories + +### 3.1 Rename managed folder with metadata + +* **Given** - a folder is listed in `UserConfig.folders` and has a metadata cache file +* **When** - `Core.renameFolder({ from, to })` runs +* **Then** - disk path is renamed, `folders` contains `to` (platform form via existing helper), new metadata cache exists with rewritten paths, old cache is gone + +```mermaid +sequenceDiagram + participant Caller + participant Core + participant UserConfig + participant FsPort + participant Helpers as packages/core helpers + + Caller->>Core: renameFolder({ from, to }) + Core->>UserConfig: read folders (managed check) + Core->>FsPort: read metadata cache (from) + Core->>Helpers: renameFolderInMediaMetadata + Core->>FsPort: write new cache, delete old + Core->>Helpers: renameFolderInUserConfig + Core->>UserConfig: persist smm.json + Core->>FsPort: rename(from, to) + Core-->>Caller: void +``` + +### 3.2 Reject unmanaged or missing metadata + +* **Given** - folder is not in `folders`, or metadata cache is missing +* **When** - `Core.renameFolder` is called +* **Then** - it throws with the messages above and does not call `FsPort.rename` / does not persist a partial rename beyond what tests assert for early exits + +## 4. Out of scope + +- HTTP / CLI / MCP / UI integration and v3 feature-flag wiring +- Editing `packages/core-routes` `doRenameFolder` or UI rename mutation +- Implementing a real `NetworkFsAdapter.rename` HTTP mapping +- Transactional rollback if disk rename fails after config/metadata updates (existing `doRenameFolder` has the same gap; preserve it) + +## 5. Implementation notes + +- Prefer a small private helper on Core (or `pipeline/renameFolder.ts`) that mirrors `doRenameFolder` body so `Core.ts` stays thin. +- Update all `FsPort` implementors and in-memory test fakes so TypeScript stays green. +- Follow TDD: failing Core tests first, then minimal green implementation. From b81661ab8cc565c28f91483bce62baa70017ac5d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 01:46:51 +0800 Subject: [PATCH 055/214] docs: add Core.renameFolder implementation plan Co-authored-by: Cursor --- .../plans/2026-08-19-core-rename-folder.md | 484 ++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-core-rename-folder.md diff --git a/docs/superpowers/plans/2026-08-19-core-rename-folder.md b/docs/superpowers/plans/2026-08-19-core-rename-folder.md new file mode 100644 index 00000000..435de815 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-core-rename-folder.md @@ -0,0 +1,484 @@ +# Core.renameFolder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Core.renameFolder({ from, to })` in `apps/core` that mirrors `packages/core-routes` `doRenameFolder` (metadata cache + user config + on-disk rename) without editing UI or core-routes source. + +**Architecture:** Extend `FsPort` with `rename`. Port `doRenameFolder` step order into a Core pipeline helper that reuses `renameFolderInMediaMetadata` and `renameFolderInUserConfig`. Throw on failure. No HTTP/CLI/UI/v3 wiring. + +**Tech Stack:** TypeScript, Vitest, `apps/core` ports/adapters, `@smm/core` / `@core` path + mediaMetadata helpers. + +**Spec:** `docs/superpowers/specs/2026-08-19-core-rename-folder-design.md` + +## Global Constraints + +- Do **not** modify `apps/ui/**` or `packages/core-routes/**` source. +- Preserve `doRenameFolder` step order and error message strings (no “improvements”). +- `Core.renameFolder` throws; return type is `Promise`. +- `NetworkFsAdapter.rename` throws Not Implemented this round. +- Scope is `apps/core` (+ its tests) only; no integration. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/ports/FsPort.ts` | Add `rename(from, to): Promise` | +| `apps/core/src/adapters/node/NodejsFsAdapter.ts` | Implement via `fs.promises.rename` + `Path.toPlatformPath` | +| `apps/core/src/adapters/network/NetworkFsAdapter.ts` | `rename` throws NYI | +| `apps/core/src/pipeline/renameFolder.ts` | Orchestration mirroring `doRenameFolder` (pure deps injected) | +| `apps/core/src/Core.ts` | Public `renameFolder({ from, to })` → pipeline | +| `apps/core/src/Core.test.ts` | Behavior tests for renameFolder | +| Adapter `*.test.ts` | FsPort.rename coverage | +| `apps/core/src/index.ts` | Export `RenameFolderArgs` if exported from Core | + +--- + +### Task 1: FsPort.rename + NodejsFsAdapter + +**Files:** +- Modify: `apps/core/src/ports/FsPort.ts` +- Modify: `apps/core/src/adapters/node/NodejsFsAdapter.ts` +- Modify: `apps/core/src/adapters/node/NodejsFsAdapter.test.ts` + +**Interfaces:** +- Produces: `FsPort.rename(from: string, to: string): Promise` — paths are POSIX; adapter converts at the boundary (same as other methods). + +- [ ] **Step 1: Write the failing test** + +Append to `NodejsFsAdapter.test.ts`: + +```typescript + it("renames a directory on disk", async () => { + const adapter = new NodejsFsAdapter(); + const from = joinPosix(tmpPosix, "old-dir"); + const to = joinPosix(tmpPosix, "new-dir"); + await fsp.mkdir(Path.toPlatformPath(from)); + await fsp.writeFile(join(Path.toPlatformPath(from), "a.txt"), "x"); + + await adapter.rename(from, to); + + expect(await adapter.exists(from)).toBe(false); + expect(await adapter.exists(joinPosix(to, "a.txt"))).toBe(true); + expect(await adapter.readTextFile(joinPosix(to, "a.txt"))).toBe("x"); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/adapters/node/NodejsFsAdapter.test.ts` + +Expected: FAIL — `adapter.rename is not a function` (or type/compile error if tests still typecheck against old interface). + +- [ ] **Step 3: Minimal implementation** + +In `FsPort.ts`, add: + +```typescript + /** Rename/move a file or directory. Paths are POSIX. Missing source should reject. */ + rename(from: string, to: string): Promise; +``` + +In `NodejsFsAdapter.ts`: + +```typescript + async rename(from: string, to: string): Promise { + await fsp.rename(Path.toPlatformPath(from), Path.toPlatformPath(to)); + } +``` + +Temporarily stub `NetworkFsAdapter.rename` and any in-test `FsPort` objects so the package still typechecks (throw `new Error("Not Implemented")` in Network; add `rename: vi.fn()` / map-based rename in Core.test fakes in this step **only if** TypeScript fails the build — otherwise leave those stubs for Tasks 2–3). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter core-app exec vitest run src/adapters/node/NodejsFsAdapter.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/ports/FsPort.ts \ + apps/core/src/adapters/node/NodejsFsAdapter.ts \ + apps/core/src/adapters/node/NodejsFsAdapter.test.ts \ + apps/core/src/adapters/network/NetworkFsAdapter.ts +git commit -m "feat(core): add FsPort.rename for NodejsFsAdapter" +``` + +(Include Network stub in this commit if required for typecheck.) + +--- + +### Task 2: NetworkFsAdapter.rename Not Implemented + +**Files:** +- Modify: `apps/core/src/adapters/network/NetworkFsAdapter.ts` +- Modify: `apps/core/src/adapters/network/NetworkFsAdapter.test.ts` + +**Interfaces:** +- Consumes: `FsPort.rename` +- Produces: `NetworkFsAdapter.rename` always rejects with a clear Not Implemented error (no HTTP call). + +- [ ] **Step 1: Write the failing test** + +```typescript + it("rename throws Not Implemented", async () => { + const { network } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await expect(adapter.rename("/m/a", "/m/b")).rejects.toThrow(/Not Implemented/i); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/adapters/network/NetworkFsAdapter.test.ts` + +Expected: FAIL — missing `rename` or wrong error. + +- [ ] **Step 3: Implement** + +```typescript + async rename(_from: string, _to: string): Promise { + throw new Error("Not Implemented: NetworkFsAdapter.rename"); + } +``` + +- [ ] **Step 4: Run tests** + +Run: `pnpm --filter core-app exec vitest run src/adapters/network/NetworkFsAdapter.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/adapters/network/NetworkFsAdapter.ts \ + apps/core/src/adapters/network/NetworkFsAdapter.test.ts +git commit -m "feat(core): stub NetworkFsAdapter.rename as Not Implemented" +``` + +--- + +### Task 3: Core.renameFolder happy path (TDD) + +**Files:** +- Create: `apps/core/src/pipeline/renameFolder.ts` +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` (extend `inMemoryFs` with `rename`; add describe block) +- Modify: `apps/core/src/index.ts` (export `RenameFolderArgs` if defined on Core) + +**Interfaces:** +- Consumes: `FsPort.rename`, `UserConfig`, `metadataCachePath`, `renameFolderInMediaMetadata`, `renameFolderInUserConfig`, `Path` +- Produces: + - `export interface RenameFolderArgs { from: string; to: string }` + - `Core.renameFolder(args: RenameFolderArgs): Promise` + - `export async function renameFolderPipeline(...): Promise` (internal orchestration) + +**Reference logic (do not edit):** `packages/core-routes/src/renameFolder.ts` `doRenameFolder` lines 37–86. + +- [ ] **Step 1: Extend `inMemoryFs` in `Core.test.ts` with `rename`** + +Move map keys under `from` / `from/` to `to` / `to/` (directory move semantics). Example: + +```typescript + rename: vi.fn(async (from: string, to: string) => { + if (!files.has(from) && ![...files.keys()].some((k) => k === from || k.startsWith(from + "/"))) { + throw new Error("ENOENT: " + from); + } + const entries = [...files.entries()]; + for (const [key, value] of entries) { + if (key === from || key.startsWith(from + "/")) { + files.delete(key); + files.set(to + key.slice(from.length), value); + } + } + // Ensure destination directory marker if only empty dirs matter — file keys alone are enough for these tests. + }), +``` + +Also update **every** other inline `FsPort` object in `Core.test.ts` (e.g. concurrent unimport fake) with a `rename` stub (`async () => {}` or `vi.fn()`) so the file typechecks. + +- [ ] **Step 2: Write failing happy-path test** + +```typescript +describe("renameFolder", () => { + it("updates metadata cache, user config, and renames on disk", async () => { + const from = "/m/Show"; + const to = "/m/Show Renamed"; + const oldCache = metadataCachePath("/data/smm", from); + const newCache = metadataCachePath("/data/smm", to); + const mm = { + mediaFolderPath: from, + type: "tvshow-folder" as const, + files: [`${from}/S01E01.mkv`], + mediaFiles: [{ absolutePath: `${from}/S01E01.mkv` }], + }; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: [from], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + [oldCache]: JSON.stringify(mm), + [`${from}/S01E01.mkv`]: "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.renameFolder({ from, to }); + + expect(await fs.exists(oldCache)).toBe(false); + expect(await fs.exists(newCache)).toBe(true); + const written = JSON.parse(await fs.readTextFile(newCache)) as typeof mm; + expect(written.mediaFolderPath).toBe(to); + expect(written.files).toEqual([`${to}/S01E01.mkv`]); + expect(written.mediaFiles?.[0]?.absolutePath).toBe(`${to}/S01E01.mkv`); + + const folders = await core.getFolders(); + // renameFolderInUserConfig stores Path.toPlatformPath(to) + expect(folders.map((f) => Path.posix(f))).toEqual([Path.posix(to)]); + + expect(fs.rename).toHaveBeenCalledWith(from, to); + expect(await fs.exists(`${to}/S01E01.mkv`)).toBe(true); + }); +}); +``` + +Import `Path` is already present in `Core.test.ts`. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts -t renameFolder` + +Expected: FAIL — `core.renameFolder is not a function`. + +- [ ] **Step 4: Implement pipeline + Core method** + +Create `apps/core/src/pipeline/renameFolder.ts`: + +```typescript +import { Path } from "@core/path"; +import { renameFolderInMediaMetadata } from "@core/mediaMetadata"; +import { renameFolderInUserConfig } from "@core/userConfig"; +import type { MediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath } from "./paths"; +import type { UserConfig } from "./userConfig"; + +export interface RenameFolderArgs { + from: string; + to: string; +} + +export interface RenameFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +/** Mirrors packages/core-routes doRenameFolder orchestration (throws instead of { error }). */ +export async function renameFolderPipeline( + args: RenameFolderArgs, + deps: RenameFolderDeps, +): Promise { + const fromAsPosix = Path.posix(args.from); + const toAsPosix = Path.posix(args.to); + + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], args.from)) { + throw new Error(`${fromAsPosix} is not managed by SMM`); + } + + const cachePath = metadataCachePath(deps.appDataDir, fromAsPosix); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${args.from}`); + } + let mediaMetadata: MediaMetadata; + try { + mediaMetadata = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${args.from}`); + } + + const updatedMetadata = renameFolderInMediaMetadata(mediaMetadata, fromAsPosix, toAsPosix); + if (!updatedMetadata.mediaFolderPath) { + throw new Error("Media folder path is required"); + } + const newCachePath = metadataCachePath(deps.appDataDir, Path.posix(updatedMetadata.mediaFolderPath)); + await deps.fs.writeTextFile(newCachePath, JSON.stringify(updatedMetadata, null, 2)); + await deps.fs.deleteFile(cachePath); + + await deps.userConfig.update((current) => + renameFolderInUserConfig(current, fromAsPosix, toAsPosix), + ); + + await deps.fs.rename(fromAsPosix, toAsPosix); +} +``` + +Note: Prefer matching `doRenameFolder`’s “metadata not found” path (null from read helper) rather than inventing new messages. If `readTextFile` throws ENOENT, map to the same message. Do **not** add validation schema unless tests require it. + +In `Core.ts`: + +```typescript +import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; + + async renameFolder(args: RenameFolderArgs): Promise { + await renameFolderPipeline(args, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (path) => this.normalizePosix(path), + }); + } +``` + +Export `RenameFolderArgs` from `apps/core/src/index.ts` alongside `Core`. + +Check `@smm/core/mediaMetadata` and `@smm/core/userConfig` resolve in `apps/core` the same way other packages do (if import path fails, use the package’s existing alias pattern from `apps/core` tsconfig / vitest — e.g. relative into `packages/core` only if already done elsewhere; prefer the same import style as `Path` from `@core/path`). + +If `@smm/core/mediaMetadata` is not wired for core-app, either: +- add vitest/tsconfig alias matching monorepo convention, **or** +- import from a path that already works in this package. + +Verify with a quick look at how `packages/core-routes` imports them and how `apps/core` currently imports `@smm/core` types. + +- [ ] **Step 5: Run happy-path test** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts -t "updates metadata cache"` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/src/pipeline/renameFolder.ts \ + apps/core/src/Core.ts \ + apps/core/src/Core.test.ts \ + apps/core/src/index.ts +git commit -m "feat(core): add Core.renameFolder happy path" +``` + +--- + +### Task 4: Core.renameFolder rejection paths + +**Files:** +- Modify: `apps/core/src/Core.test.ts` +- Modify: `apps/core/src/pipeline/renameFolder.ts` only if messages/behavior need alignment + +**Interfaces:** +- Consumes: `Core.renameFolder` +- Produces: stable throw messages matching `doRenameFolder` + +- [ ] **Step 1: Write failing rejection tests** + +```typescript + it("rejects when the folder is not managed", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: ["/m/Other"], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.renameFolder({ from: "/m/Show", to: "/m/X" })).rejects.toThrow( + "/m/Show is not managed by SMM", + ); + expect(fs.rename).not.toHaveBeenCalled(); + }); + + it("rejects when media metadata cache is missing", async () => { + const from = "/m/Show"; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: [from], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.renameFolder({ from, to: "/m/X" })).rejects.toThrow( + `Media metadata not found: ${from}`, + ); + expect(fs.rename).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 2: Run tests — expect FAIL only if implementation missing; otherwise PASS** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts -t renameFolder` + +If Task 3 already implemented the throws correctly, these should PASS immediately — that is OK **only if** you first confirm they would fail when the throw lines are temporarily removed (red-green check per project unit-test guideline): briefly comment out the managed/metadata checks, run once to see FAIL, restore, run PASS. + +- [ ] **Step 3: Align messages if needed** + +Ensure unmanaged uses **posix** `fromAsPosix` in the message (as `doRenameFolder` does) and missing metadata uses the raw `from` argument string. + +- [ ] **Step 4: Full core-app test run** + +Run: `pnpm --filter core-app test` + +Expected: all green. + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/Core.test.ts apps/core/src/pipeline/renameFolder.ts +git commit -m "test(core): cover renameFolder unmanaged and missing metadata" +``` + +--- + +### Task 5: Final verification + +- [ ] **Step 1: Typecheck** + +Run: `pnpm --filter core-app typecheck` + +Expected: exit 0. + +- [ ] **Step 2: Confirm no UI / core-routes edits** + +Run: `git diff --name-only HEAD~5..HEAD` (or since branch point) and assert no `apps/ui/` or `packages/core-routes/` paths in the renameFolder commits. + +- [ ] **Step 3: Optional doc touch** — none required beyond existing design; do not invent HTTP docs. + +--- + +## Self-review (plan vs spec) + +| Spec requirement | Task | +|------------------|------| +| `Core.renameFolder({ from, to })` throws | 3–4 | +| Step order = doRenameFolder | 3 (`renameFolderPipeline`) | +| Reuse pure helpers; leave UI/core-routes untouched | 3, Global Constraints, Task 5 | +| `FsPort.rename`; Node implements; Network NYI | 1–2 | +| Happy path: cache + config + disk | 3 | +| Unmanaged / missing metadata errors | 4 | +| No integration / v3 wiring | Global Constraints | + +No TBD placeholders. Signatures consistent: `RenameFolderArgs`, `FsPort.rename(from, to)`. From 845b28eb6c3f2f98673096bb44eecef4077b8891 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 02:11:31 +0800 Subject: [PATCH 056/214] feat(core): add FsPort.rename for NodejsFsAdapter Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 2 ++ apps/core/src/adapters/network/NetworkFsAdapter.ts | 4 ++++ .../core/src/adapters/node/NodejsFsAdapter.test.ts | 14 ++++++++++++++ apps/core/src/adapters/node/NodejsFsAdapter.ts | 4 ++++ .../importFolderPipeline.integration.test.ts | 1 + .../core/src/pipeline/importFolderPipeline.test.ts | 1 + .../core/src/pipeline/recognizeMediaFolder.test.ts | 1 + apps/core/src/pipeline/userConfig.test.ts | 1 + apps/core/src/ports/FsPort.ts | 2 ++ 9 files changed, 30 insertions(+) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index c1e08770..a539d717 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -28,6 +28,7 @@ function inMemoryFs(seed: Record = {}): FsPort { deleteFile: vi.fn(async (path: string) => { files.delete(path); }), + rename: vi.fn(async () => {}), }; } @@ -334,6 +335,7 @@ describe("unimportFolder", () => { deleteFile: async (path: string) => { files.delete(path); }, + rename: async () => {}, }; const core = new Core({ fs, network: emptyNetwork(), appDataDir }); diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index 7f2be979..ecdf4fe2 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -61,4 +61,8 @@ export class NetworkFsAdapter implements FsPort { const json = await this.post<{ error?: string }>("/api/deleteFile", { path }); if (json.error !== undefined) throw new Error(json.error); } + + async rename(_from: string, _to: string): Promise { + throw new Error("Not Implemented"); + } } diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts index 9194ade5..8addfbbe 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts @@ -62,4 +62,18 @@ describe("NodejsFsAdapter", () => { const adapter = new NodejsFsAdapter(); await expect(adapter.deleteFile(joinPosix(tmpPosix, "nope.txt"))).resolves.toBeUndefined(); }); + + it("renames a directory on disk", async () => { + const adapter = new NodejsFsAdapter(); + const from = joinPosix(tmpPosix, "old-dir"); + const to = joinPosix(tmpPosix, "new-dir"); + await fsp.mkdir(Path.toPlatformPath(from)); + await fsp.writeFile(join(Path.toPlatformPath(from), "a.txt"), "x"); + + await adapter.rename(from, to); + + expect(await adapter.exists(from)).toBe(false); + expect(await adapter.exists(joinPosix(to, "a.txt"))).toBe(true); + expect(await adapter.readTextFile(joinPosix(to, "a.txt"))).toBe("x"); + }); }); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts index cf891453..0990e4b8 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -52,4 +52,8 @@ export class NodejsFsAdapter implements FsPort { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } + + async rename(from: string, to: string): Promise { + await fsp.rename(Path.toPlatformPath(from), Path.toPlatformPath(to)); + } } diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts index 2606c1f7..91edbd14 100644 --- a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -35,6 +35,7 @@ function inMemoryFs(seed: Record = {}): FsPort { return out; }), deleteFile: vi.fn(async () => {}), + rename: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index afc2a9c0..809b820e 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -33,6 +33,7 @@ function inMemoryFs(seed: Record = {}): FsPort { return out; }), deleteFile: vi.fn(async () => {}), + rename: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index fe3c8ec7..fa8a3d1d 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -28,6 +28,7 @@ function fsPort(files: Record): FsPort { exists: vi.fn(async () => true), listFiles: vi.fn(async () => []), deleteFile: vi.fn(async () => {}), + rename: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/userConfig.test.ts b/apps/core/src/pipeline/userConfig.test.ts index f4c1339f..9b489ca0 100644 --- a/apps/core/src/pipeline/userConfig.test.ts +++ b/apps/core/src/pipeline/userConfig.test.ts @@ -25,6 +25,7 @@ function delayedFs(seed: Record, delayMs = 15): FsPort { deleteFile: async (path: string) => { files.delete(path); }, + rename: async () => {}, }; } diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts index 67e2669b..5e97710a 100644 --- a/apps/core/src/ports/FsPort.ts +++ b/apps/core/src/ports/FsPort.ts @@ -7,4 +7,6 @@ export interface FsPort { listFiles(dir: string): Promise; /** Delete a file; missing files count as success (idempotent). */ deleteFile(path: string): Promise; + /** Rename/move a file or directory. Paths are POSIX. Missing source should reject. */ + rename(from: string, to: string): Promise; } From ea26d98cc2904c7a39ff3469217de56f933e2e2a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 02:13:11 +0800 Subject: [PATCH 057/214] feat(core): stub NetworkFsAdapter.rename as Not Implemented Co-authored-by: Cursor --- apps/core/src/adapters/network/NetworkFsAdapter.test.ts | 7 +++++++ apps/core/src/adapters/network/NetworkFsAdapter.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts index 44360ce3..c771cffe 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts @@ -107,4 +107,11 @@ describe("NetworkFsAdapter", () => { await expect(adapter.deleteFile("/m/denied.txt")).rejects.toThrow("Permission denied"); }); + + it("rename throws Not Implemented", async () => { + const { network } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await expect(adapter.rename("/m/a", "/m/b")).rejects.toThrow(/Not Implemented/i); + }); }); diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index ecdf4fe2..080aa25c 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -63,6 +63,6 @@ export class NetworkFsAdapter implements FsPort { } async rename(_from: string, _to: string): Promise { - throw new Error("Not Implemented"); + throw new Error("Not Implemented: NetworkFsAdapter.rename"); } } From 1651c4beb7138c872a823cd0ecd022150a6fdc08 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 02:15:47 +0800 Subject: [PATCH 058/214] feat(core): add Core.renameFolder happy path Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 58 +++++++++++++++++++++- apps/core/src/Core.ts | 12 +++++ apps/core/src/index.ts | 2 +- apps/core/src/pipeline/renameFolder.ts | 67 ++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 apps/core/src/pipeline/renameFolder.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index a539d717..27b1fafe 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -28,7 +28,19 @@ function inMemoryFs(seed: Record = {}): FsPort { deleteFile: vi.fn(async (path: string) => { files.delete(path); }), - rename: vi.fn(async () => {}), + rename: vi.fn(async (from: string, to: string) => { + if (!files.has(from) && ![...files.keys()].some((k) => k === from || k.startsWith(from + "/"))) { + throw new Error("ENOENT: " + from); + } + const entries = [...files.entries()]; + for (const [key, value] of entries) { + if (key === from || key.startsWith(from + "/")) { + files.delete(key); + files.set(to + key.slice(from.length), value); + } + } + // Ensure destination directory marker if only empty dirs matter — file keys alone are enough for these tests. + }), }; } @@ -345,3 +357,47 @@ describe("unimportFolder", () => { expect(saved.folders).toEqual([]); }); }); + +describe("renameFolder", () => { + it("updates metadata cache, user config, and renames on disk", async () => { + const from = "/m/Show"; + const to = "/m/Show Renamed"; + const oldCache = metadataCachePath("/data/smm", from); + const newCache = metadataCachePath("/data/smm", to); + const mm = { + mediaFolderPath: from, + type: "tvshow-folder" as const, + files: [`${from}/S01E01.mkv`], + mediaFiles: [{ absolutePath: `${from}/S01E01.mkv` }], + }; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: [from], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + [oldCache]: JSON.stringify(mm), + [`${from}/S01E01.mkv`]: "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.renameFolder({ from, to }); + + expect(await fs.exists(oldCache)).toBe(false); + expect(await fs.exists(newCache)).toBe(true); + const written = JSON.parse(await fs.readTextFile(newCache)) as typeof mm; + expect(written.mediaFolderPath).toBe(to); + expect(written.files).toEqual([`${to}/S01E01.mkv`]); + expect(written.mediaFiles?.[0]?.absolutePath).toBe(`${to}/S01E01.mkv`); + + const folders = await core.getFolders(); + // renameFolderInUserConfig stores Path.toPlatformPath(to) + expect(folders.map((f) => Path.posix(f))).toEqual([Path.posix(to)]); + + expect(fs.rename).toHaveBeenCalledWith(from, to); + expect(await fs.exists(`${to}/S01E01.mkv`)).toBe(true); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index e81c37fb..07bea8f7 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -7,10 +7,13 @@ import type { DiscoverPort } from "./ports/DiscoverPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; +import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; +export type { RenameFolderArgs }; + export interface CoreOptions { fs: FsPort; network: NetworkPort; @@ -135,6 +138,15 @@ export class Core { } } + async renameFolder(args: RenameFolderArgs): Promise { + await renameFolderPipeline(args, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (path) => this.normalizePosix(path), + }); + } + private normalizePosix(path: string): string { try { return Path.posix(path); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 6d3296a9..dc17b2c1 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -1,4 +1,4 @@ -export { Core, type CoreOptions, type ImportFolderHandle, type ImportFolderOptions } from "./Core"; +export { Core, type CoreOptions, type ImportFolderHandle, type ImportFolderOptions, type RenameFolderArgs } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; export type { LoggerPort } from "./ports/LoggerPort"; diff --git a/apps/core/src/pipeline/renameFolder.ts b/apps/core/src/pipeline/renameFolder.ts new file mode 100644 index 00000000..6c1737cc --- /dev/null +++ b/apps/core/src/pipeline/renameFolder.ts @@ -0,0 +1,67 @@ +import { Path } from "@core/path"; +import { renameFolderInMediaMetadata } from "@core/mediaMetadata"; +import { renameFolderInUserConfig } from "@core/userConfig"; +import type { MediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath } from "./paths"; +import type { UserConfig } from "./userConfig"; + +export interface RenameFolderArgs { + from: string; + to: string; +} + +export interface RenameFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +/** Mirrors packages/core-routes doRenameFolder orchestration (throws instead of { error }). */ +export async function renameFolderPipeline( + args: RenameFolderArgs, + deps: RenameFolderDeps, +): Promise { + const fromAsPosix = Path.posix(args.from); + const toAsPosix = Path.posix(args.to); + + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], args.from)) { + throw new Error(`${fromAsPosix} is not managed by SMM`); + } + + const cachePath = metadataCachePath(deps.appDataDir, fromAsPosix); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${args.from}`); + } + let mediaMetadata: MediaMetadata; + try { + mediaMetadata = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${args.from}`); + } + + const updatedMetadata = renameFolderInMediaMetadata(mediaMetadata, fromAsPosix, toAsPosix); + if (!updatedMetadata.mediaFolderPath) { + throw new Error("Media folder path is required"); + } + const newCachePath = metadataCachePath(deps.appDataDir, Path.posix(updatedMetadata.mediaFolderPath)); + await deps.fs.writeTextFile(newCachePath, JSON.stringify(updatedMetadata, null, 2)); + await deps.fs.deleteFile(cachePath); + + await deps.userConfig.update((current) => + renameFolderInUserConfig(current, fromAsPosix, toAsPosix), + ); + + await deps.fs.rename(fromAsPosix, toAsPosix); +} From 09c371cd9a1ed7e51d43e512ce7fe3ef3436a2c4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 02:18:22 +0800 Subject: [PATCH 059/214] test(core): cover renameFolder unmanaged and missing metadata Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 27b1fafe..709e0456 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -400,4 +400,43 @@ describe("renameFolder", () => { expect(fs.rename).toHaveBeenCalledWith(from, to); expect(await fs.exists(`${to}/S01E01.mkv`)).toBe(true); }); + + it("rejects when the folder is not managed", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: ["/m/Other"], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.renameFolder({ from: "/m/Show", to: "/m/X" })).rejects.toThrow( + "/m/Show is not managed by SMM", + ); + expect(fs.rename).not.toHaveBeenCalled(); + }); + + it("rejects when media metadata cache is missing", async () => { + const from = "/m/Show"; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: [from], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.renameFolder({ from, to: "/m/X" })).rejects.toThrow( + `Media metadata not found: ${from}`, + ); + expect(fs.rename).not.toHaveBeenCalled(); + }); }); From 22042d697b525fb6851b5fccb5a68bd9c03a7b92 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 14:00:14 +0800 Subject: [PATCH 060/214] docs: add UI v3 rename-folder design Co-authored-by: Cursor --- .../2026-08-19-ui-v3-rename-folder-design.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md diff --git a/docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md b/docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md new file mode 100644 index 00000000..63579be0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-ui-v3-rename-folder-design.md @@ -0,0 +1,49 @@ +# UI v3 rename via POST /api/rename-folder → Core.renameFolder + +## 1. Background + +Sidebar rename today calls `POST /api/renameFolder` → `packages/core-routes` `doRenameFolder`. Layer 2 now has `Core.renameFolder` with the same orchestration. Under `localStorage["smm.v3.enabled"] === "true"`, the UI should drive rename through Core (same pattern as `get-folders` / `unimport-folder`), without changing the legacy `/api/renameFolder` path used by non-v3 UI, MCP, and tools. + +## 2. Architecture + +``` +Sidebar RenameDialog + → useRenameMediaFolderMutation + ├─ v3 OFF → POST /api/renameFolder → core-routes doRenameFolder (unchanged) + └─ v3 ON → POST /api/rename-folder → getCore().renameFolder → FsPort + UserConfig + → refreshUiAfterFolderRename + invalidateFoldersQueryIfV3 + → socket broadcasts (same events as legacy rename) for listeners +``` + +| Layer | Change | +|-------|--------| +| `apps/core` | No change (already has `renameFolder`) | +| `apps/cli` | New `POST /api/rename-folder` route; register in `server.ts` | +| `apps/ui` | API client + mutation branches on `isSmmV3Enabled()` | +| `packages/core-routes` | Untouched | + +## 2.3 Key Design + +**HTTP** `POST /api/rename-folder` + +- Body: `{ from: string, to: string }` (absolute paths; platform or POSIX) +- Success: `{ data: { from, to } }` (echo request paths) +- Failure: `{ error: "Error Reason: …" }` (map `Core.renameFolder` throws) +- HTTP status: `200` for business success/failure (project API guideline) +- After success: broadcast `userConfigFolderRenamed` + `userConfigUpdated` (parity with legacy `RenameFolder.ts` so socket listeners keep working) + +**UI** + +- `isSmmV3Enabled()` → call new client; else existing `renameFolder` / `postRenameFolder` +- After successful rename: existing `refreshUiAfterFolderRename` **plus** `invalidateFoldersQueryIfV3` so `useFoldersQuery` picks up the new path + +## 3. Out of scope + +- Changing MCP / AI rename tools +- Removing or rewriting `POST /api/renameFolder` +- Browser `NetworkFsAdapter.rename` (still NYI) + +## 4. Testing + +- CLI route unit test: happy path + validation + Core throw → error body (TDD) +- UI mutation unit test if existing patterns cover v3 branching; otherwise thin test on API helper choice From 7d53acade5a30a3c5b4cda41383dc20e69c77349 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 14:33:37 +0800 Subject: [PATCH 061/214] feat: wire v3 folder rename through Core.renameFolder Add POST /api/rename-folder and UI Sidebar branching on smm.v3.enabled, share TestFolder fixtures in @smm/test, and consolidate CLI init helpers for rename e2e coverage. Co-authored-by: Cursor --- apps/cli/package.json | 1 + apps/cli/server.ts | 2 + apps/cli/src/cli/metadata.test.ts | 27 +++ apps/cli/src/cli/runCli.ts | 14 +- apps/cli/src/route/RenameFolderV3.test.ts | 131 +++++++++++++++ apps/cli/src/route/RenameFolderV3.ts | 83 +++++++++ apps/cli/test/README.md | 1 + apps/cli/test/helpers/testFolders.test.ts | 46 +++++ apps/cli/test/helpers/testFolders.ts | 146 ++++++++++------ apps/cli/test/rename-e2e.test.ts | 134 +++++++++++++++ apps/core/src/Core.test.ts | 78 +++++++++ apps/core/src/Core.ts | 13 ++ apps/e2e/test/actions/import-folders.ts | 158 ++++-------------- apps/ui/src/api/renameFolderV3.ts | 38 +++++ .../useRenameMediaFolderMutation.test.ts | 36 ++++ .../src/hooks/useRenameMediaFolderMutation.ts | 11 +- docs/api/index.md | 4 + .../plans/2026-08-19-core-rename-folder.md | 8 +- packages/core/types.ts | 4 + packages/test/package.json | 4 +- packages/test/src/index.ts | 15 ++ packages/test/src/testFolders.ts | 120 +++++++++++++ pnpm-lock.yaml | 6 + 23 files changed, 888 insertions(+), 192 deletions(-) create mode 100644 apps/cli/src/route/RenameFolderV3.test.ts create mode 100644 apps/cli/src/route/RenameFolderV3.ts create mode 100644 apps/cli/test/README.md create mode 100644 apps/cli/test/helpers/testFolders.test.ts create mode 100644 apps/cli/test/rename-e2e.test.ts create mode 100644 apps/ui/src/api/renameFolderV3.ts create mode 100644 packages/test/src/testFolders.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index e75103d2..51dd651a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,6 +19,7 @@ "@hono/node-server": "^1.19.0", "@modelcontextprotocol/sdk": "^1.25.3", "@smm/core-routes": "workspace:*", + "@smm/test": "workspace:*", "@smm/tvdb4": "workspace:*", "@types/shelljs": "^0.10.0", "ai": "^6.0.36", diff --git a/apps/cli/server.ts b/apps/cli/server.ts index a758b5ca..75388c6e 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -14,6 +14,7 @@ import { handleIsFolderAvailable } from './src/route/IsFolderAvailable'; import { handleWriteFile } from './src/route/WriteFile'; import { handleRenameFiles } from './src/route/RenameFiles'; import { handleRenameFolder } from './src/route/RenameFolder'; +import { handleRenameFolderV3 } from './src/route/RenameFolderV3'; import { handleGetEpisodesRoute } from './src/route/getEpisodes'; import { handleListFilesInMediaFolderRoute } from './src/route/listFilesInMediaFolder'; import { handleValidateRenameOperationsRoute } from './src/route/validateRenameOperations'; @@ -253,6 +254,7 @@ export class Server { handleWriteFile(this.app); handleRenameFiles(this.app); handleRenameFolder(this.app); + handleRenameFolderV3(this.app); handleGetEpisodesRoute(this.app); handleListFilesInMediaFolderRoute(this.app); handleValidateRenameOperationsRoute(this.app); diff --git a/apps/cli/src/cli/metadata.test.ts b/apps/cli/src/cli/metadata.test.ts index 6dd7741d..68ae6604 100644 --- a/apps/cli/src/cli/metadata.test.ts +++ b/apps/cli/src/cli/metadata.test.ts @@ -61,6 +61,33 @@ describe('smm metadata', () => { ) }) + it('writes metadata from --set JSON after skip-init add', async () => { + writeFileSync(join(mediaFolder, 'track.mp3'), 'x') + const { runCli } = await import('./runCli') + expect(await runCli(['node', 'smm', 'add', mediaFolder, '--type', 'music', '--skip-init'])).toBe(0) + logSpy.mockClear() + errorSpy.mockClear() + + const jsonFile = join(userDataDir, 'mm.json') + writeFileSync( + jsonFile, + JSON.stringify({ + type: 'music-folder', + mediaFiles: [{ absolutePath: join(mediaFolder, 'track.mp3') }], + }), + 'utf-8', + ) + + expect(await runCli(['node', 'smm', 'metadata', mediaFolder, '--set', jsonFile])).toBe(0) + logSpy.mockClear() + + expect(await runCli(['node', 'smm', 'metadata', mediaFolder])).toBe(0) + const text = output() + expect(text).toContain('type: music-folder') + expect(text).toContain('mediaFiles:') + expect(text).not.toContain('(empty)') + }) + it('prints human-readable metadata after a successful music import', async () => { writeFileSync(join(mediaFolder, 'track.mp3'), 'x') const { runCli } = await import('./runCli') diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 35cfdd41..e436e17a 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -1,5 +1,7 @@ import { Command, Option } from 'commander' +import { readFile } from 'node:fs/promises' import type { FolderType } from 'core-app' +import type { MediaMetadata } from '@smm/core' import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' import { waitUntilImportSettled } from './addProgress' @@ -131,15 +133,23 @@ export async function runCli(argv: string[] = process.argv): Promise { program .command('metadata') - .description('Show human-readable media metadata for an imported folder') + .description('Show or write media metadata for an imported folder') .argument('', 'Folder path') - .action(async (folder: string) => { + .option('--set ', 'Write media metadata from a JSON file') + .action(async (folder: string, opts: { set?: string }) => { try { if (!(await isFolderImported(folder))) { console.error(`Folder is not imported: ${folder}`) exitCode = 1 return } + if (opts.set !== undefined) { + const raw = await readFile(opts.set, 'utf-8') + const mm = JSON.parse(raw) as MediaMetadata + await getCore().setMetadata({ ...mm, mediaFolderPath: folder }) + console.log(`updated metadata for ${folder}`) + return + } const mm = await getCore().getMediaMetadata(folder) if (mm === null) { console.error(`No metadata cache for folder: ${folder}`) diff --git a/apps/cli/src/route/RenameFolderV3.test.ts b/apps/cli/src/route/RenameFolderV3.test.ts new file mode 100644 index 00000000..57f5214c --- /dev/null +++ b/apps/cli/src/route/RenameFolderV3.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { Path } from '@core/path' +import { handleRenameFolderV3 } from './RenameFolderV3' +import { resetCoreForTests } from '../core/getCore' + +vi.mock('@/events/userConfigUpdatedEvent', () => ({ + broadcastUserConfigFolderRenamedEvent: vi.fn(), +})) +vi.mock('@/utils/socketIO', () => ({ + broadcast: vi.fn(), +})) + +describe('POST /api/rename-folder', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-rename-folder-v3-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-rename-folder-v3-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleRenameFolderV3(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + async function post(body: unknown) { + return app.request('/api/rename-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('renames via Core and returns data.from/to', async () => { + const from = join(mediaDir, 'Show') + const to = join(mediaDir, 'Show Renamed') + mkdirSync(from) + writeFileSync(join(from, 'S01E01.mkv'), '') + + const fromPosix = Path.posix(from) + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ + folders: [from], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: 'plex', + }), + 'utf-8', + ) + const cacheName = fromPosix.replace(/[/\\:?*|<>"]/g, '_') + const cacheDir = join(userDataDir, 'metadata') + mkdirSync(cacheDir, { recursive: true }) + writeFileSync( + join(cacheDir, `${cacheName}.json`), + JSON.stringify({ + mediaFolderPath: fromPosix, + type: 'tvshow-folder', + files: [Path.posix(join(from, 'S01E01.mkv'))], + mediaFiles: [{ absolutePath: Path.posix(join(from, 'S01E01.mkv')) }], + }), + 'utf-8', + ) + resetCoreForTests() + + const res = await post({ from, to }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { from, to } }) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(to, 'S01E01.mkv'))).toBe(true) + + const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + folders: string[] + } + expect(saved.folders.map((f) => Path.posix(f))).toEqual([Path.posix(to)]) + }) + + it('returns Error Reason when from/to missing', async () => { + const res = await post({ from: '/a' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason:/) + }) + + it('returns Error Reason when folder is not managed', async () => { + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: 'plex', + }), + 'utf-8', + ) + resetCoreForTests() + + const res = await post({ from: '/media/Missing', to: '/media/X' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/not managed by SMM/) + }) +}) diff --git a/apps/cli/src/route/RenameFolderV3.ts b/apps/cli/src/route/RenameFolderV3.ts new file mode 100644 index 00000000..cf566d43 --- /dev/null +++ b/apps/cli/src/route/RenameFolderV3.ts @@ -0,0 +1,83 @@ +import { Path } from '@core/path' +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { broadcastUserConfigFolderRenamedEvent } from '@/events/userConfigUpdatedEvent' +import { broadcast } from '@/utils/socketIO' +import { logger } from '../../lib/logger' + +export interface RenameFolderV3RequestBody { + from: string + to: string +} + +export interface RenameFolderV3ResponseBody { + data?: { from: string; to: string } + error?: string +} + +/** + * Layer-2 rename: POST /api/rename-folder → Core.renameFolder. + * Keeps socket broadcasts so UI listeners stay in sync (parity with legacy /api/renameFolder). + */ +export function handleRenameFolderV3(app: Hono): void { + app.post('/api/rename-folder', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + + const from = + typeof body === 'object' && body !== null && 'from' in body + ? (body as { from: unknown }).from + : undefined + const to = + typeof body === 'object' && body !== null && 'to' in body + ? (body as { to: unknown }).to + : undefined + + if (typeof from !== 'string' || from.trim() === '') { + const err: RenameFolderV3ResponseBody = { + error: 'Error Reason: from is required', + } + return c.json(err, 200) + } + if (typeof to !== 'string' || to.trim() === '') { + const err: RenameFolderV3ResponseBody = { + error: 'Error Reason: to is required', + } + return c.json(err, 200) + } + + const clientId = c.req.header('clientId') + logger.info( + `[HTTP_IN] ${c.req.method} ${c.req.url} ${from} -> ${to} (clientId: ${clientId || 'not provided'})`, + ) + + await getCore().renameFolder({ from, to }) + + const fromAsPosix = Path.posix(from) + const toAsPosix = Path.posix(to) + broadcastUserConfigFolderRenamedEvent({ + from: Path.toPlatformPath(fromAsPosix), + to: Path.toPlatformPath(toAsPosix), + }) + broadcast({ + clientId: clientId ?? undefined, + event: 'userConfigUpdated', + data: {}, + }) + + const ok: RenameFolderV3ResponseBody = { data: { from, to } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/rename-folder] route error') + const err: RenameFolderV3ResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/test/README.md b/apps/cli/test/README.md new file mode 100644 index 00000000..eaa9b2d9 --- /dev/null +++ b/apps/cli/test/README.md @@ -0,0 +1 @@ +This folder holds e2e test for apps/cli. \ No newline at end of file diff --git a/apps/cli/test/helpers/testFolders.test.ts b/apps/cli/test/helpers/testFolders.test.ts new file mode 100644 index 00000000..71560740 --- /dev/null +++ b/apps/cli/test/helpers/testFolders.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './smm' +import { createAndImportInitializedFolder, musicFolder } from './testFolders' + +describe('createAndImportInitializedFolder', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-init-folder-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-init-folder-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it('skip-init imports then writes metadata that smm metadata can read', async () => { + const created = await createAndImportInitializedFolder(mediaDir, musicFolder, { + mediaMetadata: { + type: 'music-folder', + mediaFiles: [], + }, + }) + + expect(created.path).toBeDefined() + const listed = await smm(['list']) + expect(listed.code).toBe(0) + expect(listed.stdout.split('\n').filter(Boolean)).toContain(created.path) + + const meta = await smm(['metadata', created.path!]) + expect(meta.code, meta.stderr || meta.stdout).toBe(0) + expect(meta.stdout).toContain('type: music-folder') + }) +}) diff --git a/apps/cli/test/helpers/testFolders.ts b/apps/cli/test/helpers/testFolders.ts index c83cad9e..12bec03f 100644 --- a/apps/cli/test/helpers/testFolders.ts +++ b/apps/cli/test/helpers/testFolders.ts @@ -1,63 +1,105 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect } from 'vitest' +import { Path } from '@core/path' +import type { MediaMetadata } from '@smm/core' +import { getCore } from '../../src/core/getCore' +import { smm } from './smm' -/** - * Mirrors `apps/e2e/test/actions/import-folders.ts` TestFolder fixtures - * so CLI e2e uses the same folder names, files, and media ids. - */ -export interface TestFolder { - folderName: string - mediaName?: string - files: string[] - type: 'tvshow' | 'movie' | 'music' - path?: string +export { + type LangCode, + type TestFolder, + folder1, + folder2, + folder3, + folder4, + folder5, + folder6, + musicFolder, + tvShowFolder, + movieFolder, + createFolderInTestFolder, +} from '@smm/test' + +import { createFolderInTestFolder, type TestFolder } from '@smm/test' + +const helpersDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(helpersDir, '../../../..') +const MEDIA_METADATA_TEMPLATES_DIR = join(repoRoot, 'test', 'templates', 'mediaMetadatas') + +/** Same sanitization as Core `metadataCachePath` / core-routes cache. */ +export function metadataCachePath(userDataDir: string, folderPathInPosix: string): string { + const filename = folderPathInPosix.replace(/[/\\:?*|<>"]/g, '_') + return join(userDataDir, 'metadata', `${filename}.json`) } -/** Same as e2e `folder1` — TMDB id in the folder name. */ -export const tvShowFolder: TestFolder = { - folderName: '天使降临到我身边! (2019) {tmdbid=84666}', - mediaName: '天使降临到我身边!', - files: [ - 'S01E01.mkv', - 'S01E01.jpg', - 'S01E01.sc.ass', - 'S01E01.tc.ass', - 'S01E01.nfo', - 'S01E02.mkv', - 'S01E02.jpg', - 'S01E02.sc.ass', - 'S01E02.tc.ass', - 'S01E02.nfo', - 'S01E03.mkv', - 'S01E03.jpg', - 'S01E03.sc.ass', - 'S01E03.tc.ass', - 'S01E03.nfo', - ], - type: 'tvshow', +/** Sibling path with " - Renamed" suffix (same naming as e2e RenameFolder). */ +export function renamedFolderPath(folderPath: string, folderName: string): string { + return join(dirname(folderPath), `${folderName} - Renamed`) } -/** Same as e2e `folder5` — TVDB id in the folder name. */ -export const movieFolder: TestFolder = { - folderName: 'The Dark Knight {tvdbid=116}', - mediaName: '蝙蝠侠:黑暗骑士', - files: ['The Dark Knight [1080P].mkv'], - type: 'movie', +function loadTemplate(templateFileName: string): MediaMetadata { + const templatePath = join(MEDIA_METADATA_TEMPLATES_DIR, templateFileName) + return JSON.parse(readFileSync(templatePath, 'utf-8')) as MediaMetadata } -/** Music fixture (same idea as MusicPanel template `BilibiliMusic`). */ -export const musicFolder: TestFolder = { - folderName: 'BilibiliMusic', - files: ['01.mp3'], - type: 'music', +function rewritePathsForFolder(mediaMetadata: MediaMetadata, folderPath: string): MediaMetadata { + const folderPosix = Path.posix(folderPath) + return { + ...mediaMetadata, + mediaFolderPath: folderPosix, + mediaFiles: mediaMetadata.mediaFiles?.map((file) => ({ + ...file, + absolutePath: Path.posix(join(folderPath, file.absolutePath)), + })), + } +} + +export type CreateAndImportInitializedFolderOptions = { + /** Explicit metadata. When omitted, loads `templateFileName` (or default TV template). */ + mediaMetadata?: MediaMetadata + templateFileName?: string + updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata } -/** Create empty placeholder files under `mediaDir/`. */ -export function createFolderInTestFolder(mediaDir: string, folder: TestFolder): TestFolder { - const testMediaFolder = path.join(mediaDir, folder.folderName) - fs.mkdirSync(testMediaFolder, { recursive: true }) - for (const file of folder.files) { - fs.writeFileSync(path.join(testMediaFolder, file), '') +/** + * Create on-disk files from a TestFolder fixture, `smm add --skip-init`, + * then seed metadata via `Core.setMetadata` (no recognition / TMDB). + */ +export async function createAndImportInitializedFolder( + mediaDir: string, + folder: TestFolder, + options: CreateAndImportInitializedFolderOptions = {}, +): Promise { + if (!process.env.USER_DATA_DIR) { + throw new Error('createAndImportInitializedFolder: USER_DATA_DIR must be set') + } + + const created = createFolderInTestFolder(mediaDir, folder) + const folderPath = created.path! + + const added = await smm(['add', folderPath, '--type', created.type, '--skip-init']) + expect(added.code, added.stderr || added.stdout).toBe(0) + + let mediaMetadata: MediaMetadata + if (options.mediaMetadata) { + mediaMetadata = { + ...options.mediaMetadata, + mediaFolderPath: Path.posix(folderPath), + } + } else { + const templateFileName = options.templateFileName ?? '天使降临到我身边.metadata.json' + mediaMetadata = rewritePathsForFolder(loadTemplate(templateFileName), folderPath) + mediaMetadata.files = created.files.map((file) => Path.posix(join(folderPath, file))) } - return { ...folder, path: testMediaFolder } + + if (options.updateMediaMetadata) { + mediaMetadata = options.updateMediaMetadata(mediaMetadata) + } + mediaMetadata.mediaFolderPath = Path.posix(folderPath) + + await getCore().setMetadata(mediaMetadata) + + return created } diff --git a/apps/cli/test/rename-e2e.test.ts b/apps/cli/test/rename-e2e.test.ts new file mode 100644 index 00000000..eee40e30 --- /dev/null +++ b/apps/cli/test/rename-e2e.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Path } from '@core/path' +import { getCore, resetCoreForTests } from '../src/core/getCore' +import { smm } from './helpers/smm' +import { + createAndImportInitializedFolder, + metadataCachePath, + movieFolder, + renamedFolderPath, + tvShowFolder, +} from './helpers/testFolders' + +describe('smm renameFolder CLI e2e (pre-initialized import)', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-e2e-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-e2e-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it('renames a pre-initialized tvshow folder and rewrites metadata paths', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) + const from = folder.path! + const to = renamedFolderPath(from, folder.folderName) + + await getCore().renameFolder({ from, to }) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(to, 'S01E01.mkv'))).toBe(true) + + const listed = await smm(['list']) + expect(listed.code).toBe(0) + expect(listed.stdout.split('\n').filter(Boolean).map((p) => Path.posix(p))).toEqual([ + Path.posix(to), + ]) + + expect(existsSync(metadataCachePath(userDataDir, Path.posix(from)))).toBe(false) + expect(existsSync(metadataCachePath(userDataDir, Path.posix(to)))).toBe(true) + + const mm = await getCore().getMediaMetadata(to) + expect(mm).not.toBeNull() + expect(mm!.type).toBe('tvshow-folder') + expect(mm!.mediaFolderPath).toBe(Path.posix(to)) + expect(mm!.tvShow?.id).toBe('84666') + expect(mm!.mediaFiles).toEqual([ + { + absolutePath: Path.posix(join(to, 'S01E01.mkv')), + seasonNumber: 1, + episodeNumber: 1, + }, + { + absolutePath: Path.posix(join(to, 'S01E02.mkv')), + seasonNumber: 1, + episodeNumber: 2, + }, + { + absolutePath: Path.posix(join(to, 'S01E03.mkv')), + seasonNumber: 1, + episodeNumber: 3, + }, + ]) + }) + + it('renames a pre-initialized movie folder and rewrites metadata paths', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { + updateMediaMetadata: (mediaMetadata) => { + const folderPath = Path.toPlatformPath(mediaMetadata.mediaFolderPath!) + return { + ...mediaMetadata, + type: 'movie-folder', + tvShow: undefined, + mediaFiles: movieFolder.files.map((file) => ({ + absolutePath: Path.posix(join(folderPath, file)), + })), + movie: { + database: 'TVDB', + id: '116', + name: 'The Dark Knight', + }, + } + }, + }) + const from = folder.path! + const to = renamedFolderPath(from, folder.folderName) + + const seeded = await getCore().getMediaMetadata(from) + expect(seeded?.type).toBe('movie-folder') + expect(seeded?.movie?.id).toBe('116') + expect(seeded?.mediaFiles?.[0]?.absolutePath).toBe( + Path.posix(join(from, 'The Dark Knight [1080P].mkv')), + ) + + await getCore().renameFolder({ from, to }) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(to, 'The Dark Knight [1080P].mkv'))).toBe(true) + + const listed = await smm(['list']) + expect(listed.code).toBe(0) + expect(listed.stdout.split('\n').filter(Boolean).map((p) => Path.posix(p))).toEqual([ + Path.posix(to), + ]) + + const mm = await getCore().getMediaMetadata(to) + expect(mm).not.toBeNull() + expect(mm!.type).toBe('movie-folder') + expect(mm!.mediaFolderPath).toBe(Path.posix(to)) + expect(mm!.movie?.id).toBe('116') + expect(mm!.tvShow).toBeUndefined() + expect(mm!.mediaFiles).toEqual([ + { + absolutePath: Path.posix(join(to, 'The Dark Knight [1080P].mkv')), + }, + ]) + }) +}) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 709e0456..f2cc9181 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -256,6 +256,84 @@ describe("getFolders", () => { }); }); +describe("setMetadata", () => { + const cache = metadataCachePath("/data/smm", "/m/Show"); + + it("persists metadata and strips files so getMediaMetadata round-trips without files", async () => { + const fs = inMemoryFs(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + const mm = { + mediaFolderPath: "/m/Show", + type: "tvshow-folder" as const, + files: ["/m/Show/S01E01.mkv"], + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv" }], + }; + + await core.setMetadata(mm); + + const written = JSON.parse(await fs.readTextFile(cache)) as Record; + expect(written).toEqual({ + mediaFolderPath: "/m/Show", + type: "tvshow-folder", + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv" }], + }); + expect(written).not.toHaveProperty("files"); + expect(await core.getMediaMetadata("/m/Show")).toEqual(written); + }); + + it("fully replaces an existing cache file", async () => { + const fs = inMemoryFs({ + [cache]: JSON.stringify({ mediaFolderPath: "/m/Show", type: "tvshow-folder", tvShow: { name: "Old" } }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.setMetadata({ + mediaFolderPath: "/m/Show", + type: "movie-folder", + movie: { id: "1", name: "New", database: "TMDB" }, + }); + + expect(await core.getMediaMetadata("/m/Show")).toEqual({ + mediaFolderPath: "/m/Show", + type: "movie-folder", + movie: { id: "1", name: "New", database: "TMDB" }, + }); + }); + + it("rejects missing mediaFolderPath without writing", async () => { + const fs = inMemoryFs(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.setMetadata({ type: "tvshow-folder" })).rejects.toThrow("Media folder path is required"); + expect(fs.writeTextFile).not.toHaveBeenCalled(); + }); + + it("rejects an empty mediaFolderPath without writing", async () => { + const fs = inMemoryFs(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await expect(core.setMetadata({ mediaFolderPath: "", type: "tvshow-folder" })).rejects.toThrow( + "Media folder path is required", + ); + expect(fs.writeTextFile).not.toHaveBeenCalled(); + }); + + it("uses the same cache key as getMediaMetadata for a Windows path", async () => { + const stored = "C:\\Movies\\Show"; + const winCache = metadataCachePath("/data/smm", Path.posix(stored)); + const fs = inMemoryFs(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); + + await core.setMetadata({ mediaFolderPath: stored, type: "tvshow-folder" }); + + expect(await fs.exists(winCache)).toBe(true); + expect(await core.getMediaMetadata(stored)).toEqual({ + mediaFolderPath: stored, + type: "tvshow-folder", + }); + }); +}); + describe("getMediaMetadata", () => { const cache = metadataCachePath("/data/smm", "/m/Show"); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 07bea8f7..96961f48 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -123,6 +123,19 @@ export class Core { } } + /** Writes the metadata cache for `mm.mediaFolderPath`. Strips `files`. Full replace. */ + async setMetadata(mm: MediaMetadata): Promise { + if (!mm.mediaFolderPath) { + throw new Error("Media folder path is required"); + } + const posixPath = this.normalizePosix(mm.mediaFolderPath); + const { files: _files, ...toPersist } = mm; + await this.fs.writeTextFile( + metadataCachePath(this.appDataDir, posixPath), + JSON.stringify(toPersist, null, 2), + ); + } + /** Removes a folder from the user config and deletes its metadata cache. Idempotent. */ async unimportFolder(path: string): Promise { const posixPath = this.normalizePosix(path); diff --git a/apps/e2e/test/actions/import-folders.ts b/apps/e2e/test/actions/import-folders.ts index da9d137f..a7ae0c39 100644 --- a/apps/e2e/test/actions/import-folders.ts +++ b/apps/e2e/test/actions/import-folders.ts @@ -2,139 +2,39 @@ import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' import Menu from '../componentobjects/Menu' +import { + createFolderInTestFolder as createFolderInMediaDir, + type TestFolder, +} from '@smm/test' + +export { + type LangCode, + type TestFolder, + folder1, + folder2, + folder3, + folder4, + folder5, + folder6, + musicFolder, + tvShowFolder, + movieFolder, +} from '@smm/test' const tmpMediaRoot = path.join(os.tmpdir(), 'smm-test-media') const mediaDir = path.join(tmpMediaRoot, 'media') -export type LangCode = 'en-US' | 'zh-CN' - -export interface TestFolder { - folderName: string, - mediaName?: string, - files: string[], - type: "tvshow" | "movie" | "music" - path?: string - translations?: Record> -} - -export const folder1: TestFolder = { - folderName: "天使降临到我身边! (2019) {tmdbid=84666}", - mediaName: '天使降临到我身边!', - translations: { - title: { - 'en-US': 'WATATEN!: an Angel Flew Down to Me', - 'zh-CN': '天使降临到我身边!' - } - }, - files: [ - "S01E01.mkv", - "S01E01.jpg", - "S01E01.sc.ass", - "S01E01.tc.ass", - "S01E01.nfo", - "S01E02.mkv", - "S01E02.jpg", - "S01E02.sc.ass", - "S01E02.tc.ass", - "S01E02.nfo", - "S01E03.mkv", - "S01E03.jpg", - "S01E03.sc.ass", - "S01E03.tc.ass", - "S01E03.nfo", - ], - type: "tvshow" -} - -export const folder2: TestFolder = { - folderName: "咒术回战 涩谷事变×死灭回游 剧场版", - mediaName: "咒术回战 涩谷事变×死灭回游 剧场版", - files: [ - "movie.mkv", - ], - translations: { - title: { - 'en-US': 'JUJUTSU KAISEN: Execution', - 'zh-CN': '咒术回战 涩谷事变×死灭回游 剧场版' - } - }, - type: "movie" -} - -export const folder3: TestFolder = { - folderName: "我推的孩子", - mediaName: "我推的孩子", - files: [ - "S01E01.mkv", - ], - type: "tvshow" -} - -export const folder4: TestFolder = { - folderName: "我推的孩子 {tvdbid=421069}", - mediaName: "我推的孩子", - files: [ - "S01E01.mkv", - ], - type: "tvshow" -} - -export const folder5: TestFolder = { - folderName: "The Dark Knight {tvdbid=116}", - mediaName: "蝙蝠侠:黑暗骑士", - translations: { - title: { - 'en-US': 'The Dark Knight', - 'zh-CN': '蝙蝠侠:黑暗骑士' - } - }, - files: [ - "The Dark Knight [1080P].mkv", - ], - type: "movie" -} - -export const folder6: TestFolder = { - folderName: "天使降临到我身边! (2019) {tmdbid=84666}", - mediaName: '天使降临到我身边!', - translations: { - title: { - 'en-US': 'WATATEN!: an Angel Flew Down to Me', - 'zh-CN': '天使降临到我身边!' - } - }, - files: [ - "S01E01.mkv", - "S01E01.jpg", - "S01E01.sc.ass", - "S01E01.tc.ass", - "S01E01.nfo", - "S01E02.mkv", - "S01E02.jpg", - "S01E02.sc.ass", - "S01E02.tc.ass", - "S01E02.nfo", - "S01E03.mkv", - "S01E03.jpg", - "S01E03.sc.ass", - "S01E03.tc.ass", - "S01E03.nfo", - ], - type: "tvshow" -} - +/** + * Materialize a shared TestFolder under the e2e tmp media root. + * Mutates `folder.path` for backward compatibility with existing e2e callers. + */ export function createFolderInTestFolder(folder: TestFolder) { - const testMediaFolder = path.join(mediaDir, folder.folderName) - fs.mkdirSync(testMediaFolder, { recursive: true }) - - for (const file of folder.files) { - const filePath = path.join(testMediaFolder, file) - fs.writeFileSync(filePath, '') - } - - console.log(`Created test folder "${folder.folderName}" with ${folder.files.length} files:`, testMediaFolder) - - folder.path = testMediaFolder + const created = createFolderInMediaDir(mediaDir, folder) + console.log( + `Created test folder "${folder.folderName}" with ${folder.files.length} files:`, + created.path, + ) + folder.path = created.path return folder } @@ -204,4 +104,4 @@ export function renameFileInFolder(folderName: string, oldFileName: string, newF } fs.renameSync(oldPath, newPath) console.log(`Renamed "${oldFileName}" to "${newFileName}" in ${folderName}`) -} \ No newline at end of file +} diff --git a/apps/ui/src/api/renameFolderV3.ts b/apps/ui/src/api/renameFolderV3.ts new file mode 100644 index 00000000..b6c280e3 --- /dev/null +++ b/apps/ui/src/api/renameFolderV3.ts @@ -0,0 +1,38 @@ +import { apiFetch } from '@/lib/apiFetch' + +export interface RenameFolderV3Params { + from: string + to: string +} + +export interface RenameFolderV3ResponseBody { + data?: { from: string; to: string } + error?: string +} + +/** Layer-2 rename via Core (`POST /api/rename-folder`). Used when SMM v3 is enabled. */ +export async function renameFolderV3( + params: RenameFolderV3Params, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/rename-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: params.from, to: params.to }), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as RenameFolderV3ResponseBody +} + +/** Throws on business error. */ +export async function renameFolderViaCore(params: RenameFolderV3Params): Promise { + const data = await renameFolderV3(params) + if (data.error) { + throw new Error(data.error) + } +} diff --git a/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts b/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts index 4261404a..67ff8daf 100644 --- a/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts +++ b/apps/ui/src/hooks/useRenameMediaFolderMutation.test.ts @@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useRenameMediaFolderMutation } from "./useRenameMediaFolderMutation" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { renameFolder } from "@/api/renameFolder" +import { renameFolderViaCore } from "@/api/renameFolderV3" +import { isSmmV3Enabled } from "@/lib/localStorages" import { helloQueryKey } from "@/lib/appQueryKeys" import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" @@ -16,6 +18,15 @@ vi.mock("sonner", () => ({ toast: { error: vi.fn() } })) vi.mock("@/api/renameFolder", () => ({ renameFolder: vi.fn().mockResolvedValue({}), })) +vi.mock("@/api/renameFolderV3", () => ({ + renameFolderViaCore: vi.fn().mockResolvedValue(undefined), +})) +vi.mock("@/lib/localStorages", () => ({ + isSmmV3Enabled: vi.fn().mockReturnValue(false), +})) +vi.mock("@/hooks/folders/invalidateFoldersQuery", () => ({ + invalidateFoldersQueryIfV3: vi.fn(), +})) vi.mock("@/stores/uiMediaFolderStore") describe("useRenameMediaFolderMutation", () => { @@ -24,12 +35,14 @@ describe("useRenameMediaFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(isSmmV3Enabled).mockReturnValue(false) vi.mocked(useUIMediaFolderStore).mockReturnValue({ folders: [{ path, status: "idle", test: false }], setFolders: vi.fn(), setSelectedFolder: vi.fn(), } as unknown as ReturnType) vi.mocked(renameFolder).mockResolvedValue({}) + vi.mocked(renameFolderViaCore).mockResolvedValue(undefined) }) it("renames folder and refreshes userConfig + mediaMetadata queries", async () => { @@ -52,6 +65,7 @@ describe("useRenameMediaFolderMutation", () => { await waitFor(() => { expect(renameFolder).toHaveBeenCalledTimes(1) }) + expect(renameFolderViaCore).not.toHaveBeenCalled() expect(renameFolder).toHaveBeenCalledWith({ from: path, to: "/media/New", @@ -68,6 +82,27 @@ describe("useRenameMediaFolderMutation", () => { }) }) + it("uses Core rename-folder API when SMM v3 is enabled", async () => { + vi.mocked(isSmmV3Enabled).mockReturnValue(true) + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useRenameMediaFolderMutation(), { + wrapper, + }) + + await result.current.mutateAsync({ mediaFolderPath: path, newName }) + + expect(renameFolderViaCore).toHaveBeenCalledWith({ + from: path, + to: "/media/New", + }) + expect(renameFolder).not.toHaveBeenCalled() + }) + it("fails when folder is missing", async () => { vi.mocked(useUIMediaFolderStore).mockReturnValue({ folders: [], @@ -88,5 +123,6 @@ describe("useRenameMediaFolderMutation", () => { result.current.mutateAsync({ mediaFolderPath: path, newName }) ).rejects.toThrow(/Media folder not found/) expect(renameFolder).not.toHaveBeenCalled() + expect(renameFolderViaCore).not.toHaveBeenCalled() }) }) diff --git a/apps/ui/src/hooks/useRenameMediaFolderMutation.ts b/apps/ui/src/hooks/useRenameMediaFolderMutation.ts index aaa46f4d..f9e04fb7 100644 --- a/apps/ui/src/hooks/useRenameMediaFolderMutation.ts +++ b/apps/ui/src/hooks/useRenameMediaFolderMutation.ts @@ -2,7 +2,10 @@ import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/ import { toast } from 'sonner' import { useTranslation } from '@/lib/i18n' import { renameFolder } from '@/api/renameFolder' +import { renameFolderViaCore } from '@/api/renameFolderV3' import { refreshUiAfterFolderRename } from '@/lib/refreshUiAfterFolderRename' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { invalidateFoldersQueryIfV3 } from '@/hooks/folders/invalidateFoldersQuery' import { useUIMediaFolderStore } from '@/stores/uiMediaFolderStore' import { dirname, join } from '@/lib/path' @@ -13,6 +16,7 @@ export interface RenameMediaFolderVariables { /** * Renames a media folder via API and refreshes client state. + * When `smm.v3.enabled` is on, uses `POST /api/rename-folder` → Core.renameFolder. */ export function useRenameMediaFolderMutation( options?: Omit< @@ -34,7 +38,11 @@ export function useRenameMediaFolderMutation( } const newFolderPath = join(dirname(mediaFolderPath), newName) - await renameFolder({ from: mediaFolderPath, to: newFolderPath }) + if (isSmmV3Enabled()) { + await renameFolderViaCore({ from: mediaFolderPath, to: newFolderPath }) + } else { + await renameFolder({ from: mediaFolderPath, to: newFolderPath }) + } await refreshUiAfterFolderRename({ queryClient, @@ -44,6 +52,7 @@ export function useRenameMediaFolderMutation( from: mediaFolderPath, to: newFolderPath, }) + invalidateFoldersQueryIfV3(queryClient) }, onError: (error, variables, context, mutation) => { userOnError?.(error, variables, context, mutation) diff --git a/docs/api/index.md b/docs/api/index.md index 33164155..cf6aedb7 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -70,6 +70,10 @@ HTTP: `POST /api/folder-metadata` — MediaMetadata for an imported folder via ` Source Code: apps/cli/src/route/UnimportFolder.ts HTTP: `POST /api/unimport-folder` — removes an imported media folder from `userDataDir/smm.json` and deletes its metadata cache via Layer 2 `Core.unimportFolder(path)`. Request body: `{ path: string }`. Response: `{ data: { path } }` or `{ error }`. Idempotent when the path is not in the config. Used by UI delete (context menu, Delete key, multi-select) when `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm rm`. +## RenameFolder (v3) +Source Code: apps/cli/src/route/RenameFolderV3.ts +HTTP: `POST /api/rename-folder` — renames a managed media folder via Layer 2 `Core.renameFolder({ from, to })` (metadata cache + `UserConfig.folders` + on-disk rename). Request body: `{ from: string, to: string }`. Response: `{ data: { from, to } }` or `{ error }`. Broadcasts the same folder-renamed / userConfigUpdated socket events as legacy `POST /api/renameFolder`. Used by UI Sidebar rename when `localStorage["smm.v3.enabled"] === "true"`. + ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts HTTP: `POST /api/setWatchedFolder` — sets the single media folder the CLI `FolderWatcher` listens to (UI primary `selectedFolder`). Request body: `{ folderPath: string | null }` (platform absolute path, or null/empty to stop watching). Response: `{ data: { watchedFolder: string | null }, error?: string }`. Startup no longer watches all imported folders. diff --git a/docs/superpowers/plans/2026-08-19-core-rename-folder.md b/docs/superpowers/plans/2026-08-19-core-rename-folder.md index 435de815..080b4f75 100644 --- a/docs/superpowers/plans/2026-08-19-core-rename-folder.md +++ b/docs/superpowers/plans/2026-08-19-core-rename-folder.md @@ -349,13 +349,7 @@ import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFo Export `RenameFolderArgs` from `apps/core/src/index.ts` alongside `Core`. -Check `@smm/core/mediaMetadata` and `@smm/core/userConfig` resolve in `apps/core` the same way other packages do (if import path fails, use the package’s existing alias pattern from `apps/core` tsconfig / vitest — e.g. relative into `packages/core` only if already done elsewhere; prefer the same import style as `Path` from `@core/path`). - -If `@smm/core/mediaMetadata` is not wired for core-app, either: -- add vitest/tsconfig alias matching monorepo convention, **or** -- import from a path that already works in this package. - -Verify with a quick look at how `packages/core-routes` imports them and how `apps/core` currently imports `@smm/core` types. +Use `@core/mediaMetadata` and `@core/userConfig` (same `@core/*` → `packages/core/*` alias as `Path`). Do **not** use `@smm/core/mediaMetadata` in apps/core — tsconfig maps `@smm/core` only to `types.ts`. - [ ] **Step 5: Run happy-path test** diff --git a/packages/core/types.ts b/packages/core/types.ts index da9c4e94..7a92324d 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -595,6 +595,10 @@ export interface MediaMetadata { mediaFolderPath?: string, /** + * @deprecated + * + * DO NOT USE this field anymore, it will be removed in the future. + * * The absolute paths of files (all files, media files, subtitle files, poster files, etc.) in media folder * The path is in POSIX format * string[] - the files in the folder, empyt array means there is no files in the folder. diff --git a/packages/test/package.json b/packages/test/package.json index 980f580c..8377c663 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -4,7 +4,8 @@ "type": "module", "main": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./testFolders": "./src/testFolders.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -15,6 +16,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "@smm/core": "workspace:*", "dotenv": "^17.3.1" } } diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 41b13d12..ac9ef90c 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -3,6 +3,21 @@ * * This package provides shared testing utilities for SMM tests. */ +export { + type LangCode, + type TestFolder, + folder1, + folder2, + folder3, + folder4, + folder5, + folder6, + musicFolder, + tvShowFolder, + movieFolder, + createFolderInTestFolder, +} from './testFolders' + import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' diff --git a/packages/test/src/testFolders.ts b/packages/test/src/testFolders.ts new file mode 100644 index 00000000..47300da8 --- /dev/null +++ b/packages/test/src/testFolders.ts @@ -0,0 +1,120 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' + +export type LangCode = 'en-US' | 'zh-CN' + +/** + * Shared test media folder fixture used by apps/e2e and apps/cli. + * `path` is filled when the folder is materialized on disk. + */ +export interface TestFolder { + folderName: string + mediaName?: string + files: string[] + type: 'tvshow' | 'movie' | 'music' + path?: string + translations?: Record> +} + +/** TMDB-tagged TV show (天使降临到我身边). */ +export const folder1: TestFolder = { + folderName: '天使降临到我身边! (2019) {tmdbid=84666}', + mediaName: '天使降临到我身边!', + translations: { + title: { + 'en-US': 'WATATEN!: an Angel Flew Down to Me', + 'zh-CN': '天使降临到我身边!', + }, + }, + files: [ + 'S01E01.mkv', + 'S01E01.jpg', + 'S01E01.sc.ass', + 'S01E01.tc.ass', + 'S01E01.nfo', + 'S01E02.mkv', + 'S01E02.jpg', + 'S01E02.sc.ass', + 'S01E02.tc.ass', + 'S01E02.nfo', + 'S01E03.mkv', + 'S01E03.jpg', + 'S01E03.sc.ass', + 'S01E03.tc.ass', + 'S01E03.nfo', + ], + type: 'tvshow', +} + +/** Movie without database id in the folder name. */ +export const folder2: TestFolder = { + folderName: '咒术回战 涩谷事变×死灭回游 剧场版', + mediaName: '咒术回战 涩谷事变×死灭回游 剧场版', + files: ['movie.mkv'], + translations: { + title: { + 'en-US': 'JUJUTSU KAISEN: Execution', + 'zh-CN': '咒术回战 涩谷事变×死灭回游 剧场版', + }, + }, + type: 'movie', +} + +/** TV show without database id in the folder name. */ +export const folder3: TestFolder = { + folderName: '我推的孩子', + mediaName: '我推的孩子', + files: ['S01E01.mkv'], + type: 'tvshow', +} + +/** TVDB-tagged TV show. */ +export const folder4: TestFolder = { + folderName: '我推的孩子 {tvdbid=421069}', + mediaName: '我推的孩子', + files: ['S01E01.mkv'], + type: 'tvshow', +} + +/** TVDB-tagged movie. */ +export const folder5: TestFolder = { + folderName: 'The Dark Knight {tvdbid=116}', + mediaName: '蝙蝠侠:黑暗骑士', + translations: { + title: { + 'en-US': 'The Dark Knight', + 'zh-CN': '蝙蝠侠:黑暗骑士', + }, + }, + files: ['The Dark Knight [1080P].mkv'], + type: 'movie', +} + +/** Same fixture as {@link folder1} (kept as a separate export for existing e2e imports). */ +export const folder6: TestFolder = { ...folder1 } + +/** Music panel fixture (CLI / music templates). */ +export const musicFolder: TestFolder = { + folderName: 'BilibiliMusic', + files: ['01.mp3'], + type: 'music', +} + +/** Alias of {@link folder1} for CLI readability. */ +export const tvShowFolder: TestFolder = folder1 + +/** Alias of {@link folder5} for CLI readability. */ +export const movieFolder: TestFolder = folder5 + +/** + * Create empty placeholder files under `mediaDir/`. + * Returns a new object with `path` set; does not mutate the input fixture. + */ +export function createFolderInTestFolder(mediaDir: string, folder: TestFolder): TestFolder { + const testMediaFolder = path.join(mediaDir, folder.folderName) + fs.mkdirSync(testMediaFolder, { recursive: true }) + for (const file of folder.files) { + fs.writeFileSync(path.join(testMediaFolder, file), '') + } + return { ...folder, path: testMediaFolder } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d20fa20..4d199e68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@smm/core-routes': specifier: workspace:* version: link:../../packages/core-routes + '@smm/test': + specifier: workspace:* + version: link:../../packages/test '@smm/tvdb4': specifier: workspace:* version: link:../../packages/tvdb4 @@ -724,6 +727,9 @@ importers: packages/test: dependencies: + '@smm/core': + specifier: workspace:* + version: link:../core dotenv: specifier: ^17.3.1 version: 17.3.1 From c45e78d2d10b9078b8eb8f3a7d287699b01cf3c1 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 14:59:39 +0800 Subject: [PATCH 062/214] docs: add Core tryToRecognizeFolder / applyPlan design Co-authored-by: Cursor --- ...-19-core-try-to-recognize-folder-design.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-core-try-to-recognize-folder-design.md diff --git a/docs/superpowers/specs/2026-08-19-core-try-to-recognize-folder-design.md b/docs/superpowers/specs/2026-08-19-core-try-to-recognize-folder-design.md new file mode 100644 index 00000000..ab99c906 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-core-try-to-recognize-folder-design.md @@ -0,0 +1,152 @@ +# Core.tryToRecognizeFolder / applyPlan + +This design document describes migrating rule-based episode recognition (TvShowPanel `useRuleBasedRecognizeFlow`) into Layer 2 `apps/core`, exposing CLI commands for operators. UI and existing HTTP plan APIs stay unchanged in this milestone. + +## 1. Background + +Sidebar / TvShowPanel **rule-based recognize** today: + +1. UI creates a `recognize-media-file` plan (`preparing`) via `POST /api/createPlan`. +2. `buildTemporaryRecognitionPlanAsync` runs `recognizeEpisodesAsync` against `MediaMetadata.files` + `tvShow.seasons`. +3. Plan moves to `pending` for user review; confirm calls `applyRecognizeMediaFilePlan` (rewrites `mediaFiles`) and marks plan `completed` (file deleted). + +Layer 2 already has a synchronous `recognizeEpisodes` used by import. Operators need the same “preview plan → apply” loop without the UI. This change adds **Core + CLI only**. + +**Constraints (from product):** + +- Scope: Core + CLI. No UI / `smm.v3` / new HTTP routes this round. +- Do not edit `packages/core-routes` plan HTTP handlers or UI recognize hooks. +- Reuse `apps/core` `recognizeEpisodes`; do not re-port UI Worker copy. +- Zero matches → return a **pending** plan with `files: []` (do not throw solely for empty matches). +- `MediaMetadata.files` is deprecated: list files via `FsPort.listFiles` at recognize time (in-memory for matching only; do not persist `files`). + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +apps/ui useRuleBasedRecognizeFlow ──unchanged──► POST /api/createPlan|updatePlan + │ +packages/core-routes Plans ──unchanged──► {userDataDir}/plans/*.plan.json + │ +apps/cli smm try-to-recognize / smm apply ──new──► Core + │ +apps/core Core.tryToRecognizeFolder / getPlan / applyPlan ──new──► FsPort + recognizeEpisodes +``` + +Same on-disk plan layout as today: `{appDataDir}/plans/{id}.plan.json`. + +## 2.2 App Level Architecture + +| Piece | Role | +|-------|------| +| `Core.tryToRecognizeFolder(path)` | Managed check → metadata → listFiles → recognizeEpisodes → write pending plan → return | +| `Core.getPlan(id)` | Read plan JSON (CLI `apply`) | +| `Core.applyPlan(plan)` | This milestone: only `recognize-media-file` → merge `mediaFiles` → `setMetadata` → delete plan file | +| `pipeline/plans.ts` (new) | Path helpers + read/write/delete via `FsPort` | +| `pipeline/applyRecognizeMediaFilePlan.ts` (new) | Pure merge of plan files into `mediaFiles` (port of UI `updateMediaFileMetadatas` semantics) | +| Existing `recognizeEpisodes` | Pattern match SxxEyy / 第x季第y集 / single-season numeric | + +**`tryToRecognizeFolder` step order:** + +1. Normalize `path` to posix; assert managed (`UserConfig.folders`). +2. `getMediaMetadata(path)`; fail if missing. +3. Require `type === "tvshow-folder"` (or equivalent) and `tvShow.seasons` with episodes; otherwise throw. +4. `fs.listFiles(path)` → posix paths; build in-memory `mm` with `files` for `recognizeEpisodes` only. +5. `recognizeEpisodes(mm)` → `RecognizedFile[]` (may be empty). +6. Create plan: `id` (uuid), `task: "recognize-media-file"`, `status: "pending"`, `creator: "app"`, `mediaFolderPath`, `files` (possibly `[]`). +7. Persist plan file; return plan. + +**`applyPlan` (recognize-media-file only):** + +1. Validate `plan.task === "recognize-media-file"`. +2. Load metadata for `plan.mediaFolderPath`; fail if missing. +3. For each `plan.files` entry, merge into `mediaFiles` (same rules as UI `updateMediaFileMetadatas`: one path ↔ one S/E). +4. `setMetadata` (still strips deprecated `files` on write). +5. Delete plan file (terminal `completed`, same as core-routes `updatePlan`). + +Empty `files: []` apply is a no-op on `mediaFiles` but still completes/deletes the plan. + +## 2.3 Key Design + +### Core signatures + +```ts +tryToRecognizeFolder(path: string): Promise +getPlan(id: string): Promise // RecognizeMediaFilePlan | RenameFilesPlan +applyPlan(plan: Plan): Promise +``` + +- Prefer async (I/O). Return type of `tryToRecognizeFolder` is specifically `RecognizeMediaFilePlan`. +- `applyPlan` accepts the union `Plan`; unsupported tasks throw. + +### Errors (throw) + +| Case | Message shape (illustrative) | +|------|------------------------------| +| Not managed | `{path} is not managed by SMM` | +| No metadata | `Media metadata not found: {path}` | +| Not TV / no seasons | `Folder is not a TV show with episodes: {path}` | +| Plan missing | `Plan not found: {id}` | +| Unsupported apply task | `Unsupported plan task: {task}` | + +Empty recognition is **not** an error. + +### CLI + +```bash +smm try-to-recognize +# stdout: plan id + detail (including files: [] case) + +smm apply +# load via getPlan → applyPlan; print summary +``` + +### Out of scope + +- UI / v3 switch / HTTP wrappers +- AI recognize (`creator: "ai"`) +- `applyPlan` for `rename-files` +- Changing import pipeline + +## 3. User Stories + +### 3.1 Try recognize with matches + +* **Given** - an imported tvshow folder with metadata seasons/episodes and videos named `S01E01.mkv` … +* **When** - `Core.tryToRecognizeFolder(path)` runs +* **Then** - a pending plan file exists with matching `files`, and the returned plan matches the file + +```mermaid +sequenceDiagram + participant CLI + participant Core + participant Fs as FsPort + participant Rec as recognizeEpisodes + + CLI->>Core: tryToRecognizeFolder(path) + Core->>Core: managed + getMediaMetadata + Core->>Fs: listFiles(path) + Core->>Rec: recognizeEpisodes(mm') + Rec-->>Core: [{season,episode,file}, ...] + Core->>Fs: writeTextFile(plans/id.plan.json) + Core-->>CLI: RecognizeMediaFilePlan +``` + +### 3.2 Try recognize with zero matches + +* **Given** - imported tvshow metadata but no pattern-matching video names +* **When** - `tryToRecognizeFolder(path)` runs +* **Then** - returns pending plan with `files: []` and persists that plan (no throw) + +### 3.3 Apply recognize plan + +* **Given** - a pending recognize plan with one or more files +* **When** - `applyPlan(plan)` runs +* **Then** - metadata `mediaFiles` updated, plan file deleted + +### 3.4 CLI round-trip + +* **Given** - same as 3.1 +* **When** - `smm try-to-recognize` then `smm apply ` +* **Then** - exit 0; `getMediaMetadata` reflects mappings; plan file gone From 443b20bfcaf9900e9b18e93c45716bb72367949d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:03:21 +0800 Subject: [PATCH 063/214] docs: add Core tryToRecognizeFolder implementation plan Co-authored-by: Cursor --- ...2026-08-19-core-try-to-recognize-folder.md | 664 ++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-core-try-to-recognize-folder.md diff --git a/docs/superpowers/plans/2026-08-19-core-try-to-recognize-folder.md b/docs/superpowers/plans/2026-08-19-core-try-to-recognize-folder.md new file mode 100644 index 00000000..6cbe60d5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-core-try-to-recognize-folder.md @@ -0,0 +1,664 @@ +# Core.tryToRecognizeFolder / applyPlan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Core.tryToRecognizeFolder`, `Core.getPlan`, and `Core.applyPlan` (recognize-media-file only) plus CLI `smm try-to-recognize` / `smm apply`, without changing UI or core-routes plan HTTP APIs. + +**Architecture:** Persist plans under `{appDataDir}/plans/{id}.plan.json` via `FsPort`. List on-disk files with `FsPort.listFiles`, match with existing `recognizeEpisodes`, merge `mediaFiles` with a port of UI `updateMediaFileMetadatas` semantics, then delete the plan file on apply. + +**Tech Stack:** TypeScript, Vitest, `apps/core` ports/adapters, Commander CLI in `apps/cli`, `@smm/core` plan types. + +**Spec:** `docs/superpowers/specs/2026-08-19-core-try-to-recognize-folder-design.md` + +## Global Constraints + +- Do **not** modify `apps/ui/**` or `packages/core-routes/**` source. +- Scope is **Core + CLI only** (no HTTP / v3 / UI wiring). +- Reuse `apps/core/src/pipeline/recognizeEpisodes.ts`; do not re-port UI Worker `recognizeEpisodesAsync`. +- Zero matches → return / persist pending plan with `files: []` (do **not** throw solely for empty matches). +- Do **not** persist deprecated `MediaMetadata.files` (listFiles is in-memory for matching only; `setMetadata` already strips `files`). +- `applyPlan` this milestone supports only `task === "recognize-media-file"`; other tasks throw. +- Core methods throw `Error` (CLI prints message, exit 1). + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/pipeline/paths.ts` | Add `planFilePath(appDataDir, id)` | +| `apps/core/src/pipeline/updateMediaFileMetadatas.ts` | Pure merge helper (UI parity) | +| `apps/core/src/pipeline/updateMediaFileMetadatas.test.ts` | Unit tests for merge | +| `apps/core/src/pipeline/plans.ts` | Read/write/delete plan JSON via `FsPort` | +| `apps/core/src/pipeline/plans.test.ts` | Plan storage tests | +| `apps/core/src/pipeline/tryToRecognizeFolder.ts` | Orchestration for try-to-recognize | +| `apps/core/src/pipeline/applyPlan.ts` | Orchestration for apply (recognize only) | +| `apps/core/src/Core.ts` | Public `tryToRecognizeFolder` / `getPlan` / `applyPlan` | +| `apps/core/src/Core.test.ts` | Integration-style Core tests | +| `apps/core/src/index.ts` | Export `Plan` type alias if useful | +| `apps/cli/src/cli/runCli.ts` | Register `try-to-recognize` and `apply` | +| `apps/cli/test/recognize-e2e.test.ts` | CLI round-trip e2e | +| `docs/api/index.md` | Brief CLI note (optional; include in Task 5) | + +--- + +### Task 1: Pure `updateMediaFileMetadatas` + +**Files:** +- Create: `apps/core/src/pipeline/updateMediaFileMetadatas.ts` +- Create: `apps/core/src/pipeline/updateMediaFileMetadatas.test.ts` + +**Interfaces:** +- Produces: `updateMediaFileMetadatas(mediaFiles, videoFilePath, seasonNumber, episodeNumber): MediaFileMetadata[]` +- Semantics: remove any entry with the same season/episode **or** same `absolutePath`, then append `{ absolutePath, seasonNumber, episodeNumber }` (paths stored as POSIX via `Path.posix`). + +- [ ] **Step 1: Write the failing test** + +Create `apps/core/src/pipeline/updateMediaFileMetadatas.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; + +describe("updateMediaFileMetadatas", () => { + it("adds a new mapping", () => { + const next = updateMediaFileMetadatas([], "/m/Show/S01E01.mkv", 1, 1); + expect(next).toEqual([ + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + ]); + }); + + it("replaces same season/episode and same path", () => { + const prev = [ + { absolutePath: "/m/Show/old.mkv", seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + ]; + const next = updateMediaFileMetadatas(prev, "/m/Show/S01E01.mkv", 1, 1); + expect(next).toEqual([ + { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/updateMediaFileMetadatas.test.ts` + +Expected: FAIL — module not found / cannot resolve `./updateMediaFileMetadatas`. + +- [ ] **Step 3: Minimal implementation** + +Create `apps/core/src/pipeline/updateMediaFileMetadatas.ts`: + +```typescript +import { Path } from "@core/path"; +import type { MediaFileMetadata } from "@smm/core"; + +/** Same semantics as apps/ui TvShowPanelUtils.updateMediaFileMetadatas. */ +export function updateMediaFileMetadatas( + mediaFiles: MediaFileMetadata[], + videoFilePath: string, + seasonNumber: number, + episodeNumber: number, +): MediaFileMetadata[] { + const absolutePath = Path.posix(videoFilePath); + const next = mediaFiles + .filter((m) => !(m.seasonNumber === seasonNumber && m.episodeNumber === episodeNumber)) + .filter((m) => m.absolutePath !== absolutePath); + next.push({ absolutePath, seasonNumber, episodeNumber }); + return next; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/updateMediaFileMetadatas.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/updateMediaFileMetadatas.ts \ + apps/core/src/pipeline/updateMediaFileMetadatas.test.ts +git commit -m "feat(core): add updateMediaFileMetadatas for recognize apply" +``` + +--- + +### Task 2: Plan file path + FsPort storage helpers + +**Files:** +- Modify: `apps/core/src/pipeline/paths.ts` +- Create: `apps/core/src/pipeline/plans.ts` +- Create: `apps/core/src/pipeline/plans.test.ts` + +**Interfaces:** +- Produces: + - `planFilePath(appDataDir: string, planId: string): string` → `{appDataDir}/plans/{id}.plan.json` (POSIX via `joinPosix`) + - `type Plan = RecognizeMediaFilePlan | RenameFilesPlan` + - `writePlan(fs, appDataDir, plan): Promise` + - `readPlan(fs, appDataDir, id): Promise` + - `deletePlan(fs, appDataDir, id): Promise` (idempotent via `fs.deleteFile`) + +- [ ] **Step 1: Write the failing test** + +Create `apps/core/src/pipeline/plans.test.ts` (reuse a tiny in-memory `FsPort` like `Core.test.ts`, or import a shared fake if you extract one — duplicating the Map fake in this file is fine): + +```typescript +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; +import { planFilePath } from "./paths"; +import { deletePlan, readPlan, writePlan } from "./plans"; + +function inMemoryFs(): FsPort & { raw: Map } { + const files = new Map(); + return { + raw: files, + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async () => {}), + }; +} + +describe("plans storage", () => { + it("writes and reads a recognize plan", async () => { + const fs = inMemoryFs(); + const plan: RecognizeMediaFilePlan = { + id: "p1", + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [{ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }], + }; + await writePlan(fs, "/data", plan); + expect(fs.raw.has(planFilePath("/data", "p1"))).toBe(true); + await expect(readPlan(fs, "/data", "p1")).resolves.toEqual(plan); + }); + + it("deletePlan is idempotent", async () => { + const fs = inMemoryFs(); + await deletePlan(fs, "/data", "missing"); + expect(fs.deleteFile).toHaveBeenCalledWith(planFilePath("/data", "missing")); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/plans.test.ts` + +Expected: FAIL — missing `./plans` or `planFilePath`. + +- [ ] **Step 3: Minimal implementation** + +In `paths.ts` add: + +```typescript +/** `/plans/{id}.plan.json`, POSIX form (same layout as core-routes). */ +export function planFilePath(appDataDir: string, planId: string): string { + return joinPosix(Path.posix(appDataDir), "plans", `${planId}.plan.json`); +} +``` + +Create `plans.ts`: + +```typescript +import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import type { FsPort } from "../ports/FsPort"; +import { planFilePath } from "./paths"; + +export type Plan = RecognizeMediaFilePlan | RenameFilesPlan; + +export async function writePlan(fs: FsPort, appDataDir: string, plan: Plan): Promise { + await fs.writeTextFile(planFilePath(appDataDir, plan.id), JSON.stringify(plan, null, 2)); +} + +export async function readPlan( + fs: FsPort, + appDataDir: string, + id: string, +): Promise { + const path = planFilePath(appDataDir, id); + if (!(await fs.exists(path))) return null; + try { + return JSON.parse(await fs.readTextFile(path)) as Plan; + } catch { + return null; + } +} + +export async function deletePlan(fs: FsPort, appDataDir: string, id: string): Promise { + await fs.deleteFile(planFilePath(appDataDir, id)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/plans.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/paths.ts \ + apps/core/src/pipeline/plans.ts \ + apps/core/src/pipeline/plans.test.ts +git commit -m "feat(core): add plan file storage helpers" +``` + +--- + +### Task 3: `tryToRecognizeFolder` pipeline + Core method + +**Files:** +- Create: `apps/core/src/pipeline/tryToRecognizeFolder.ts` +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` +- Modify: `apps/core/src/index.ts` (export if needed) + +**Interfaces:** +- Consumes: `UserConfig.read`, `FsPort.listFiles` / `writeTextFile`, `recognizeEpisodes`, `writePlan`, `metadataCachePath` / metadata read +- Produces: `tryToRecognizeFolderPipeline(path, deps): Promise` +- Produces: `Core.tryToRecognizeFolder(path: string): Promise` + +**Deps shape:** + +```typescript +export interface TryToRecognizeFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + /** Injected for tests; default `randomUUID` from `node:crypto`. */ + createId?: () => string; +} +``` + +**Behavior (must match spec):** + +1. `isManaged` same compare as `renameFolder.ts` (`Path.toPlatformPath` + `Path.posix`). +2. Throw `{posix} is not managed by SMM` if unmanaged. +3. Read metadata cache; throw `Media metadata not found: {path}` if missing/corrupt. +4. If `type !== "tvshow-folder"` OR no `tvShow.seasons` with at least one episode → throw `Folder is not a TV show with episodes: {path}` (use original `path` or posix consistently; prefer the caller `path` string in the message like renameFolder’s metadata miss). +5. `listed = await fs.listFiles(posixPath)` → map each with `Path.posix`. +6. `recognizeEpisodes({ ...mm, mediaFolderPath: posixPath, files: listedPosix })`. +7. Map results to `RecognizedFile[]` (`path: Path.posix(file)`, season, episode). Empty array allowed. +8. Build plan: `status: "pending"`, `creator: "app"`, `task: "recognize-media-file"`, `id: createId()`. +9. `writePlan`; return plan. + +- [ ] **Step 1: Write the failing Core tests** + +Append to `Core.test.ts` a new `describe("tryToRecognizeFolder", () => { ... })` using existing `inMemoryFs` / `emptyNetwork`. Seed: + +- `userConfigPath("/data")` with `folders: ["/m/Show"]` +- metadata cache for `/m/Show` with `type: "tvshow-folder"`, `tvShow: { id: "1", name: "Show", seasons: [{ season: 1, episodes: [{ season: 1, episode: 1 }, { season: 1, episode: 2 }] }] }`, `mediaFiles: []` +- Disk keys: `"/m/Show/S01E01.mkv": ""`, `"/m/Show/S01E02.mkv": ""` (so `listFiles` returns them) + +```typescript +describe("tryToRecognizeFolder", () => { + it("creates a pending plan with matched files", async () => { + // seed as above; use vi.spyOn or inject createId via making Core call pipeline — + // Prefer testing through Core: stub randomUUID by exporting createId only on pipeline + // and calling Core which uses randomUUID — assert plan.id is uuid-shaped OR + // read whatever id is returned. + const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data" }); + const plan = await core.tryToRecognizeFolder("/m/Show"); + expect(plan.task).toBe("recognize-media-file"); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("app"); + expect(plan.files).toEqual([ + { season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }, + { season: 1, episode: 2, path: "/m/Show/S01E02.mkv" }, + ]); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(true); + }); + + it("returns pending plan with empty files when nothing matches", async () => { + // same metadata seasons, but only "/m/Show/random.mkv" + const plan = await core.tryToRecognizeFolder("/m/Show"); + expect(plan.files).toEqual([]); + expect(plan.status).toBe("pending"); + }); + + it("rejects unmanaged folders", async () => { + await expect(core.tryToRecognizeFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); + }); +}); +``` + +Also import `planFilePath` from `./pipeline/paths`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts -t tryToRecognizeFolder` + +Expected: FAIL — `tryToRecognizeFolder is not a function`. + +- [ ] **Step 3: Implement pipeline + Core wrapper** + +Create `tryToRecognizeFolder.ts` implementing the steps above (copy `isManaged` from `renameFolder.ts` into this file or a tiny shared helper — duplicating the 8-line function is OK for this milestone). + +In `Core.ts`: + +```typescript +import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; +import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; + +async tryToRecognizeFolder(path: string): Promise { + return tryToRecognizeFolderPipeline(path, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + }); +} +``` + +Use `import { randomUUID } from "node:crypto"` inside the pipeline default `createId`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts -t tryToRecognizeFolder` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/tryToRecognizeFolder.ts \ + apps/core/src/Core.ts \ + apps/core/src/Core.test.ts \ + apps/core/src/index.ts +git commit -m "feat(core): add Core.tryToRecognizeFolder" +``` + +--- + +### Task 4: `getPlan` + `applyPlan` + +**Files:** +- Create: `apps/core/src/pipeline/applyPlan.ts` +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` +- Modify: `apps/core/src/index.ts` — export `type Plan` from `./pipeline/plans` + +**Interfaces:** +- Consumes: `readPlan`, `deletePlan`, `updateMediaFileMetadatas`, `getMediaMetadata`/`setMetadata` via deps +- Produces: + - `Core.getPlan(id: string): Promise` — throws `Plan not found: {id}` if null + - `Core.applyPlan(plan: Plan): Promise` + +**`applyPlan` behavior:** + +1. If `plan.task !== "recognize-media-file"` → throw `Unsupported plan task: {task}`. +2. Load metadata for `plan.mediaFolderPath`; throw `Media metadata not found: {path}` if missing. +3. Start from `mediaMetadata.mediaFiles ?? []`; for each `plan.files` entry call `updateMediaFileMetadatas(..., file.path, file.season, file.episode)`. +4. `setMetadata({ ...mm, mediaFiles })` (via writing cache the same way as `Core.setMetadata`, or call through deps). +5. `deletePlan(fs, appDataDir, plan.id)`. +6. Empty `files` still deletes the plan. + +Prefer implementing apply in `applyPlan.ts` with deps `{ fs, appDataDir, getMediaMetadata, setMetadata }` **or** inline metadata read/write like rename pipeline — pick one style and stay consistent with `tryToRecognizeFolder.ts` (inject `fs` + `appDataDir` + `normalizePosix` and read/write cache directly is fine). + +- [ ] **Step 1: Write the failing tests** + +In `Core.test.ts`: + +```typescript +describe("applyPlan", () => { + it("merges mediaFiles and deletes the plan file", async () => { + const core = /* seeded Show with empty mediaFiles + pending plan already written OR tryToRecognize first */; + const plan = await core.tryToRecognizeFolder("/m/Show"); + await core.applyPlan(plan); + const mm = await core.getMediaMetadata("/m/Show"); + expect(mm?.mediaFiles).toEqual([ + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + ]); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); + }); + + it("applies empty files plan as no-op on mediaFiles but deletes plan", async () => { + // seed unmatched videos; tryToRecognize → files []; apply + const before = await core.getMediaMetadata("/m/Show"); + const plan = await core.tryToRecognizeFolder("/m/Show"); + expect(plan.files).toEqual([]); + await core.applyPlan(plan); + const after = await core.getMediaMetadata("/m/Show"); + expect(after?.mediaFiles).toEqual(before?.mediaFiles ?? []); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); + }); + + it("getPlan throws when missing", async () => { + await expect(core.getPlan("nope")).rejects.toThrow("Plan not found: nope"); + }); + + it("rejects unsupported tasks", async () => { + await expect( + core.applyPlan({ + id: "r1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [], + }), + ).rejects.toThrow(/Unsupported plan task/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts -t "applyPlan|getPlan"` + +Expected: FAIL — methods missing. + +- [ ] **Step 3: Implement** + +```typescript +// Core.ts +import { applyRecognizeMediaFilePlanPipeline } from "./pipeline/applyPlan"; +import { readPlan, type Plan } from "./pipeline/plans"; + +async getPlan(id: string): Promise { + const plan = await readPlan(this.fs, this.appDataDir, id); + if (!plan) throw new Error(`Plan not found: ${id}`); + return plan; +} + +async applyPlan(plan: Plan): Promise { + await applyRecognizeMediaFilePlanPipeline(plan, { + fs: this.fs, + appDataDir: this.appDataDir, + normalizePosix: (p) => this.normalizePosix(p), + setMetadata: (mm) => this.setMetadata(mm), + getMediaMetadata: (folder) => this.getMediaMetadata(folder), + }); +} +``` + +In `applyPlan.ts`, implement the recognize-only branch and `deletePlan` after success. + +Export `Plan` from `index.ts`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter core-app exec vitest run src/Core.test.ts` + +Expected: PASS (full Core suite still green). + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/applyPlan.ts \ + apps/core/src/Core.ts \ + apps/core/src/Core.test.ts \ + apps/core/src/index.ts +git commit -m "feat(core): add Core.getPlan and applyPlan for recognize" +``` + +--- + +### Task 5: CLI `try-to-recognize` / `apply` + e2e + +**Files:** +- Modify: `apps/cli/src/cli/runCli.ts` +- Create: `apps/cli/test/recognize-e2e.test.ts` +- Modify: `docs/api/index.md` (short “CLI recognize” note under a sensible heading, or append near RenameFolder v3) + +**Interfaces:** +- Consumes: `getCore().tryToRecognizeFolder` / `getPlan` / `applyPlan` +- CLI: + - `smm try-to-recognize ` + - `smm apply ` + +- [ ] **Step 1: Write the failing e2e test** + +Create `apps/cli/test/recognize-e2e.test.ts` mirroring `rename-e2e.test.ts` setup (`USER_DATA_DIR`, `createAndImportInitializedFolder`, `tvShowFolder`). + +```typescript +it("try-to-recognize then apply updates mediaFiles", async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }, { + updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }), + }); + const path = folder.path!; + + const tried = await smm(["try-to-recognize", path]); + expect(tried.code, tried.stderr || tried.stdout).toBe(0); + expect(tried.stdout).toMatch(/plan:\s+[0-9a-f-]{36}/i); + const planId = tried.stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1]; + expect(planId).toBeTruthy(); + + const applied = await smm(["apply", planId!]); + expect(applied.code, applied.stderr || applied.stdout).toBe(0); + + const mm = await getCore().getMediaMetadata(path); + expect(mm?.mediaFiles?.length).toBeGreaterThan(0); + expect(mm?.mediaFiles?.[0]?.seasonNumber).toBe(1); +}); +``` + +(Adjust assertions to match real template seasons/files in `天使降临到我身边.metadata.json` + `tvShowFolder` file names — if template seasons do not include S01E01.. from fixture, either use `updateMediaMetadata` to set seasons that match fixture names, or assert against whatever `recognizeEpisodes` returns for that fixture.) + +- [ ] **Step 2: Run e2e to verify it fails** + +Run: `pnpm --filter cli exec vitest run test/recognize-e2e.test.ts` + +Expected: FAIL — unknown command `try-to-recognize` / exit !== 0. + +- [ ] **Step 3: Implement CLI commands** + +In `runCli.ts`, after `rm` (or near metadata): + +```typescript + program + .command('try-to-recognize') + .description('Build a pending recognize-media-file plan for a TV show folder') + .argument('', 'Imported media folder path') + .action(async (folder: string) => { + try { + const plan = await getCore().tryToRecognizeFolder(folder) + console.log(`plan: ${plan.id}`) + console.log(`task: ${plan.task}`) + console.log(`status: ${plan.status}`) + console.log(`folder: ${plan.mediaFolderPath}`) + console.log('files:') + if (plan.files.length === 0) { + console.log(' (none)') + } else { + for (const f of plan.files) { + const ep = `S${String(f.season).padStart(2, '0')}E${String(f.episode).padStart(2, '0')}` + console.log(` ${ep} ${f.path}`) + } + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + + program + .command('apply') + .description('Apply a pending plan by id (recognize-media-file)') + .argument('', 'Plan id from try-to-recognize') + .action(async (planId: string) => { + try { + const plan = await getCore().getPlan(planId) + await getCore().applyPlan(plan) + const count = plan.task === 'recognize-media-file' ? plan.files.length : 0 + console.log(`applied ${plan.id} (${count} file(s))`) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) +``` + +Add a short section to `docs/api/index.md`: + +```markdown +## CLI: try-to-recognize / apply +Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / applyPlan +`smm try-to-recognize ` — rule-based episode recognition → pending plan under `{userDataDir}/plans/`. +`smm apply ` — apply recognize-media-file plan (updates metadata cache, deletes plan file). +``` + +- [ ] **Step 4: Run e2e to verify it passes** + +Run: `pnpm --filter cli exec vitest run test/recognize-e2e.test.ts` + +Expected: PASS + +Also run: `pnpm --filter core-app exec vitest run src/Core.test.ts src/pipeline/` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/cli/src/cli/runCli.ts \ + apps/cli/test/recognize-e2e.test.ts \ + docs/api/index.md +git commit -m "feat(cli): add smm try-to-recognize and apply" +``` + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| `tryToRecognizeFolder` + pending plan | 3 | +| Zero matches → `files: []` pending | 3 | +| `getPlan` / `applyPlan` recognize only | 4 | +| listFiles in-memory; no persist `files` | 3–4 (`setMetadata` strips) | +| Reuse `recognizeEpisodes` | 3 | +| CLI try-to-recognize / apply | 5 | +| No UI / core-routes edits | Global | +| Plan path `{appDataDir}/plans/{id}.plan.json` | 2 | +| Empty apply deletes plan | 4 | + +## Type consistency + +- `Plan` = `RecognizeMediaFilePlan | RenameFilesPlan` in `pipeline/plans.ts` +- `tryToRecognizeFolder` returns `RecognizeMediaFilePlan` +- `RecognizedFile.path` / `MediaFileMetadata.absolutePath` are POSIX +- Error strings: `not managed by SMM`, `Media metadata not found:`, `Folder is not a TV show with episodes:`, `Plan not found:`, `Unsupported plan task:` From de0fb741704b5a1911d0ef483546fcdb5882d78f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:09:44 +0800 Subject: [PATCH 064/214] feat(core): add updateMediaFileMetadatas for recognize apply Co-authored-by: Cursor --- .../pipeline/updateMediaFileMetadatas.test.ts | 23 +++++++++++++++++++ .../src/pipeline/updateMediaFileMetadatas.ts | 17 ++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 apps/core/src/pipeline/updateMediaFileMetadatas.test.ts create mode 100644 apps/core/src/pipeline/updateMediaFileMetadatas.ts diff --git a/apps/core/src/pipeline/updateMediaFileMetadatas.test.ts b/apps/core/src/pipeline/updateMediaFileMetadatas.test.ts new file mode 100644 index 00000000..cb2cc35d --- /dev/null +++ b/apps/core/src/pipeline/updateMediaFileMetadatas.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; + +describe("updateMediaFileMetadatas", () => { + it("adds a new mapping", () => { + const next = updateMediaFileMetadatas([], "/m/Show/S01E01.mkv", 1, 1); + expect(next).toEqual([ + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + ]); + }); + + it("replaces same season/episode and same path", () => { + const prev = [ + { absolutePath: "/m/Show/old.mkv", seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + ]; + const next = updateMediaFileMetadatas(prev, "/m/Show/S01E01.mkv", 1, 1); + expect(next).toEqual([ + { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + ]); + }); +}); diff --git a/apps/core/src/pipeline/updateMediaFileMetadatas.ts b/apps/core/src/pipeline/updateMediaFileMetadatas.ts new file mode 100644 index 00000000..d52d2546 --- /dev/null +++ b/apps/core/src/pipeline/updateMediaFileMetadatas.ts @@ -0,0 +1,17 @@ +import { Path } from "@core/path"; +import type { MediaFileMetadata } from "@smm/core"; + +/** Same semantics as apps/ui TvShowPanelUtils.updateMediaFileMetadatas. */ +export function updateMediaFileMetadatas( + mediaFiles: MediaFileMetadata[], + videoFilePath: string, + seasonNumber: number, + episodeNumber: number, +): MediaFileMetadata[] { + const absolutePath = Path.posix(videoFilePath); + const next = mediaFiles + .filter((m) => !(m.seasonNumber === seasonNumber && m.episodeNumber === episodeNumber)) + .filter((m) => m.absolutePath !== absolutePath); + next.push({ absolutePath, seasonNumber, episodeNumber }); + return next; +} From 71a309ffb05807f677369b677e3f03e1ff7fb5b0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:12:18 +0800 Subject: [PATCH 065/214] feat(core): add plan file storage helpers Co-authored-by: Cursor --- apps/core/src/pipeline/paths.ts | 5 +++ apps/core/src/pipeline/plans.test.ts | 49 ++++++++++++++++++++++++++++ apps/core/src/pipeline/plans.ts | 28 ++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 apps/core/src/pipeline/plans.test.ts create mode 100644 apps/core/src/pipeline/plans.ts diff --git a/apps/core/src/pipeline/paths.ts b/apps/core/src/pipeline/paths.ts index 8e8606db..fe4d0775 100644 --- a/apps/core/src/pipeline/paths.ts +++ b/apps/core/src/pipeline/paths.ts @@ -20,6 +20,11 @@ export function userConfigPath(appDataDir: string): string { return joinPosix(Path.posix(appDataDir), "smm.json"); } +/** `/plans/{id}.plan.json`, POSIX form (same layout as core-routes). */ +export function planFilePath(appDataDir: string, planId: string): string { + return joinPosix(Path.posix(appDataDir), "plans", `${planId}.plan.json`); +} + /** * Metadata cache file for a media folder, mirroring `metadataCacheFilePath` * in `apps/ui/src/api/readMediaMetadataV2.ts`. POSIX form. diff --git a/apps/core/src/pipeline/plans.test.ts b/apps/core/src/pipeline/plans.test.ts new file mode 100644 index 00000000..cfd911d7 --- /dev/null +++ b/apps/core/src/pipeline/plans.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; +import { planFilePath } from "./paths"; +import { deletePlan, readPlan, writePlan } from "./plans"; + +function inMemoryFs(): FsPort & { raw: Map } { + const files = new Map(); + return { + raw: files, + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async () => {}), + }; +} + +describe("plans storage", () => { + it("writes and reads a recognize plan", async () => { + const fs = inMemoryFs(); + const plan: RecognizeMediaFilePlan = { + id: "p1", + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [{ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }], + }; + await writePlan(fs, "/data", plan); + expect(fs.raw.has(planFilePath("/data", "p1"))).toBe(true); + await expect(readPlan(fs, "/data", "p1")).resolves.toEqual(plan); + }); + + it("deletePlan is idempotent", async () => { + const fs = inMemoryFs(); + await deletePlan(fs, "/data", "missing"); + expect(fs.deleteFile).toHaveBeenCalledWith(planFilePath("/data", "missing")); + }); +}); diff --git a/apps/core/src/pipeline/plans.ts b/apps/core/src/pipeline/plans.ts new file mode 100644 index 00000000..699ce0c4 --- /dev/null +++ b/apps/core/src/pipeline/plans.ts @@ -0,0 +1,28 @@ +import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import type { FsPort } from "../ports/FsPort"; +import { planFilePath } from "./paths"; + +export type Plan = RecognizeMediaFilePlan | RenameFilesPlan; + +export async function writePlan(fs: FsPort, appDataDir: string, plan: Plan): Promise { + await fs.writeTextFile(planFilePath(appDataDir, plan.id), JSON.stringify(plan, null, 2)); +} + +export async function readPlan( + fs: FsPort, + appDataDir: string, + id: string, +): Promise { + const path = planFilePath(appDataDir, id); + if (!(await fs.exists(path))) return null; + try { + return JSON.parse(await fs.readTextFile(path)) as Plan; + } catch { + return null; + } +} + +export async function deletePlan(fs: FsPort, appDataDir: string, id: string): Promise { + await fs.deleteFile(planFilePath(appDataDir, id)); +} From 352f7359755aad9e2e608d60b0877b25df63f133 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:16:23 +0800 Subject: [PATCH 066/214] feat(core): add Core.tryToRecognizeFolder Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 64 ++++++++++++- apps/core/src/Core.ts | 11 +++ apps/core/src/index.ts | 1 + .../core/src/pipeline/tryToRecognizeFolder.ts | 89 +++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 apps/core/src/pipeline/tryToRecognizeFolder.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index f2cc9181..c3cc7f15 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -4,7 +4,7 @@ import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { Core } from "./Core"; -import { metadataCachePath, userConfigPath } from "./pipeline/paths"; +import { metadataCachePath, planFilePath, userConfigPath } from "./pipeline/paths"; function inMemoryFs(seed: Record = {}): FsPort { const files = new Map(Object.entries(seed)); @@ -518,3 +518,65 @@ describe("renameFolder", () => { expect(fs.rename).not.toHaveBeenCalled(); }); }); + +describe("tryToRecognizeFolder", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + const tvMetadata = { + mediaFolderPath: folder, + type: "tvshow-folder" as const, + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [ + { season: 1, episode: 1 }, + { season: 1, episode: 2 }, + ], + }, + ], + }, + mediaFiles: [], + }; + + function seed(disk: Record = {}) { + return inMemoryFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(tvMetadata), + ...disk, + }); + } + + it("creates a pending plan with matched files", async () => { + const fs = seed({ + "/m/Show/S01E01.mkv": "", + "/m/Show/S01E02.mkv": "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.tryToRecognizeFolder("/m/Show"); + expect(plan.task).toBe("recognize-media-file"); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("app"); + expect(plan.files).toEqual([ + { season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }, + { season: 1, episode: 2, path: "/m/Show/S01E02.mkv" }, + ]); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(true); + }); + + it("returns pending plan with empty files when nothing matches", async () => { + const fs = seed({ "/m/Show/random.mkv": "" }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.tryToRecognizeFolder("/m/Show"); + expect(plan.files).toEqual([]); + expect(plan.status).toBe("pending"); + }); + + it("rejects unmanaged folders", async () => { + const fs = seed(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + await expect(core.tryToRecognizeFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 96961f48..bfefc9c7 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -1,5 +1,6 @@ import { Path } from "@core/path"; import type { AppConfig, FolderType, MediaMetadata, UserConfig as UserConfigData } from "@smm/core"; +import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; @@ -8,6 +9,7 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; +import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; @@ -160,6 +162,15 @@ export class Core { }); } + async tryToRecognizeFolder(path: string): Promise { + return tryToRecognizeFolderPipeline(path, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + }); + } + private normalizePosix(path: string): string { try { return Path.posix(path); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index dc17b2c1..65fe7815 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -17,6 +17,7 @@ export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFo export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } from "./pipeline/userConfig"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; +export { tryToRecognizeFolderPipeline, type TryToRecognizeFolderDeps } from "./pipeline/tryToRecognizeFolder"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; export type { FolderType } from "@smm/core"; diff --git a/apps/core/src/pipeline/tryToRecognizeFolder.ts b/apps/core/src/pipeline/tryToRecognizeFolder.ts new file mode 100644 index 00000000..2c639a5a --- /dev/null +++ b/apps/core/src/pipeline/tryToRecognizeFolder.ts @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import { Path } from "@core/path"; +import type { MediaMetadata } from "@smm/core"; +import type { RecognizeMediaFilePlan, RecognizedFile } from "@smm/core/types/RecognizeMediaFilePlan"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath } from "./paths"; +import { writePlan } from "./plans"; +import { recognizeEpisodes } from "./recognizeEpisodes"; +import type { UserConfig } from "./userConfig"; + +export interface TryToRecognizeFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + /** Injected for tests; default `randomUUID` from `node:crypto`. */ + createId?: () => string; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +function hasTvShowEpisodes(mm: MediaMetadata): boolean { + if (mm.type !== "tvshow-folder") return false; + const seasons = mm.tvShow?.seasons; + if (!seasons || seasons.length === 0) return false; + return seasons.some((season) => (season.episodes?.length ?? 0) > 0); +} + +/** Rule-based episode matching → pending recognize-media-file plan (throws instead of { error }). */ +export async function tryToRecognizeFolderPipeline( + path: string, + deps: TryToRecognizeFolderDeps, +): Promise { + const posixPath = deps.normalizePosix(path); + const createId = deps.createId ?? randomUUID; + + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], path)) { + throw new Error(`${posixPath} is not managed by SMM`); + } + + const cachePath = metadataCachePath(deps.appDataDir, posixPath); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${path}`); + } + let mediaMetadata: MediaMetadata; + try { + mediaMetadata = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${path}`); + } + + if (!hasTvShowEpisodes(mediaMetadata)) { + throw new Error(`Folder is not a TV show with episodes: ${path}`); + } + + const listed = await deps.fs.listFiles(posixPath); + const listedPosix = listed.map((file) => Path.posix(file)); + const recognized = recognizeEpisodes({ + ...mediaMetadata, + mediaFolderPath: posixPath, + files: listedPosix, + }); + + const files: RecognizedFile[] = recognized.map((item) => ({ + season: item.season, + episode: item.episode, + path: Path.posix(item.file), + })); + + const plan: RecognizeMediaFilePlan = { + id: createId(), + task: "recognize-media-file", + status: "pending", + creator: "app", + mediaFolderPath: posixPath, + files, + }; + + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; +} From d6427b287d861ee1058ee2b56ab64236c93b570a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:20:57 +0800 Subject: [PATCH 067/214] feat(core): add Core.getPlan and applyPlan for recognize Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 78 +++++++++++++++++++++++++++++ apps/core/src/Core.ts | 18 +++++++ apps/core/src/index.ts | 1 + apps/core/src/pipeline/applyPlan.ts | 36 +++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 apps/core/src/pipeline/applyPlan.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index c3cc7f15..dbec3ab0 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -580,3 +580,81 @@ describe("tryToRecognizeFolder", () => { await expect(core.tryToRecognizeFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); }); }); + +describe("applyPlan", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + const tvMetadata = { + mediaFolderPath: folder, + type: "tvshow-folder" as const, + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [ + { season: 1, episode: 1 }, + { season: 1, episode: 2 }, + ], + }, + ], + }, + mediaFiles: [] as { absolutePath: string; seasonNumber?: number; episodeNumber?: number }[], + }; + + function seed(disk: Record = {}) { + return inMemoryFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(tvMetadata), + ...disk, + }); + } + + it("merges mediaFiles and deletes the plan file", async () => { + const fs = seed({ + "/m/Show/S01E01.mkv": "", + "/m/Show/S01E02.mkv": "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.tryToRecognizeFolder("/m/Show"); + await core.applyPlan(plan); + const mm = await core.getMediaMetadata("/m/Show"); + expect(mm?.mediaFiles).toEqual([ + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, + ]); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); + }); + + it("applies empty files plan as no-op on mediaFiles but deletes plan", async () => { + const fs = seed({ "/m/Show/random.mkv": "" }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const before = await core.getMediaMetadata("/m/Show"); + const plan = await core.tryToRecognizeFolder("/m/Show"); + expect(plan.files).toEqual([]); + await core.applyPlan(plan); + const after = await core.getMediaMetadata("/m/Show"); + expect(after?.mediaFiles).toEqual(before?.mediaFiles ?? []); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); + }); + + it("getPlan throws when missing", async () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir }); + await expect(core.getPlan("nope")).rejects.toThrow("Plan not found: nope"); + }); + + it("rejects unsupported tasks", async () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir }); + await expect( + core.applyPlan({ + id: "r1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [], + }), + ).rejects.toThrow(/Unsupported plan task/); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index bfefc9c7..b8907db9 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -9,6 +9,8 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; +import { applyRecognizeMediaFilePlanPipeline } from "./pipeline/applyPlan"; +import { readPlan, type Plan } from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; @@ -171,6 +173,22 @@ export class Core { }); } + async getPlan(id: string): Promise { + const plan = await readPlan(this.fs, this.appDataDir, id); + if (!plan) throw new Error(`Plan not found: ${id}`); + return plan; + } + + async applyPlan(plan: Plan): Promise { + await applyRecognizeMediaFilePlanPipeline(plan, { + fs: this.fs, + appDataDir: this.appDataDir, + normalizePosix: (p) => this.normalizePosix(p), + setMetadata: (mm) => this.setMetadata(mm), + getMediaMetadata: (folder) => this.getMediaMetadata(folder), + }); + } + private normalizePosix(path: string): string { try { return Path.posix(path); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 65fe7815..a7df0ec1 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -18,6 +18,7 @@ export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } fr export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { tryToRecognizeFolderPipeline, type TryToRecognizeFolderDeps } from "./pipeline/tryToRecognizeFolder"; +export type { Plan } from "./pipeline/plans"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; export type { FolderType } from "@smm/core"; diff --git a/apps/core/src/pipeline/applyPlan.ts b/apps/core/src/pipeline/applyPlan.ts new file mode 100644 index 00000000..ec351ad9 --- /dev/null +++ b/apps/core/src/pipeline/applyPlan.ts @@ -0,0 +1,36 @@ +import type { MediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { deletePlan, type Plan } from "./plans"; +import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; + +export interface ApplyPlanDeps { + fs: FsPort; + appDataDir: string; + normalizePosix: (path: string) => string; + getMediaMetadata: (folder: string) => Promise; + setMetadata: (mm: MediaMetadata) => Promise; +} + +/** Applies a pending recognize-media-file plan: merge mediaFiles, persist, delete plan. */ +export async function applyRecognizeMediaFilePlanPipeline( + plan: Plan, + deps: ApplyPlanDeps, +): Promise { + if (plan.task !== "recognize-media-file") { + throw new Error(`Unsupported plan task: ${plan.task}`); + } + + const folder = deps.normalizePosix(plan.mediaFolderPath); + const mm = await deps.getMediaMetadata(folder); + if (!mm) { + throw new Error(`Media metadata not found: ${plan.mediaFolderPath}`); + } + + let mediaFiles = mm.mediaFiles ?? []; + for (const file of plan.files) { + mediaFiles = updateMediaFileMetadatas(mediaFiles, file.path, file.season, file.episode); + } + + await deps.setMetadata({ ...mm, mediaFiles }); + await deletePlan(deps.fs, deps.appDataDir, plan.id); +} From 310a59f80f2ca91605024ac08aa310b551ce9bd6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:24:46 +0800 Subject: [PATCH 068/214] feat(cli): add smm try-to-recognize and apply Co-authored-by: Cursor --- apps/cli/src/cli/runCli.ts | 44 ++++++++++++++++++- apps/cli/test/recognize-e2e.test.ts | 67 +++++++++++++++++++++++++++++ docs/api/index.md | 5 +++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 apps/cli/test/recognize-e2e.test.ts diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index e436e17a..949a76d3 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -40,7 +40,7 @@ function printJson(value: unknown): void { const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `config`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `apply`, `config`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -186,6 +186,48 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('try-to-recognize') + .description('Build a pending recognize-media-file plan for a TV show folder') + .argument('', 'Imported media folder path') + .action(async (folder: string) => { + try { + const plan = await getCore().tryToRecognizeFolder(folder) + console.log(`plan: ${plan.id}`) + console.log(`task: ${plan.task}`) + console.log(`status: ${plan.status}`) + console.log(`folder: ${plan.mediaFolderPath}`) + console.log('files:') + if (plan.files.length === 0) { + console.log(' (none)') + } else { + for (const f of plan.files) { + const ep = `S${String(f.season).padStart(2, '0')}E${String(f.episode).padStart(2, '0')}` + console.log(` ${ep} ${f.path}`) + } + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + + program + .command('apply') + .description('Apply a pending plan by id (recognize-media-file)') + .argument('', 'Plan id from try-to-recognize') + .action(async (planId: string) => { + try { + const plan = await getCore().getPlan(planId) + await getCore().applyPlan(plan) + const count = plan.task === 'recognize-media-file' ? plan.files.length : 0 + console.log(`applied ${plan.id} (${count} file(s))`) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + const configCmd = program.command('config').description('Read or write user config (smm.json)') configCmd diff --git a/apps/cli/test/recognize-e2e.test.ts b/apps/cli/test/recognize-e2e.test.ts new file mode 100644 index 00000000..fbc08583 --- /dev/null +++ b/apps/cli/test/recognize-e2e.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Path } from '@core/path' +import { getCore, resetCoreForTests } from '../src/core/getCore' +import { smm } from './helpers/smm' +import { createAndImportInitializedFolder, tvShowFolder } from './helpers/testFolders' + +describe('smm try-to-recognize / apply CLI e2e', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-recognize-e2e-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-recognize-e2e-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it('try-to-recognize then apply updates mediaFiles', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }, { + updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }), + }) + const path = folder.path! + + const tried = await smm(['try-to-recognize', path]) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + expect(tried.stdout).toMatch(/plan:\s+[0-9a-f-]{36}/i) + const planId = tried.stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] + expect(planId).toBeTruthy() + + const applied = await smm(['apply', planId!]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + + const mm = await getCore().getMediaMetadata(path) + expect(mm?.mediaFiles?.length).toBeGreaterThan(0) + expect(mm?.mediaFiles?.[0]?.seasonNumber).toBe(1) + expect(mm!.mediaFiles).toEqual([ + { + absolutePath: Path.posix(join(path, 'S01E01.mkv')), + seasonNumber: 1, + episodeNumber: 1, + }, + { + absolutePath: Path.posix(join(path, 'S01E02.mkv')), + seasonNumber: 1, + episodeNumber: 2, + }, + { + absolutePath: Path.posix(join(path, 'S01E03.mkv')), + seasonNumber: 1, + episodeNumber: 3, + }, + ]) + }) +}) diff --git a/docs/api/index.md b/docs/api/index.md index cf6aedb7..00db6af2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -74,6 +74,11 @@ HTTP: `POST /api/unimport-folder` — removes an imported media folder from `use Source Code: apps/cli/src/route/RenameFolderV3.ts HTTP: `POST /api/rename-folder` — renames a managed media folder via Layer 2 `Core.renameFolder({ from, to })` (metadata cache + `UserConfig.folders` + on-disk rename). Request body: `{ from: string, to: string }`. Response: `{ data: { from, to } }` or `{ error }`. Broadcasts the same folder-renamed / userConfigUpdated socket events as legacy `POST /api/renameFolder`. Used by UI Sidebar rename when `localStorage["smm.v3.enabled"] === "true"`. +## CLI: try-to-recognize / apply +Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / applyPlan +`smm try-to-recognize ` — rule-based episode recognition → pending plan under `{userDataDir}/plans/`. +`smm apply ` — apply recognize-media-file plan (updates metadata cache, deletes plan file). + ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts HTTP: `POST /api/setWatchedFolder` — sets the single media folder the CLI `FolderWatcher` listens to (UI primary `selectedFolder`). Request body: `{ folderPath: string | null }` (platform absolute path, or null/empty to stop watching). Response: `{ data: { watchedFolder: string | null }, error?: string }`. Startup no longer watches all imported folders. From a925adb84b594b2615e13f611245a13fd2c6117f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 15:28:42 +0800 Subject: [PATCH 069/214] fix(cli): register try-to-recognize and apply in index dispatcher Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 396518b3..78bd1119 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'apply']) if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); From 43bfa26dea1312841d7906a7824e75c72a7719e0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 17:46:12 +0800 Subject: [PATCH 070/214] docs: add Core tryToRenameFolder / applyPlan rename-files design Co-authored-by: Cursor --- ...-08-19-core-try-to-rename-folder-design.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-core-try-to-rename-folder-design.md diff --git a/docs/superpowers/specs/2026-08-19-core-try-to-rename-folder-design.md b/docs/superpowers/specs/2026-08-19-core-try-to-rename-folder-design.md new file mode 100644 index 00000000..5cf02eb1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-core-try-to-rename-folder-design.md @@ -0,0 +1,169 @@ +# Core.tryToRenameFolder / applyPlan (rename-files) + +This design document describes migrating rule-based TV episode **file** renaming (TvShowPanel `useRuleBasedRenameFilesFlow`) into Layer 2 `apps/core`, exposing CLI commands for operators. UI and existing HTTP rename/plan APIs stay unchanged in this milestone. + +## 1. Background + +Toolbar / TvShowPanel **rule-based rename** today: + +1. UI creates a `rename-files` plan (`preparing`) via `POST /api/createPlan`. +2. `buildTvShowRenamePlanFileEntries(mm, plex|emby)` builds video `from` → `to` pairs (skips already-matching names). +3. Plan moves to `pending` for review; confirm expands associated files (subs / nfo / jpg) via `buildTvShowRenameListForPlan`, calls `POST /api/renameFiles`, then updates `mediaFiles` paths. + +Folder rename (`Core.renameFolder`) already exists and is **out of scope**. This change ports **episode file rename** only: **Core + CLI**, mirroring the recognize migration (`tryToRecognizeFolder` + `applyPlan`). + +**Constraints (from product):** + +- Scope: Core + CLI. No UI / `smm.v3` / new HTTP routes this round. +- Do not edit `packages/core-routes` or UI rename hooks. +- Align with recognize: `tryToRenameFolder` → pending plan; extend existing `applyPlan` for `rename-files`. +- Zero rename candidates → return a **pending** plan with `files: []` (do not throw solely for empty plans). +- Default naming rule: **`plex`** when CLI / API omit the rule. +- TV show only this milestone (not movie rename). +- Prerequisite: episodes should already be linked in `mediaFiles` (typically after recognize); empty `mediaFiles` yields `files: []`. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +apps/ui useRuleBasedRenameFilesFlow ──unchanged──► POST /api/createPlan|updatePlan|/renameFiles + │ +packages/core-routes ──unchanged──► plans + renameFiles + │ +apps/cli smm try-to-rename / smm apply ──new──► Core + │ +apps/core Core.tryToRenameFolder + applyPlan(rename-files) ──new──► FsPort.rename + naming rules +``` + +Same on-disk plan layout: `{appDataDir}/plans/{id}.plan.json`. + +## 2.2 App Level Architecture + +| Piece | Role | +|-------|------| +| `Core.tryToRenameFolder(path, rule?)` | Managed check → metadata → build video rename entries → write pending `RenameFilesPlan` | +| `Core.applyPlan(plan)` | Extend: if `rename-files`, expand associates → rename on disk → rewrite `mediaFiles` → delete plan | +| Existing `applyPlan` recognize path | Unchanged | +| `pipeline/renameRules.ts` (new) | Port plex/emby `generateNewFileName` (TV) | +| `pipeline/buildTvShowRenamePlanFileEntries.ts` (new) | Port UI builder (omit unchanged paths) | +| `pipeline/buildTvShowRenameListForPlan.ts` (new) | Expand plan videos with associated files | +| `pipeline/applyRenameFilesPlan.ts` (new) | Orchestrate disk renames + metadata path rewrite | +| Existing `pipeline/plans.ts` | Reuse write/read/delete | + +**`tryToRenameFolder` step order:** + +1. Normalize `path`; assert managed. +2. `getMediaMetadata`; fail if missing. +3. Require TV show with seasons/episodes (same spirit as try-to-recognize); otherwise throw. +4. `rule = rule ?? "plex"`. +5. `buildTvShowRenamePlanFileEntries(mm, rule)` → `{ from, to }[]` (may be empty). +6. Create plan: `task: "rename-files"`, `status: "pending"`, `creator: "app"`, `files` (possibly `[]`). +7. Persist; return plan. + +**`applyPlan` (rename-files branch):** + +1. Validate `plan.task === "rename-files"`. +2. Load metadata; fail if missing. +3. `listFiles(mediaFolderPath)` for associate discovery. +4. Build full rename list (videos + associates), same semantics as UI `buildTvShowRenameListForPlan`. +5. For each pair: ensure destination parent exists as needed; `FsPort.rename(from, to)`. Empty list → skip disk ops. +6. Rewrite `mediaFiles[].absolutePath` where `from` matches a plan video (or expanded list video roots). +7. `setMetadata`; `deletePlan`. + +Empty `files: []` apply: no disk renames; still deletes the plan file. + +## 2.3 Key Design + +### Core signatures + +```ts +type RenameRuleName = "plex" | "emby" + +tryToRenameFolder(path: string, rule?: RenameRuleName): Promise +// existing: +getPlan(id: string): Promise +applyPlan(plan: Plan): Promise // recognize-media-file | rename-files +``` + +### Naming rules (TV) + +Port from `apps/ui/src/lib/renameRules.ts`: + +- **plex:** `Season {SS}/{show} - S{SS}E{EE} - {episodeName}{ext}` +- **emby:** `Season {S}/{show} S{S}E{E} {episodeName}{ext}` + +Omit entries where generated absolute path equals current video path (POSIX-normalized compare). + +### Errors (throw) + +| Case | Message shape | +|------|----------------| +| Not managed | `{path} is not managed by SMM` | +| No metadata | `Media metadata not found: {path}` | +| Not TV / no episodes | `Folder is not a TV show with episodes: {path}` | +| Invalid rule | `Unsupported rename rule: {rule}` | +| Plan missing | `Plan not found: {id}` (existing) | +| Unsupported apply task | `Unsupported plan task: {task}` (only if neither recognize nor rename) | + +Empty rename candidates are **not** an error. + +### CLI + +```bash +smm try-to-rename [--rule plex|emby] +# stdout: plan id, task, status, folder, from → to lines (or (none)) + +smm apply +# already exists; must accept rename-files plans and print applied summary +``` + +Register `try-to-rename` in `apps/cli/index.ts` `cliCommands` (same pitfall as recognize). + +### Out of scope + +- UI / v3 switch / HTTP wrappers +- AI rename (`creator: "ai"`) +- Movie rename +- Changing `Core.renameFolder` (directory rename) + +## 3. User Stories + +### 3.1 Try rename with candidates + +* **Given** - imported TV folder with `mediaFiles` linked to `S01E01.mkv` etc. and plex-style target names differ +* **When** - `Core.tryToRenameFolder(path)` or `smm try-to-rename path` +* **Then** - pending `rename-files` plan exists with non-empty `files` + +```mermaid +sequenceDiagram + participant CLI + participant Core + participant Rules as renameRules + participant Fs as FsPort + + CLI->>Core: tryToRenameFolder(path, rule?) + Core->>Core: managed + getMediaMetadata + Core->>Rules: buildTvShowRenamePlanFileEntries + Rules-->>Core: [{from,to},...] + Core->>Fs: writeTextFile(plans/id.plan.json) + Core-->>CLI: RenameFilesPlan +``` + +### 3.2 Try rename with zero candidates + +* **Given** - all video paths already match the selected rule (or no `mediaFiles`) +* **When** - `tryToRenameFolder` runs +* **Then** - returns pending plan with `files: []` (no throw) + +### 3.3 Apply rename plan + +* **Given** - pending rename plan with video entries and associated `.ass` / `.nfo` beside the videos +* **When** - `applyPlan(plan)` / `smm apply ` +* **Then** - videos and associates renamed on disk, `mediaFiles` paths updated, plan file deleted + +### 3.4 CLI round-trip after recognize + +* **Given** - folder imported with empty `mediaFiles`, then `try-to-recognize` + `apply` +* **When** - `try-to-rename --rule plex` then `apply` +* **Then** - on-disk names follow plex layout under `Season XX/` From 3388cb7ff5be65b8e44a33ddbab3e5d3f241cc25 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:47:31 +0800 Subject: [PATCH 071/214] docs: add Core tryToRenameFolder implementation plan Co-authored-by: Cursor --- .../2026-08-19-core-try-to-rename-folder.md | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-core-try-to-rename-folder.md diff --git a/docs/superpowers/plans/2026-08-19-core-try-to-rename-folder.md b/docs/superpowers/plans/2026-08-19-core-try-to-rename-folder.md new file mode 100644 index 00000000..ee0b8aae --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-core-try-to-rename-folder.md @@ -0,0 +1,539 @@ +# Core.tryToRenameFolder / applyPlan (rename-files) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Core.tryToRenameFolder(path, rule?)` and extend `Core.applyPlan` for `rename-files` (disk rename + metadata path rewrite), plus CLI `smm try-to-rename` and update `smm apply`, without changing UI or core-routes. + +**Architecture:** Port plex/emby naming and plan builders into `apps/core` pipelines. Persist pending `RenameFilesPlan` via existing plan helpers. On apply, expand associated files, `FsPort.mkdir` parents, `FsPort.rename` each pair, rewrite `mediaFiles` paths, delete plan. + +**Tech Stack:** TypeScript, Vitest, `apps/core` FsPort adapters, Commander CLI, `@smm/core` plan types + `videoFileExtensions` / `subtitleFileExtensions` / `getFullExtensionForAssociatedFile`. + +**Spec:** `docs/superpowers/specs/2026-08-19-core-try-to-rename-folder-design.md` + +## Global Constraints + +- Do **not** modify `apps/ui/**` or `packages/core-routes/**` source. +- Scope is **Core + CLI only** (no HTTP / v3 / UI wiring). +- TV show only; not movie rename; not `Core.renameFolder` (directory rename). +- Default naming rule: **`plex`** when `rule` is omitted. +- Zero rename candidates → pending plan with `files: []` (do **not** throw solely for empty plans). +- Extend existing `applyPlan` for `rename-files`; keep recognize-media-file behavior unchanged. +- Register `try-to-rename` in `apps/cli/index.ts` `cliCommands` (same pitfall as recognize). +- Core methods throw `Error` (CLI prints message, exit 1). + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/pipeline/renameRules.ts` | plex/emby `generateNewFileName` (TV; movie helper optional unused) | +| `apps/core/src/pipeline/renameRules.test.ts` | Naming rule unit tests | +| `apps/core/src/pipeline/mediaFilePathEqual.ts` | POSIX path equality | +| `apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts` | Video from→to plan entries | +| `apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts` | Builder tests | +| `apps/core/src/pipeline/findAssociatedFiles.ts` | Same-stem associates from listFiles | +| `apps/core/src/pipeline/buildTvShowRenameListForPlan.ts` | Expand plan + associates | +| `apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts` | Expand tests | +| `apps/core/src/ports/FsPort.ts` | Add `mkdir(path)` | +| `apps/core/src/adapters/node/NodejsFsAdapter.ts` | Implement mkdir | +| `apps/core/src/adapters/network/NetworkFsAdapter.ts` | mkdir NYI or HTTP if exists | +| `apps/core/src/pipeline/tryToRenameFolder.ts` | Orchestration | +| `apps/core/src/pipeline/applyRenameFilesPlan.ts` | Apply rename-files | +| `apps/core/src/pipeline/applyPlan.ts` | Dispatch recognize vs rename | +| `apps/core/src/Core.ts` | `tryToRenameFolder` + applyPlan wiring | +| `apps/core/src/Core.test.ts` | Core integration tests | +| `apps/cli/src/cli/runCli.ts` | `try-to-rename`; fix `apply` summary for rename | +| `apps/cli/index.ts` | Register `try-to-rename` | +| `apps/cli/test/rename-files-e2e.test.ts` | CLI e2e (distinct from folder `rename-e2e`) | +| `docs/api/index.md` | Short CLI note | + +--- + +### Task 1: Pure renameRules (TV plex/emby) + +**Files:** +- Create: `apps/core/src/pipeline/renameRules.ts` +- Create: `apps/core/src/pipeline/renameRules.test.ts` + +**Interfaces:** +- Produces: + - `export type RenameRuleName = "plex" | "emby"` + - `export interface NewFileNameContext { type: "tv" | "movie"; seasonNumber: number; episodeNumber: number; episodeName?: string; tvshowName?: string; movieName?: string; file: string; tmdbId?: string; releaseYear: string }` + - `generateNewFileName(ruleName: RenameRuleName, context: NewFileNameContext): string` — returns **relative** path with extension from `context.file` + +Port TV formulas from `apps/ui/src/lib/renameRules.ts` verbatim: + +- plex: `Season ${SS}/${tvshowName} - S${SS}E${EE} - ${episodeName}${ext}` (SS/EE pad 2) +- emby: `Season ${S}/${tvshowName} S${S}E${E} ${episodeName}${ext}` (no pad) + +Use `extname` from `./paths` (already in core). + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from "vitest"; +import { generateNewFileName } from "./renameRules"; + +describe("generateNewFileName", () => { + const base = { + type: "tv" as const, + seasonNumber: 1, + episodeNumber: 2, + episodeName: "Pilot", + tvshowName: "Show", + file: "/m/Show/S01E02.mkv", + releaseYear: "2019", + }; + + it("builds plex relative path", () => { + expect(generateNewFileName("plex", base)).toBe( + "Season 01/Show - S01E02 - Pilot.mkv", + ); + }); + + it("builds emby relative path", () => { + expect(generateNewFileName("emby", base)).toBe( + "Season 1/Show S1E2 Pilot.mkv", + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/renameRules.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Minimal implementation** + +Create `renameRules.ts` by porting UI `generatePlexTvFileName` / `generateEmbyTvFileName` / `generateNewFileName` (include movie branch for parity with UI helper even if unused this milestone — keeps copy faithful; or omit movie and only handle `type === "tv"` — **prefer TV-only** and throw/ignore movie to stay YAGNI: if `context.type !== "tv"` throw `new Error("Only TV rename is supported")`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/renameRules.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/renameRules.ts apps/core/src/pipeline/renameRules.test.ts +git commit -m "feat(core): add plex/emby TV renameRules" +``` + +--- + +### Task 2: buildTvShowRenamePlanFileEntries + +**Files:** +- Create: `apps/core/src/pipeline/mediaFilePathEqual.ts` +- Create: `apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts` +- Create: `apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts` + +**Interfaces:** +- Consumes: `generateNewFileName`, `RenameRuleName` +- Produces: + - `mediaFilePathEqual(a, b): boolean` — `Path.posix` compare (same as UI) + - `buildTvShowRenamePlanFileEntries(mm, rule): Array<{ from: string; to: string }>` + - Join: `joinPosix(mediaFolderPath, relativePath)` from `./paths` + - Skip when `mediaFilePathEqual(from, to)`; skip episodes without `mediaFiles` entry + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from "vitest"; +import type { MediaMetadata } from "@smm/core"; +import { buildTvShowRenamePlanFileEntries } from "./buildTvShowRenamePlanFileEntries"; + +function mm(partial: Partial & Pick): MediaMetadata { + return { + type: "tvshow-folder", + mediaFiles: [], + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [{ season: 1, episode: 1, name: "Ep1" }], + }, + ], + }, + ...partial, + } as MediaMetadata; +} + +describe("buildTvShowRenamePlanFileEntries", () => { + it("emits from→to for plex when current name differs", () => { + const meta = mm({ + mediaFolderPath: "/m/Show", + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }); + const files = buildTvShowRenamePlanFileEntries(meta, "plex"); + expect(files).toEqual([ + { + from: "/m/Show/S01E01.mkv", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.mkv", + }, + ]); + }); + + it("omits already-matching paths", () => { + const to = "/m/Show/Season 01/Show - S01E01 - Ep1.mkv"; + const meta = mm({ + mediaFolderPath: "/m/Show", + mediaFiles: [{ absolutePath: to, seasonNumber: 1, episodeNumber: 1 }], + }); + expect(buildTvShowRenamePlanFileEntries(meta, "plex")).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/buildTvShowRenamePlanFileEntries.test.ts` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** + +`mediaFilePathEqual.ts` — copy UI logic with `@core/path`. + +`buildTvShowRenamePlanFileEntries.ts` — port UI loop; `to = joinPosix(Path.posix(mediaFolderPath), relative)` with Path.posix on `from`/`to`. + +- [ ] **Step 4: Run test to verify it passes** + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/mediaFilePathEqual.ts \ + apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts \ + apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts +git commit -m "feat(core): build TV rename plan file entries" +``` + +--- + +### Task 3: findAssociatedFiles + buildTvShowRenameListForPlan + +**Files:** +- Create: `apps/core/src/pipeline/findAssociatedFiles.ts` +- Create: `apps/core/src/pipeline/buildTvShowRenameListForPlan.ts` +- Create: `apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts` + +**Interfaces:** +- Produces: + - `findAssociatedFiles(mediaFolderPath, filePaths, videoFilePath): string[]` — return **absolute** POSIX paths of associates (simpler than UI `File[]`; Core apply only needs paths) + - `buildTvShowRenameListForPlan({ mediaFolderPath, localFiles, plan }): Array<{ from: string; to: string }>` — plan.files first, then associates with stem-matched extensions via `getFullExtensionForAssociatedFile` from `@smm/core` / `@core/utils` (check which export path core-app already uses) + +Associate matching: same stem rules as UI `findAssociatedFiles` (exact stem+ext and language-tagged `stem.*.ext`) using `extensions.imageFileExtensions`, `subtitleFileExtensions`, `audioTrackFileExtensions`, and `.nfo` from `@core/utils` or `@smm/core`. + +Subtitle multi-dot: use `getFullExtensionForAssociatedFile` when building `to` (UI used `ext(filename, 2)` for subtitle case — prefer shared `getFullExtensionForAssociatedFile` for correctness). + +- [ ] **Step 1: Write the failing test** + +```typescript +import { describe, expect, it } from "vitest"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import { buildTvShowRenameListForPlan } from "./buildTvShowRenameListForPlan"; + +describe("buildTvShowRenameListForPlan", () => { + it("includes video and same-stem subtitle", () => { + const plan: RenameFilesPlan = { + id: "p1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [ + { + from: "/m/Show/S01E01.mkv", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.mkv", + }, + ], + }; + const localFiles = [ + "/m/Show/S01E01.mkv", + "/m/Show/S01E01.sc.ass", + "/m/Show/other.mkv", + ]; + const list = buildTvShowRenameListForPlan({ + mediaFolderPath: "/m/Show", + localFiles, + plan, + }); + expect(list[0]).toEqual(plan.files[0]); + expect(list).toContainEqual({ + from: "/m/Show/S01E01.sc.ass", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.sc.ass", + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement** `findAssociatedFiles` + `buildTvShowRenameListForPlan` + +Normalize all paths with `Path.posix` at boundaries. + +- [ ] **Step 4: Run test to verify it passes** + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/findAssociatedFiles.ts \ + apps/core/src/pipeline/buildTvShowRenameListForPlan.ts \ + apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts +git commit -m "feat(core): expand rename plan with associated files" +``` + +--- + +### Task 4: FsPort.mkdir + +**Files:** +- Modify: `apps/core/src/ports/FsPort.ts` +- Modify: `apps/core/src/adapters/node/NodejsFsAdapter.ts` +- Modify: `apps/core/src/adapters/node/NodejsFsAdapter.test.ts` +- Modify: `apps/core/src/adapters/network/NetworkFsAdapter.ts` — throw `Not Implemented: NetworkFsAdapter.mkdir` unless an existing mkdir HTTP API is already used elsewhere (prefer NYI this round) +- Modify: in-memory `FsPort` fakes in `Core.test.ts` / pipeline tests — add `mkdir: vi.fn(async () => {})` (no-op or track created dirs) + +**Interfaces:** +- Produces: `FsPort.mkdir(path: string): Promise` — create directory (and parents). Idempotent if exists. + +- [ ] **Step 1: Write failing adapter test** + +```typescript + it("creates nested directories", async () => { + const adapter = new NodejsFsAdapter(); + const dir = joinPosix(tmpPosix, "Season 01"); + await adapter.mkdir(dir); + expect(await adapter.exists(dir)).toBe(true); + }); +``` + +(Use existing tmp helpers in `NodejsFsAdapter.test.ts`.) + +- [ ] **Step 2: Run test to verify it fails** + +Expected: FAIL — `mkdir is not a function`. + +- [ ] **Step 3: Implement** + +```typescript +// FsPort +/** Create a directory (and parents). Idempotent if it already exists. Paths are POSIX. */ +mkdir(path: string): Promise; + +// NodejsFsAdapter +async mkdir(path: string): Promise { + await fsp.mkdir(Path.toPlatformPath(path), { recursive: true }); +} +``` + +Stub Network + update test fakes so the package typechecks. + +- [ ] **Step 4: Run adapter test — PASS** + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/ports/FsPort.ts \ + apps/core/src/adapters/node/NodejsFsAdapter.ts \ + apps/core/src/adapters/node/NodejsFsAdapter.test.ts \ + apps/core/src/adapters/network/NetworkFsAdapter.ts \ + apps/core/src/Core.test.ts +git commit -m "feat(core): add FsPort.mkdir for rename parents" +``` + +--- + +### Task 5: tryToRenameFolder + Core method + +**Files:** +- Create: `apps/core/src/pipeline/tryToRenameFolder.ts` +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` +- Modify: `apps/core/src/index.ts` — export `RenameRuleName` if useful + +**Interfaces:** +- Consumes: `buildTvShowRenamePlanFileEntries`, `writePlan`, `UserConfig`, managed check (duplicate `isManaged` from `tryToRecognizeFolder` / `renameFolder`) +- Produces: + - `tryToRenameFolderPipeline(path, rule | undefined, deps): Promise` + - `Core.tryToRenameFolder(path: string, rule?: RenameRuleName): Promise` + +**Behavior:** + +1. Managed check → `{posix} is not managed by SMM` +2. Metadata miss → `Media metadata not found: {path}` +3. Not TV with episodes → `Folder is not a TV show with episodes: {path}` (reuse same helper spirit as tryToRecognizeFolder) +4. If `rule` provided and not `plex`|`emby` → `Unsupported rename rule: {rule}` +5. `effectiveRule = rule ?? "plex"` +6. Build entries; create pending plan `creator: "app"`, `task: "rename-files"`, `files` may be `[]` +7. `writePlan`; return + +- [ ] **Step 1: Write failing Core tests** + +Seed managed folder + metadata with `mediaFiles` pointing at `/m/Show/S01E01.mkv` and episode name; assert plan.files to plex target; assert empty when already matching; assert unmanaged throws. + +- [ ] **Step 2: Run — FAIL** (`tryToRenameFolder is not a function`) + +- [ ] **Step 3: Implement pipeline + Core wrapper** + +- [ ] **Step 4: Run Core tests focused — PASS** + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(core): add Core.tryToRenameFolder" +``` + +--- + +### Task 6: applyPlan rename-files branch + +**Files:** +- Create: `apps/core/src/pipeline/applyRenameFilesPlan.ts` +- Modify: `apps/core/src/pipeline/applyPlan.ts` — dispatch by `plan.task` +- Modify: `apps/core/src/Core.ts` — pass `mkdir` / listFiles through deps +- Modify: `apps/core/src/Core.test.ts` + +**Interfaces:** +- Produces: `applyRenameFilesPlanPipeline(plan, deps): Promise` +- Deps: `{ fs, appDataDir, normalizePosix, getMediaMetadata, setMetadata }` +- `applyPlan.ts` becomes: + +```typescript +export async function applyPlanPipeline(plan: Plan, deps: ApplyPlanDeps): Promise { + if (plan.task === "recognize-media-file") { + return applyRecognizeMediaFilePlanPipeline(plan, deps); + } + if (plan.task === "rename-files") { + return applyRenameFilesPlanPipeline(plan, deps); + } + throw new Error(`Unsupported plan task: ${plan.task}`); +} +``` + +Rename existing export name if needed so Core calls one dispatcher (update imports). + +**applyRenameFilesPlan behavior:** + +1. Assert `task === "rename-files"` +2. Load metadata; throw if missing +3. `localFiles = (await fs.listFiles(folder)).map(Path.posix)` +4. `renameList = buildTvShowRenameListForPlan(...)` +5. For each `{ from, to }`: `dirname` of `to` → `fs.mkdir(parent)`; then `fs.rename(from, to)` +6. Rewrite `mediaFiles`: for each entry, if `absolutePath` equals any rename `from` (posix), set to corresponding `to` (only video roots that appear in `plan.files`, or any pair in renameList — **prefer**: map all renameList pairs onto mediaFiles paths so video updates; associates are not in mediaFiles) +7. `setMetadata`; `deletePlan` +8. Empty files: skip steps 5–6 disk/mediaFiles mutation; still `deletePlan` + +- [ ] **Step 1: Write failing tests** + +Happy path: inMemoryFs with `/m/Show/S01E01.mkv` + `/m/Show/S01E01.ass`, pending plan, apply → files at Season path, mediaFiles updated, plan gone. + +Empty plan: delete plan, mediaFiles unchanged. + +- [ ] **Step 2: Run — FAIL** + +- [ ] **Step 3: Implement** + +- [ ] **Step 4: Full `Core.test.ts` PASS** (recognize + rename) + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(core): applyPlan supports rename-files" +``` + +--- + +### Task 7: CLI try-to-rename + apply summary + e2e + +**Files:** +- Modify: `apps/cli/src/cli/runCli.ts` +- Modify: `apps/cli/index.ts` — add `'try-to-rename'` to `cliCommands` +- Create: `apps/cli/test/rename-files-e2e.test.ts` +- Modify: `docs/api/index.md` + +**CLI:** + +```typescript +program + .command('try-to-rename') + .description('Build a pending rename-files plan (plex/emby)') + .argument('', 'Imported media folder path') + .option('--rule ', 'Naming rule: plex | emby', 'plex') + .action(async (folder: string, opts: { rule: string }) => { + // validate rule; call tryToRenameFolder(folder, opts.rule as ...) + // print plan id, task, status, folder, files as " from → to" or (none) + }) +``` + +Update `apply` description and count: + +```typescript +const count = + plan.task === 'recognize-media-file' || plan.task === 'rename-files' + ? plan.files.length + : 0 +console.log(`applied ${plan.id} (${count} file(s))`) +``` + +**E2e** (mirror recognize-e2e setup): + +1. `createAndImportInitializedFolder` with `mediaFiles: []` +2. `smm try-to-recognize` + `smm apply` +3. `smm try-to-rename --rule plex` +4. Assert plan stdout contains `Season 01` +5. `smm apply ` +6. Assert `existsSync` for renamed video under `Season 01/`; old `S01E01.mkv` gone +7. Assert `getMediaMetadata` absolutePath updated + +Also: empty candidates case optional; unmanaged reject optional. + +- [ ] **Step 1: Write failing e2e** + +- [ ] **Step 2: Run — FAIL** (unknown command or apply fails) + +- [ ] **Step 3: Implement CLI + index registration + docs** + +- [ ] **Step 4: E2e + core-app tests PASS** + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(cli): add smm try-to-rename and apply rename-files" +``` + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| `tryToRenameFolder` + pending plan | 5 | +| Default rule plex | 5, 7 | +| Empty `files: []` pending | 5 | +| applyPlan rename-files + associates | 3, 6 | +| mkdir Season parents | 4, 6 | +| CLI try-to-rename / apply | 7 | +| index.ts cliCommands | 7 | +| No UI / core-routes | Global | +| TV only | 1–2, 5 | + +## Type consistency + +- `RenameRuleName = "plex" | "emby"` +- `RenameFilesPlan.files: { from, to }[]` (POSIX) +- Error strings match design table +- `applyPlan` supports both recognize and rename tasks after Task 6 From 0d65937c912c404cc7b117a641c36046f6b6a384 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:53:16 +0800 Subject: [PATCH 072/214] feat(core): add plex/emby TV renameRules Co-authored-by: Cursor --- apps/core/src/pipeline/renameRules.test.ts | 26 ++++++++++++ apps/core/src/pipeline/renameRules.ts | 46 ++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 apps/core/src/pipeline/renameRules.test.ts create mode 100644 apps/core/src/pipeline/renameRules.ts diff --git a/apps/core/src/pipeline/renameRules.test.ts b/apps/core/src/pipeline/renameRules.test.ts new file mode 100644 index 00000000..70688787 --- /dev/null +++ b/apps/core/src/pipeline/renameRules.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { generateNewFileName } from "./renameRules"; + +describe("generateNewFileName", () => { + const base = { + type: "tv" as const, + seasonNumber: 1, + episodeNumber: 2, + episodeName: "Pilot", + tvshowName: "Show", + file: "/m/Show/S01E02.mkv", + releaseYear: "2019", + }; + + it("builds plex relative path", () => { + expect(generateNewFileName("plex", base)).toBe( + "Season 01/Show - S01E02 - Pilot.mkv", + ); + }); + + it("builds emby relative path", () => { + expect(generateNewFileName("emby", base)).toBe( + "Season 1/Show S1E2 Pilot.mkv", + ); + }); +}); diff --git a/apps/core/src/pipeline/renameRules.ts b/apps/core/src/pipeline/renameRules.ts new file mode 100644 index 00000000..2f674c4b --- /dev/null +++ b/apps/core/src/pipeline/renameRules.ts @@ -0,0 +1,46 @@ +import { extname } from "./paths"; + +export type RenameRuleName = "plex" | "emby"; + +export interface NewFileNameContext { + type: "tv" | "movie"; + seasonNumber: number; + episodeNumber: number; + episodeName?: string; + tvshowName?: string; + movieName?: string; + file: string; + tmdbId?: string; + releaseYear: string; +} + +function generatePlexTvFileName(context: NewFileNameContext, ext: string): string { + const season = context.seasonNumber.toString().padStart(2, "0"); + const episode = context.episodeNumber.toString().padStart(2, "0"); + const folder = `Season ${season}`; + return `${folder}/${context.tvshowName} - S${season}E${episode} - ${context.episodeName}${ext}`; +} + +function generateEmbyTvFileName(context: NewFileNameContext, ext: string): string { + const season = context.seasonNumber.toString(); + const episode = context.episodeNumber.toString(); + const folder = `Season ${season}`; + return `${folder}/${context.tvshowName} S${season}E${episode} ${context.episodeName}${ext}`; +} + +export function generateNewFileName( + ruleName: RenameRuleName, + context: NewFileNameContext, +): string { + if (context.type !== "tv") { + throw new Error("Only TV rename is supported"); + } + + const ext = extname(context.file); + + if (ruleName === "plex") { + return generatePlexTvFileName(context, ext); + } + + return generateEmbyTvFileName(context, ext); +} From d44bcb5e6fd0c20b1331561d60f398de806fe941 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:54:11 +0800 Subject: [PATCH 073/214] feat(core): build TV rename plan file entries Co-authored-by: Cursor --- .../buildTvShowRenamePlanFileEntries.test.ts | 46 +++++++++++++++ .../buildTvShowRenamePlanFileEntries.ts | 58 +++++++++++++++++++ apps/core/src/pipeline/mediaFilePathEqual.ts | 12 ++++ 3 files changed, 116 insertions(+) create mode 100644 apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts create mode 100644 apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts create mode 100644 apps/core/src/pipeline/mediaFilePathEqual.ts diff --git a/apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts b/apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts new file mode 100644 index 00000000..000ff1b9 --- /dev/null +++ b/apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { MediaMetadata } from "@smm/core"; +import { buildTvShowRenamePlanFileEntries } from "./buildTvShowRenamePlanFileEntries"; + +function mm(partial: Partial & Pick): MediaMetadata { + return { + type: "tvshow-folder", + mediaFiles: [], + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [{ season: 1, episode: 1, name: "Ep1" }], + }, + ], + }, + ...partial, + } as MediaMetadata; +} + +describe("buildTvShowRenamePlanFileEntries", () => { + it("emits from→to for plex when current name differs", () => { + const meta = mm({ + mediaFolderPath: "/m/Show", + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }); + const files = buildTvShowRenamePlanFileEntries(meta, "plex"); + expect(files).toEqual([ + { + from: "/m/Show/S01E01.mkv", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.mkv", + }, + ]); + }); + + it("omits already-matching paths", () => { + const to = "/m/Show/Season 01/Show - S01E01 - Ep1.mkv"; + const meta = mm({ + mediaFolderPath: "/m/Show", + mediaFiles: [{ absolutePath: to, seasonNumber: 1, episodeNumber: 1 }], + }); + expect(buildTvShowRenamePlanFileEntries(meta, "plex")).toEqual([]); + }); +}); diff --git a/apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts b/apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts new file mode 100644 index 00000000..9e9dc646 --- /dev/null +++ b/apps/core/src/pipeline/buildTvShowRenamePlanFileEntries.ts @@ -0,0 +1,58 @@ +import type { MediaMetadata } from "@smm/core"; +import { Path } from "@core/path"; +import { generateNewFileName, type RenameRuleName } from "./renameRules"; +import { joinPosix } from "./paths"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; + +/** + * Build rename plan entries for a TV show using the selected naming rule. + * Omits episodes whose generated path equals the current video path. + */ +export function buildTvShowRenamePlanFileEntries( + mediaMetadata: MediaMetadata, + selectedNamingRule: RenameRuleName, +): Array<{ from: string; to: string }> { + const files: Array<{ from: string; to: string }> = []; + const tvShow = mediaMetadata.tvShow; + const mediaFolderPath = mediaMetadata.mediaFolderPath; + + if (!tvShow || !mediaFolderPath) { + return files; + } + + for (const season of tvShow.seasons) { + if (!season.episodes) continue; + + for (const episode of season.episodes) { + const mediaFile = mediaMetadata.mediaFiles?.find( + (file) => + file.seasonNumber === season.season && file.episodeNumber === episode.episode, + ); + + if (!mediaFile) continue; + + const relativePath = generateNewFileName(selectedNamingRule, { + type: "tv", + seasonNumber: season.season, + episodeNumber: episode.episode, + episodeName: episode.name || "", + tvshowName: tvShow.name || "", + file: mediaFile.absolutePath, + tmdbId: tvShow.id?.toString() || "", + releaseYear: tvShow.airDate ?? "", + }); + + const absolutePath = joinPosix(Path.posix(mediaFolderPath), relativePath); + const from = Path.posix(mediaFile.absolutePath); + const to = Path.posix(absolutePath); + + if (mediaFilePathEqual(from, to)) { + continue; + } + + files.push({ from, to }); + } + } + + return files; +} diff --git a/apps/core/src/pipeline/mediaFilePathEqual.ts b/apps/core/src/pipeline/mediaFilePathEqual.ts new file mode 100644 index 00000000..81e4bcde --- /dev/null +++ b/apps/core/src/pipeline/mediaFilePathEqual.ts @@ -0,0 +1,12 @@ +import { Path } from "@core/path"; + +export function mediaFilePathEqual(a: string | undefined, b: string | undefined): boolean { + if (a == null || b == null) { + return false; + } + try { + return Path.posix(a) === Path.posix(b); + } catch { + return a === b; + } +} From aa3c710b00bf712f779d1c3519f011186b96b9ff Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:55:57 +0800 Subject: [PATCH 074/214] feat(core): expand rename plan with associated files Co-authored-by: Cursor --- .../buildTvShowRenameListForPlan.test.ts | 36 ++++++++++++++ .../pipeline/buildTvShowRenameListForPlan.ts | 48 +++++++++++++++++++ apps/core/src/pipeline/findAssociatedFiles.ts | 46 ++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts create mode 100644 apps/core/src/pipeline/buildTvShowRenameListForPlan.ts create mode 100644 apps/core/src/pipeline/findAssociatedFiles.ts diff --git a/apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts b/apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts new file mode 100644 index 00000000..797418f1 --- /dev/null +++ b/apps/core/src/pipeline/buildTvShowRenameListForPlan.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import { buildTvShowRenameListForPlan } from "./buildTvShowRenameListForPlan"; + +describe("buildTvShowRenameListForPlan", () => { + it("includes video and same-stem subtitle", () => { + const plan: RenameFilesPlan = { + id: "p1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [ + { + from: "/m/Show/S01E01.mkv", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.mkv", + }, + ], + }; + const localFiles = [ + "/m/Show/S01E01.mkv", + "/m/Show/S01E01.sc.ass", + "/m/Show/other.mkv", + ]; + const list = buildTvShowRenameListForPlan({ + mediaFolderPath: "/m/Show", + localFiles, + plan, + }); + expect(list[0]).toEqual(plan.files[0]); + expect(list).toContainEqual({ + from: "/m/Show/S01E01.sc.ass", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.sc.ass", + }); + }); +}); diff --git a/apps/core/src/pipeline/buildTvShowRenameListForPlan.ts b/apps/core/src/pipeline/buildTvShowRenameListForPlan.ts new file mode 100644 index 00000000..5d41290f --- /dev/null +++ b/apps/core/src/pipeline/buildTvShowRenameListForPlan.ts @@ -0,0 +1,48 @@ +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import { Path } from "@core/path"; +import { getFullExtensionForAssociatedFile } from "@core/utils"; +import { findAssociatedFiles } from "./findAssociatedFiles"; +import { extname, joinPosix } from "./paths"; + +export function buildTvShowRenameListForPlan(options: { + mediaFolderPath: string; + localFiles: string[]; + plan: RenameFilesPlan; +}): Array<{ from: string; to: string }> { + const mediaFolderPath = Path.posix(options.mediaFolderPath); + const localFiles = options.localFiles.map((p) => Path.posix(p)); + const { plan } = options; + + const renameList: Array<{ from: string; to: string }> = []; + renameList.push(...plan.files); + + for (const file of plan.files) { + const { from, to } = file; + + const newFileRelativePath = to.replace(mediaFolderPath, ""); + const newFileRelativePathWithExt = newFileRelativePath.replace( + extname(newFileRelativePath), + "", + ); + + const associatedFiles = findAssociatedFiles( + mediaFolderPath, + localFiles, + from, + ); + + for (const associatedFile of associatedFiles) { + const fullExt = getFullExtensionForAssociatedFile(associatedFile); + const fromA = Path.posix(associatedFile); + const relativeDest = (newFileRelativePathWithExt + fullExt).replace( + /^\//, + "", + ); + const toA = Path.posix(joinPosix(mediaFolderPath, relativeDest)); + + renameList.push({ from: fromA, to: toA }); + } + } + + return renameList; +} diff --git a/apps/core/src/pipeline/findAssociatedFiles.ts b/apps/core/src/pipeline/findAssociatedFiles.ts new file mode 100644 index 00000000..611e7c96 --- /dev/null +++ b/apps/core/src/pipeline/findAssociatedFiles.ts @@ -0,0 +1,46 @@ +import { Path } from "@core/path"; +import { + extensions, + imageFileExtensions, + subtitleFileExtensions, +} from "@core/utils"; +import { basename, extname } from "./paths"; + +export function findAssociatedFiles( + mediaFolderPath: string, + filePaths: string[], + videoFilePath: string, +): string[] { + const normalizedVideoPath = Path.posix(videoFilePath); + const normalizedFilePaths = filePaths.map((p) => Path.posix(p)); + + const filename = basename(normalizedVideoPath); + const extension = extname(filename); + const filenameWithoutExtension = filename.replace(extension, ""); + + const findFiles = (extensionsList: string[]) => { + const possibleFileNames = extensionsList.map( + (ext) => `${filenameWithoutExtension}${ext}`, + ); + return normalizedFilePaths + .filter((filePath) => + extensionsList.some((ext) => filePath.endsWith(ext)), + ) + .filter((filePath) => { + const name = basename(filePath); + if (possibleFileNames.includes(name)) return true; + if (name.startsWith(filenameWithoutExtension + ".")) { + return extensionsList.some((ext) => name.endsWith(ext)); + } + return false; + }) + .map((filePath) => Path.posix(filePath)); + }; + + return [ + ...findFiles(imageFileExtensions), + ...findFiles(subtitleFileExtensions), + ...findFiles(extensions.audioTrackFileExtensions), + ...findFiles([".nfo"]), + ]; +} From b23bb3491fde992efa103bfe09f6b378a863ee6d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:57:01 +0800 Subject: [PATCH 075/214] feat(core): add FsPort.mkdir for rename parents Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 2 ++ apps/core/src/adapters/network/NetworkFsAdapter.ts | 4 ++++ apps/core/src/adapters/node/NodejsFsAdapter.test.ts | 7 +++++++ apps/core/src/adapters/node/NodejsFsAdapter.ts | 4 ++++ .../src/pipeline/importFolderPipeline.integration.test.ts | 1 + apps/core/src/pipeline/importFolderPipeline.test.ts | 1 + apps/core/src/pipeline/plans.test.ts | 1 + apps/core/src/pipeline/recognizeMediaFolder.test.ts | 1 + apps/core/src/pipeline/userConfig.test.ts | 1 + apps/core/src/ports/FsPort.ts | 2 ++ 10 files changed, 24 insertions(+) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index dbec3ab0..429af44d 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -41,6 +41,7 @@ function inMemoryFs(seed: Record = {}): FsPort { } // Ensure destination directory marker if only empty dirs matter — file keys alone are enough for these tests. }), + mkdir: vi.fn(async () => {}), }; } @@ -426,6 +427,7 @@ describe("unimportFolder", () => { files.delete(path); }, rename: async () => {}, + mkdir: async () => {}, }; const core = new Core({ fs, network: emptyNetwork(), appDataDir }); diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index 080aa25c..e06c3fca 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -65,4 +65,8 @@ export class NetworkFsAdapter implements FsPort { async rename(_from: string, _to: string): Promise { throw new Error("Not Implemented: NetworkFsAdapter.rename"); } + + async mkdir(_path: string): Promise { + throw new Error("Not Implemented: NetworkFsAdapter.mkdir"); + } } diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts index 8addfbbe..e9ce5104 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts @@ -63,6 +63,13 @@ describe("NodejsFsAdapter", () => { await expect(adapter.deleteFile(joinPosix(tmpPosix, "nope.txt"))).resolves.toBeUndefined(); }); + it("creates nested directories", async () => { + const adapter = new NodejsFsAdapter(); + const dir = joinPosix(tmpPosix, "Season 01"); + await adapter.mkdir(dir); + expect(await adapter.exists(dir)).toBe(true); + }); + it("renames a directory on disk", async () => { const adapter = new NodejsFsAdapter(); const from = joinPosix(tmpPosix, "old-dir"); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts index 0990e4b8..8e8e0241 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -56,4 +56,8 @@ export class NodejsFsAdapter implements FsPort { async rename(from: string, to: string): Promise { await fsp.rename(Path.toPlatformPath(from), Path.toPlatformPath(to)); } + + async mkdir(path: string): Promise { + await fsp.mkdir(Path.toPlatformPath(path), { recursive: true }); + } } diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts index 91edbd14..628ee5a5 100644 --- a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -36,6 +36,7 @@ function inMemoryFs(seed: Record = {}): FsPort { }), deleteFile: vi.fn(async () => {}), rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index 809b820e..8719d0dc 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -34,6 +34,7 @@ function inMemoryFs(seed: Record = {}): FsPort { }), deleteFile: vi.fn(async () => {}), rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/plans.test.ts b/apps/core/src/pipeline/plans.test.ts index cfd911d7..5adb3175 100644 --- a/apps/core/src/pipeline/plans.test.ts +++ b/apps/core/src/pipeline/plans.test.ts @@ -22,6 +22,7 @@ function inMemoryFs(): FsPort & { raw: Map } { files.delete(path); }), rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index fa8a3d1d..6612af00 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -29,6 +29,7 @@ function fsPort(files: Record): FsPort { listFiles: vi.fn(async () => []), deleteFile: vi.fn(async () => {}), rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), }; } diff --git a/apps/core/src/pipeline/userConfig.test.ts b/apps/core/src/pipeline/userConfig.test.ts index 9b489ca0..f7db67d6 100644 --- a/apps/core/src/pipeline/userConfig.test.ts +++ b/apps/core/src/pipeline/userConfig.test.ts @@ -26,6 +26,7 @@ function delayedFs(seed: Record, delayMs = 15): FsPort { files.delete(path); }, rename: async () => {}, + mkdir: async () => {}, }; } diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts index 5e97710a..2595fdd9 100644 --- a/apps/core/src/ports/FsPort.ts +++ b/apps/core/src/ports/FsPort.ts @@ -9,4 +9,6 @@ export interface FsPort { deleteFile(path: string): Promise; /** Rename/move a file or directory. Paths are POSIX. Missing source should reject. */ rename(from: string, to: string): Promise; + /** Create a directory (and parents). Idempotent if it already exists. Paths are POSIX. */ + mkdir(path: string): Promise; } From 05221d7a7a278a01d76d2151851e63203ded8dcf Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:57:43 +0800 Subject: [PATCH 076/214] feat(core): add Core.tryToRenameFolder Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 65 +++++++++++++++ apps/core/src/Core.ts | 12 +++ apps/core/src/index.ts | 2 + apps/core/src/pipeline/tryToRenameFolder.ts | 88 +++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 apps/core/src/pipeline/tryToRenameFolder.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 429af44d..5adbd5e8 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -583,6 +583,71 @@ describe("tryToRecognizeFolder", () => { }); }); +describe("tryToRenameFolder", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + const tvMetadata = { + mediaFolderPath: folder, + type: "tvshow-folder" as const, + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [{ season: 1, episode: 1, name: "Ep1" }], + }, + ], + }, + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + + function seed() { + return inMemoryFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(tvMetadata), + }); + } + + it("creates a pending rename-files plan with plex targets", async () => { + const fs = seed(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.tryToRenameFolder("/m/Show"); + expect(plan.task).toBe("rename-files"); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("app"); + expect(plan.mediaFolderPath).toBe("/m/Show"); + expect(plan.files).toEqual([ + { + from: "/m/Show/S01E01.mkv", + to: "/m/Show/Season 01/Show - S01E01 - Ep1.mkv", + }, + ]); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(true); + }); + + it("returns pending plan with empty files when paths already match", async () => { + const matching = "/m/Show/Season 01/Show - S01E01 - Ep1.mkv"; + const fs = inMemoryFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), + [metadataCachePath(appDataDir, folder)]: JSON.stringify({ + ...tvMetadata, + mediaFiles: [{ absolutePath: matching, seasonNumber: 1, episodeNumber: 1 }], + }), + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.tryToRenameFolder("/m/Show"); + expect(plan.files).toEqual([]); + expect(plan.status).toBe("pending"); + }); + + it("rejects unmanaged folders", async () => { + const fs = seed(); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + await expect(core.tryToRenameFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); + }); +}); + describe("applyPlan", () => { const appDataDir = "/data"; const folder = "/m/Show"; diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index b8907db9..8a50fc49 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -1,6 +1,7 @@ import { Path } from "@core/path"; import type { AppConfig, FolderType, MediaMetadata, UserConfig as UserConfigData } from "@smm/core"; import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; @@ -12,6 +13,8 @@ import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFo import { applyRecognizeMediaFilePlanPipeline } from "./pipeline/applyPlan"; import { readPlan, type Plan } from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; +import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; +import type { RenameRuleName } from "./pipeline/renameRules"; import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; @@ -173,6 +176,15 @@ export class Core { }); } + async tryToRenameFolder(path: string, rule?: RenameRuleName): Promise { + return tryToRenameFolderPipeline(path, rule, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + }); + } + async getPlan(id: string): Promise { const plan = await readPlan(this.fs, this.appDataDir, id); if (!plan) throw new Error(`Plan not found: ${id}`); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index a7df0ec1..286ea3c3 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -18,6 +18,8 @@ export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } fr export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { tryToRecognizeFolderPipeline, type TryToRecognizeFolderDeps } from "./pipeline/tryToRecognizeFolder"; +export { tryToRenameFolderPipeline, type TryToRenameFolderDeps } from "./pipeline/tryToRenameFolder"; +export type { RenameRuleName } from "./pipeline/renameRules"; export type { Plan } from "./pipeline/plans"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; diff --git a/apps/core/src/pipeline/tryToRenameFolder.ts b/apps/core/src/pipeline/tryToRenameFolder.ts new file mode 100644 index 00000000..d41f8d80 --- /dev/null +++ b/apps/core/src/pipeline/tryToRenameFolder.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { Path } from "@core/path"; +import type { MediaMetadata } from "@smm/core"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import type { FsPort } from "../ports/FsPort"; +import { buildTvShowRenamePlanFileEntries } from "./buildTvShowRenamePlanFileEntries"; +import { metadataCachePath } from "./paths"; +import { writePlan } from "./plans"; +import type { RenameRuleName } from "./renameRules"; +import type { UserConfig } from "./userConfig"; + +export interface TryToRenameFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + /** Injected for tests; default `randomUUID` from `node:crypto`. */ + createId?: () => string; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +function hasTvShowEpisodes(mm: MediaMetadata): boolean { + if (mm.type !== "tvshow-folder") return false; + const seasons = mm.tvShow?.seasons; + if (!seasons || seasons.length === 0) return false; + return seasons.some((season) => (season.episodes?.length ?? 0) > 0); +} + +function assertSupportedRule(rule: string | undefined): asserts rule is RenameRuleName | undefined { + if (rule !== undefined && rule !== "plex" && rule !== "emby") { + throw new Error(`Unsupported rename rule: ${rule}`); + } +} + +/** Build a pending rename-files plan for a TV show folder (throws instead of { error }). */ +export async function tryToRenameFolderPipeline( + path: string, + rule: RenameRuleName | undefined, + deps: TryToRenameFolderDeps, +): Promise { + const posixPath = deps.normalizePosix(path); + const createId = deps.createId ?? randomUUID; + + assertSupportedRule(rule); + const effectiveRule: RenameRuleName = rule ?? "plex"; + + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], path)) { + throw new Error(`${posixPath} is not managed by SMM`); + } + + const cachePath = metadataCachePath(deps.appDataDir, posixPath); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${path}`); + } + let mediaMetadata: MediaMetadata; + try { + mediaMetadata = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${path}`); + } + + if (!hasTvShowEpisodes(mediaMetadata)) { + throw new Error(`Folder is not a TV show with episodes: ${path}`); + } + + const files = buildTvShowRenamePlanFileEntries(mediaMetadata, effectiveRule); + + const plan: RenameFilesPlan = { + id: createId(), + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: posixPath, + files, + }; + + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; +} From beda3c9c2c76d2a7a08604eda3626ffefb7b2564 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 19:58:53 +0800 Subject: [PATCH 077/214] feat(core): applyPlan supports rename-files Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 74 ++++++++++++++++++- apps/core/src/Core.ts | 4 +- apps/core/src/pipeline/applyPlan.ts | 12 +++ .../core/src/pipeline/applyRenameFilesPlan.ts | 57 ++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 apps/core/src/pipeline/applyRenameFilesPlan.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 5adbd5e8..c2454684 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -711,12 +711,82 @@ describe("applyPlan", () => { await expect(core.getPlan("nope")).rejects.toThrow("Plan not found: nope"); }); + it("renames video and subtitle and updates mediaFiles", async () => { + const renameMetadata = { + ...tvMetadata, + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [{ season: 1, episode: 1, name: "Ep1" }], + }, + ], + }, + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(renameMetadata), + "/m/Show/S01E01.mkv": "", + "/m/Show/S01E01.ass": "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.tryToRenameFolder("/m/Show"); + await core.applyPlan(plan); + + const targetVideo = "/m/Show/Season 01/Show - S01E01 - Ep1.mkv"; + const targetSubtitle = "/m/Show/Season 01/Show - S01E01 - Ep1.ass"; + expect(await fs.exists(targetVideo)).toBe(true); + expect(await fs.exists(targetSubtitle)).toBe(true); + expect(await fs.exists("/m/Show/S01E01.mkv")).toBe(false); + expect(await fs.exists("/m/Show/S01E01.ass")).toBe(false); + + const mm = await core.getMediaMetadata("/m/Show"); + expect(mm?.mediaFiles).toEqual([ + { absolutePath: targetVideo, seasonNumber: 1, episodeNumber: 1 }, + ]); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); + }); + + it("applies empty rename-files plan as delete only", async () => { + const matching = "/m/Show/Season 01/Show - S01E01 - Ep1.mkv"; + const renameMetadata = { + ...tvMetadata, + tvShow: { + id: "1", + name: "Show", + seasons: [ + { + season: 1, + episodes: [{ season: 1, episode: 1, name: "Ep1" }], + }, + ], + }, + mediaFiles: [{ absolutePath: matching, seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(renameMetadata), + [matching]: "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const before = await core.getMediaMetadata("/m/Show"); + const plan = await core.tryToRenameFolder("/m/Show"); + expect(plan.files).toEqual([]); + await core.applyPlan(plan); + const after = await core.getMediaMetadata("/m/Show"); + expect(after?.mediaFiles).toEqual(before?.mediaFiles ?? []); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); + }); + it("rejects unsupported tasks", async () => { const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir }); await expect( core.applyPlan({ - id: "r1", - task: "rename-files", + id: "x1", + task: "unknown-task" as "recognize-media-file", status: "pending", creator: "app", mediaFolderPath: "/m/Show", diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 8a50fc49..416450ce 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -10,7 +10,7 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; -import { applyRecognizeMediaFilePlanPipeline } from "./pipeline/applyPlan"; +import { applyPlanPipeline } from "./pipeline/applyPlan"; import { readPlan, type Plan } from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; @@ -192,7 +192,7 @@ export class Core { } async applyPlan(plan: Plan): Promise { - await applyRecognizeMediaFilePlanPipeline(plan, { + await applyPlanPipeline(plan, { fs: this.fs, appDataDir: this.appDataDir, normalizePosix: (p) => this.normalizePosix(p), diff --git a/apps/core/src/pipeline/applyPlan.ts b/apps/core/src/pipeline/applyPlan.ts index ec351ad9..a93ab8a2 100644 --- a/apps/core/src/pipeline/applyPlan.ts +++ b/apps/core/src/pipeline/applyPlan.ts @@ -1,5 +1,6 @@ import type { MediaMetadata } from "@smm/core"; import type { FsPort } from "../ports/FsPort"; +import { applyRenameFilesPlanPipeline } from "./applyRenameFilesPlan"; import { deletePlan, type Plan } from "./plans"; import { updateMediaFileMetadatas } from "./updateMediaFileMetadatas"; @@ -11,6 +12,17 @@ export interface ApplyPlanDeps { setMetadata: (mm: MediaMetadata) => Promise; } +/** Dispatches apply by plan task (recognize-media-file or rename-files). */ +export async function applyPlanPipeline(plan: Plan, deps: ApplyPlanDeps): Promise { + if (plan.task === "recognize-media-file") { + return applyRecognizeMediaFilePlanPipeline(plan, deps); + } + if (plan.task === "rename-files") { + return applyRenameFilesPlanPipeline(plan, deps); + } + throw new Error(`Unsupported plan task: ${plan.task}`); +} + /** Applies a pending recognize-media-file plan: merge mediaFiles, persist, delete plan. */ export async function applyRecognizeMediaFilePlanPipeline( plan: Plan, diff --git a/apps/core/src/pipeline/applyRenameFilesPlan.ts b/apps/core/src/pipeline/applyRenameFilesPlan.ts new file mode 100644 index 00000000..1d70514a --- /dev/null +++ b/apps/core/src/pipeline/applyRenameFilesPlan.ts @@ -0,0 +1,57 @@ +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import { Path } from "@core/path"; +import type { ApplyPlanDeps } from "./applyPlan"; +import { buildTvShowRenameListForPlan } from "./buildTvShowRenameListForPlan"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; +import { deletePlan } from "./plans"; + +function dirnamePosix(path: string): string { + const idx = path.lastIndexOf("/"); + return idx <= 0 ? "/" : path.slice(0, idx); +} + +/** Applies a pending rename-files plan: disk rename, mediaFiles rewrite, delete plan. */ +export async function applyRenameFilesPlanPipeline( + plan: RenameFilesPlan, + deps: ApplyPlanDeps, +): Promise { + if (plan.task !== "rename-files") { + throw new Error(`Unsupported plan task: ${plan.task}`); + } + + const folder = deps.normalizePosix(plan.mediaFolderPath); + const mm = await deps.getMediaMetadata(folder); + if (!mm) { + throw new Error(`Media metadata not found: ${plan.mediaFolderPath}`); + } + + if (plan.files.length === 0) { + await deletePlan(deps.fs, deps.appDataDir, plan.id); + return; + } + + const localFiles = (await deps.fs.listFiles(folder)).map((p) => Path.posix(p)); + const renameList = buildTvShowRenameListForPlan({ + mediaFolderPath: folder, + localFiles, + plan, + }); + + for (const { from, to } of renameList) { + const parent = dirnamePosix(Path.posix(to)); + await deps.fs.mkdir(parent); + await deps.fs.rename(from, to); + } + + let mediaFiles = mm.mediaFiles ?? []; + for (const { from, to } of renameList) { + mediaFiles = mediaFiles.map((entry) => + mediaFilePathEqual(entry.absolutePath, from) + ? { ...entry, absolutePath: Path.posix(to) } + : entry, + ); + } + + await deps.setMetadata({ ...mm, mediaFiles }); + await deletePlan(deps.fs, deps.appDataDir, plan.id); +} From 3ededf48987bb95fa182728863d1bee86ca1fcd3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 20:01:40 +0800 Subject: [PATCH 078/214] feat(cli): add smm try-to-rename and apply rename-files Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/src/cli/runCli.ts | 49 +++++++++- apps/cli/test/rename-files-e2e.test.ts | 124 +++++++++++++++++++++++++ docs/api/index.md | 7 +- 4 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 apps/cli/test/rename-files-e2e.test.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 78bd1119..0b436d0e 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'apply']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply']) if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 949a76d3..ccc2b28f 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -1,6 +1,6 @@ import { Command, Option } from 'commander' import { readFile } from 'node:fs/promises' -import type { FolderType } from 'core-app' +import type { FolderType, RenameRuleName } from 'core-app' import type { MediaMetadata } from '@smm/core' import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' @@ -24,6 +24,15 @@ function resolveFolderType(value: string): FolderType { throw new Error(`Invalid folder type: ${value}`) } +const RENAME_RULES: readonly RenameRuleName[] = ['plex', 'emby'] + +function resolveRenameRule(value: string): RenameRuleName { + if ((RENAME_RULES as readonly string[]).includes(value)) { + return value as RenameRuleName + } + throw new Error(`Unsupported rename rule: ${value}`) +} + /** Parse CLI value as JSON when possible; otherwise keep the raw string. */ function parseConfigValue(raw: string): unknown { try { @@ -40,7 +49,7 @@ function printJson(value: unknown): void { const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `apply`, `config`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `config`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -212,15 +221,45 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('try-to-rename') + .description('Build a pending rename-files plan (plex/emby)') + .argument('', 'Imported media folder path') + .option('--rule ', 'Naming rule: plex | emby', 'plex') + .action(async (folder: string, opts: { rule: string }) => { + try { + const rule = resolveRenameRule(opts.rule) + const plan = await getCore().tryToRenameFolder(folder, rule) + console.log(`plan: ${plan.id}`) + console.log(`task: ${plan.task}`) + console.log(`status: ${plan.status}`) + console.log(`folder: ${plan.mediaFolderPath}`) + console.log('files:') + if (plan.files.length === 0) { + console.log(' (none)') + } else { + for (const f of plan.files) { + console.log(` ${f.from} → ${f.to}`) + } + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + program .command('apply') - .description('Apply a pending plan by id (recognize-media-file)') - .argument('', 'Plan id from try-to-recognize') + .description('Apply a pending plan by id (recognize-media-file or rename-files)') + .argument('', 'Plan id from try-to-recognize or try-to-rename') .action(async (planId: string) => { try { const plan = await getCore().getPlan(planId) await getCore().applyPlan(plan) - const count = plan.task === 'recognize-media-file' ? plan.files.length : 0 + const count = + plan.task === 'recognize-media-file' || plan.task === 'rename-files' + ? plan.files.length + : 0 console.log(`applied ${plan.id} (${count} file(s))`) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) diff --git a/apps/cli/test/rename-files-e2e.test.ts b/apps/cli/test/rename-files-e2e.test.ts new file mode 100644 index 00000000..b4746a57 --- /dev/null +++ b/apps/cli/test/rename-files-e2e.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Path } from '@core/path' +import type { MediaMetadata } from '@smm/core' +import { getCore, resetCoreForTests } from '../src/core/getCore' +import { smm } from './helpers/smm' +import { + createAndImportInitializedFolder, + tvShowFolder, +} from './helpers/testFolders' + +function planPath(userDataDir: string, planId: string): string { + return join(userDataDir, 'plans', `${planId}.plan.json`) +} + +function parsePlanId(stdout: string): string { + const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] + expect(planId, stdout).toBeTruthy() + return planId! +} + +/** TMDB template name contains ":" which is invalid on Windows paths. */ +function withWindowsSafeTvShowName(mm: MediaMetadata): MediaMetadata { + return { + ...mm, + tvShow: mm.tvShow + ? { ...mm.tvShow, name: 'WATATEN an Angel Flew Down to Me' } + : mm.tvShow, + } +} + +async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: string) { + return createAndImportInitializedFolder( + mediaDir, + { ...tvShowFolder, ...(folderName ? { folderName } : {}) }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) +} + +const PLEX_S01E01_BASENAME = + 'WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv' + +describe('smm try-to-rename / apply CLI e2e', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-files-e2e-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-files-e2e-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it('plex rename: recognize then try-to-rename then apply moves S01E01 into Season 01', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'RenameFiles 123123') + const path = folder.path! + + const recognized = await smm(['try-to-recognize', path]) + expect(recognized.code, recognized.stderr || recognized.stdout).toBe(0) + const recognizePlanId = parsePlanId(recognized.stdout) + const appliedRecognize = await smm(['apply', recognizePlanId]) + expect(appliedRecognize.code, appliedRecognize.stderr || appliedRecognize.stdout).toBe(0) + + const tried = await smm(['try-to-rename', path, '--rule', 'plex']) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + expect(tried.stdout).toMatch(/task:\s+rename-files/) + expect(tried.stdout).toMatch(/status:\s+pending/) + expect(tried.stdout).toContain('Season 01') + expect(tried.stdout).toContain('S01E01.mkv') + expect(tried.stdout).toContain(PLEX_S01E01_BASENAME) + + const planId = parsePlanId(tried.stdout) + expect(existsSync(planPath(userDataDir, planId))).toBe(true) + + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + expect(applied.stdout).toMatch(/applied .* \(3 file\(s\)\)/) + expect(existsSync(planPath(userDataDir, planId))).toBe(false) + + const renamedVideo = join(path, 'Season 01', PLEX_S01E01_BASENAME) + expect(existsSync(renamedVideo)).toBe(true) + expect(existsSync(join(path, 'S01E01.mkv'))).toBe(false) + + const mm = await getCore().getMediaMetadata(path) + expect(mm!.mediaFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + absolutePath: Path.posix(join(path, 'Season 01', PLEX_S01E01_BASENAME)), + seasonNumber: 1, + episodeNumber: 1, + }), + ]), + ) + }) + + it('rejects unmanaged folder', async () => { + const unmanaged = join(mediaDir, 'not-imported') + const result = await smm(['try-to-rename', unmanaged]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not managed by SMM/i) + }) + + it('rejects unsupported rename rule', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'BadRule 123123') + const result = await smm(['try-to-rename', folder.path!, '--rule', 'jellyfin']) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/Unsupported rename rule/i) + }) +}) diff --git a/docs/api/index.md b/docs/api/index.md index 00db6af2..c5c89dd9 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -74,10 +74,11 @@ HTTP: `POST /api/unimport-folder` — removes an imported media folder from `use Source Code: apps/cli/src/route/RenameFolderV3.ts HTTP: `POST /api/rename-folder` — renames a managed media folder via Layer 2 `Core.renameFolder({ from, to })` (metadata cache + `UserConfig.folders` + on-disk rename). Request body: `{ from: string, to: string }`. Response: `{ data: { from, to } }` or `{ error }`. Broadcasts the same folder-renamed / userConfigUpdated socket events as legacy `POST /api/renameFolder`. Used by UI Sidebar rename when `localStorage["smm.v3.enabled"] === "true"`. -## CLI: try-to-recognize / apply -Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / applyPlan +## CLI: try-to-recognize / try-to-rename / apply +Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / Core.tryToRenameFolder / applyPlan `smm try-to-recognize ` — rule-based episode recognition → pending plan under `{userDataDir}/plans/`. -`smm apply ` — apply recognize-media-file plan (updates metadata cache, deletes plan file). +`smm try-to-rename [--rule plex|emby]` — build a pending rename-files plan (default rule: plex). +`smm apply ` — apply a pending recognize-media-file or rename-files plan (updates metadata cache, deletes plan file). ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts From 83d1c584df65f41e4be11e6b23f9d22b8ac59718 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 20:18:58 +0800 Subject: [PATCH 079/214] test(cli): expand recognize and rename-files e2e coverage Add partial/zero-match/error-path cases for try-to-recognize, and emby, associated files, already-renamed, and error cases for try-to-rename. Co-authored-by: Cursor --- apps/cli/test/recognize-e2e.test.ts | 180 +++++++++++++++++++++++-- apps/cli/test/rename-files-e2e.test.ts | 128 +++++++++++++++++- 2 files changed, 289 insertions(+), 19 deletions(-) diff --git a/apps/cli/test/recognize-e2e.test.ts b/apps/cli/test/recognize-e2e.test.ts index fbc08583..0295bf02 100644 --- a/apps/cli/test/recognize-e2e.test.ts +++ b/apps/cli/test/recognize-e2e.test.ts @@ -1,11 +1,36 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, renameSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Path } from '@core/path' import { getCore, resetCoreForTests } from '../src/core/getCore' import { smm } from './helpers/smm' -import { createAndImportInitializedFolder, tvShowFolder } from './helpers/testFolders' +import { + createAndImportInitializedFolder, + movieFolder, + tvShowFolder, +} from './helpers/testFolders' + +function planPath(userDataDir: string, planId: string): string { + return join(userDataDir, 'plans', `${planId}.plan.json`) +} + +function parsePlanId(stdout: string): string { + const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] + expect(planId, stdout).toBeTruthy() + return planId! +} + +/** Mirrors e2e: import with mediaFiles=[] so rule-based recognize has work to do. */ +async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: string) { + return createAndImportInitializedFolder( + mediaDir, + { ...tvShowFolder, ...(folderName ? { folderName } : {}) }, + { + updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }), + }, + ) +} describe('smm try-to-recognize / apply CLI e2e', () => { let userDataDir: string @@ -28,24 +53,28 @@ describe('smm try-to-recognize / apply CLI e2e', () => { rmSync(mediaDir, { recursive: true, force: true }) }) - it('try-to-recognize then apply updates mediaFiles', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }, { - updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }), - }) + // Mirrors apps/e2e TVShow-Recognize: import with no mediaFiles → recognize → confirm + it('rule-based recognize: try-to-recognize then apply maps S01E01..03', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'UnKnown Folder 123123123123') const path = folder.path! const tried = await smm(['try-to-recognize', path]) expect(tried.code, tried.stderr || tried.stdout).toBe(0) - expect(tried.stdout).toMatch(/plan:\s+[0-9a-f-]{36}/i) - const planId = tried.stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] - expect(planId).toBeTruthy() + expect(tried.stdout).toMatch(/task:\s+recognize-media-file/) + expect(tried.stdout).toMatch(/status:\s+pending/) + expect(tried.stdout).toContain('S01E01') + expect(tried.stdout).toContain('S01E02') + expect(tried.stdout).toContain('S01E03') - const applied = await smm(['apply', planId!]) + const planId = parsePlanId(tried.stdout) + expect(existsSync(planPath(userDataDir, planId))).toBe(true) + + const applied = await smm(['apply', planId]) expect(applied.code, applied.stderr || applied.stdout).toBe(0) + expect(applied.stdout).toMatch(/applied .* \(3 file\(s\)\)/) + expect(existsSync(planPath(userDataDir, planId))).toBe(false) const mm = await getCore().getMediaMetadata(path) - expect(mm?.mediaFiles?.length).toBeGreaterThan(0) - expect(mm?.mediaFiles?.[0]?.seasonNumber).toBe(1) expect(mm!.mediaFiles).toEqual([ { absolutePath: Path.posix(join(path, 'S01E01.mkv')), @@ -64,4 +93,131 @@ describe('smm try-to-recognize / apply CLI e2e', () => { }, ]) }) + + // Mirrors e2e PartialRecognition intent: drop S01E03 naming so only two episodes match. + // (e2e renames to S01E03-renamed.mkv which still matches pattern1 via includes('S01E03'); + // use a name without that token so CLI partial coverage is real.) + it('partial coverage: only S01E01..02 match when S01E03 video loses season-episode name', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'PartialRecognition 123123') + const path = folder.path! + renameSync(join(path, 'S01E03.mkv'), join(path, 'extra-video.mkv')) + + const tried = await smm(['try-to-recognize', path]) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + const planId = parsePlanId(tried.stdout) + expect(tried.stdout).toContain('S01E01') + expect(tried.stdout).toContain('S01E02') + expect(tried.stdout).not.toMatch(/S01E03\s+/) + + const plan = await getCore().getPlan(planId) + expect(plan.task).toBe('recognize-media-file') + if (plan.task === 'recognize-media-file') { + expect(plan.files).toHaveLength(2) + expect(plan.files.map((f) => `${f.season}:${f.episode}`)).toEqual(['1:1', '1:2']) + } + + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + + const mm = await getCore().getMediaMetadata(path) + expect(mm!.mediaFiles).toEqual([ + { + absolutePath: Path.posix(join(path, 'S01E01.mkv')), + seasonNumber: 1, + episodeNumber: 1, + }, + { + absolutePath: Path.posix(join(path, 'S01E02.mkv')), + seasonNumber: 1, + episodeNumber: 2, + }, + ]) + }) + + // Spec: zero matches → pending plan with files: [] (not an error) + it('zero matches: pending plan with empty files, apply is no-op on mediaFiles', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'NoPatternMatch 123123') + const path = folder.path! + for (const name of ['S01E01.mkv', 'S01E02.mkv', 'S01E03.mkv']) { + renameSync(join(path, name), join(path, name.replace('S01E', 'Episode'))) + } + + const tried = await smm(['try-to-recognize', path]) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + expect(tried.stdout).toMatch(/status:\s+pending/) + expect(tried.stdout).toContain('(none)') + const planId = parsePlanId(tried.stdout) + + const plan = await getCore().getPlan(planId) + expect(plan.task).toBe('recognize-media-file') + if (plan.task === 'recognize-media-file') { + expect(plan.files).toEqual([]) + } + + const before = await getCore().getMediaMetadata(path) + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + expect(applied.stdout).toMatch(/\(0 file\(s\)\)/) + expect(existsSync(planPath(userDataDir, planId))).toBe(false) + + const after = await getCore().getMediaMetadata(path) + expect(after?.mediaFiles ?? []).toEqual(before?.mediaFiles ?? []) + }) + + // Mirrors e2e AlreadyRecognized: folder already has mediaFiles mappings + it('already recognized: try-to-recognize still builds a plan that can be applied', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { + ...tvShowFolder, + folderName: 'AlreadyRecognized 123123', + }) + const path = folder.path! + + const seeded = await getCore().getMediaMetadata(path) + expect(seeded?.mediaFiles?.length).toBeGreaterThan(0) + + const tried = await smm(['try-to-recognize', path]) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + const planId = parsePlanId(tried.stdout) + expect(tried.stdout).toContain('S01E01') + + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + + const mm = await getCore().getMediaMetadata(path) + expect(mm!.mediaFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + absolutePath: Path.posix(join(path, 'S01E01.mkv')), + seasonNumber: 1, + episodeNumber: 1, + }), + ]), + ) + }) + + it('rejects unmanaged folder', async () => { + const unmanaged = join(mediaDir, 'not-imported') + const result = await smm(['try-to-recognize', unmanaged]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not managed by SMM/i) + }) + + it('rejects movie folder (not a TV show with episodes)', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { + mediaMetadata: { + type: 'movie-folder', + mediaFiles: [], + movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, + }, + }) + const result = await smm(['try-to-recognize', folder.path!]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not a TV show with episodes/i) + }) + + it('apply rejects missing plan id', async () => { + const result = await smm(['apply', '00000000-0000-0000-0000-000000000000']) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/Plan not found/i) + }) }) diff --git a/apps/cli/test/rename-files-e2e.test.ts b/apps/cli/test/rename-files-e2e.test.ts index b4746a57..7520cd15 100644 --- a/apps/cli/test/rename-files-e2e.test.ts +++ b/apps/cli/test/rename-files-e2e.test.ts @@ -8,6 +8,7 @@ import { getCore, resetCoreForTests } from '../src/core/getCore' import { smm } from './helpers/smm' import { createAndImportInitializedFolder, + movieFolder, tvShowFolder, } from './helpers/testFolders' @@ -42,8 +43,19 @@ async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: st ) } -const PLEX_S01E01_BASENAME = - 'WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv' +async function recognizeAndApply(path: string): Promise { + const recognized = await smm(['try-to-recognize', path]) + expect(recognized.code, recognized.stderr || recognized.stdout).toBe(0) + const planId = parsePlanId(recognized.stdout) + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) +} + +const SHOW_NAME = 'WATATEN an Angel Flew Down to Me' +const EP1_NAME = 'A Funny, Squirmy Feeling' + +const PLEX_S01E01_BASENAME = `${SHOW_NAME} - S01E01 - ${EP1_NAME}.mkv` +const EMBY_S01E01_BASENAME = `${SHOW_NAME} S1E1 ${EP1_NAME}.mkv` describe('smm try-to-rename / apply CLI e2e', () => { let userDataDir: string @@ -70,11 +82,7 @@ describe('smm try-to-rename / apply CLI e2e', () => { const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'RenameFiles 123123') const path = folder.path! - const recognized = await smm(['try-to-recognize', path]) - expect(recognized.code, recognized.stderr || recognized.stdout).toBe(0) - const recognizePlanId = parsePlanId(recognized.stdout) - const appliedRecognize = await smm(['apply', recognizePlanId]) - expect(appliedRecognize.code, appliedRecognize.stderr || appliedRecognize.stdout).toBe(0) + await recognizeAndApply(path) const tried = await smm(['try-to-rename', path, '--rule', 'plex']) expect(tried.code, tried.stderr || tried.stdout).toBe(0) @@ -108,6 +116,93 @@ describe('smm try-to-rename / apply CLI e2e', () => { ) }) + it('emby rename: recognize then try-to-rename --rule emby moves into Season 1', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'EmbyRename 123123') + const path = folder.path! + + await recognizeAndApply(path) + + const tried = await smm(['try-to-rename', path, '--rule', 'emby']) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + expect(tried.stdout).toContain('Season 1') + expect(tried.stdout).toContain(EMBY_S01E01_BASENAME) + + const planId = parsePlanId(tried.stdout) + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + + const renamedVideo = join(path, 'Season 1', EMBY_S01E01_BASENAME) + expect(existsSync(renamedVideo)).toBe(true) + expect(existsSync(join(path, 'S01E01.mkv'))).toBe(false) + + const mm = await getCore().getMediaMetadata(path) + expect(mm!.mediaFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + absolutePath: Path.posix(join(path, 'Season 1', EMBY_S01E01_BASENAME)), + seasonNumber: 1, + episodeNumber: 1, + }), + ]), + ) + }) + + it('associated files: apply renames same-stem subtitle alongside video', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'AssocRename 123123') + const path = folder.path! + + await recognizeAndApply(path) + + const tried = await smm(['try-to-rename', path, '--rule', 'plex']) + expect(tried.code, tried.stderr || tried.stdout).toBe(0) + const planId = parsePlanId(tried.stdout) + + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) + + const seasonDir = join(path, 'Season 01') + const videoBase = PLEX_S01E01_BASENAME.replace(/\.mkv$/, '') + expect(existsSync(join(seasonDir, `${videoBase}.sc.ass`))).toBe(true) + expect(existsSync(join(seasonDir, `${videoBase}.tc.ass`))).toBe(true) + expect(existsSync(join(seasonDir, `${videoBase}.nfo`))).toBe(true) + expect(existsSync(join(seasonDir, `${videoBase}.jpg`))).toBe(true) + expect(existsSync(join(path, 'S01E01.sc.ass'))).toBe(false) + }) + + it('already at target paths: pending plan with empty files, apply is no-op', async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'AlreadyRenamed 123123') + const path = folder.path! + + await recognizeAndApply(path) + + const first = await smm(['try-to-rename', path, '--rule', 'plex']) + expect(first.code, first.stderr || first.stdout).toBe(0) + const firstPlanId = parsePlanId(first.stdout) + const firstApply = await smm(['apply', firstPlanId]) + expect(firstApply.code, firstApply.stderr || firstApply.stdout).toBe(0) + + const before = await getCore().getMediaMetadata(path) + + const second = await smm(['try-to-rename', path, '--rule', 'plex']) + expect(second.code, second.stderr || second.stdout).toBe(0) + expect(second.stdout).toContain('(none)') + const secondPlanId = parsePlanId(second.stdout) + + const plan = await getCore().getPlan(secondPlanId) + expect(plan.task).toBe('rename-files') + if (plan.task === 'rename-files') { + expect(plan.files).toEqual([]) + } + + const secondApply = await smm(['apply', secondPlanId]) + expect(secondApply.code, secondApply.stderr || secondApply.stdout).toBe(0) + expect(secondApply.stdout).toMatch(/\(0 file\(s\)\)/) + expect(existsSync(planPath(userDataDir, secondPlanId))).toBe(false) + + const after = await getCore().getMediaMetadata(path) + expect(after?.mediaFiles ?? []).toEqual(before?.mediaFiles ?? []) + }) + it('rejects unmanaged folder', async () => { const unmanaged = join(mediaDir, 'not-imported') const result = await smm(['try-to-rename', unmanaged]) @@ -121,4 +216,23 @@ describe('smm try-to-rename / apply CLI e2e', () => { expect(result.code).toBe(1) expect(result.stderr).toMatch(/Unsupported rename rule/i) }) + + it('rejects movie folder (not a TV show with episodes)', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { + mediaMetadata: { + type: 'movie-folder', + mediaFiles: [], + movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, + }, + }) + const result = await smm(['try-to-rename', folder.path!]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not a TV show with episodes/i) + }) + + it('apply rejects missing plan id', async () => { + const result = await smm(['apply', '00000000-0000-0000-0000-000000000000']) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/Plan not found/i) + }) }) From 3610150a6c523fb4907f19b07c7da9baffe8273c Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 20:57:40 +0800 Subject: [PATCH 080/214] feat(core): add FsPort.writeBinaryFile and tmdbImageUrl Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 2 ++ .../adapters/network/NetworkFsAdapter.test.ts | 9 +++++++ .../src/adapters/network/NetworkFsAdapter.ts | 4 +++ .../src/adapters/node/NodejsFsAdapter.test.ts | 13 +++++++++ .../core/src/adapters/node/NodejsFsAdapter.ts | 6 +++++ .../importFolderPipeline.integration.test.ts | 1 + .../src/pipeline/importFolderPipeline.test.ts | 1 + apps/core/src/pipeline/plans.test.ts | 1 + .../src/pipeline/recognizeMediaFolder.test.ts | 1 + .../src/pipeline/scrape/tmdbImageUrl.test.ts | 27 +++++++++++++++++++ apps/core/src/pipeline/scrape/tmdbImageUrl.ts | 20 ++++++++++++++ apps/core/src/pipeline/userConfig.test.ts | 1 + apps/core/src/ports/FsPort.ts | 2 ++ 13 files changed, 88 insertions(+) create mode 100644 apps/core/src/pipeline/scrape/tmdbImageUrl.test.ts create mode 100644 apps/core/src/pipeline/scrape/tmdbImageUrl.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index c2454684..7d4a58f5 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -17,6 +17,7 @@ function inMemoryFs(seed: Record = {}): FsPort { writeTextFile: vi.fn(async (path: string, content: string) => { files.set(path, content); }), + writeBinaryFile: vi.fn(async () => {}), exists: vi.fn(async (path: string) => files.has(path)), listFiles: vi.fn(async (dir: string) => { const out: string[] = []; @@ -421,6 +422,7 @@ describe("unimportFolder", () => { await pause(); files.set(path, content); }, + writeBinaryFile: async () => {}, exists: async (path: string) => files.has(path), listFiles: async () => [], deleteFile: async (path: string) => { diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts index c771cffe..cd98732c 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts @@ -114,4 +114,13 @@ describe("NetworkFsAdapter", () => { await expect(adapter.rename("/m/a", "/m/b")).rejects.toThrow(/Not Implemented/i); }); + + it("writeBinaryFile throws Not Implemented", async () => { + const { network } = mockNetwork(); + const adapter = new NetworkFsAdapter({ network, baseUrl: "http://127.0.0.1:30000" }); + + await expect(adapter.writeBinaryFile("/m/image.jpg", new Uint8Array([1, 2, 3]))).rejects.toThrow( + /Not Implemented/i, + ); + }); }); diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index e06c3fca..417db642 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -43,6 +43,10 @@ export class NetworkFsAdapter implements FsPort { if (json.error !== undefined) throw new Error(json.error); } + async writeBinaryFile(_path: string, _data: Uint8Array): Promise { + throw new Error("Not Implemented: NetworkFsAdapter.writeBinaryFile"); + } + async exists(path: string): Promise { const json = await this.post<{ error?: string }>("/api/readFile", { path }); return !(json.error !== undefined && json.error.startsWith("File Not Found")); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts index e9ce5104..afbd953c 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.test.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.test.ts @@ -29,6 +29,19 @@ describe("NodejsFsAdapter", () => { expect(await adapter.exists(file)).toBe(true); }); + it("writes binary data to disk", async () => { + const adapter = new NodejsFsAdapter(); + const file = joinPosix(tmpPosix, "image.bin"); + const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + + await adapter.writeBinaryFile(file, data); + + expect(await adapter.exists(file)).toBe(true); + const onDisk = await fsp.readFile(Path.toPlatformPath(file)); + expect(onDisk).toEqual(Buffer.from(data)); + expect(onDisk.length).toBe(data.length); + }); + it("recursively lists files, not directories", async () => { const adapter = new NodejsFsAdapter(); await adapter.writeTextFile(joinPosix(tmpPosix, "a.mkv"), ""); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts index 8e8e0241..f02551e2 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -18,6 +18,12 @@ export class NodejsFsAdapter implements FsPort { await fsp.writeFile(platform, content, "utf-8"); } + async writeBinaryFile(path: string, data: Uint8Array): Promise { + const platform = Path.toPlatformPath(path); + await fsp.mkdir(dirname(platform), { recursive: true }); + await fsp.writeFile(platform, data); + } + async exists(path: string): Promise { try { await fsp.access(Path.toPlatformPath(path)); diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts index 628ee5a5..7ce456e1 100644 --- a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -26,6 +26,7 @@ function inMemoryFs(seed: Record = {}): FsPort { writeTextFile: vi.fn(async (path: string, content: string) => { files.set(path, content); }), + writeBinaryFile: vi.fn(async () => {}), exists: vi.fn(async (path: string) => files.has(path)), listFiles: vi.fn(async (dir: string) => { const out: string[] = []; diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index 8719d0dc..ccd76913 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -24,6 +24,7 @@ function inMemoryFs(seed: Record = {}): FsPort { writeTextFile: vi.fn(async (path: string, content: string) => { files.set(path, content); }), + writeBinaryFile: vi.fn(async () => {}), exists: vi.fn(async (path: string) => files.has(path)), listFiles: vi.fn(async (dir: string) => { const out: string[] = []; diff --git a/apps/core/src/pipeline/plans.test.ts b/apps/core/src/pipeline/plans.test.ts index 5adb3175..202eb55d 100644 --- a/apps/core/src/pipeline/plans.test.ts +++ b/apps/core/src/pipeline/plans.test.ts @@ -16,6 +16,7 @@ function inMemoryFs(): FsPort & { raw: Map } { writeTextFile: vi.fn(async (path: string, content: string) => { files.set(path, content); }), + writeBinaryFile: vi.fn(async () => {}), exists: vi.fn(async (path: string) => files.has(path)), listFiles: vi.fn(async () => []), deleteFile: vi.fn(async (path: string) => { diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 6612af00..eb3eff28 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -25,6 +25,7 @@ function fsPort(files: Record): FsPort { return content; }), writeTextFile: vi.fn(async () => {}), + writeBinaryFile: vi.fn(async () => {}), exists: vi.fn(async () => true), listFiles: vi.fn(async () => []), deleteFile: vi.fn(async () => {}), diff --git a/apps/core/src/pipeline/scrape/tmdbImageUrl.test.ts b/apps/core/src/pipeline/scrape/tmdbImageUrl.test.ts new file mode 100644 index 00000000..8da52cdf --- /dev/null +++ b/apps/core/src/pipeline/scrape/tmdbImageUrl.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { getTmdbImageUrl } from "./tmdbImageUrl"; + +describe("getTmdbImageUrl", () => { + it("returns null for missing or empty paths", () => { + expect(getTmdbImageUrl(null)).toBeNull(); + expect(getTmdbImageUrl(undefined)).toBeNull(); + expect(getTmdbImageUrl("")).toBeNull(); + expect(getTmdbImageUrl(" ")).toBeNull(); + }); + + it("returns absolute http(s) URLs unchanged", () => { + expect(getTmdbImageUrl("https://example.com/poster.jpg")).toBe("https://example.com/poster.jpg"); + expect(getTmdbImageUrl("http://example.com/poster.jpg")).toBe("http://example.com/poster.jpg"); + }); + + it("builds TMDB CDN URLs for relative paths", () => { + expect(getTmdbImageUrl("/abc123.jpg", "w500")).toBe("https://image.tmdb.org/t/p/w500/abc123.jpg"); + expect(getTmdbImageUrl("/abc123.jpg", "original")).toBe( + "https://image.tmdb.org/t/p/original/abc123.jpg", + ); + }); + + it("defaults to w500 and trims whitespace", () => { + expect(getTmdbImageUrl(" /poster.jpg ")).toBe("https://image.tmdb.org/t/p/w500/poster.jpg"); + }); +}); diff --git a/apps/core/src/pipeline/scrape/tmdbImageUrl.ts b/apps/core/src/pipeline/scrape/tmdbImageUrl.ts new file mode 100644 index 00000000..eb9e575d --- /dev/null +++ b/apps/core/src/pipeline/scrape/tmdbImageUrl.ts @@ -0,0 +1,20 @@ +/** + * Build a TMDB image CDN URL from a relative path or pass through absolute URLs. + * Image URLs are served by the TMDB image CDN directly. + */ +export function getTmdbImageUrl( + path: string | null | undefined, + size: "original" | "w500" = "w500", +): string | null { + if (!path || typeof path !== "string") return null; + + const trimmedPath = path.trim(); + if (trimmedPath.length === 0) return null; + + if (trimmedPath.startsWith("http://") || trimmedPath.startsWith("https://")) { + return trimmedPath; + } + + const baseUrl = "https://image.tmdb.org/t/p"; + return `${baseUrl}/${size}${trimmedPath}`; +} diff --git a/apps/core/src/pipeline/userConfig.test.ts b/apps/core/src/pipeline/userConfig.test.ts index f7db67d6..cf7136fe 100644 --- a/apps/core/src/pipeline/userConfig.test.ts +++ b/apps/core/src/pipeline/userConfig.test.ts @@ -17,6 +17,7 @@ function delayedFs(seed: Record, delayMs = 15): FsPort { await pause(); files.set(path, content); }, + writeBinaryFile: async () => {}, exists: async (path: string) => { await pause(); return files.has(path); diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts index 2595fdd9..b0246d0c 100644 --- a/apps/core/src/ports/FsPort.ts +++ b/apps/core/src/ports/FsPort.ts @@ -2,6 +2,8 @@ export interface FsPort { readTextFile(path: string): Promise; writeTextFile(path: string, content: string): Promise; + /** Write raw bytes; parent directories are created if needed. Paths are POSIX. */ + writeBinaryFile(path: string, data: Uint8Array): Promise; exists(path: string): Promise; /** Recursively list all files under `dir` (not directories). */ listFiles(dir: string): Promise; From 04155a68ac3eca91abd6dba3ce6edec771843cb4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 20:59:23 +0800 Subject: [PATCH 081/214] feat(core): add checkScrapeCompletion for scrape skip Port TV show scrape artifact detection from UI checkTaskCompletion using FsPort.listFiles and pipeline path helpers for orchestrator skip semantics. Co-authored-by: Cursor --- apps/core/src/pipeline/paths.test.ts | 8 +- apps/core/src/pipeline/paths.ts | 5 + .../scrape/checkScrapeCompletion.test.ts | 183 ++++++++++++++++++ .../pipeline/scrape/checkScrapeCompletion.ts | 101 ++++++++++ apps/core/src/pipeline/scrape/types.ts | 13 ++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts create mode 100644 apps/core/src/pipeline/scrape/checkScrapeCompletion.ts create mode 100644 apps/core/src/pipeline/scrape/types.ts diff --git a/apps/core/src/pipeline/paths.test.ts b/apps/core/src/pipeline/paths.test.ts index 232c35f8..1bf7e97a 100644 --- a/apps/core/src/pipeline/paths.test.ts +++ b/apps/core/src/pipeline/paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { basename, extname, joinPosix, metadataCachePath, userConfigPath } from "./paths"; +import { basename, dirname, extname, joinPosix, metadataCachePath, userConfigPath } from "./paths"; describe("paths", () => { it("basename returns the last path segment", () => { @@ -7,6 +7,12 @@ describe("paths", () => { expect(basename("no-separator")).toBe("no-separator"); }); + it("dirname returns the parent path segment", () => { + expect(dirname("/media/My.Show/S01E01.mkv")).toBe("/media/My.Show"); + expect(dirname("/media/My.Show")).toBe("/media"); + expect(dirname("/foo")).toBe("/"); + }); + it("extname returns the extension with leading dot", () => { expect(extname("/C:/media/a.mkv")).toBe(".mkv"); expect(extname("noext")).toBe(""); diff --git a/apps/core/src/pipeline/paths.ts b/apps/core/src/pipeline/paths.ts index fe4d0775..7bc98d01 100644 --- a/apps/core/src/pipeline/paths.ts +++ b/apps/core/src/pipeline/paths.ts @@ -9,6 +9,11 @@ export function basename(path: string): string { return parts[parts.length - 1] ?? ""; } +export function dirname(path: string): string { + const idx = path.lastIndexOf("/"); + return idx <= 0 ? "/" : path.slice(0, idx); +} + export function extname(path: string): string { const base = basename(path); const idx = base.lastIndexOf("."); diff --git a/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts b/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts new file mode 100644 index 00000000..662b2150 --- /dev/null +++ b/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts @@ -0,0 +1,183 @@ +import type { MediaMetadata } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../../ports/FsPort"; +import { checkScrapeCompletion } from "./checkScrapeCompletion"; + +function createFs(listFiles: string[] | (() => never)): FsPort { + return { + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + writeBinaryFile: vi.fn(), + exists: vi.fn(), + listFiles: vi.fn(async () => { + if (typeof listFiles === "function") { + listFiles(); + } + return listFiles as string[]; + }), + deleteFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + }; +} + +const tvMetadata = (overrides: Partial = {}): MediaMetadata => ({ + mediaFolderPath: "/media/My.Show", + type: "tvshow-folder", + mediaFiles: [ + { + absolutePath: "/media/My.Show/S01E01.mkv", + seasonNumber: 1, + episodeNumber: 1, + }, + { + absolutePath: "/media/My.Show/S01E02.mkv", + seasonNumber: 1, + episodeNumber: 2, + }, + ], + ...overrides, +}); + +describe("checkScrapeCompletion", () => { + it("returns all false when mediaFolderPath is missing", async () => { + const result = await checkScrapeCompletion({ type: "tvshow-folder" }, createFs([])); + + expect(result).toEqual({ + poster: false, + fanart: false, + thumbnails: false, + nfo: false, + }); + }); + + it("returns all false for non-TV metadata", async () => { + const fs = createFs(["/media/movie/poster.jpg"]); + const result = await checkScrapeCompletion( + { mediaFolderPath: "/media/movie", type: "movie-folder" }, + fs, + ); + + expect(result).toEqual({ + poster: false, + fanart: false, + thumbnails: false, + nfo: false, + }); + expect(fs.listFiles).not.toHaveBeenCalled(); + }); + + it("returns all false when listFiles throws", async () => { + const fs = createFs(() => { + throw new Error("disk error"); + }); + + const result = await checkScrapeCompletion(tvMetadata(), fs); + + expect(result).toEqual({ + poster: false, + fanart: false, + thumbnails: false, + nfo: false, + }); + }); + + it("detects poster and fanart by filename prefix and image extension", async () => { + const fs = createFs([ + "/media/My.Show/poster.jpg", + "/media/My.Show/fanart.PNG", + "/media/My.Show/S01E01.mkv", + ]); + + const result = await checkScrapeCompletion(tvMetadata(), fs); + + expect(result.poster).toBe(true); + expect(result.fanart).toBe(true); + }); + + it("detects NFO completion when tvshow.nfo and episode nfos exist", async () => { + const fs = createFs([ + "/media/My.Show/tvshow.nfo", + "/media/My.Show/S01E01.mkv", + "/media/My.Show/S01E01.nfo", + "/media/My.Show/S01E02.mkv", + "/media/My.Show/S01E02.nfo", + ]); + + const result = await checkScrapeCompletion(tvMetadata(), fs); + + expect(result.nfo).toBe(true); + }); + + it("reports NFO incomplete when an episode nfo is missing", async () => { + const fs = createFs([ + "/media/My.Show/tvshow.nfo", + "/media/My.Show/S01E01.mkv", + "/media/My.Show/S01E01.nfo", + "/media/My.Show/S01E02.mkv", + ]); + + const result = await checkScrapeCompletion(tvMetadata(), fs); + + expect(result.nfo).toBe(false); + }); + + it("detects thumbnails when each linked episode has a same-stem image", async () => { + const fs = createFs([ + "/media/My.Show/S01E01.mkv", + "/media/My.Show/S01E01.jpg", + "/media/My.Show/S01E02.mkv", + "/media/My.Show/S01E02.png", + ]); + + const result = await checkScrapeCompletion(tvMetadata(), fs); + + expect(result.thumbnails).toBe(true); + }); + + it("reports thumbnails incomplete when one episode thumb is missing", async () => { + const fs = createFs([ + "/media/My.Show/S01E01.mkv", + "/media/My.Show/S01E01.jpg", + "/media/My.Show/S01E02.mkv", + ]); + + const result = await checkScrapeCompletion(tvMetadata(), fs); + + expect(result.thumbnails).toBe(false); + }); + + it("reports thumbnails incomplete when no episodes are linked", async () => { + const fs = createFs(["/media/My.Show/S01E01.mkv"]); + + const result = await checkScrapeCompletion(tvMetadata({ mediaFiles: [] }), fs); + + expect(result.thumbnails).toBe(false); + }); + + it("ignores media files without season and episode numbers", async () => { + const fs = createFs([ + "/media/My.Show/tvshow.nfo", + "/media/My.Show/S01E01.mkv", + "/media/My.Show/S01E01.nfo", + "/media/My.Show/S01E01.jpg", + ]); + + const result = await checkScrapeCompletion( + tvMetadata({ + mediaFiles: [ + { absolutePath: "/media/My.Show/S01E01.mkv" }, + { + absolutePath: "/media/My.Show/S01E01.mkv", + seasonNumber: 1, + episodeNumber: 1, + }, + ], + }), + fs, + ); + + expect(result.nfo).toBe(true); + expect(result.thumbnails).toBe(true); + }); +}); diff --git a/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts b/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts new file mode 100644 index 00000000..8022b1fc --- /dev/null +++ b/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts @@ -0,0 +1,101 @@ +import { Path } from "@core/path"; +import { imageFileExtensions } from "@core/utils"; +import type { MediaMetadata } from "@smm/core"; +import type { FsPort } from "../../ports/FsPort"; +import { basename, dirname, extname } from "../paths"; +import type { ScrapeTaskId } from "./types"; + +const DEFAULT_COMPLETION: Record = { + poster: false, + fanart: false, + thumbnails: false, + nfo: false, +}; + +function hasImageNamed(files: string[], prefix: "poster" | "fanart"): boolean { + return files.some((file) => { + const fileName = basename(file); + if (!fileName) return false; + return ( + fileName.startsWith(`${prefix}.`) && + imageFileExtensions.some((ext) => fileName.toLowerCase().endsWith(ext.toLowerCase())) + ); + }); +} + +function checkTvShowNfoCompletion(files: string[], mediaMetadata: MediaMetadata): boolean { + const tvshowNfoOk = files.some((file) => basename(file) === "tvshow.nfo"); + let episodeNfosOk = true; + + for (const mediaFile of mediaMetadata.mediaFiles ?? []) { + if (mediaFile.seasonNumber === undefined || mediaFile.episodeNumber === undefined) continue; + + const videoBase = basename(mediaFile.absolutePath); + if (!videoBase) continue; + + const videoExt = extname(videoBase); + const noExt = videoExt ? videoBase.slice(0, -videoExt.length) : videoBase; + const expectedNfo = `${noExt}.nfo`; + const videoDir = dirname(mediaFile.absolutePath); + const found = files.some((file) => dirname(file) === videoDir && basename(file) === expectedNfo); + + if (!found) { + episodeNfosOk = false; + break; + } + } + + return tvshowNfoOk && episodeNfosOk; +} + +function checkTvShowThumbnailsCompletion(files: string[], mediaMetadata: MediaMetadata): boolean { + let recognizedEpisodeCount = 0; + + for (const mediaFile of mediaMetadata.mediaFiles ?? []) { + if (mediaFile.seasonNumber === undefined || mediaFile.episodeNumber === undefined) continue; + + recognizedEpisodeCount += 1; + const videoBase = basename(mediaFile.absolutePath); + if (!videoBase) return false; + + const videoExt = extname(videoBase); + const noExt = videoBase.replace(videoExt, ""); + const videoDir = dirname(mediaFile.absolutePath); + const filesInSameDir = files.filter((file) => dirname(file) === videoDir); + const hasThumb = filesInSameDir.some((file) => { + const fileName = basename(file); + if (!fileName) return false; + return ( + fileName.startsWith(`${noExt}.`) && + imageFileExtensions.some((ext) => fileName.toLowerCase().endsWith(ext.toLowerCase())) + ); + }); + + if (!hasThumb) return false; + } + + return recognizedEpisodeCount > 0; +} + +/** Returns whether each scrape artifact already exists on disk (TV show folders only). */ +export async function checkScrapeCompletion( + mediaMetadata: MediaMetadata, + fs: FsPort, +): Promise> { + if (!mediaMetadata.mediaFolderPath || mediaMetadata.type !== "tvshow-folder") { + return { ...DEFAULT_COMPLETION }; + } + + try { + const files = (await fs.listFiles(mediaMetadata.mediaFolderPath)).map((p) => Path.posix(p)); + + return { + poster: hasImageNamed(files, "poster"), + fanart: hasImageNamed(files, "fanart"), + nfo: checkTvShowNfoCompletion(files, mediaMetadata), + thumbnails: checkTvShowThumbnailsCompletion(files, mediaMetadata), + }; + } catch { + return { ...DEFAULT_COMPLETION }; + } +} diff --git a/apps/core/src/pipeline/scrape/types.ts b/apps/core/src/pipeline/scrape/types.ts new file mode 100644 index 00000000..131f6879 --- /dev/null +++ b/apps/core/src/pipeline/scrape/types.ts @@ -0,0 +1,13 @@ +export type ScrapeTaskId = "poster" | "fanart" | "thumbnails" | "nfo"; + +export type ScrapeTaskStatus = "skipped" | "completed" | "failed"; + +export interface ScrapeTaskResult { + status: ScrapeTaskStatus; + error?: string; +} + +export interface ScrapeFolderResult { + mediaFolderPath: string; + tasks: Record; +} From 9e9700e143e5c7973d2085937d0944646fdbe89f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:01:57 +0800 Subject: [PATCH 082/214] feat(core): add downloadScrapeImage for scrape pipeline Port resolveScrapeHttpProxy from UI and add NetworkPort-based image download with browser-like headers, parent mkdir, and binary write. Co-authored-by: Cursor --- .../src/adapters/FetchNetworkAdapter.test.ts | 1 + .../adapters/network/NetworkFsAdapter.test.ts | 1 + apps/core/src/clients/TmdbClient.test.ts | 1 + apps/core/src/clients/TvdbClient.test.ts | 1 + .../clients/mediaDatabaseTransport.test.ts | 2 + .../importFolderPipeline.integration.test.ts | 1 + .../scrape/downloadScrapeImage.test.ts | 198 ++++++++++++++++++ .../pipeline/scrape/downloadScrapeImage.ts | 81 +++++++ .../scrape/resolveScrapeHttpProxy.test.ts | 97 +++++++++ .../pipeline/scrape/resolveScrapeHttpProxy.ts | 36 ++++ apps/core/src/ports/NetworkPort.ts | 1 + 11 files changed, 420 insertions(+) create mode 100644 apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts create mode 100644 apps/core/src/pipeline/scrape/downloadScrapeImage.ts create mode 100644 apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts create mode 100644 apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.ts diff --git a/apps/core/src/adapters/FetchNetworkAdapter.test.ts b/apps/core/src/adapters/FetchNetworkAdapter.test.ts index 01f5f9b9..9853f543 100644 --- a/apps/core/src/adapters/FetchNetworkAdapter.test.ts +++ b/apps/core/src/adapters/FetchNetworkAdapter.test.ts @@ -10,6 +10,7 @@ function fakeResponse(overrides: Partial = {}): HttpResponse { headers: {}, text: () => Promise.resolve("body-text"), json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), ...overrides, }; } diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts index cd98732c..9d4b4dc5 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.test.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.test.ts @@ -10,6 +10,7 @@ function jsonResponse(body: unknown): HttpResponse { headers: {}, text: () => Promise.resolve(JSON.stringify(body)), json: () => Promise.resolve(body as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), }; } diff --git a/apps/core/src/clients/TmdbClient.test.ts b/apps/core/src/clients/TmdbClient.test.ts index 3d01492b..9d963d06 100644 --- a/apps/core/src/clients/TmdbClient.test.ts +++ b/apps/core/src/clients/TmdbClient.test.ts @@ -10,6 +10,7 @@ function jsonResponse(body: unknown): HttpResponse { headers: {}, text: () => Promise.resolve(JSON.stringify(body)), json: () => Promise.resolve(body as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), }; } diff --git a/apps/core/src/clients/TvdbClient.test.ts b/apps/core/src/clients/TvdbClient.test.ts index 83142e73..899e1823 100644 --- a/apps/core/src/clients/TvdbClient.test.ts +++ b/apps/core/src/clients/TvdbClient.test.ts @@ -10,6 +10,7 @@ function jsonResponse(body: unknown): HttpResponse { headers: {}, text: () => Promise.resolve(JSON.stringify(body)), json: () => Promise.resolve(body as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), }; } diff --git a/apps/core/src/clients/mediaDatabaseTransport.test.ts b/apps/core/src/clients/mediaDatabaseTransport.test.ts index 43e27dc4..0cca8fe3 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.test.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.test.ts @@ -11,6 +11,7 @@ function jsonOk(body: unknown = {}): HttpResponse { headers: {}, text: () => Promise.resolve(JSON.stringify(body)), json: () => Promise.resolve(body as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), }; } @@ -24,6 +25,7 @@ function jsonFail(status: number): HttpResponse { json: async () => { throw new Error("no json"); }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), }; } diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts index 7ce456e1..c2da38ef 100644 --- a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -12,6 +12,7 @@ function jsonResponse(body: unknown): HttpResponse { headers: {}, text: () => Promise.resolve(JSON.stringify(body)), json: () => Promise.resolve(body as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), }; } diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts new file mode 100644 index 00000000..29769d99 --- /dev/null +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts @@ -0,0 +1,198 @@ +import type { MediaMetadata, UserConfig } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; +import type { FsPort } from "../../ports/FsPort"; +import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { downloadScrapeImage } from "./downloadScrapeImage"; + +function createInMemoryFs(): FsPort & { + binaryFiles: Map; + mkdirCalls: string[]; +} { + const binaryFiles = new Map(); + const mkdirCalls: string[] = []; + return { + binaryFiles, + mkdirCalls, + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + writeBinaryFile: vi.fn(async (path, data) => { + binaryFiles.set(path, data); + }), + exists: vi.fn(async (path) => binaryFiles.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(async (path) => { + mkdirCalls.push(path); + }), + }; +} + +function fakeImageResponse(bytes: Uint8Array, ok = true): HttpResponse { + return { + ok, + status: ok ? 200 : 503, + statusText: ok ? "OK" : "Service Unavailable", + headers: { "content-type": "image/jpeg" }, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new Uint8Array(bytes).buffer), + }; +} + +const movieMetadata: MediaMetadata = { + type: "movie-folder", + mediaFolderPath: "/media/Fight Club", + movie: { id: "550", database: "TMDB", name: "Fight Club" }, +} as MediaMetadata; + +function userConfig(overrides: Partial = {}): UserConfig { + return { ...DEFAULT_USER_CONFIG, ...overrides }; +} + +describe("downloadScrapeImage", () => { + it("fetches image bytes and writes them via writeBinaryFile", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const network: NetworkPort = { + fetch: vi.fn(async () => fakeImageResponse(fakeBytes)), + }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/t/p/original/poster.jpg", + "/media/Fight Club/poster.jpg", + userConfig(), + fs, + network, + ); + + expect(fs.binaryFiles.get("/media/Fight Club/poster.jpg")).toEqual(fakeBytes); + expect(fs.writeBinaryFile).toHaveBeenCalledWith("/media/Fight Club/poster.jpg", fakeBytes); + }); + + it("creates the parent directory before writing", async () => { + const fs = createInMemoryFs(); + const network: NetworkPort = { + fetch: vi.fn(async () => fakeImageResponse(new Uint8Array([1]))), + }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/x.jpg", + "/media/Show/sub/poster.jpg", + userConfig(), + fs, + network, + ); + + expect(fs.mkdirCalls).toContain("/media/Show/sub"); + }); + + it("sends browser-like headers on fetch", async () => { + const fs = createInMemoryFs(); + const fetch = vi.fn(async () => fakeImageResponse(new Uint8Array([1]))); + const network: NetworkPort = { fetch }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/x.jpg", + "/media/poster.jpg", + userConfig(), + fs, + network, + ); + + expect(fetch).toHaveBeenCalledWith( + "https://image.tmdb.org/x.jpg", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + accept: expect.stringContaining("image/"), + "user-agent": expect.stringContaining("Mozilla"), + }), + }), + ); + }); + + it("normalizes protocol-relative image URLs", async () => { + const fs = createInMemoryFs(); + const fetch = vi.fn(async () => fakeImageResponse(new Uint8Array([1]))); + const network: NetworkPort = { fetch }; + + await downloadScrapeImage( + movieMetadata, + "//image.tmdb.org/t/p/original/poster.jpg", + "/media/poster.jpg", + userConfig(), + fs, + network, + ); + + expect(fetch).toHaveBeenCalledWith( + "https://image.tmdb.org/t/p/original/poster.jpg", + expect.any(Object), + ); + }); + + it("throws when the upstream response is not ok", async () => { + const fs = createInMemoryFs(); + const network: NetworkPort = { + fetch: vi.fn(async () => fakeImageResponse(new Uint8Array(), false)), + }; + + await expect( + downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/x.jpg", + "/media/poster.jpg", + userConfig(), + fs, + network, + ), + ).rejects.toThrow("HTTP error! status: 503"); + }); + + it("passes resolved httpProxy to deps.fetch when configured", async () => { + const fs = createInMemoryFs(); + const customFetch = vi.fn(async (_url: string, _init?: FetchInit, httpProxy?: string) => { + expect(httpProxy).toBe("http://proxy:8080"); + return fakeImageResponse(new Uint8Array([1])); + }); + const network: NetworkPort = { fetch: vi.fn() }; + const uc = userConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: "http://proxy:8080" }, + }); + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/x.jpg", + "/media/poster.jpg", + uc, + fs, + network, + { fetch: customFetch }, + ); + + expect(customFetch).toHaveBeenCalledOnce(); + expect(network.fetch).not.toHaveBeenCalled(); + }); + + it("uses network.fetch directly when no proxy is configured", async () => { + const fs = createInMemoryFs(); + const fetch = vi.fn(async () => fakeImageResponse(new Uint8Array([1]))); + const network: NetworkPort = { fetch }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/x.jpg", + "/media/poster.jpg", + userConfig({ tmdb: { host: "", httpProxy: "" } }), + fs, + network, + ); + + expect(fetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.ts new file mode 100644 index 00000000..12c8d097 --- /dev/null +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.ts @@ -0,0 +1,81 @@ +import { isEmpty } from "es-toolkit/compat"; +import type { MediaMetadata, UserConfig } from "@smm/core"; +import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; +import type { FsPort } from "../../ports/FsPort"; +import { dirname } from "../paths"; +import { resolveScrapeHttpProxy } from "./resolveScrapeHttpProxy"; + +/** Browser-like request headers for remote TMDB/TVDB image fetches. */ +export const REMOTE_IMAGE_REQUEST_HEADERS: Record = { + accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "cache-control": "no-cache", + "sec-fetch-dest": "image", + "sec-fetch-mode": "no-cors", + "sec-fetch-site": "cross-site", + "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", +}; + +export interface DownloadScrapeImageDeps { + /** + * Optional fetch override (e.g. proxied). Receives the resolved httpProxy + * when a custom media-database host + proxy pair is configured. + */ + fetch?: (input: string, init?: FetchInit, httpProxy?: string) => Promise; +} + +function normalizeImageUrl(url: string): string { + if (url.startsWith("//")) { + return `https:${url}`; + } + return url; +} + +/** + * Download one scrape image: resolve HTTP proxy from metadata, fetch via + * NetworkPort, mkdir parent, write bytes with FsPort.writeBinaryFile. + */ +export async function downloadScrapeImage( + mediaMetadata: MediaMetadata, + imageUrl: string, + filePath: string, + userConfig: UserConfig, + fs: FsPort, + network: NetworkPort, + deps: DownloadScrapeImageDeps = {}, +): Promise { + const httpProxy = resolveScrapeHttpProxy(mediaMetadata, userConfig); + const normalizedUrl = normalizeImageUrl(imageUrl); + + if ( + !normalizedUrl.startsWith("http://") && + !normalizedUrl.startsWith("https://") + ) { + throw new Error( + `Invalid image URL: ${imageUrl}. Must be http://, https://, or protocol-relative (//)`, + ); + } + + const init: FetchInit = { + method: "GET", + headers: REMOTE_IMAGE_REQUEST_HEADERS, + }; + + const response = deps.fetch + ? await deps.fetch(normalizedUrl, init, httpProxy) + : await network.fetch(normalizedUrl, init); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + + const parentDir = dirname(filePath); + if (parentDir !== "/") { + await fs.mkdir(parentDir); + } + await fs.writeBinaryFile(filePath, data); +} diff --git a/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts b/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts new file mode 100644 index 00000000..92bb6117 --- /dev/null +++ b/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { MediaMetadata, UserConfig } from "@smm/core"; +import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { + resolveMediaDatabaseHttpProxy, + resolveScrapeHttpProxy, +} from "./resolveScrapeHttpProxy"; + +function buildUserConfig( + overrides: { tmdb?: UserConfig["tmdb"]; tvdb?: UserConfig["tvdb"] } = {}, +): UserConfig { + return { + ...DEFAULT_USER_CONFIG, + tmdb: { ...DEFAULT_USER_CONFIG.tmdb, ...overrides.tmdb }, + tvdb: { ...DEFAULT_USER_CONFIG.tvdb, ...overrides.tvdb }, + }; +} + +describe("resolveMediaDatabaseHttpProxy", () => { + it("returns tmdb httpProxy when custom tmdb host + proxy configured", () => { + const uc = buildUserConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: "http://proxy:8080" }, + }); + expect(resolveMediaDatabaseHttpProxy("TMDB", uc)).toBe("http://proxy:8080"); + }); + + it("returns tvdb httpProxy when custom tvdb host + proxy configured", () => { + const uc = buildUserConfig({ + tvdb: { host: "https://api4.thetvdb.com", httpProxy: "http://proxy:9090" }, + }); + expect(resolveMediaDatabaseHttpProxy("TVDB", uc)).toBe("http://proxy:9090"); + }); + + it("returns undefined when host is empty", () => { + const uc = buildUserConfig({ tmdb: { host: "", httpProxy: "http://proxy:8080" } }); + expect(resolveMediaDatabaseHttpProxy("TMDB", uc)).toBeUndefined(); + }); + + it("returns undefined when host is not a parseable URL", () => { + const uc = buildUserConfig({ tmdb: { host: "not a url", httpProxy: "http://proxy:8080" } }); + expect(resolveMediaDatabaseHttpProxy("TMDB", uc)).toBeUndefined(); + }); + + it("returns undefined when httpProxy is blank", () => { + const uc = buildUserConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: " " }, + }); + expect(resolveMediaDatabaseHttpProxy("TMDB", uc)).toBeUndefined(); + }); + + it("trims surrounding whitespace from httpProxy", () => { + const uc = buildUserConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: " http://proxy:8080 " }, + }); + expect(resolveMediaDatabaseHttpProxy("TMDB", uc)).toBe("http://proxy:8080"); + }); +}); + +describe("resolveScrapeHttpProxy", () => { + it("resolves the proxy from the tvshow database", () => { + const uc = buildUserConfig({ + tvdb: { host: "https://api4.thetvdb.com", httpProxy: "http://proxy:9090" }, + }); + const md = { + type: "tvshow-folder", + tvShow: { id: "1", database: "TVDB", name: "T", seasons: [] }, + } as MediaMetadata; + expect(resolveScrapeHttpProxy(md, uc)).toBe("http://proxy:9090"); + }); + + it("resolves the proxy from the movie database", () => { + const uc = buildUserConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: "http://proxy:8080" }, + }); + const md = { + type: "movie-folder", + movie: { id: "1", database: "TMDB", name: "M" }, + } as MediaMetadata; + expect(resolveScrapeHttpProxy(md, uc)).toBe("http://proxy:8080"); + }); + + it("returns undefined for music folders", () => { + const uc = buildUserConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: "http://proxy:8080" }, + }); + const md = { type: "music-folder" } as MediaMetadata; + expect(resolveScrapeHttpProxy(md, uc)).toBeUndefined(); + }); + + it("returns undefined when the database is missing", () => { + const uc = buildUserConfig({ + tmdb: { host: "https://api.themoviedb.org", httpProxy: "http://proxy:8080" }, + }); + const md = { type: "movie-folder", movie: { id: "1", name: "M" } } as MediaMetadata; + expect(resolveScrapeHttpProxy(md, uc)).toBeUndefined(); + }); +}); diff --git a/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.ts b/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.ts new file mode 100644 index 00000000..b39142e1 --- /dev/null +++ b/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.ts @@ -0,0 +1,36 @@ +import { isEmpty } from "es-toolkit/compat"; +import type { MediaMetadata, UserConfig } from "@smm/core"; + +/** + * Resolve the outbound HTTP proxy for a media database. Mirrors the rule in + * UI `fetchTmdb` / `fetchTvdb`: the proxy only applies when the user configured + * a custom host (non-empty, parseable) AND set an httpProxy. + */ +export function resolveMediaDatabaseHttpProxy( + database: "TMDB" | "TVDB", + userConfig: UserConfig, +): string | undefined { + const cfg = database === "TMDB" ? userConfig.tmdb : userConfig.tvdb; + if (!cfg) return undefined; + if (isEmpty(cfg.host)) return undefined; + if (!URL.canParse(cfg.host!)) return undefined; + const proxy = cfg.httpProxy?.trim(); + return proxy || undefined; +} + +/** + * Resolve the proxy for a scrape task from the media metadata's database. + */ +export function resolveScrapeHttpProxy( + mediaMetadata: MediaMetadata, + userConfig: UserConfig, +): string | undefined { + const database = + mediaMetadata.type === "tvshow-folder" + ? mediaMetadata.tvShow?.database + : mediaMetadata.type === "movie-folder" + ? mediaMetadata.movie?.database + : undefined; + if (!database) return undefined; + return resolveMediaDatabaseHttpProxy(database, userConfig); +} diff --git a/apps/core/src/ports/NetworkPort.ts b/apps/core/src/ports/NetworkPort.ts index af2c492a..a0b9cd17 100644 --- a/apps/core/src/ports/NetworkPort.ts +++ b/apps/core/src/ports/NetworkPort.ts @@ -13,6 +13,7 @@ export interface HttpResponse { headers: Record; text(): Promise; json(): Promise; + arrayBuffer(): Promise; } /** Thin HTTP capability only. No business parsing here. */ From f3a1537bbab29263e0f9f5fab21694892c44dc98 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:03:48 +0800 Subject: [PATCH 083/214] feat(core): add TMDB TV NFO build and XML for scrape Port TMDB TV show/episode NFO builders and Node-compatible XML serializers for the scrape pipeline, with unit tests for field mapping and XML output. Co-authored-by: Cursor --- .../scrape/buildTvShowNfoTmdb.test.ts | 199 ++++++++++ .../src/pipeline/scrape/buildTvShowNfoTmdb.ts | 176 +++++++++ apps/core/src/pipeline/scrape/nfoTypes.ts | 104 +++++ apps/core/src/pipeline/scrape/nfoXml.test.ts | 72 ++++ apps/core/src/pipeline/scrape/nfoXml.ts | 355 ++++++++++++++++++ 5 files changed, 906 insertions(+) create mode 100644 apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts create mode 100644 apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.ts create mode 100644 apps/core/src/pipeline/scrape/nfoTypes.ts create mode 100644 apps/core/src/pipeline/scrape/nfoXml.test.ts create mode 100644 apps/core/src/pipeline/scrape/nfoXml.ts diff --git a/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts new file mode 100644 index 00000000..14ed7c55 --- /dev/null +++ b/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts @@ -0,0 +1,199 @@ +import type { TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import { buildTvShowEpisodeNfo, buildTvShowNfo } from "./buildTvShowNfoTmdb"; + +const seriesDetails: TmdbSeriesDetails = { + id: 123876, + name: "Test Show", + original_name: "Original Show", + overview: "Show overview", + poster_path: "/poster.jpg", + backdrop_path: "/fanart.jpg", + first_air_date: "2021-01-15", + vote_average: 8.3, + vote_count: 545, + popularity: 100, + genre_ids: [16, 35], + origin_country: ["JP"], + number_of_seasons: 1, + number_of_episodes: 1, + seasons: [], + status: "Ended", + type: "Scripted", + in_production: false, + last_air_date: "2021-03-26", + networks: [], + production_companies: [{ id: 1, name: "Studio Alpha", logo_path: null }], + genres: [{ id: 16, name: "Animation" }], + production_countries: [{ iso_3166_1: "JP", name: "Japan" }], + logo_path: "/logo.png", +} as TmdbSeriesDetails; + +const seasonDetails: TmdbSeasonDetails = { + id: 1, + name: "Season 1", + overview: "Season overview", + poster_path: "/season1.jpg", + season_number: 1, + air_date: "2021-01-15", + episode_count: 1, + episodes: [ + { + id: 8415207, + name: "Episode One", + overview: "Episode overview", + still_path: "/still.jpg", + air_date: "2021-01-15", + episode_number: 1, + season_number: 1, + runtime: 24, + vote_average: 7.8, + vote_count: 13, + crew: [ + { + department: "Directing", + job: "Director", + credit_id: "c1", + adult: false, + gender: 0, + id: 100, + known_for_department: "Directing", + name: "Director One", + original_name: "Director One", + popularity: 1, + profile_path: null, + }, + { + department: "Writing", + job: "Writer", + credit_id: "c2", + adult: false, + gender: 0, + id: 101, + known_for_department: "Writing", + name: "Writer One", + original_name: "Writer One", + popularity: 1, + profile_path: null, + }, + ], + guest_stars: [ + { + character: "Guest Role", + credit_id: "g1", + order: 1, + adult: false, + gender: 0, + id: 200, + known_for_department: "Acting", + name: "Guest Star", + original_name: "Guest Star", + popularity: 1, + profile_path: "/guest.jpg", + }, + ], + }, + ], +}; + +describe("buildTvShowNfo", () => { + it("maps TMDB series details to TvShowNFO fields", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-19T12:00:00.000Z")); + + const nfo = buildTvShowNfo(seriesDetails, [seasonDetails]); + + expect(nfo.title).toBe("Test Show"); + expect(nfo.originalTitle).toBe("Original Show"); + expect(nfo.showTitle).toBe("Test Show"); + expect(nfo.year).toBe(2021); + expect(nfo.tmdbid).toBe("123876"); + expect(nfo.id).toBe("123876"); + expect(nfo.episodeguide).toBe(JSON.stringify({ tmdb: "123876" })); + expect(nfo.ratings).toEqual([ + { + default: true, + max: 10, + name: "themoviedb", + value: 8.3, + votes: 545, + }, + ]); + expect(nfo.uniqueIds).toEqual([ + { default: true, type: "tmdb", value: "123876" }, + ]); + expect(nfo.thumbs).toEqual([ + { + url: "https://image.tmdb.org/t/p/original/poster.jpg", + aspect: "poster", + }, + { + url: "https://image.tmdb.org/t/p/original/logo.png", + aspect: "clearlogo", + }, + { + url: "https://image.tmdb.org/t/p/original/season1.jpg", + aspect: "poster", + season: 1, + type: "season", + }, + ]); + expect(nfo.namedSeasons).toEqual([{ number: 1, name: "Season 1" }]); + expect(nfo.fanartThumbs).toEqual([ + "https://image.tmdb.org/t/p/original/fanart.jpg", + ]); + expect(nfo.runtime).toBe(24); + expect(nfo.genres).toEqual(["Animation"]); + expect(nfo.studios).toEqual(["Studio Alpha"]); + expect(nfo.countries).toEqual(["Japan"]); + expect(nfo.dateadded).toBe("2026-08-19 12:00:00"); + + vi.useRealTimers(); + }); +}); + +describe("buildTvShowEpisodeNfo", () => { + it("maps TMDB episode details to EpisodeNfo fields", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-19T12:00:00.000Z")); + + const episode = seasonDetails.episodes![0]; + const nfo = buildTvShowEpisodeNfo(seriesDetails, seasonDetails, episode); + + expect(nfo.id).toBe("8415207"); + expect(nfo.title).toBe("Episode One"); + expect(nfo.showTitle).toBe("Test Show"); + expect(nfo.season).toBe(1); + expect(nfo.episode).toBe(1); + expect(nfo.plot).toBe("Episode overview"); + expect(nfo.runtime).toBe(24); + expect(nfo.thumb).toBe("https://image.tmdb.org/t/p/original/still.jpg"); + expect(nfo.uniqueIds).toEqual([ + { default: true, type: "tmdb", value: "8415207" }, + ]); + expect(nfo.ratings).toEqual([ + { + default: false, + max: 10, + name: "themoviedb", + value: 7.8, + votes: 13, + }, + ]); + expect(nfo.directors).toEqual([{ tmdbid: "100", name: "Director One" }]); + expect(nfo.credits).toEqual([{ tmdbid: "101", name: "Writer One" }]); + expect(nfo.actors).toEqual([ + { + name: "Guest Star", + role: "Guest Role", + thumb: "https://image.tmdb.org/t/p/original/guest.jpg", + profile: "https://www.themoviedb.org/person/200", + type: "GuestStar", + tmdbid: "200", + }, + ]); + expect(nfo.dateadded).toBe("2026-08-19 12:00:00"); + + vi.useRealTimers(); + }); +}); diff --git a/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.ts b/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.ts new file mode 100644 index 00000000..34add4ae --- /dev/null +++ b/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.ts @@ -0,0 +1,176 @@ +import type { TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import type { EpisodeNfo, TvShowNFO } from "./nfoTypes"; +import { getTmdbImageUrl } from "./tmdbImageUrl"; + +type TmdbSeriesDetailsWithExtras = TmdbSeriesDetails & { + logo_path?: string | null; + genres?: Array<{ name: string }>; + production_countries?: Array<{ name: string }>; +}; + +type TmdbEpisodeDetails = NonNullable[number]; + +export function buildTvShowNfo( + tmdbTvSeriesDetails: TmdbSeriesDetails, + tmdbTvShowSeasons: TmdbSeasonDetails[], +): TvShowNFO { + const series = tmdbTvSeriesDetails as TmdbSeriesDetailsWithExtras; + const thumbs: TvShowNFO["thumbs"] = []; + const namedSeasons: TvShowNFO["namedSeasons"] = []; + + const poster = getTmdbImageUrl(series.poster_path, "original"); + if (poster) { + thumbs.push({ + url: poster, + aspect: "poster", + }); + } + + const clearLogo = getTmdbImageUrl(series.logo_path, "original"); + if (clearLogo) { + thumbs.push({ + url: clearLogo, + aspect: "clearlogo", + }); + } + + for (const season of tmdbTvShowSeasons ?? []) { + namedSeasons.push({ + number: season.season_number, + name: season.name, + }); + const seasonPoster = getTmdbImageUrl(season.poster_path, "original"); + if (seasonPoster) { + thumbs.push({ + url: seasonPoster, + aspect: "poster", + season: season.season_number, + type: "season", + }); + } + } + + const fanart = getTmdbImageUrl(series.backdrop_path, "original"); + const runtime = + (tmdbTvShowSeasons ?? []) + .flatMap((s) => s.episodes ?? []) + .find((ep) => typeof ep.runtime === "number" && ep.runtime > 0)?.runtime; + + return { + title: series.name, + originalTitle: series.original_name, + showTitle: series.name, + year: parseInt(series.first_air_date?.slice(0, 4) ?? "", 10) || undefined, + top250: 0, + ratings: [ + { + default: true, + max: 10, + name: "themoviedb", + value: series.vote_average, + votes: series.vote_count, + }, + ], + userRating: 0, + outline: series.overview, + plot: series.overview, + tagline: undefined, + runtime, + thumbs: thumbs.length > 0 ? thumbs : undefined, + namedSeasons: namedSeasons.length > 0 ? namedSeasons : undefined, + fanartThumbs: fanart ? [fanart] : undefined, + episodeguide: JSON.stringify({ tmdb: String(series.id) }), + id: String(series.id), + tmdbid: String(series.id), + uniqueIds: [ + { + default: true, + type: "tmdb", + value: String(series.id), + }, + ], + premiered: series.first_air_date, + status: series.status, + watched: false, + genres: series.genres?.map((g) => g.name).filter(Boolean), + studios: (series.production_companies ?? []).map((c) => c.name).filter(Boolean), + countries: series.production_countries?.map((c) => c.name).filter(Boolean), + dateadded: new Date().toISOString().slice(0, 19).replace("T", " "), + }; +} + +export function buildTvShowEpisodeNfo( + tmdbTvSeriesDetails: TmdbSeriesDetails, + tmdbSeason: TmdbSeasonDetails, + tmdbEpisode: TmdbEpisodeDetails, +): EpisodeNfo { + const thumb = getTmdbImageUrl(tmdbEpisode.still_path, "original") ?? undefined; + const studios = + (tmdbTvSeriesDetails.production_companies ?? []) + .map((company) => company.name) + .filter(Boolean); + + const directors = (tmdbEpisode.crew ?? []) + .filter((crew) => crew.job === "Director") + .map((crew) => ({ + tmdbid: String(crew.id), + name: crew.name, + })); + + const credits = (tmdbEpisode.crew ?? []) + .filter((crew) => crew.department === "Writing") + .map((crew) => ({ + tmdbid: String(crew.id), + name: crew.name, + })); + + const actors = (tmdbEpisode.guest_stars ?? []).map((guest) => ({ + name: guest.name, + role: guest.character, + thumb: getTmdbImageUrl(guest.profile_path, "original") ?? undefined, + profile: `https://www.themoviedb.org/person/${guest.id}`, + type: "GuestStar", + tmdbid: String(guest.id), + })); + + return { + id: String(tmdbEpisode.id), + title: tmdbEpisode.name, + originalTitle: tmdbEpisode.name, + showTitle: tmdbTvSeriesDetails.name, + season: tmdbSeason.season_number, + episode: tmdbEpisode.episode_number, + uniqueIds: [ + { + default: true, + type: "tmdb", + value: String(tmdbEpisode.id), + }, + ], + ratings: + tmdbEpisode.vote_average > 0 || tmdbEpisode.vote_count > 0 + ? [ + { + default: false, + max: 10, + name: "themoviedb", + value: tmdbEpisode.vote_average, + votes: tmdbEpisode.vote_count, + }, + ] + : undefined, + userRating: 0, + plot: tmdbEpisode.overview, + runtime: tmdbEpisode.runtime > 0 ? tmdbEpisode.runtime : undefined, + thumb, + premiered: tmdbEpisode.air_date, + aired: tmdbEpisode.air_date, + watched: false, + playcount: 0, + studios: studios.length > 0 ? studios : undefined, + credits: credits.length > 0 ? credits : undefined, + directors: directors.length > 0 ? directors : undefined, + actors: actors.length > 0 ? actors : undefined, + dateadded: new Date().toISOString().slice(0, 19).replace("T", " "), + }; +} diff --git a/apps/core/src/pipeline/scrape/nfoTypes.ts b/apps/core/src/pipeline/scrape/nfoTypes.ts new file mode 100644 index 00000000..a0345ddf --- /dev/null +++ b/apps/core/src/pipeline/scrape/nfoTypes.ts @@ -0,0 +1,104 @@ +export type ThumbAspect = "poster" | "clearlogo" | null; + +export interface NfoThumb { + url: string; + aspect: ThumbAspect; + season?: number; + type?: string; +} + +export interface TvShowNFORating { + default?: boolean; + max?: number; + name?: string; + value?: number; + votes?: number; +} + +export interface TvShowNFOUniqueId { + default?: boolean; + type?: string; + value?: string; +} + +export interface TvShowNFOActor { + name?: string; + role?: string; + thumb?: string; + profile?: string; + type?: string; + tmdbid?: string; +} + +export interface TvShowNFONamedSeason { + number?: number; + name?: string; +} + +export interface TvShowNFO { + title?: string; + originalTitle?: string; + showTitle?: string; + sortTitle?: string; + year?: number; + top250?: number; + ratings?: TvShowNFORating[]; + userRating?: number; + outline?: string; + plot?: string; + tagline?: string; + runtime?: number; + thumbs?: NfoThumb[]; + namedSeasons?: TvShowNFONamedSeason[]; + fanartThumbs?: string[]; + mpaa?: string; + certification?: string; + episodeguide?: string; + id?: string; + imdbid?: string; + tmdbid?: string; + tvdbid?: string; + uniqueIds?: TvShowNFOUniqueId[]; + premiered?: string; + status?: string; + watched?: boolean; + playcount?: number; + genres?: string[]; + studios?: string[]; + countries?: string[]; + tags?: string[]; + actors?: TvShowNFOActor[]; + trailer?: string; + dateadded?: string; + userNote?: string; +} + +export interface EpisodeNfo { + id?: string; + title?: string; + originalTitle?: string; + showTitle?: string; + season?: number; + episode?: number; + uniqueIds?: TvShowNFOUniqueId[]; + ratings?: TvShowNFORating[]; + userRating?: number; + plot?: string; + runtime?: number; + thumb?: string; + mpaa?: string; + premiered?: string; + aired?: string; + watched?: boolean; + playcount?: number; + studios?: string[]; + credits?: Array<{ tmdbid?: string; name?: string }>; + directors?: Array<{ tmdbid?: string; name?: string }>; + actors?: TvShowNFOActor[]; + dateadded?: string; + source?: string; + edition?: string; + originalFilename?: string; + userNote?: string; + episodeGroups?: Array<{ id?: string; name?: string; season?: number; episode?: number }>; +} diff --git a/apps/core/src/pipeline/scrape/nfoXml.test.ts b/apps/core/src/pipeline/scrape/nfoXml.test.ts new file mode 100644 index 00000000..97b25797 --- /dev/null +++ b/apps/core/src/pipeline/scrape/nfoXml.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { EpisodeNfo, TvShowNFO } from "./nfoTypes"; +import { + convertTvShowEpisodeNfoToXml, + convertTvShowNfoToXml, +} from "./nfoXml"; + +describe("convertTvShowNfoToXml", () => { + it("includes title and tmdbid in tvshow XML", () => { + const nfo: TvShowNFO = { + title: "Test Show", + tmdbid: "123876", + id: "123876", + }; + + const xml = convertTvShowNfoToXml(nfo); + + expect(xml).toContain(''); + expect(xml).toContain(""); + expect(xml).toContain("Test Show"); + expect(xml).toContain("123876"); + }); + + it("serializes ratings, uniqueIds, and fanart thumbs", () => { + const nfo: TvShowNFO = { + title: "Rated Show", + tmdbid: "99", + ratings: [ + { + default: true, + max: 10, + name: "themoviedb", + value: 8.5, + votes: 100, + }, + ], + uniqueIds: [{ default: true, type: "tmdb", value: "99" }], + fanartThumbs: ["https://image.tmdb.org/t/p/original/fan.jpg"], + }; + + const xml = convertTvShowNfoToXml(nfo); + + expect(xml).toContain('name="themoviedb"'); + expect(xml).toContain("8.5"); + expect(xml).toContain('99'); + expect(xml).toContain(""); + expect(xml).toContain("https://image.tmdb.org/t/p/original/fan.jpg"); + }); +}); + +describe("convertTvShowEpisodeNfoToXml", () => { + it("includes title and episode metadata in episodedetails XML", () => { + const nfo: EpisodeNfo = { + title: "Episode One", + showTitle: "Test Show", + season: 1, + episode: 1, + id: "8415207", + uniqueIds: [{ default: true, type: "tmdb", value: "8415207" }], + }; + + const xml = convertTvShowEpisodeNfoToXml(nfo); + + expect(xml).toContain(''); + expect(xml).toContain(""); + expect(xml).toContain("Episode One"); + expect(xml).toContain("Test Show"); + expect(xml).toContain("1"); + expect(xml).toContain("1"); + expect(xml).toContain('8415207'); + }); +}); diff --git a/apps/core/src/pipeline/scrape/nfoXml.ts b/apps/core/src/pipeline/scrape/nfoXml.ts new file mode 100644 index 00000000..8b09b60e --- /dev/null +++ b/apps/core/src/pipeline/scrape/nfoXml.ts @@ -0,0 +1,355 @@ +import type { EpisodeNfo, TvShowNFO } from "./nfoTypes"; + +interface XmlNode { + tag: string; + attrs?: Record; + text?: string; + children?: XmlNode[]; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function serializeNode(node: XmlNode): string { + const attrs = node.attrs + ? Object.entries(node.attrs) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => ` ${key}="${escapeXml(String(value))}"`) + .join("") + : ""; + + if (!node.children?.length && node.text === undefined) { + return `<${node.tag}${attrs}/>`; + } + + const inner = + node.text !== undefined + ? escapeXml(node.text) + : (node.children ?? []).map(serializeNode).join(""); + return `<${node.tag}${attrs}>${inner}`; +} + +function formatXml(xml: string): string { + const padding = " "; + const reg = /(>)(<)(\/*)/g; + let formatted = ""; + xml = xml.replace(reg, "$1\n$2$3"); + let pad = 0; + xml.split("\n").forEach((node) => { + let indent = 0; + if (node.match(/.+<\/\w[^>]*>$/)) { + indent = 0; + } else if (node.match(/^<\/\w/)) { + if (pad > 0) pad -= 1; + } else if (node.match(/^<\w([^>]*[^/])?>.*$/)) { + indent = 1; + } + formatted += padding.repeat(pad) + node + "\n"; + pad += indent; + }); + return formatted.trim(); +} + +function textElement(tag: string, value: string | undefined): XmlNode | undefined { + if (value === undefined) return undefined; + return { tag, text: value }; +} + +function numberElement(tag: string, value: number | undefined): XmlNode | undefined { + if (value === undefined) return undefined; + return { tag, text: String(value) }; +} + +function buildTvShowChildren(nfo: TvShowNFO): XmlNode[] { + const children: XmlNode[] = []; + + for (const node of [ + textElement("title", nfo.title), + textElement("originaltitle", nfo.originalTitle), + textElement("showtitle", nfo.showTitle), + textElement("sorttitle", nfo.sortTitle), + numberElement("year", nfo.year), + numberElement("top250", nfo.top250), + ]) { + if (node) children.push(node); + } + + if (nfo.ratings?.length) { + children.push({ + tag: "ratings", + children: nfo.ratings.map((rating) => ({ + tag: "rating", + attrs: { + default: rating.default !== undefined ? String(rating.default) : undefined, + max: rating.max !== undefined ? String(rating.max) : undefined, + name: rating.name, + }, + children: [ + ...(rating.value !== undefined + ? [{ tag: "value", text: String(rating.value) }] + : []), + ...(rating.votes !== undefined + ? [{ tag: "votes", text: String(rating.votes) }] + : []), + ], + })), + }); + } + + for (const node of [ + numberElement("userrating", nfo.userRating), + textElement("outline", nfo.outline), + textElement("plot", nfo.plot), + textElement("tagline", nfo.tagline), + numberElement("runtime", nfo.runtime), + ]) { + if (node) children.push(node); + } + + nfo.thumbs?.forEach((thumb) => { + if (!thumb.url) return; + children.push({ + tag: "thumb", + attrs: { + aspect: thumb.aspect ?? undefined, + season: thumb.season !== undefined ? String(thumb.season) : undefined, + type: thumb.type, + }, + text: thumb.url, + }); + }); + + nfo.namedSeasons?.forEach((namedSeason) => { + children.push({ + tag: "namedseason", + attrs: { + number: + namedSeason.number !== undefined ? String(namedSeason.number) : undefined, + }, + text: namedSeason.name, + }); + }); + + if (nfo.fanartThumbs?.length) { + children.push({ + tag: "fanart", + children: nfo.fanartThumbs + .filter(Boolean) + .map((thumbUrl) => ({ tag: "thumb", text: thumbUrl })), + }); + } + + for (const node of [ + textElement("mpaa", nfo.mpaa), + textElement("certification", nfo.certification), + textElement("episodeguide", nfo.episodeguide), + textElement("id", nfo.id), + textElement("imdbid", nfo.imdbid), + textElement("tmdbid", nfo.tmdbid), + textElement("tvdbid", nfo.tvdbid), + ]) { + if (node) children.push(node); + } + + nfo.uniqueIds?.forEach((uniqueId) => { + children.push({ + tag: "uniqueid", + attrs: { + default: uniqueId.default !== undefined ? String(uniqueId.default) : undefined, + type: uniqueId.type, + }, + text: uniqueId.value, + }); + }); + + for (const node of [ + textElement("premiered", nfo.premiered), + textElement("status", nfo.status), + nfo.watched !== undefined ? { tag: "watched", text: String(nfo.watched) } : undefined, + numberElement("playcount", nfo.playcount), + ]) { + if (node) children.push(node); + } + + nfo.genres?.forEach((genre) => { + if (genre) children.push({ tag: "genre", text: genre }); + }); + nfo.studios?.forEach((studio) => { + if (studio) children.push({ tag: "studio", text: studio }); + }); + nfo.countries?.forEach((country) => { + if (country) children.push({ tag: "country", text: country }); + }); + nfo.tags?.forEach((tag) => { + if (tag) children.push({ tag: "tag", text: tag }); + }); + + nfo.actors?.forEach((actor) => { + children.push({ + tag: "actor", + children: [ + ...(actor.name !== undefined ? [{ tag: "name", text: actor.name }] : []), + ...(actor.role !== undefined ? [{ tag: "role", text: actor.role }] : []), + ...(actor.thumb !== undefined ? [{ tag: "thumb", text: actor.thumb }] : []), + ...(actor.profile !== undefined ? [{ tag: "profile", text: actor.profile }] : []), + ...(actor.tmdbid !== undefined ? [{ tag: "tmdbid", text: actor.tmdbid }] : []), + ], + }); + }); + + for (const node of [ + textElement("trailer", nfo.trailer), + textElement("dateadded", nfo.dateadded), + textElement("user_note", nfo.userNote), + ]) { + if (node) children.push(node); + } + + return children; +} + +function buildEpisodeChildren(nfo: EpisodeNfo): XmlNode[] { + const children: XmlNode[] = []; + + for (const node of [ + textElement("title", nfo.title), + textElement("originaltitle", nfo.originalTitle), + textElement("showtitle", nfo.showTitle), + numberElement("season", nfo.season), + numberElement("episode", nfo.episode), + textElement("id", nfo.id), + ]) { + if (node) children.push(node); + } + + nfo.uniqueIds?.forEach((uniqueId) => { + children.push({ + tag: "uniqueid", + attrs: { + default: uniqueId.default !== undefined ? String(uniqueId.default) : undefined, + type: uniqueId.type, + }, + text: uniqueId.value, + }); + }); + + if (nfo.ratings?.length) { + children.push({ + tag: "ratings", + children: nfo.ratings.map((rating) => ({ + tag: "rating", + attrs: { + default: rating.default !== undefined ? String(rating.default) : undefined, + max: rating.max !== undefined ? String(rating.max) : undefined, + name: rating.name, + }, + children: [ + ...(rating.value !== undefined + ? [{ tag: "value", text: String(rating.value) }] + : []), + ...(rating.votes !== undefined + ? [{ tag: "votes", text: String(rating.votes) }] + : []), + ], + })), + }); + } + + for (const node of [ + numberElement("userrating", nfo.userRating), + textElement("plot", nfo.plot), + numberElement("runtime", nfo.runtime), + textElement("thumb", nfo.thumb), + textElement("mpaa", nfo.mpaa), + textElement("premiered", nfo.premiered), + textElement("aired", nfo.aired), + nfo.watched !== undefined ? { tag: "watched", text: String(nfo.watched) } : undefined, + numberElement("playcount", nfo.playcount), + ]) { + if (node) children.push(node); + } + + nfo.studios?.forEach((studio) => { + if (studio) children.push({ tag: "studio", text: studio }); + }); + + nfo.credits?.forEach((credit) => { + children.push({ + tag: "credits", + attrs: { tmdbid: credit.tmdbid }, + text: credit.name, + }); + }); + + nfo.directors?.forEach((director) => { + children.push({ + tag: "director", + attrs: { tmdbid: director.tmdbid }, + text: director.name, + }); + }); + + nfo.actors?.forEach((actor) => { + children.push({ + tag: "actor", + children: [ + ...(actor.name !== undefined ? [{ tag: "name", text: actor.name }] : []), + ...(actor.role !== undefined ? [{ tag: "role", text: actor.role }] : []), + ...(actor.thumb !== undefined ? [{ tag: "thumb", text: actor.thumb }] : []), + ...(actor.profile !== undefined ? [{ tag: "profile", text: actor.profile }] : []), + ...(actor.type !== undefined ? [{ tag: "type", text: actor.type }] : []), + ...(actor.tmdbid !== undefined ? [{ tag: "tmdbid", text: actor.tmdbid }] : []), + ], + }); + }); + + for (const node of [ + textElement("dateadded", nfo.dateadded), + textElement("source", nfo.source), + textElement("edition", nfo.edition), + textElement("original_filename", nfo.originalFilename), + textElement("user_note", nfo.userNote), + ]) { + if (node) children.push(node); + } + + if (nfo.episodeGroups?.length) { + children.push({ + tag: "episode_groups", + children: nfo.episodeGroups.map((group) => ({ + tag: "group", + attrs: { + episode: group.episode !== undefined ? String(group.episode) : undefined, + id: group.id, + name: group.name, + season: group.season !== undefined ? String(group.season) : undefined, + }, + })), + }); + } + + return children; +} + +function toXmlDocument(rootTag: string, children: XmlNode[]): string { + const body = serializeNode({ tag: rootTag, children }); + return ( + '\n' + formatXml(body) + ); +} + +export function convertTvShowNfoToXml(nfo: TvShowNFO): string { + return toXmlDocument("tvshow", buildTvShowChildren(nfo)); +} + +export function convertTvShowEpisodeNfoToXml(nfo: EpisodeNfo): string { + return toXmlDocument("episodedetails", buildEpisodeChildren(nfo)); +} + +export type { EpisodeNfo, TvShowNFO } from "./nfoTypes"; From d9fae8d218ef88168e42d236b387360920daea63 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:05:49 +0800 Subject: [PATCH 084/214] feat(core): add TMDB TV scrape task runners Port poster, fanart, thumbnail, and NFO scrape tasks from UI mutations into core pipeline runners with skip-on-existing and unit tests. Co-authored-by: Cursor --- .../pipeline/scrape/scrapeFanartTmdb.test.ts | 78 +++++++++ .../src/pipeline/scrape/scrapeFanartTmdb.ts | 62 +++++++ .../src/pipeline/scrape/scrapeNfoTmdb.test.ts | 149 +++++++++++++++++ .../core/src/pipeline/scrape/scrapeNfoTmdb.ts | 95 +++++++++++ .../pipeline/scrape/scrapePosterTmdb.test.ts | 153 ++++++++++++++++++ .../src/pipeline/scrape/scrapePosterTmdb.ts | 62 +++++++ .../src/pipeline/scrape/scrapeTaskDeps.ts | 39 +++++ .../scrape/scrapeThumbnailsTmdb.test.ts | 107 ++++++++++++ .../pipeline/scrape/scrapeThumbnailsTmdb.ts | 115 +++++++++++++ 9 files changed, 860 insertions(+) create mode 100644 apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts create mode 100644 apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts create mode 100644 apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts create mode 100644 apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts create mode 100644 apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts create mode 100644 apps/core/src/pipeline/scrape/scrapePosterTmdb.ts create mode 100644 apps/core/src/pipeline/scrape/scrapeTaskDeps.ts create mode 100644 apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts create mode 100644 apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.ts diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts new file mode 100644 index 00000000..75227dd8 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts @@ -0,0 +1,78 @@ +import type { MediaMetadata, TmdbSeriesDetails } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import type { TmdbClient } from "../../clients/TmdbClient"; +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; +import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { scrapeFanartTmdb } from "./scrapeFanartTmdb"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; + +const seriesDetails: TmdbSeriesDetails = { + id: 123876, + name: "Test Show", + poster_path: "/poster.jpg", + backdrop_path: "/fanart.jpg", + seasons: [], +} as TmdbSeriesDetails; + +const tvShowMetadata: MediaMetadata = { + type: "tvshow-folder", + mediaFolderPath: "/media/Test Show", + tvShow: { id: "123876", database: "TMDB", name: "Test Show", seasons: [] }, +} as MediaMetadata; + +function createInMemoryFs(): FsPort & { binaryFiles: Map } { + const binaryFiles = new Map(); + return { + binaryFiles, + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + writeBinaryFile: vi.fn(async (path, data) => { + binaryFiles.set(path, data); + }), + exists: vi.fn(async (path) => binaryFiles.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + }; +} + +function createDeps( + fs: FsPort, + network: NetworkPort, + tmdb: Partial, +): ScrapeTaskDeps { + return { + fs, + network, + tmdb: tmdb as TmdbClient, + mediaMetadata: tvShowMetadata, + language: "en-US", + userConfig: DEFAULT_USER_CONFIG, + }; +} + +describe("scrapeFanartTmdb", () => { + it("downloads fanart from backdrop_path", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([0x89, 0x50]); + const network: NetworkPort = { + fetch: vi.fn(async () => ({ + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new Uint8Array(fakeBytes).buffer), + })), + }; + const tmdb = { getTvShowById: vi.fn().mockResolvedValue(seriesDetails) }; + + const result = await scrapeFanartTmdb(createDeps(fs, network, tmdb)); + + expect(result).toEqual({ status: "completed" }); + expect(fs.binaryFiles.get("/media/Test Show/fanart.jpg")).toEqual(fakeBytes); + }); +}); diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts new file mode 100644 index 00000000..43cfb414 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts @@ -0,0 +1,62 @@ +import type { TmdbSeriesDetails } from "@smm/core"; +import { downloadScrapeImage } from "./downloadScrapeImage"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import { + fileExtensionFromUrl, + parseTmdbSeriesId, + scrapeErrorMessage, +} from "./scrapeTaskDeps"; +import { getTmdbImageUrl } from "./tmdbImageUrl"; +import type { ScrapeTaskResult } from "./types"; +import { joinPosix } from "../paths"; + +export async function resolveFanartUrl( + seriesId: number, + language: string, + getTvShowById: (id: number, language: string) => Promise, +): Promise { + const series = await getTvShowById(seriesId, language); + return getTmdbImageUrl(series.backdrop_path, "original") ?? undefined; +} + +/** Download TMDB TV fanart as `fanart.{ext}` in the media folder root. */ +export async function scrapeFanartTmdb(deps: ScrapeTaskDeps): Promise { + const { fs, network, tmdb, mediaMetadata, language, userConfig } = deps; + const folderPath = mediaMetadata.mediaFolderPath; + const seriesId = parseTmdbSeriesId(mediaMetadata); + + if (!folderPath) { + return { status: "failed", error: "Missing media folder path" }; + } + if (seriesId === undefined) { + return { status: "failed", error: "TV show TMDB metadata is required" }; + } + + try { + const fanartUrl = await resolveFanartUrl(seriesId, language, (id, lang) => + tmdb.getTvShowById(id, lang), + ); + if (!fanartUrl) { + return { status: "failed", error: "No TMDB fanart available" }; + } + + const ext = fileExtensionFromUrl(fanartUrl); + const fanartPath = joinPosix(folderPath, `fanart.${ext}`); + + if (await fs.exists(fanartPath)) { + return { status: "skipped" }; + } + + await downloadScrapeImage( + mediaMetadata, + fanartUrl, + fanartPath, + userConfig, + fs, + network, + ); + return { status: "completed" }; + } catch (error) { + return { status: "failed", error: scrapeErrorMessage(error) }; + } +} diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts new file mode 100644 index 00000000..b5771ef6 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts @@ -0,0 +1,149 @@ +import type { MediaMetadata, TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import type { TmdbClient } from "../../clients/TmdbClient"; +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; +import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { scrapeNfoTmdb } from "./scrapeNfoTmdb"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; + +const seriesDetails: TmdbSeriesDetails = { + id: 123876, + name: "Test Show", + original_name: "Original Show", + overview: "Show overview", + poster_path: "/poster.jpg", + backdrop_path: "/fanart.jpg", + first_air_date: "2021-01-15", + vote_average: 8.3, + vote_count: 545, + popularity: 100, + genre_ids: [16], + origin_country: ["JP"], + number_of_seasons: 1, + number_of_episodes: 1, + seasons: [{ id: 1, name: "Season 1", season_number: 1, episode_count: 1, poster_path: null }], + status: "Ended", + type: "Scripted", + in_production: false, + last_air_date: "2021-03-26", + networks: [], + production_companies: [{ id: 1, name: "Studio Alpha", logo_path: null }], + genres: [{ id: 16, name: "Animation" }], +} as TmdbSeriesDetails; + +const seasonDetails: TmdbSeasonDetails = { + id: 1, + name: "Season 1", + overview: "Season overview", + poster_path: "/season1.jpg", + season_number: 1, + air_date: "2021-01-15", + episode_count: 1, + episodes: [ + { + id: 8415207, + name: "Episode One", + overview: "Episode overview", + still_path: "/still.jpg", + air_date: "2021-01-15", + episode_number: 1, + season_number: 1, + runtime: 24, + vote_average: 7.8, + vote_count: 13, + crew: [], + guest_stars: [], + }, + ], +}; + +const tvShowMetadata: MediaMetadata = { + type: "tvshow-folder", + mediaFolderPath: "/media/Test Show", + tvShow: { id: "123876", database: "TMDB", name: "Test Show", seasons: [] }, + mediaFiles: [ + { + absolutePath: "/media/Test Show/Season 01/S01E01.mkv", + seasonNumber: 1, + episodeNumber: 1, + }, + ], +} as MediaMetadata; + +function createInMemoryFs(initialText: Record = {}): FsPort & { + textFiles: Map; +} { + const textFiles = new Map(Object.entries(initialText)); + return { + textFiles, + readTextFile: vi.fn(async (path) => textFiles.get(path) ?? ""), + writeTextFile: vi.fn(async (path, content) => { + textFiles.set(path, content); + }), + writeBinaryFile: vi.fn(), + exists: vi.fn(async (path) => textFiles.has(path)), + listFiles: vi.fn(async () => [...textFiles.keys()]), + deleteFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + }; +} + +function createDeps( + fs: FsPort, + tmdb: Partial, + mediaMetadata: MediaMetadata = tvShowMetadata, +): ScrapeTaskDeps { + return { + fs, + network: { fetch: vi.fn() }, + tmdb: tmdb as TmdbClient, + mediaMetadata, + language: "en-US", + userConfig: DEFAULT_USER_CONFIG, + }; +} + +describe("scrapeNfoTmdb", () => { + it("writes tvshow.nfo and episode nfos from TMDB data", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-19T12:00:00.000Z")); + + const fs = createInMemoryFs(); + const tmdb = { + getTvShowById: vi.fn().mockResolvedValue(seriesDetails), + getTvSeasonById: vi.fn().mockResolvedValue(seasonDetails), + }; + + const result = await scrapeNfoTmdb(createDeps(fs, tmdb)); + + expect(result).toEqual({ status: "completed" }); + const tvshowNfo = fs.textFiles.get("/media/Test Show/tvshow.nfo"); + expect(tvshowNfo).toContain("Test Show"); + expect(tvshowNfo).toContain("123876"); + + const episodeNfo = fs.textFiles.get("/media/Test Show/Season 01/S01E01.nfo"); + expect(episodeNfo).toContain("Episode One"); + expect(episodeNfo).toContain("1"); + expect(episodeNfo).toContain("1"); + + vi.useRealTimers(); + }); + + it("skips files that already exist", async () => { + const fs = createInMemoryFs({ + "/media/Test Show/tvshow.nfo": "", + "/media/Test Show/Season 01/S01E01.nfo": "", + }); + const tmdb = { + getTvShowById: vi.fn().mockResolvedValue(seriesDetails), + getTvSeasonById: vi.fn().mockResolvedValue(seasonDetails), + }; + + const result = await scrapeNfoTmdb(createDeps(fs, tmdb)); + + expect(result).toEqual({ status: "skipped" }); + expect(fs.writeTextFile).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts new file mode 100644 index 00000000..345591ed --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts @@ -0,0 +1,95 @@ +import type { TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import { buildTvShowEpisodeNfo, buildTvShowNfo } from "./buildTvShowNfoTmdb"; +import { + convertTvShowEpisodeNfoToXml, + convertTvShowNfoToXml, +} from "./nfoXml"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import { newFilePathWithExt, parseTmdbSeriesId, scrapeErrorMessage } from "./scrapeTaskDeps"; +import type { ScrapeTaskResult } from "./types"; +import { joinPosix } from "../paths"; + +async function fetchTvShowSeasons( + seriesId: number, + language: string, + getTvShowById: (id: number, language: string) => Promise, + getTvSeasonById: ( + seriesId: number, + seasonNumber: number, + language: string, + ) => Promise, +): Promise<{ series: TmdbSeriesDetails; seasons: TmdbSeasonDetails[] }> { + const series = await getTvShowById(seriesId, language); + const seasons = await Promise.all( + (series.seasons ?? []).map((season) => + getTvSeasonById(seriesId, season.season_number, language), + ), + ); + return { series, seasons }; +} + +/** Write `tvshow.nfo` and per-episode NFO files from TMDB metadata. */ +export async function scrapeNfoTmdb(deps: ScrapeTaskDeps): Promise { + const { fs, tmdb, mediaMetadata, language } = deps; + const folderPath = mediaMetadata.mediaFolderPath; + const seriesId = parseTmdbSeriesId(mediaMetadata); + + if (!folderPath) { + return { status: "failed", error: "Missing media folder path" }; + } + if (seriesId === undefined) { + return { status: "failed", error: "TV show TMDB metadata is required" }; + } + + try { + const { series, seasons } = await fetchTvShowSeasons( + seriesId, + language, + (id, lang) => tmdb.getTvShowById(id, lang), + (id, season, lang) => tmdb.getTvSeasonById(id, season, lang), + ); + + let wroteAny = false; + let skippedAny = false; + + const tvShowNfoPath = joinPosix(folderPath, "tvshow.nfo"); + if (await fs.exists(tvShowNfoPath)) { + skippedAny = true; + } else { + const tvShowNfo = buildTvShowNfo(series, seasons); + await fs.writeTextFile(tvShowNfoPath, convertTvShowNfoToXml(tvShowNfo)); + wroteAny = true; + } + + for (const mediaFile of mediaMetadata.mediaFiles ?? []) { + const { seasonNumber, episodeNumber, absolutePath } = mediaFile; + if (seasonNumber === undefined || episodeNumber === undefined) continue; + + const episodeNfoPath = newFilePathWithExt(absolutePath, ".nfo"); + if (await fs.exists(episodeNfoPath)) { + skippedAny = true; + continue; + } + + const tmdbSeason = seasons.find((season) => season.season_number === seasonNumber); + const tmdbEpisode = tmdbSeason?.episodes?.find( + (episode) => episode.episode_number === episodeNumber, + ); + if (!tmdbSeason || !tmdbEpisode) continue; + + const episodeNfo = buildTvShowEpisodeNfo(series, tmdbSeason, tmdbEpisode); + await fs.writeTextFile(episodeNfoPath, convertTvShowEpisodeNfoToXml(episodeNfo)); + wroteAny = true; + } + + if (wroteAny) { + return { status: "completed" }; + } + if (skippedAny) { + return { status: "skipped" }; + } + return { status: "failed", error: "No linked episodes to write NFO files for" }; + } catch (error) { + return { status: "failed", error: scrapeErrorMessage(error) }; + } +} diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts new file mode 100644 index 00000000..c06639c1 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -0,0 +1,153 @@ +import type { MediaMetadata, TmdbSeasonDetails, TmdbSeriesDetails, UserConfig } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import type { TmdbClient } from "../../clients/TmdbClient"; +import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; +import type { FsPort } from "../../ports/FsPort"; +import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { resolvePosterUrl, scrapePosterTmdb } from "./scrapePosterTmdb"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; + +const seriesDetails: TmdbSeriesDetails = { + id: 123876, + name: "Test Show", + original_name: "Original Show", + overview: "Show overview", + poster_path: "/poster.jpg", + backdrop_path: "/fanart.jpg", + first_air_date: "2021-01-15", + vote_average: 8.3, + vote_count: 545, + popularity: 100, + genre_ids: [16, 35], + origin_country: ["JP"], + number_of_seasons: 1, + number_of_episodes: 1, + seasons: [{ id: 1, name: "Season 1", season_number: 1, episode_count: 1, poster_path: null }], + status: "Ended", + type: "Scripted", + in_production: false, + last_air_date: "2021-03-26", + networks: [], + production_companies: [], +} as TmdbSeriesDetails; + +const tvShowMetadata: MediaMetadata = { + type: "tvshow-folder", + mediaFolderPath: "/media/Test Show", + tvShow: { id: "123876", database: "TMDB", name: "Test Show", seasons: [] }, + mediaFiles: [], +} as MediaMetadata; + +function createInMemoryFs(initialFiles: Record = {}): FsPort & { + binaryFiles: Map; + textFiles: Map; +} { + const binaryFiles = new Map(); + const textFiles = new Map(); + + for (const [path, content] of Object.entries(initialFiles)) { + if (typeof content === "string") { + textFiles.set(path, content); + } else { + binaryFiles.set(path, content); + } + } + + return { + binaryFiles, + textFiles, + readTextFile: vi.fn(async (path) => textFiles.get(path) ?? ""), + writeTextFile: vi.fn(async (path, content) => { + textFiles.set(path, content); + }), + writeBinaryFile: vi.fn(async (path, data) => { + binaryFiles.set(path, data); + }), + exists: vi.fn(async (path) => binaryFiles.has(path) || textFiles.has(path)), + listFiles: vi.fn(async () => [...binaryFiles.keys(), ...textFiles.keys()]), + deleteFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + }; +} + +function fakeImageResponse(bytes: Uint8Array): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: { "content-type": "image/jpeg" }, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new Uint8Array(bytes).buffer), + }; +} + +function createDeps( + fs: FsPort, + network: NetworkPort, + tmdb: Partial, + mediaMetadata: MediaMetadata = tvShowMetadata, +): ScrapeTaskDeps { + return { + fs, + network, + tmdb: tmdb as TmdbClient, + mediaMetadata, + language: "en-US", + userConfig: DEFAULT_USER_CONFIG, + }; +} + +describe("resolvePosterUrl", () => { + it("resolves TMDB poster_path to an original-size CDN URL", async () => { + const result = await resolvePosterUrl(123876, "en-US", vi.fn().mockResolvedValue(seriesDetails)); + expect(result).toBe("https://image.tmdb.org/t/p/original/poster.jpg"); + }); +}); + +describe("scrapePosterTmdb", () => { + it("downloads poster bytes when the file is missing", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([0xff, 0xd8, 0xff]); + const network: NetworkPort = { + fetch: vi.fn(async () => fakeImageResponse(fakeBytes)), + }; + const tmdb = { + getTvShowById: vi.fn().mockResolvedValue(seriesDetails), + }; + + const result = await scrapePosterTmdb(createDeps(fs, network, tmdb)); + + expect(result).toEqual({ status: "completed" }); + expect(fs.binaryFiles.get("/media/Test Show/poster.jpg")).toEqual(fakeBytes); + expect(network.fetch).toHaveBeenCalledWith( + "https://image.tmdb.org/t/p/original/poster.jpg", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("skips when poster already exists", async () => { + const fs = createInMemoryFs({ "/media/Test Show/poster.jpg": new Uint8Array([1]) }); + const network: NetworkPort = { fetch: vi.fn() }; + const tmdb = { getTvShowById: vi.fn().mockResolvedValue(seriesDetails) }; + + const result = await scrapePosterTmdb(createDeps(fs, network, tmdb)); + + expect(result).toEqual({ status: "skipped" }); + expect(network.fetch).not.toHaveBeenCalled(); + }); + + it("returns failed when TMDB has no poster", async () => { + const fs = createInMemoryFs(); + const network: NetworkPort = { fetch: vi.fn() }; + const tmdb = { + getTvShowById: vi.fn().mockResolvedValue({ ...seriesDetails, poster_path: null }), + }; + + const result = await scrapePosterTmdb(createDeps(fs, network, tmdb)); + + expect(result.status).toBe("failed"); + expect(result.error).toContain("No TMDB poster"); + }); +}); diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts new file mode 100644 index 00000000..9b4b07dd --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts @@ -0,0 +1,62 @@ +import type { TmdbSeriesDetails } from "@smm/core"; +import { downloadScrapeImage } from "./downloadScrapeImage"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import { + fileExtensionFromUrl, + parseTmdbSeriesId, + scrapeErrorMessage, +} from "./scrapeTaskDeps"; +import { getTmdbImageUrl } from "./tmdbImageUrl"; +import type { ScrapeTaskResult } from "./types"; +import { joinPosix } from "../paths"; + +export async function resolvePosterUrl( + seriesId: number, + language: string, + getTvShowById: (id: number, language: string) => Promise, +): Promise { + const series = await getTvShowById(seriesId, language); + return getTmdbImageUrl(series.poster_path, "original") ?? undefined; +} + +/** Download TMDB TV poster as `poster.{ext}` in the media folder root. */ +export async function scrapePosterTmdb(deps: ScrapeTaskDeps): Promise { + const { fs, network, tmdb, mediaMetadata, language, userConfig } = deps; + const folderPath = mediaMetadata.mediaFolderPath; + const seriesId = parseTmdbSeriesId(mediaMetadata); + + if (!folderPath) { + return { status: "failed", error: "Missing media folder path" }; + } + if (seriesId === undefined) { + return { status: "failed", error: "TV show TMDB metadata is required" }; + } + + try { + const posterUrl = await resolvePosterUrl(seriesId, language, (id, lang) => + tmdb.getTvShowById(id, lang), + ); + if (!posterUrl) { + return { status: "failed", error: "No TMDB poster available" }; + } + + const ext = fileExtensionFromUrl(posterUrl); + const posterPath = joinPosix(folderPath, `poster.${ext}`); + + if (await fs.exists(posterPath)) { + return { status: "skipped" }; + } + + await downloadScrapeImage( + mediaMetadata, + posterUrl, + posterPath, + userConfig, + fs, + network, + ); + return { status: "completed" }; + } catch (error) { + return { status: "failed", error: scrapeErrorMessage(error) }; + } +} diff --git a/apps/core/src/pipeline/scrape/scrapeTaskDeps.ts b/apps/core/src/pipeline/scrape/scrapeTaskDeps.ts new file mode 100644 index 00000000..56bd9122 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeTaskDeps.ts @@ -0,0 +1,39 @@ +import type { MediaMetadata, UserConfig } from "@smm/core"; +import type { TmdbClient } from "../../clients/TmdbClient"; +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; +import { extname } from "../paths"; + +export interface ScrapeTaskDeps { + fs: FsPort; + network: NetworkPort; + tmdb: TmdbClient; + mediaMetadata: MediaMetadata; + language: string; + userConfig: UserConfig; + reverseProxyUrl?: string; +} + +export function fileExtensionFromUrl(url: string): string { + const matched = url.match(/\.([a-zA-Z0-9]{2,5})(?:[?#].*)?$/); + return matched?.[1]?.toLowerCase() || "jpg"; +} + +export function newFilePathWithExt(filePath: string, newExt: string): string { + const normalizedExt = newExt.startsWith(".") ? newExt : `.${newExt}`; + const currentExt = extname(filePath); + const base = currentExt ? filePath.slice(0, -currentExt.length) : filePath; + return `${base}${normalizedExt}`; +} + +export function parseTmdbSeriesId(mediaMetadata: MediaMetadata): number | undefined { + if (mediaMetadata.type !== "tvshow-folder" || mediaMetadata.tvShow?.database !== "TMDB") { + return undefined; + } + const id = Number.parseInt(mediaMetadata.tvShow.id, 10); + return Number.isFinite(id) ? id : undefined; +} + +export function scrapeErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts new file mode 100644 index 00000000..39bdb59e --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts @@ -0,0 +1,107 @@ +import type { MediaMetadata, TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import { describe, expect, it, vi } from "vitest"; +import type { TmdbClient } from "../../clients/TmdbClient"; +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; +import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { scrapeThumbnailsTmdb } from "./scrapeThumbnailsTmdb"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; + +const seriesDetails: TmdbSeriesDetails = { + id: 123876, + name: "Test Show", + seasons: [{ id: 1, name: "Season 1", season_number: 1, episode_count: 1, poster_path: null }], +} as TmdbSeriesDetails; + +const seasonDetails: TmdbSeasonDetails = { + id: 1, + name: "Season 1", + season_number: 1, + episode_count: 1, + episodes: [ + { + id: 8415207, + name: "Episode One", + still_path: "/still.jpg", + episode_number: 1, + season_number: 1, + runtime: 24, + vote_average: 0, + vote_count: 0, + crew: [], + guest_stars: [], + }, + ], +}; + +const tvShowMetadata: MediaMetadata = { + type: "tvshow-folder", + mediaFolderPath: "/media/Test Show", + tvShow: { id: "123876", database: "TMDB", name: "Test Show", seasons: [] }, + mediaFiles: [ + { + absolutePath: "/media/Test Show/Season 01/S01E01.mkv", + seasonNumber: 1, + episodeNumber: 1, + }, + ], +} as MediaMetadata; + +function createInMemoryFs(): FsPort & { binaryFiles: Map } { + const binaryFiles = new Map(); + return { + binaryFiles, + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + writeBinaryFile: vi.fn(async (path, data) => { + binaryFiles.set(path, data); + }), + exists: vi.fn(async (path) => binaryFiles.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + }; +} + +function createDeps( + fs: FsPort, + network: NetworkPort, + tmdb: Partial, +): ScrapeTaskDeps { + return { + fs, + network, + tmdb: tmdb as TmdbClient, + mediaMetadata: tvShowMetadata, + language: "en-US", + userConfig: DEFAULT_USER_CONFIG, + }; +} + +describe("scrapeThumbnailsTmdb", () => { + it("downloads episode still beside each linked video", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([0x47, 0x49]); + const network: NetworkPort = { + fetch: vi.fn(async () => ({ + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new Uint8Array(fakeBytes).buffer), + })), + }; + const tmdb = { + getTvShowById: vi.fn().mockResolvedValue(seriesDetails), + getTvSeasonById: vi.fn().mockResolvedValue(seasonDetails), + }; + + const result = await scrapeThumbnailsTmdb(createDeps(fs, network, tmdb)); + + expect(result).toEqual({ status: "completed" }); + expect(fs.binaryFiles.get("/media/Test Show/Season 01/S01E01.jpg")).toEqual(fakeBytes); + }); +}); diff --git a/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.ts b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.ts new file mode 100644 index 00000000..8d0c83d7 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.ts @@ -0,0 +1,115 @@ +import type { TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import { downloadScrapeImage } from "./downloadScrapeImage"; +import { extname } from "../paths"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import { + newFilePathWithExt, + parseTmdbSeriesId, + scrapeErrorMessage, +} from "./scrapeTaskDeps"; +import { getTmdbImageUrl } from "./tmdbImageUrl"; +import type { ScrapeTaskResult } from "./types"; + +export interface EpisodeStillPath { + season: number; + episode: number; + stillUrl: string; +} + +export async function getEpisodeStillPathsFromTmdb( + seriesId: number, + language: string, + getTvShowById: (id: number, language: string) => Promise, + getTvSeasonById: ( + seriesId: number, + seasonNumber: number, + language: string, + ) => Promise, +): Promise { + const tvshow = await getTvShowById(seriesId, language); + const stillPaths: EpisodeStillPath[] = []; + + for (const season of tvshow.seasons ?? []) { + const tmdbSeason = await getTvSeasonById(seriesId, season.season_number, language); + for (const episode of tmdbSeason.episodes ?? []) { + if (!episode.still_path) continue; + const stillUrl = getTmdbImageUrl(episode.still_path, "original"); + if (!stillUrl) continue; + stillPaths.push({ + season: season.season_number, + episode: episode.episode_number, + stillUrl, + }); + } + } + + return stillPaths; +} + +/** Download TMDB episode stills beside each linked video file. */ +export async function scrapeThumbnailsTmdb(deps: ScrapeTaskDeps): Promise { + const { fs, network, tmdb, mediaMetadata, language, userConfig } = deps; + const seriesId = parseTmdbSeriesId(mediaMetadata); + + if (seriesId === undefined) { + return { status: "failed", error: "TV show TMDB metadata is required" }; + } + + const mediaFiles = (mediaMetadata.mediaFiles ?? []).filter( + (file) => file.seasonNumber !== undefined && file.episodeNumber !== undefined, + ); + if (mediaFiles.length === 0) { + return { status: "skipped" }; + } + + try { + const stillPaths = await getEpisodeStillPathsFromTmdb( + seriesId, + language, + (id, lang) => tmdb.getTvShowById(id, lang), + (id, season, lang) => tmdb.getTvSeasonById(id, season, lang), + ); + + let downloaded = 0; + let skipped = 0; + + for (const mediaFile of mediaFiles) { + const still = stillPaths.find( + (path) => + path.season === mediaFile.seasonNumber && + path.episode === mediaFile.episodeNumber, + ); + if (!still) continue; + + const thumbPath = newFilePathWithExt( + mediaFile.absolutePath, + extname(still.stillUrl), + ); + + if (await fs.exists(thumbPath)) { + skipped += 1; + continue; + } + + await downloadScrapeImage( + mediaMetadata, + still.stillUrl, + thumbPath, + userConfig, + fs, + network, + ); + downloaded += 1; + } + + if (downloaded > 0) { + return { status: "completed" }; + } + if (skipped > 0) { + return { status: "skipped" }; + } + return { status: "failed", error: "No TMDB episode stills available for linked files" }; + } catch (error) { + return { status: "failed", error: scrapeErrorMessage(error) }; + } +} From e56530eb68f5f2ab2afb88c917f43f181bf8c3f2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:07:42 +0800 Subject: [PATCH 085/214] feat(core): add Core.scrapeFolder Wire scrapeFolder orchestrator with managed/TMDB validation, orchestrator-level skip via checkScrapeCompletion, and Core integration tests. Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 216 +++++++++++++++++- apps/core/src/Core.ts | 19 +- apps/core/src/index.ts | 19 +- apps/core/src/pipeline/scrape/scrapeFolder.ts | 124 ++++++++++ 4 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 apps/core/src/pipeline/scrape/scrapeFolder.ts diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 7d4a58f5..6463a906 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { Path } from "@core/path"; +import type { TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; import type { FsPort } from "./ports/FsPort"; -import type { NetworkPort } from "./ports/NetworkPort"; +import type { HttpResponse, NetworkPort } from "./ports/NetworkPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { Core } from "./Core"; import { metadataCachePath, planFilePath, userConfigPath } from "./pipeline/paths"; @@ -797,3 +798,216 @@ describe("applyPlan", () => { ).rejects.toThrow(/Unsupported plan task/); }); }); + +describe("scrapeFolder", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + + const seriesDetails: TmdbSeriesDetails = { + id: 123876, + name: "Show", + original_name: "Show", + overview: "Overview", + poster_path: "/poster.jpg", + backdrop_path: "/fanart.jpg", + first_air_date: "2021-01-15", + vote_average: 8, + vote_count: 100, + popularity: 50, + genre_ids: [16], + origin_country: ["US"], + number_of_seasons: 1, + number_of_episodes: 1, + seasons: [{ id: 1, name: "Season 1", season_number: 1, episode_count: 1, poster_path: null }], + status: "Ended", + type: "Scripted", + in_production: false, + last_air_date: "2021-03-26", + networks: [], + production_companies: [], + } as TmdbSeriesDetails; + + const seasonDetails: TmdbSeasonDetails = { + id: 1, + name: "Season 1", + overview: "Season overview", + poster_path: null, + season_number: 1, + air_date: "2021-01-15", + episode_count: 1, + episodes: [ + { + id: 1, + name: "Ep1", + overview: "Episode overview", + still_path: "/still.jpg", + air_date: "2021-01-15", + episode_number: 1, + season_number: 1, + runtime: 24, + vote_average: 7, + vote_count: 10, + crew: [], + guest_stars: [], + }, + ], + }; + + const tvMetadata = { + mediaFolderPath: folder, + type: "tvshow-folder" as const, + tvShow: { id: "123876", database: "TMDB" as const, name: "Show", seasons: [] }, + mediaFiles: [] as { absolutePath: string; seasonNumber?: number; episodeNumber?: number }[], + }; + + function configWith(folders: string[]): string { + return JSON.stringify({ folders, tmdb: {}, tvdb: {}, renameRules: [], dryRun: false, selectedRenameRule: "plex" }); + } + + function scrapeInMemoryFs(seed: Record = {}): FsPort & { + binaryFiles: Map; + textFiles: Map; + } { + const binaryFiles = new Map(); + const textFiles = new Map(); + for (const [path, content] of Object.entries(seed)) { + if (typeof content === "string") textFiles.set(path, content); + else binaryFiles.set(path, content); + } + return { + binaryFiles, + textFiles, + readTextFile: vi.fn(async (path: string) => { + const v = textFiles.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + textFiles.set(path, content); + }), + writeBinaryFile: vi.fn(async (path: string, data: Uint8Array) => { + binaryFiles.set(path, data); + }), + exists: vi.fn(async (path: string) => binaryFiles.has(path) || textFiles.has(path)), + listFiles: vi.fn(async (dir: string) => { + const out: string[] = []; + for (const key of [...binaryFiles.keys(), ...textFiles.keys()]) { + if (key.startsWith(dir + "/") && !key.endsWith("/")) out.push(key); + } + return out; + }), + deleteFile: vi.fn(async (path: string) => { + binaryFiles.delete(path); + textFiles.delete(path); + }), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + }; + } + + function fakeImageResponse(bytes: Uint8Array): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: { "content-type": "image/jpeg" }, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), + }; + } + + function scrapeNetwork(fakeBytes: Uint8Array): NetworkPort { + return { + fetch: vi.fn(async (url: string) => { + if (url.includes("image.tmdb.org")) { + return fakeImageResponse(fakeBytes); + } + if (url.includes("/tv/123876/season/1")) { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(seasonDetails)), + json: () => Promise.resolve(seasonDetails as T), + }; + } + if (url.includes("/tv/123876")) { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(seriesDetails)), + json: () => Promise.resolve(seriesDetails as T), + }; + } + return { + ok: false, + status: 404, + statusText: "Not Found", + headers: {}, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + }; + }) as never, + }; + } + + function seed(disk: Record = {}) { + return scrapeInMemoryFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(tvMetadata), + ...disk, + }); + } + + it("downloads poster when artifacts are missing", async () => { + const fakeBytes = new Uint8Array([0xff, 0xd8, 0xff]); + const fs = seed(); + const network = scrapeNetwork(fakeBytes); + const core = new Core({ fs, network, appDataDir }); + + const result = await core.scrapeFolder("/m/Show"); + + expect(result.mediaFolderPath).toBe("/m/Show"); + expect(result.tasks.poster).toEqual({ status: "completed" }); + expect(fs.binaryFiles.get("/m/Show/poster.jpg")).toEqual(fakeBytes); + expect(network.fetch).toHaveBeenCalled(); + }); + + it("skips poster when completion is already true", async () => { + const existingPoster = new Uint8Array([1, 2, 3]); + const fs = seed({ "/m/Show/poster.jpg": existingPoster }); + const network = scrapeNetwork(new Uint8Array([0xff])); + const core = new Core({ fs, network, appDataDir }); + + const result = await core.scrapeFolder("/m/Show"); + + expect(result.tasks.poster).toEqual({ status: "skipped" }); + expect(fs.binaryFiles.get("/m/Show/poster.jpg")).toEqual(existingPoster); + const imageFetches = vi.mocked(network.fetch).mock.calls.filter(([url]) => + String(url).includes("poster.jpg"), + ); + expect(imageFetches).toHaveLength(0); + }); + + it("rejects unmanaged folders", async () => { + const fs = seed(); + const core = new Core({ fs, network: scrapeNetwork(new Uint8Array([1])), appDataDir }); + await expect(core.scrapeFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); + }); + + it("rejects non-TMDB metadata", async () => { + const fs = scrapeInMemoryFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify({ + ...tvMetadata, + tvShow: { id: "1", database: "TVDB", name: "Show", seasons: [] }, + }), + }); + const core = new Core({ fs, network: scrapeNetwork(new Uint8Array([1])), appDataDir }); + await expect(core.scrapeFolder("/m/Show")).rejects.toThrow(/must use TMDB database/); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 416450ce..091b0818 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -14,12 +14,17 @@ import { applyPlanPipeline } from "./pipeline/applyPlan"; import { readPlan, type Plan } from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; +import { + scrapeFolderPipeline, + type ScrapeFolderOptions, +} from "./pipeline/scrape/scrapeFolder"; +import type { ScrapeFolderResult } from "./pipeline/scrape/types"; import type { RenameRuleName } from "./pipeline/renameRules"; import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import type { ImportJob } from "./jobs/types"; -export type { RenameFolderArgs }; +export type { RenameFolderArgs, ScrapeFolderOptions, ScrapeFolderResult }; export interface CoreOptions { fs: FsPort; @@ -201,6 +206,18 @@ export class Core { }); } + async scrapeFolder(path: string, options?: ScrapeFolderOptions): Promise { + return scrapeFolderPipeline(path, options, { + fs: this.fs, + network: this.network, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + discover: this.discover, + reverseProxyUrl: this.reverseProxyUrl, + }); + } + private normalizePosix(path: string): string { try { return Path.posix(path); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 286ea3c3..f8bd1554 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -1,4 +1,12 @@ -export { Core, type CoreOptions, type ImportFolderHandle, type ImportFolderOptions, type RenameFolderArgs } from "./Core"; +export { + Core, + type CoreOptions, + type ImportFolderHandle, + type ImportFolderOptions, + type RenameFolderArgs, + type ScrapeFolderOptions, + type ScrapeFolderResult, +} from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; export type { LoggerPort } from "./ports/LoggerPort"; @@ -21,6 +29,15 @@ export { tryToRecognizeFolderPipeline, type TryToRecognizeFolderDeps } from "./p export { tryToRenameFolderPipeline, type TryToRenameFolderDeps } from "./pipeline/tryToRenameFolder"; export type { RenameRuleName } from "./pipeline/renameRules"; export type { Plan } from "./pipeline/plans"; +export { + scrapeFolderPipeline, + type ScrapeFolderDeps, +} from "./pipeline/scrape/scrapeFolder"; +export type { + ScrapeTaskId, + ScrapeTaskStatus, + ScrapeTaskResult, +} from "./pipeline/scrape/types"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; export type { FolderType } from "@smm/core"; diff --git a/apps/core/src/pipeline/scrape/scrapeFolder.ts b/apps/core/src/pipeline/scrape/scrapeFolder.ts new file mode 100644 index 00000000..bda09ad9 --- /dev/null +++ b/apps/core/src/pipeline/scrape/scrapeFolder.ts @@ -0,0 +1,124 @@ +import { Path } from "@core/path"; +import type { MediaMetadata } from "@smm/core"; +import { TmdbClient } from "../../clients/TmdbClient"; +import type { DiscoverPort } from "../../ports/DiscoverPort"; +import type { FsPort } from "../../ports/FsPort"; +import type { NetworkPort } from "../../ports/NetworkPort"; +import { metadataCachePath } from "../paths"; +import type { UserConfig } from "../userConfig"; +import { checkScrapeCompletion } from "./checkScrapeCompletion"; +import { scrapeFanartTmdb } from "./scrapeFanartTmdb"; +import { scrapeNfoTmdb } from "./scrapeNfoTmdb"; +import { scrapePosterTmdb } from "./scrapePosterTmdb"; +import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import { scrapeThumbnailsTmdb } from "./scrapeThumbnailsTmdb"; +import type { ScrapeFolderResult, ScrapeTaskId, ScrapeTaskResult } from "./types"; + +export interface ScrapeFolderOptions { + /** Defaults to userConfig.preferMediaLanguage */ + language?: string; +} + +export interface ScrapeFolderDeps { + fs: FsPort; + network: NetworkPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + discover?: DiscoverPort; + reverseProxyUrl?: string | null; +} + +const TASK_ORDER: ScrapeTaskId[] = ["poster", "fanart", "thumbnails", "nfo"]; + +const TASK_RUNNERS: Record< + ScrapeTaskId, + (deps: ScrapeTaskDeps) => Promise +> = { + poster: scrapePosterTmdb, + fanart: scrapeFanartTmdb, + thumbnails: scrapeThumbnailsTmdb, + nfo: scrapeNfoTmdb, +}; + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +/** Run TMDB TV scrape tasks sequentially; skip at orchestrator when artifacts exist. */ +export async function scrapeFolderPipeline( + path: string, + options: ScrapeFolderOptions | undefined, + deps: ScrapeFolderDeps, +): Promise { + const posixPath = deps.normalizePosix(path); + + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], path)) { + throw new Error(`${posixPath} is not managed by SMM`); + } + + const cachePath = metadataCachePath(deps.appDataDir, posixPath); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${path}`); + } + + let mediaMetadata: MediaMetadata; + try { + mediaMetadata = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${path}`); + } + + if (mediaMetadata.type !== "tvshow-folder") { + throw new Error(`Folder is not a TV show: ${path}`); + } + if (mediaMetadata.tvShow?.database !== "TMDB") { + throw new Error(`TV show must use TMDB database: ${path}`); + } + + const language = options?.language ?? config.preferMediaLanguage ?? "en-US"; + + const tmdb = new TmdbClient(deps.network, { + ...config.tmdb, + discover: deps.discover, + reverseProxyUrl: deps.reverseProxyUrl, + }); + + const normalizedMetadata: MediaMetadata = { + ...mediaMetadata, + mediaFolderPath: posixPath, + }; + + const completion = await checkScrapeCompletion(normalizedMetadata, deps.fs); + + const taskDeps: ScrapeTaskDeps = { + fs: deps.fs, + network: deps.network, + tmdb, + mediaMetadata: normalizedMetadata, + language, + userConfig: config, + reverseProxyUrl: deps.reverseProxyUrl ?? undefined, + }; + + const tasks = {} as Record; + + for (const taskId of TASK_ORDER) { + if (completion[taskId]) { + tasks[taskId] = { status: "skipped" }; + continue; + } + tasks[taskId] = await TASK_RUNNERS[taskId](taskDeps); + } + + return { + mediaFolderPath: posixPath, + tasks, + }; +} From 7f6bd4469eabd165974cf09b865c21aa06e23f47 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:10:09 +0800 Subject: [PATCH 086/214] feat(cli): add smm scrape for TV folders Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/src/cli/runCli.ts | 23 ++++++- apps/cli/test/scrape-e2e.test.ts | 102 +++++++++++++++++++++++++++++++ docs/api/index.md | 4 ++ 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 apps/cli/test/scrape-e2e.test.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 0b436d0e..540bc091 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape']) if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index ccc2b28f..6a3f89d6 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -49,7 +49,7 @@ function printJson(value: unknown): void { const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `config`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `config`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -267,6 +267,27 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + const SCRAPE_TASKS = ['poster', 'fanart', 'thumbnails', 'nfo'] as const + + program + .command('scrape') + .description('Scrape TMDB poster, fanart, thumbnails, and NFO for a TV show folder') + .argument('', 'Imported media folder path') + .option('--language ', 'TMDB language code (defaults to user config preferMediaLanguage)') + .action(async (folder: string, opts: { language?: string }) => { + try { + const result = await getCore().scrapeFolder(folder, { + language: opts.language, + }) + for (const taskId of SCRAPE_TASKS) { + console.log(`${taskId}: ${result.tasks[taskId].status}`) + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + const configCmd = program.command('config').description('Read or write user config (smm.json)') configCmd diff --git a/apps/cli/test/scrape-e2e.test.ts b/apps/cli/test/scrape-e2e.test.ts new file mode 100644 index 00000000..412bd521 --- /dev/null +++ b/apps/cli/test/scrape-e2e.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { MediaMetadata } from '@smm/core' +import { smm } from './helpers/smm' +import { resetCoreForTests } from '../src/core/getCore' +import { + createAndImportInitializedFolder, + tvShowFolder, +} from './helpers/testFolders' + +function parsePlanId(stdout: string): string { + const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] + expect(planId, stdout).toBeTruthy() + return planId! +} + +/** TMDB template name contains ":" which is invalid on Windows paths. */ +function withWindowsSafeTvShowName(mm: MediaMetadata): MediaMetadata { + return { + ...mm, + tvShow: mm.tvShow + ? { ...mm.tvShow, name: 'WATATEN an Angel Flew Down to Me' } + : mm.tvShow, + } +} + +async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: string) { + return createAndImportInitializedFolder( + mediaDir, + { ...tvShowFolder, ...(folderName ? { folderName } : {}) }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) +} + +async function recognizeAndApply(path: string): Promise { + const recognized = await smm(['try-to-recognize', path]) + expect(recognized.code, recognized.stderr || recognized.stdout).toBe(0) + const planId = parsePlanId(recognized.stdout) + const applied = await smm(['apply', planId]) + expect(applied.code, applied.stderr || applied.stdout).toBe(0) +} + +function findFileWithPrefix(dir: string, prefix: string): string | undefined { + return readdirSync(dir).find((name) => name.startsWith(`${prefix}.`)) +} + +describe('smm scrape CLI e2e', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-scrape-e2e-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-scrape-e2e-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it( + 'recognize then scrape creates poster, fanart, thumbnail, and nfo on disk', + { timeout: 3 * 60 * 1000 }, + async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'Scrape 123123') + const path = folder.path! + + await recognizeAndApply(path) + + const scraped = await smm(['scrape', path]) + expect(scraped.code, scraped.stderr || scraped.stdout).toBe(0) + expect(scraped.stdout).toMatch(/poster:\s+(completed|skipped)/) + expect(scraped.stdout).toMatch(/fanart:\s+(completed|skipped)/) + expect(scraped.stdout).toMatch(/thumbnails:\s+(completed|skipped)/) + expect(scraped.stdout).toMatch(/nfo:\s+(completed|skipped)/) + + expect(findFileWithPrefix(path, 'poster')).toBeTruthy() + expect(findFileWithPrefix(path, 'fanart')).toBeTruthy() + expect(existsSync(join(path, 'tvshow.nfo'))).toBe(true) + expect(existsSync(join(path, 'S01E01.jpg'))).toBe(true) + }, + ) + + it('rejects unmanaged folder', async () => { + const unmanaged = join(mediaDir, 'not-imported') + const result = await smm(['scrape', unmanaged]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not managed by SMM/i) + }) +}) diff --git a/docs/api/index.md b/docs/api/index.md index c5c89dd9..58688f77 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -80,6 +80,10 @@ Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / `smm try-to-rename [--rule plex|emby]` — build a pending rename-files plan (default rule: plex). `smm apply ` — apply a pending recognize-media-file or rename-files plan (updates metadata cache, deletes plan file). +## CLI: scrape +Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.scrapeFolder +`smm scrape [--language ]` — scrape TMDB TV poster, fanart, episode thumbnails, and NFO files for a managed TV show folder. Prints each task as `poster|fanart|thumbnails|nfo: completed|skipped|failed`. Requires TMDB metadata and linked episodes (for thumbnails / episode NFO). + ## SetWatchedFolder Source Code: apps/cli/src/route/SetWatchedFolder.ts HTTP: `POST /api/setWatchedFolder` — sets the single media folder the CLI `FolderWatcher` listens to (UI primary `selectedFolder`). Request body: `{ folderPath: string | null }` (platform absolute path, or null/empty to stop watching). Response: `{ data: { watchedFolder: string | null }, error?: string }`. Startup no longer watches all imported folders. From 2d604575cfb40338026dbd93f14c3622a2f8bb35 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:18:20 +0800 Subject: [PATCH 087/214] docs: add Core.scrapeFolder spec and implementation plan Document the TV/TMDB scrape migration (Core.scrapeFolder + smm scrape) for reference alongside the completed pipeline implementation. Co-authored-by: Cursor --- .../2026-08-19-core-scrape-folder-plan.md | 158 ++++++++++++++++++ .../2026-08-19-core-scrape-folder-design.md | 149 +++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-core-scrape-folder-plan.md create mode 100644 docs/superpowers/specs/2026-08-19-core-scrape-folder-design.md diff --git a/docs/superpowers/plans/2026-08-19-core-scrape-folder-plan.md b/docs/superpowers/plans/2026-08-19-core-scrape-folder-plan.md new file mode 100644 index 00000000..1e7fcd0e --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-core-scrape-folder-plan.md @@ -0,0 +1,158 @@ +# Core.scrapeFolder Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Core.scrapeFolder(path, opts?)` for TV/TMDB scrape (poster, fanart, thumbnails, nfo) plus CLI `smm scrape`, without changing UI or core-routes. + +**Architecture:** Port scrape helpers into `apps/core/src/pipeline/scrape/`. Extend `FsPort` with binary write. Orchestrate four task runners sequentially with skip-on-existing semantics. Return per-task results (no plan file). + +**Tech Stack:** TypeScript, Vitest, `TmdbClient`, `FsPort`, `NetworkPort`, Commander CLI. + +**Spec:** `docs/superpowers/specs/2026-08-19-core-scrape-folder-design.md` + +## Global Constraints + +- Do **not** modify `apps/ui/**` or `packages/core-routes/**` source. +- Scope is **Core + CLI only**. +- **TV show + TMDB only**; skip TVDB and movie. +- Default: run all 4 tasks; **skip** when artifacts already exist. +- Per-task errors → `failed` status; continue remaining tasks. +- Register `scrape` in `apps/cli/index.ts` `cliCommands`. +- Managed / metadata validation throws `Error` (CLI exit 1). + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/ports/FsPort.ts` | Add `writeBinaryFile` | +| `apps/core/src/adapters/node/NodejsFsAdapter.ts` | Implement binary write | +| `apps/core/src/adapters/network/NetworkFsAdapter.ts` | Stub or HTTP binary write | +| `apps/core/src/pipeline/scrape/types.ts` | ScrapeTaskId, results | +| `apps/core/src/pipeline/scrape/tmdbImageUrl.ts` | TMDB image URL builder | +| `apps/core/src/pipeline/scrape/checkScrapeCompletion.ts` | Skip detection | +| `apps/core/src/pipeline/scrape/downloadScrapeImage.ts` | Network fetch + fs write | +| `apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.ts` | NFO object builders | +| `apps/core/src/pipeline/scrape/nfoXml.ts` | NFO → XML (TV subset) | +| `apps/core/src/pipeline/scrape/scrapePosterTmdb.ts` | Poster task | +| `apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts` | Fanart task | +| `apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.ts` | Thumbnails task | +| `apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts` | NFO task | +| `apps/core/src/pipeline/scrape/scrapeFolder.ts` | Orchestrator | +| `apps/core/src/Core.ts` | `scrapeFolder` wrapper | +| `apps/core/src/Core.test.ts` | Integration tests | +| `apps/cli/src/cli/runCli.ts` | `scrape` command | +| `apps/cli/index.ts` | Register `scrape` | +| `apps/cli/test/scrape-e2e.test.ts` | CLI e2e | +| `docs/api/index.md` | CLI note | + +--- + +### Task 1: FsPort.writeBinaryFile + tmdbImageUrl + +**Files:** +- Modify: `FsPort.ts`, `NodejsFsAdapter.ts`, `NetworkFsAdapter.ts`, test fakes +- Create: `pipeline/scrape/tmdbImageUrl.ts`, `tmdbImageUrl.test.ts` + +**Interfaces:** +- `FsPort.writeBinaryFile(path: string, data: Uint8Array): Promise` +- `getTmdbImageUrl(path: string | null | undefined, size: "original" | "w500"): string | null` + +Port URL builder from `apps/ui/src/api/tmdb.ts` `getTMDBImageUrl`. + +- [ ] TDD adapter test: write binary, read back +- [ ] TDD tmdbImageUrl tests +- [ ] Commit: `feat(core): add FsPort.writeBinaryFile and tmdbImageUrl` + +--- + +### Task 2: checkScrapeCompletion + +**Files:** +- Create: `pipeline/scrape/types.ts` +- Create: `pipeline/scrape/checkScrapeCompletion.ts`, `.test.ts` + +Port logic from `apps/ui/src/lib/scrapeDialog/checkTaskCompletion.ts` using `FsPort.listFiles`, `basename`/`dirname`/`extname` from `pipeline/paths`. + +- [ ] TDD: poster/fanart/nfo/thumbnails completion cases +- [ ] Commit: `feat(core): add checkScrapeCompletion for scrape skip` + +--- + +### Task 3: downloadScrapeImage + +**Files:** +- Create: `pipeline/scrape/downloadScrapeImage.ts`, `.test.ts` +- Create: `pipeline/scrape/resolveScrapeHttpProxy.ts` (port from UI `mediaDatabaseAccess`) + +Fetch image with NetworkPort; write with writeBinaryFile; mkdir parent dir. + +- [ ] TDD with mock network + in-memory fs +- [ ] Commit: `feat(core): add downloadScrapeImage for scrape pipeline` + +--- + +### Task 4: NFO build + XML (TMDB TV) + +**Files:** +- Create: `pipeline/scrape/buildTvShowNfoTmdb.ts`, `.test.ts` +- Create: `pipeline/scrape/nfoXml.ts`, `.test.ts` + +Port `buildTvShowNfo`, `buildTvShowEpisodeNfo` from `apps/ui/src/hooks/useHandleScrapeStart.ts` and XML converters from `apps/ui/src/lib/nfo/tvshowNfo.ts` + episode converter (minimal fields used by scrape). + +- [ ] TDD: builder output shape + XML contains title/tmdbid +- [ ] Commit: `feat(core): add TMDB TV NFO build and XML for scrape` + +--- + +### Task 5: Scrape task runners (poster, fanart, thumbnails, nfo) + +**Files:** +- Create: `scrapePosterTmdb.ts`, `scrapeFanartTmdb.ts`, `scrapeThumbnailsTmdb.ts`, `scrapeNfoTmdb.ts` +- Create: unit tests with mocked TmdbClient + fs + +Each runner: resolve URL or NFO content → skip if file exists → download/write. + +- [ ] TDD each task (at least poster + nfo; fanart/thumbnails smoke) +- [ ] Commit: `feat(core): add TMDB TV scrape task runners` + +--- + +### Task 6: scrapeFolder orchestrator + Core.scrapeFolder + +**Files:** +- Create: `pipeline/scrape/scrapeFolder.ts` +- Modify: `Core.ts`, `Core.test.ts`, `index.ts` + +Wire managed check, metadata, TmdbClient, sequential tasks, result aggregation. + +- [ ] Core.test.ts: happy path creates poster; skip when exists; unmanaged throws +- [ ] Commit: `feat(core): add Core.scrapeFolder` + +--- + +### Task 7: CLI smm scrape + e2e + +**Files:** +- Modify: `runCli.ts`, `index.ts` +- Create: `apps/cli/test/scrape-e2e.test.ts` +- Modify: `docs/api/index.md` + +Flow: recognize → apply → scrape → assert poster/fanart/thumbnail/nfo on disk. + +- [ ] E2E RED → GREEN +- [ ] Commit: `feat(cli): add smm scrape for TV folders` + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| Core.scrapeFolder direct API | 6 | +| 4 tasks, skip existing | 2, 5, 6 | +| TV + TMDB only | 5, 6 | +| Per-task error isolation | 6 | +| CLI smm scrape | 7 | +| No UI / core-routes | Global | diff --git a/docs/superpowers/specs/2026-08-19-core-scrape-folder-design.md b/docs/superpowers/specs/2026-08-19-core-scrape-folder-design.md new file mode 100644 index 00000000..82db243b --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-core-scrape-folder-design.md @@ -0,0 +1,149 @@ +# Core.scrapeFolder (TV / TMDB scrape) + +This design document describes migrating the ScrapeDialog batch scrape workflow into Layer 2 `apps/core`, exposing a direct CLI command. UI and existing HTTP routes stay unchanged in this milestone. + +## 1. Background + +ScrapeDialog (`useScrapeDialog`) runs four tasks for a **recognized** media folder: + +| Task ID | Output | +|---------|--------| +| `poster` | `poster.{ext}` in media folder root | +| `fanart` | `fanart.{ext}` in media folder root | +| `thumbnails` | per-episode still image beside each video (same stem, image ext) | +| `nfo` | `tvshow.nfo` + one `{videoStem}.nfo` per linked episode | + +Today the UI invokes TanStack Query mutations (`useScrapePosterMutation`, etc.) that call TMDB/TVDB APIs, download images via HTTP proxy failover, and write NFO XML via helpers in `apps/ui/src/lib/nfo` and `useHandleScrapeStart`. + +Recognize/rename were migrated as **Core + CLI** pipelines without UI changes. Scrape follows the same pattern but uses **direct execution** (no plan review step). + +**Constraints (product decision):** + +- Scope: **Core + CLI only**; do not modify `apps/ui/**` or `packages/core-routes/**`. +- **TV show + TMDB only** this milestone (no TVDB, no movie). +- All **four tasks** run by default; **skip** when target files already exist (same as UI). +- Per-task failure does **not** abort remaining tasks (same as ScrapeDialog loop). +- Prerequisite: folder is managed, metadata exists, `tvShow.database === "TMDB"`, episodes linked in `mediaFiles` (for thumbnails / episode NFO). +- Language defaults to `userConfig.preferMediaLanguage`. + +## 2. Architecture + +### 2.1 Project Level Architecture + +``` +apps/ui ScrapeDialog ──unchanged──► UI mutations + HTTP APIs +packages/core-routes ──unchanged──► downloadImage, writeFile, etc. +apps/cli smm scrape ──new──► Core.scrapeFolder +apps/core Core.scrapeFolder ──new──► scrape pipeline (TMDB + FsPort + NetworkPort) +``` + +### 2.2 App Level Architecture + +| Piece | Role | +|-------|------| +| `Core.scrapeFolder(path, opts?)` | Managed check → metadata → run 4 tasks → return per-task results | +| `pipeline/scrape/scrapeFolder.ts` | Orchestrator: completion check, sequential tasks, aggregate results | +| `pipeline/scrape/checkScrapeCompletion.ts` | Port UI `checkTaskCompletion` using `FsPort.listFiles` | +| `pipeline/scrape/tmdbImageUrl.ts` | Port `getTMDBImageUrl` | +| `pipeline/scrape/downloadScrapeImage.ts` | Fetch image URL via `NetworkPort`, write via `FsPort.writeBinaryFile` | +| `pipeline/scrape/buildTvShowNfoTmdb.ts` | Port TMDB NFO builders from `useHandleScrapeStart` | +| `pipeline/scrape/nfoXml.ts` | Port `convertTvShowNfoToXml` / `convertTvShowEpisodeNfoToXml` (TV TMDB subset) | +| `pipeline/scrape/scrapePosterTmdb.ts` | Poster task | +| `pipeline/scrape/scrapeFanartTmdb.ts` | Fanart task | +| `pipeline/scrape/scrapeThumbnailsTmdb.ts` | Thumbnails task | +| `pipeline/scrape/scrapeNfoTmdb.ts` | NFO task | +| `FsPort.writeBinaryFile` | New port method for image bytes | +| `clients/TmdbClient` | Already has `getTvShowById`, `getTvSeasonById` | + +### 2.3 Key Design + +#### Core signatures + +```ts +export type ScrapeTaskId = "poster" | "fanart" | "thumbnails" | "nfo" + +export type ScrapeTaskStatus = "skipped" | "completed" | "failed" + +export interface ScrapeTaskResult { + status: ScrapeTaskStatus + error?: string +} + +export interface ScrapeFolderResult { + mediaFolderPath: string + tasks: Record +} + +export interface ScrapeFolderOptions { + /** Defaults to userConfig.preferMediaLanguage */ + language?: string +} + +Core.scrapeFolder(path: string, options?: ScrapeFolderOptions): Promise +``` + +#### `scrapeFolder` step order + +1. Normalize path; assert managed (`{path} is not managed by SMM`). +2. Load metadata; throw if missing (`Media metadata not found: {path}`). +3. Require `type === "tvshow-folder"` with `tvShow.database === "TMDB"`; else throw. +4. Resolve language from options or user config. +5. Build `TmdbClient` from user config + discover (same as import pipeline). +6. `completion = checkScrapeCompletion(metadata, fs)`. +7. For each task in order `[poster, fanart, thumbnails, nfo]`: + - If `completion[task]` → record `skipped`. + - Else try task runner; on success `completed`, on error `failed` + message (continue). +8. Return aggregated result (no metadata refresh in Core — CLI may optionally re-fetch later; UI refreshes separately). + +#### Skip semantics (match UI) + +- **poster / fanart:** skip if `poster.{imageExt}` / `fanart.{imageExt}` already exists under folder (recursive list). +- **nfo:** skip if `tvshow.nfo` exists and every linked episode has matching `{videoStem}.nfo`. +- **thumbnails:** skip if every linked episode has a same-stem image file in the video directory. + +#### Image download + +- Resolve TMDB image URL (`https://image.tmdb.org/t/p/original{path}`). +- Download via `NetworkPort.fetch` with browser-like headers. +- Write bytes with `FsPort.writeBinaryFile` (create parent dirs if needed via `mkdir`). +- Do **not** depend on `packages/core-routes` from `apps/core` (layering). + +#### HTTP proxy + +- Reuse `resolveScrapeHttpProxy` logic ported to core (from UI `mediaDatabaseAccess`) for TMDB image fetches when custom host + proxy configured. + +## 3. User Stories + +### 3.1 CLI operator scrapes a recognized TV folder + +* **Given** a managed TV folder with TMDB metadata and `mediaFiles` linking S01E01..03 +* **When** the operator runs `smm scrape ` +* **Then** poster, fanart, episode thumbnails, and NFO files are created on disk +* **And** stdout reports each task as `completed`, `skipped`, or `failed` + +### 3.2 Skip existing artifacts + +* **Given** `poster.jpg` already exists +* **When** `smm scrape` runs +* **Then** the poster task is `skipped` and other tasks still run + +### 3.3 Error isolation + +* **Given** TMDB returns no backdrop (fanart fails) +* **When** `smm scrape` runs +* **Then** fanart is `failed`, poster/thumbnails/nfo still attempt + +## 4. Out of scope + +- UI wiring / ScrapeDialog migration to Core +- TVDB database support +- Movie folders +- `--force` overwrite flag +- Plan-based scrape (`try-to-scrape` / `apply`) +- Refreshing metadata after scrape (UI concern; optional CLI follow-up) + +## 5. Testing strategy + +- Unit tests for `checkScrapeCompletion`, `tmdbImageUrl`, NFO XML round-trip +- `Core.test.ts` integration with in-memory FsPort + mock NetworkPort +- `apps/cli/test/scrape-e2e.test.ts`: import → recognize → apply → scrape → assert files on disk From d46aeadffa7ad23a66a3b3f57d4b3ac907db4028 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 19 Aug 2026 21:36:21 +0800 Subject: [PATCH 088/214] test(cli): expand scrape e2e coverage Add skip-all, partial skip, movie/TVDB rejection cases, and assert episode thumbnails and NFO files after recognize-then-scrape flow. Co-authored-by: Cursor --- apps/cli/test/scrape-e2e.test.ts | 146 ++++++++++++++++++++++++++----- 1 file changed, 126 insertions(+), 20 deletions(-) diff --git a/apps/cli/test/scrape-e2e.test.ts b/apps/cli/test/scrape-e2e.test.ts index 412bd521..6f2500c6 100644 --- a/apps/cli/test/scrape-e2e.test.ts +++ b/apps/cli/test/scrape-e2e.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { MediaMetadata } from '@smm/core' @@ -7,9 +14,12 @@ import { smm } from './helpers/smm' import { resetCoreForTests } from '../src/core/getCore' import { createAndImportInitializedFolder, + movieFolder, tvShowFolder, } from './helpers/testFolders' +const SCRAPE_TIMEOUT_MS = 3 * 60 * 1000 + function parsePlanId(stdout: string): string { const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] expect(planId, stdout).toBeTruthy() @@ -49,6 +59,19 @@ function findFileWithPrefix(dir: string, prefix: string): string | undefined { return readdirSync(dir).find((name) => name.startsWith(`${prefix}.`)) } +function expectAllTasksCompletedOrSkipped(stdout: string): void { + for (const task of ['poster', 'fanart', 'thumbnails', 'nfo'] as const) { + expect(stdout).toMatch(new RegExp(`${task}:\\s+(completed|skipped)`)) + } +} + +function removePlaceholderEpisodeThumbnails(path: string): void { + for (const ep of ['S01E01', 'S01E02', 'S01E03']) { + const jpg = join(path, `${ep}.jpg`) + if (existsSync(jpg)) rmSync(jpg) + } +} + describe('smm scrape CLI e2e', () => { let userDataDir: string let mediaDir: string @@ -71,26 +94,79 @@ describe('smm scrape CLI e2e', () => { }) it( - 'recognize then scrape creates poster, fanart, thumbnail, and nfo on disk', - { timeout: 3 * 60 * 1000 }, + 'recognize then scrape creates poster, fanart, thumbnails, episode nfos, and tvshow.nfo', + { timeout: SCRAPE_TIMEOUT_MS }, + async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'Scrape 123123') + const path = folder.path! + + await recognizeAndApply(path) + removePlaceholderEpisodeThumbnails(path) + + const scraped = await smm(['scrape', path]) + expect(scraped.code, scraped.stderr || scraped.stdout).toBe(0) + expectAllTasksCompletedOrSkipped(scraped.stdout) + expect(scraped.stdout).toMatch(/poster:\s+completed/) + expect(scraped.stdout).toMatch(/fanart:\s+completed/) + expect(scraped.stdout).toMatch(/thumbnails:\s+completed/) + expect(scraped.stdout).toMatch(/nfo:\s+completed/) + + expect(findFileWithPrefix(path, 'poster')).toBeTruthy() + expect(findFileWithPrefix(path, 'fanart')).toBeTruthy() + expect(existsSync(join(path, 'tvshow.nfo'))).toBe(true) + for (const ep of ['S01E01', 'S01E02', 'S01E03']) { + const jpg = join(path, `${ep}.jpg`) + expect(existsSync(jpg)).toBe(true) + expect(statSync(jpg).size).toBeGreaterThan(0) + expect(existsSync(join(path, `${ep}.nfo`))).toBe(true) + } + }, + ) + + it( + 'second scrape skips all tasks when artifacts already exist', + { timeout: SCRAPE_TIMEOUT_MS }, + async () => { + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'ScrapeSkipAll 123123') + const path = folder.path! + + await recognizeAndApply(path) + removePlaceholderEpisodeThumbnails(path) + + const first = await smm(['scrape', path]) + expect(first.code, first.stderr || first.stdout).toBe(0) + + const second = await smm(['scrape', path]) + expect(second.code, second.stderr || second.stdout).toBe(0) + expect(second.stdout).toMatch(/poster:\s+skipped/) + expect(second.stdout).toMatch(/fanart:\s+skipped/) + expect(second.stdout).toMatch(/thumbnails:\s+skipped/) + expect(second.stdout).toMatch(/nfo:\s+skipped/) + }, + ) + + it( + 'partial skip: pre-seeded poster.jpg leaves poster skipped and completes other tasks', + { timeout: SCRAPE_TIMEOUT_MS }, async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'Scrape 123123') - const path = folder.path! - - await recognizeAndApply(path) - - const scraped = await smm(['scrape', path]) - expect(scraped.code, scraped.stderr || scraped.stdout).toBe(0) - expect(scraped.stdout).toMatch(/poster:\s+(completed|skipped)/) - expect(scraped.stdout).toMatch(/fanart:\s+(completed|skipped)/) - expect(scraped.stdout).toMatch(/thumbnails:\s+(completed|skipped)/) - expect(scraped.stdout).toMatch(/nfo:\s+(completed|skipped)/) - - expect(findFileWithPrefix(path, 'poster')).toBeTruthy() - expect(findFileWithPrefix(path, 'fanart')).toBeTruthy() - expect(existsSync(join(path, 'tvshow.nfo'))).toBe(true) - expect(existsSync(join(path, 'S01E01.jpg'))).toBe(true) - }, + const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'ScrapePartial 123123') + const path = folder.path! + + await recognizeAndApply(path) + removePlaceholderEpisodeThumbnails(path) + writeFileSync(join(path, 'poster.jpg'), '') + + const scraped = await smm(['scrape', path]) + expect(scraped.code, scraped.stderr || scraped.stdout).toBe(0) + expect(scraped.stdout).toMatch(/poster:\s+skipped/) + expect(scraped.stdout).toMatch(/fanart:\s+completed/) + expect(scraped.stdout).toMatch(/thumbnails:\s+completed/) + expect(scraped.stdout).toMatch(/nfo:\s+completed/) + + expect(findFileWithPrefix(path, 'fanart')).toBeTruthy() + expect(existsSync(join(path, 'tvshow.nfo'))).toBe(true) + expect(existsSync(join(path, 'S01E01.jpg'))).toBe(true) + }, ) it('rejects unmanaged folder', async () => { @@ -99,4 +175,34 @@ describe('smm scrape CLI e2e', () => { expect(result.code).toBe(1) expect(result.stderr).toMatch(/not managed by SMM/i) }) + + it('rejects movie folder (not a TV show)', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { + mediaMetadata: { + type: 'movie-folder', + mediaFiles: [], + movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, + }, + }) + const result = await smm(['scrape', folder.path!]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not a TV show/i) + }) + + it('rejects TV show with non-TMDB database', async () => { + const folder = await createAndImportInitializedFolder( + mediaDir, + { ...tvShowFolder, folderName: 'TvdbScrape 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ + ...mm, + tvShow: mm.tvShow ? { ...mm.tvShow, database: 'TVDB' } : mm.tvShow, + }), + }, + ) + const result = await smm(['scrape', folder.path!]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/must use TMDB database/i) + }) }) From 700eae11c3f1d181c429405e310c3c1ca854ba3f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 20 Aug 2026 07:01:49 +0800 Subject: [PATCH 089/214] test(cli): adopt .e2e.ts suffix for CLI e2e tests Separate CLI e2e files from unit tests by renaming them to .e2e.ts and updating Vitest include patterns. Co-authored-by: Cursor --- apps/cli/bunfig.toml | 2 +- apps/cli/package.json | 2 +- apps/cli/test/{cli-e2e.test.ts => cli.e2e.ts} | 0 apps/cli/test/{config-e2e.test.ts => config.e2e.ts} | 0 apps/cli/test/{recognize-e2e.test.ts => recognize.e2e.ts} | 0 .../test/{rename-files-e2e.test.ts => rename-files.e2e.ts} | 0 apps/cli/test/{rename-e2e.test.ts => rename.e2e.ts} | 0 apps/cli/test/{scrape-e2e.test.ts => scrape.e2e.ts} | 0 apps/cli/test/{test-mcp.test.ts => test-mcp.e2e.ts} | 0 apps/cli/vitest.config.ts | 4 ++-- 10 files changed, 4 insertions(+), 4 deletions(-) rename apps/cli/test/{cli-e2e.test.ts => cli.e2e.ts} (100%) rename apps/cli/test/{config-e2e.test.ts => config.e2e.ts} (100%) rename apps/cli/test/{recognize-e2e.test.ts => recognize.e2e.ts} (100%) rename apps/cli/test/{rename-files-e2e.test.ts => rename-files.e2e.ts} (100%) rename apps/cli/test/{rename-e2e.test.ts => rename.e2e.ts} (100%) rename apps/cli/test/{scrape-e2e.test.ts => scrape.e2e.ts} (100%) rename apps/cli/test/{test-mcp.test.ts => test-mcp.e2e.ts} (100%) diff --git a/apps/cli/bunfig.toml b/apps/cli/bunfig.toml index 06bd83c4..ad670fe8 100644 --- a/apps/cli/bunfig.toml +++ b/apps/cli/bunfig.toml @@ -1,6 +1,6 @@ [test] # Run only unit tests in src/ directory (exclude e2e tests in test/ folder) -# E2E tests (test-mcp.test.ts) require external MCP server running +# E2E tests (test-mcp.e2e.ts) require external MCP server running root = "./src" # Enable code coverage reporting # Generate coverage reports in text and lcov formats diff --git a/apps/cli/package.json b/apps/cli/package.json index 51dd651a..96caf0a4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -51,6 +51,6 @@ "test": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", - "test:mcp": "bun test ./test/test-mcp.test.ts" + "test:mcp": "bun test ./test/test-mcp.e2e.ts" } } diff --git a/apps/cli/test/cli-e2e.test.ts b/apps/cli/test/cli.e2e.ts similarity index 100% rename from apps/cli/test/cli-e2e.test.ts rename to apps/cli/test/cli.e2e.ts diff --git a/apps/cli/test/config-e2e.test.ts b/apps/cli/test/config.e2e.ts similarity index 100% rename from apps/cli/test/config-e2e.test.ts rename to apps/cli/test/config.e2e.ts diff --git a/apps/cli/test/recognize-e2e.test.ts b/apps/cli/test/recognize.e2e.ts similarity index 100% rename from apps/cli/test/recognize-e2e.test.ts rename to apps/cli/test/recognize.e2e.ts diff --git a/apps/cli/test/rename-files-e2e.test.ts b/apps/cli/test/rename-files.e2e.ts similarity index 100% rename from apps/cli/test/rename-files-e2e.test.ts rename to apps/cli/test/rename-files.e2e.ts diff --git a/apps/cli/test/rename-e2e.test.ts b/apps/cli/test/rename.e2e.ts similarity index 100% rename from apps/cli/test/rename-e2e.test.ts rename to apps/cli/test/rename.e2e.ts diff --git a/apps/cli/test/scrape-e2e.test.ts b/apps/cli/test/scrape.e2e.ts similarity index 100% rename from apps/cli/test/scrape-e2e.test.ts rename to apps/cli/test/scrape.e2e.ts diff --git a/apps/cli/test/test-mcp.test.ts b/apps/cli/test/test-mcp.e2e.ts similarity index 100% rename from apps/cli/test/test-mcp.test.ts rename to apps/cli/test/test-mcp.e2e.ts diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 7ae9e1c0..8435d09f 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -4,8 +4,8 @@ import { resolve } from 'path' export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts', 'test/**/*.test.ts'], - exclude: ['test/test-mcp.test.ts'], + include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.e2e.ts'], + exclude: ['test/test-mcp.e2e.ts'], }, resolve: { alias: { From 52ba6ee2f831cb1b15addad7409cd4c8a8374c8b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 20 Aug 2026 13:09:38 +0800 Subject: [PATCH 090/214] feat(scrape): Core media/DB parity, HTTP job API, and UI v3 path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand scrapeFolder to TV/movie × TMDB/TVDB with job tasks, add POST /api/scrape plus CLI job UX, and poll get-job from ScrapeDialog when smm.v3.enabled. Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/server.ts | 2 + apps/cli/src/cli/addProgress.test.ts | 11 +- apps/cli/src/cli/addProgress.ts | 3 +- apps/cli/src/cli/runCli.ts | 53 ++- apps/cli/src/cli/scrapeJobFormat.test.ts | 37 ++ apps/cli/src/cli/scrapeJobFormat.ts | 29 ++ apps/cli/src/cli/waitScrapeJob.ts | 25 ++ apps/cli/src/route/GetJob.ts | 4 +- apps/cli/src/route/Scrape.test.ts | 104 +++++ apps/cli/src/route/Scrape.ts | 43 ++ apps/cli/test/scrape.e2e.ts | 113 ++--- apps/core/src/Core.test.ts | 39 +- apps/core/src/Core.ts | 83 +++- apps/core/src/clients/TvdbClient.ts | 45 ++ apps/core/src/index.ts | 11 +- apps/core/src/jobs/jobStore.test.ts | 22 +- apps/core/src/jobs/jobStore.ts | 19 +- apps/core/src/jobs/types.ts | 36 ++ .../src/pipeline/scrape/buildMovieNfoTmdb.ts | 66 +++ apps/core/src/pipeline/scrape/buildNfoTvdb.ts | 164 +++++++ .../scrape/checkScrapeCompletion.test.ts | 33 +- .../pipeline/scrape/checkScrapeCompletion.ts | 27 +- apps/core/src/pipeline/scrape/nfoTypes.ts | 36 ++ apps/core/src/pipeline/scrape/nfoXml.ts | 87 +++- .../pipeline/scrape/scrapeFanartTmdb.test.ts | 2 + .../src/pipeline/scrape/scrapeFanartTmdb.ts | 114 ++++- apps/core/src/pipeline/scrape/scrapeFolder.ts | 91 +++- .../src/pipeline/scrape/scrapeNfoTmdb.test.ts | 1 + .../core/src/pipeline/scrape/scrapeNfoTmdb.ts | 269 ++++++++++-- .../pipeline/scrape/scrapePosterTmdb.test.ts | 13 +- .../src/pipeline/scrape/scrapePosterTmdb.ts | 98 ++++- .../src/pipeline/scrape/scrapeTaskDeps.ts | 8 + .../scrape/scrapeThumbnailsTmdb.test.ts | 1 + .../pipeline/scrape/scrapeThumbnailsTmdb.ts | 140 ++++-- apps/ui/src/api/getJob.ts | 76 ++++ apps/ui/src/api/scrapeV3.ts | 47 ++ .../src/components/dialogs/useScrapeDialog.ts | 107 ++++- .../src/lib/scrapeDialog/scrapeDialog.test.ts | 28 ++ apps/ui/src/lib/scrapeDialog/taskReducer.ts | 13 + apps/ui/src/lib/scrapeDialog/types.ts | 4 + docs/dev/scrape.md | 404 ++++++++++++++++++ .../plans/2026-08-20-core-scrape-parity.md | 52 +++ refactoring.md | 205 +++++++++ ...221\350\272\253\350\276\271.metadata.json" | 2 +- 45 files changed, 2483 insertions(+), 286 deletions(-) create mode 100644 apps/cli/src/cli/scrapeJobFormat.test.ts create mode 100644 apps/cli/src/cli/scrapeJobFormat.ts create mode 100644 apps/cli/src/cli/waitScrapeJob.ts create mode 100644 apps/cli/src/route/Scrape.test.ts create mode 100644 apps/cli/src/route/Scrape.ts create mode 100644 apps/core/src/pipeline/scrape/buildMovieNfoTmdb.ts create mode 100644 apps/core/src/pipeline/scrape/buildNfoTvdb.ts create mode 100644 apps/ui/src/api/getJob.ts create mode 100644 apps/ui/src/api/scrapeV3.ts create mode 100644 docs/dev/scrape.md create mode 100644 docs/superpowers/plans/2026-08-20-core-scrape-parity.md create mode 100644 refactoring.md diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 540bc091..37547627 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job']) if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 75388c6e..051873b4 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -47,6 +47,7 @@ import { handleGetFolders } from './src/route/GetFolders'; import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleImportFolder } from './src/route/ImportFolder'; import { handleGetJob } from './src/route/GetJob'; +import { handleScrape } from './src/route/Scrape'; import { handleShowFolder } from './src/route/ShowFolder'; import { handleFolderMetadata } from './src/route/FolderMetadata'; import { handleTencentAsrTranscribe } from './src/route/tencentAsr/Transcribe'; @@ -287,6 +288,7 @@ export class Server { handleUnimportFolder(this.app); handleImportFolder(this.app); handleGetJob(this.app); + handleScrape(this.app); handleShowFolder(this.app); handleFolderMetadata(this.app); handleTencentAsrTranscribe(this.app); diff --git a/apps/cli/src/cli/addProgress.test.ts b/apps/cli/src/cli/addProgress.test.ts index 740a051f..c5109d59 100644 --- a/apps/cli/src/cli/addProgress.test.ts +++ b/apps/cli/src/cli/addProgress.test.ts @@ -4,11 +4,12 @@ import { createAddProgressState, emitAddProgress } from './addProgress' function job(partial: Partial): ImportJob { return { - id: 'j1', - folderPath: '/m/Show', - type: 'tvshow', - status: 'running', - stage: 'config', + kind: "import", + id: "j1", + folderPath: "/m/Show", + type: "tvshow", + status: "running", + stage: "config", progress: 0, createdAt: 0, updatedAt: 0, diff --git a/apps/cli/src/cli/addProgress.ts b/apps/cli/src/cli/addProgress.ts index 16e5b909..4795ba91 100644 --- a/apps/cli/src/cli/addProgress.ts +++ b/apps/cli/src/cli/addProgress.ts @@ -61,6 +61,7 @@ export function emitAddProgress( type: FolderType, log: (line: string) => void = console.log, ): AddProgressState { + if (job.kind !== "import") return state const next = { ...state } const kind = mediaKind(type) const stage = job.stage @@ -145,7 +146,7 @@ export async function waitUntilImportSettled( for (;;) { const job = core.getJob(id) - if (job) { + if (job?.kind === 'import') { if (emitProgress) { progress = emitAddProgress(progress, job, options.folder, options.type, log) } diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 6a3f89d6..82164827 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -6,6 +6,8 @@ import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' import { waitUntilImportSettled } from './addProgress' import { CliLoggerAdapter } from './cliLogger' +import { formatScrapeJobTaskLines } from './scrapeJobFormat' +import { waitUntilScrapeSettled } from './waitScrapeJob' import { formatMediaMetadata, formatShowFolder, @@ -47,9 +49,10 @@ function printJson(value: unknown): void { } const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 +const SCRAPE_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `config`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `job`, `config`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -267,21 +270,55 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) - const SCRAPE_TASKS = ['poster', 'fanart', 'thumbnails', 'nfo'] as const - program .command('scrape') - .description('Scrape TMDB poster, fanart, thumbnails, and NFO for a TV show folder') + .description('Start TMDB scrape (poster, fanart, thumbnails, NFO) for a TV show folder') .argument('', 'Imported media folder path') .option('--language ', 'TMDB language code (defaults to user config preferMediaLanguage)') - .action(async (folder: string, opts: { language?: string }) => { + .option('--wait', 'Wait until scrape finishes and print per-task status icons') + .action(async (folder: string, opts: { language?: string; wait?: boolean }) => { try { - const result = await getCore().scrapeFolder(folder, { + const core = getCore() + const { id } = await core.scrapeFolder(folder, { language: opts.language, }) - for (const taskId of SCRAPE_TASKS) { - console.log(`${taskId}: ${result.tasks[taskId].status}`) + console.log(id) + if (!opts.wait) return + + const job = await waitUntilScrapeSettled(core, id, { + timeoutMs: SCRAPE_WAIT_TIMEOUT_MS, + }) + for (const line of formatScrapeJobTaskLines(job)) { + console.log(line) + } + if (job.status !== 'succeeded') { + exitCode = 1 + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + + program + .command('job') + .description('Show job status by id (scrape: four task icon lines; import: JSON)') + .argument('', 'Job id from scrape or add') + .action(async (jobId: string) => { + try { + const job = getCore().getJob(jobId) + if (job === undefined) { + console.error(`Job not found: ${jobId}`) + exitCode = 1 + return + } + if (job.kind === 'scrape') { + for (const line of formatScrapeJobTaskLines(job)) { + console.log(line) + } + return } + printJson(job) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) exitCode = 1 diff --git a/apps/cli/src/cli/scrapeJobFormat.test.ts b/apps/cli/src/cli/scrapeJobFormat.test.ts new file mode 100644 index 00000000..49857d51 --- /dev/null +++ b/apps/cli/src/cli/scrapeJobFormat.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { ScrapeJob } from 'core-app' +import { formatScrapeJobTaskLines, scrapeStatusIcon } from './scrapeJobFormat' + +function scrapeJob(tasks: ScrapeJob['tasks']): ScrapeJob { + return { + kind: 'scrape', + id: 'j1', + folderPath: '/m/Show', + status: 'running', + tasks, + createdAt: 0, + updatedAt: 0, + } +} + +describe('scrapeJobFormat', () => { + it('maps runtime status to icons', () => { + expect(scrapeStatusIcon('pending')).toBe('○') + expect(scrapeStatusIcon('running')).toBe('◐') + expect(scrapeStatusIcon('skipped')).toBe('–') + expect(scrapeStatusIcon('completed')).toBe('✓') + expect(scrapeStatusIcon('failed')).toBe('✗') + }) + + it('formats four display lines with thumbnail label', () => { + const lines = formatScrapeJobTaskLines( + scrapeJob({ + poster: { status: 'completed' }, + fanart: { status: 'failed', error: 'network' }, + thumbnails: { status: 'running' }, + nfo: { status: 'pending' }, + }), + ) + expect(lines).toEqual(['poster ✓', 'fanart ✗', 'thumbnail ◐', 'nfo ○']) + }) +}) diff --git a/apps/cli/src/cli/scrapeJobFormat.ts b/apps/cli/src/cli/scrapeJobFormat.ts new file mode 100644 index 00000000..e282bf17 --- /dev/null +++ b/apps/cli/src/cli/scrapeJobFormat.ts @@ -0,0 +1,29 @@ +import type { ScrapeJob, ScrapeTaskRuntimeStatus } from 'core-app' + +/** CLI display order; Core task id `thumbnails` is shown as `thumbnail`. */ +export const SCRAPE_TASK_LINES = [ + { taskId: 'poster', label: 'poster' }, + { taskId: 'fanart', label: 'fanart' }, + { taskId: 'thumbnails', label: 'thumbnail' }, + { taskId: 'nfo', label: 'nfo' }, +] as const + +const STATUS_ICONS: Record = { + pending: '○', + running: '◐', + skipped: '–', + completed: '✓', + failed: '✗', +} + +export function scrapeStatusIcon(status: ScrapeTaskRuntimeStatus): string { + return STATUS_ICONS[status] +} + +/** Four lines: `poster ✓` … `nfo ○` */ +export function formatScrapeJobTaskLines(job: ScrapeJob): string[] { + return SCRAPE_TASK_LINES.map(({ taskId, label }) => { + const task = job.tasks[taskId] + return `${label} ${scrapeStatusIcon(task.status)}` + }) +} diff --git a/apps/cli/src/cli/waitScrapeJob.ts b/apps/cli/src/cli/waitScrapeJob.ts new file mode 100644 index 00000000..dfc4db3c --- /dev/null +++ b/apps/cli/src/cli/waitScrapeJob.ts @@ -0,0 +1,25 @@ +import type { Core, ScrapeJob } from 'core-app' + +const DEFAULT_POLL_MS = 20 + +export async function waitUntilScrapeSettled( + core: Core, + id: string, + options: { timeoutMs: number; pollMs?: number } = { timeoutMs: 5 * 60 * 1000 }, +): Promise { + const pollMs = options.pollMs ?? DEFAULT_POLL_MS + const deadline = Date.now() + options.timeoutMs + + for (;;) { + const job = core.getJob(id) + if (job?.kind === 'scrape') { + if (job.status !== 'pending' && job.status !== 'running') { + return job + } + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for scrape job ${id}`) + } + await new Promise((resolve) => setTimeout(resolve, pollMs)) + } +} diff --git a/apps/cli/src/route/GetJob.ts b/apps/cli/src/route/GetJob.ts index 2921abac..338f7440 100644 --- a/apps/cli/src/route/GetJob.ts +++ b/apps/cli/src/route/GetJob.ts @@ -1,10 +1,10 @@ import type { Hono } from 'hono' -import type { ImportJob } from 'core-app' +import type { Job } from 'core-app' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' export interface GetJobResponseBody { - data?: ImportJob + data?: Job error?: string } diff --git a/apps/cli/src/route/Scrape.test.ts b/apps/cli/src/route/Scrape.test.ts new file mode 100644 index 00000000..27ac6f56 --- /dev/null +++ b/apps/cli/src/route/Scrape.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleScrape } from './Scrape' +import { handleGetJob } from './GetJob' +import { handleImportFolder } from './ImportFolder' +import { getCore, resetCoreForTests } from '../core/getCore' +import { Path } from '@core/path' +import type { MediaMetadata } from '@smm/core' + +describe('POST /api/scrape', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-scrape-route-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleImportFolder(app) + handleScrape(app) + handleGetJob(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + async function postScrape(body: unknown) { + return app.request('/api/scrape', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('returns Error Reason when path is missing', async () => { + const res = await postScrape({}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + }) + + it('returns Error Reason when the folder is not managed', async () => { + const res = await postScrape({ path: '/media/not-managed' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string; data?: unknown } + expect(json.data).toBeUndefined() + expect(json.error).toMatch(/^Error Reason: .*is not managed by SMM/) + }) + + it('returns a scrape job id and get-job includes kind scrape', async () => { + const folderPath = join(userDataDir, 'show') + const imported = await app.request('/api/import-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: folderPath, type: 'tvshow', skipInit: true }), + }) + expect(imported.status).toBe(200) + const importJson = (await imported.json()) as { data?: { id: string }; error?: string } + expect(importJson.error).toBeUndefined() + + const posixPath = Path.posix(folderPath) + const metadata: MediaMetadata = { + type: 'tvshow-folder', + mediaFolderPath: posixPath, + files: [], + mediaFiles: [], + tvShow: { + database: 'TMDB', + id: '1', + name: 'Test Show', + }, + } + await getCore().setMetadata(metadata) + + const res = await postScrape({ path: folderPath }) + expect(res.status).toBe(200) + const json = (await res.json()) as { data?: { id: string }; error?: string } + expect(json.error).toBeUndefined() + expect(json.data?.id).toEqual(expect.any(String)) + + const jobRes = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: json.data!.id }), + }) + const jobJson = (await jobRes.json()) as { + data?: { kind?: string; id?: string; tasks?: Record } + error?: string + } + expect(jobJson.error).toBeUndefined() + expect(jobJson.data?.kind).toBe('scrape') + expect(jobJson.data?.id).toBe(json.data!.id) + expect(jobJson.data?.tasks).toBeDefined() + }) +}) diff --git a/apps/cli/src/route/Scrape.ts b/apps/cli/src/route/Scrape.ts new file mode 100644 index 00000000..784a2d55 --- /dev/null +++ b/apps/cli/src/route/Scrape.ts @@ -0,0 +1,43 @@ +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface ScrapeResponseBody { + data?: { id: string } + error?: string +} + +export function handleScrape(app: Hono): void { + app.post('/api/scrape', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const rec = typeof body === 'object' && body !== null ? (body as Record) : {} + const path = rec.path + if (typeof path !== 'string' || path.trim() === '') { + const err: ScrapeResponseBody = { error: 'Error Reason: path is required' } + return c.json(err, 200) + } + const language = + typeof rec.language === 'string' && rec.language.trim() !== '' + ? rec.language + : undefined + const { id } = await getCore().scrapeFolder( + path, + language !== undefined ? { language } : undefined, + ) + const ok: ScrapeResponseBody = { data: { id } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/scrape] route error') + const err: ScrapeResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/test/scrape.e2e.ts b/apps/cli/test/scrape.e2e.ts index 6f2500c6..3eb883c0 100644 --- a/apps/cli/test/scrape.e2e.ts +++ b/apps/cli/test/scrape.e2e.ts @@ -9,13 +9,14 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { MediaMetadata } from '@smm/core' import { smm } from './helpers/smm' import { resetCoreForTests } from '../src/core/getCore' import { createAndImportInitializedFolder, + folder1, movieFolder, tvShowFolder, + type TestFolder, } from './helpers/testFolders' const SCRAPE_TIMEOUT_MS = 3 * 60 * 1000 @@ -26,27 +27,6 @@ function parsePlanId(stdout: string): string { return planId! } -/** TMDB template name contains ":" which is invalid on Windows paths. */ -function withWindowsSafeTvShowName(mm: MediaMetadata): MediaMetadata { - return { - ...mm, - tvShow: mm.tvShow - ? { ...mm.tvShow, name: 'WATATEN an Angel Flew Down to Me' } - : mm.tvShow, - } -} - -async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: string) { - return createAndImportInitializedFolder( - mediaDir, - { ...tvShowFolder, ...(folderName ? { folderName } : {}) }, - { - updateMediaMetadata: (mm) => - withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), - }, - ) -} - async function recognizeAndApply(path: string): Promise { const recognized = await smm(['try-to-recognize', path]) expect(recognized.code, recognized.stderr || recognized.stdout).toBe(0) @@ -60,15 +40,8 @@ function findFileWithPrefix(dir: string, prefix: string): string | undefined { } function expectAllTasksCompletedOrSkipped(stdout: string): void { - for (const task of ['poster', 'fanart', 'thumbnails', 'nfo'] as const) { - expect(stdout).toMatch(new RegExp(`${task}:\\s+(completed|skipped)`)) - } -} - -function removePlaceholderEpisodeThumbnails(path: string): void { - for (const ep of ['S01E01', 'S01E02', 'S01E03']) { - const jpg = join(path, `${ep}.jpg`) - if (existsSync(jpg)) rmSync(jpg) + for (const task of ['poster', 'fanart', 'thumbnail', 'nfo'] as const) { + expect(stdout).toMatch(new RegExp(`${task} [✓–]`)) } } @@ -97,19 +70,23 @@ describe('smm scrape CLI e2e', () => { 'recognize then scrape creates poster, fanart, thumbnails, episode nfos, and tvshow.nfo', { timeout: SCRAPE_TIMEOUT_MS }, async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'Scrape 123123') + const testFolder: TestFolder = { + ...folder1, + folderName: 'Scrape 123123', + files: [], + } + const folder = await createAndImportInitializedFolder(mediaDir, testFolder) const path = folder.path! await recognizeAndApply(path) - removePlaceholderEpisodeThumbnails(path) - const scraped = await smm(['scrape', path]) + const scraped = await smm(['scrape', path, '--wait']) expect(scraped.code, scraped.stderr || scraped.stdout).toBe(0) expectAllTasksCompletedOrSkipped(scraped.stdout) - expect(scraped.stdout).toMatch(/poster:\s+completed/) - expect(scraped.stdout).toMatch(/fanart:\s+completed/) - expect(scraped.stdout).toMatch(/thumbnails:\s+completed/) - expect(scraped.stdout).toMatch(/nfo:\s+completed/) + expect(scraped.stdout).toMatch(/poster ✓/) + expect(scraped.stdout).toMatch(/fanart ✓/) + expect(scraped.stdout).toMatch(/thumbnail ✓/) + expect(scraped.stdout).toMatch(/nfo ✓/) expect(findFileWithPrefix(path, 'poster')).toBeTruthy() expect(findFileWithPrefix(path, 'fanart')).toBeTruthy() @@ -127,21 +104,25 @@ describe('smm scrape CLI e2e', () => { 'second scrape skips all tasks when artifacts already exist', { timeout: SCRAPE_TIMEOUT_MS }, async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'ScrapeSkipAll 123123') + const testFolder: TestFolder = { + ...folder1, + folderName: 'ScrapeSkipAll 123123', + files: [], + } + const folder = await createAndImportInitializedFolder(mediaDir, testFolder) const path = folder.path! await recognizeAndApply(path) - removePlaceholderEpisodeThumbnails(path) - const first = await smm(['scrape', path]) + const first = await smm(['scrape', path, '--wait']) expect(first.code, first.stderr || first.stdout).toBe(0) - const second = await smm(['scrape', path]) + const second = await smm(['scrape', path, '--wait']) expect(second.code, second.stderr || second.stdout).toBe(0) - expect(second.stdout).toMatch(/poster:\s+skipped/) - expect(second.stdout).toMatch(/fanart:\s+skipped/) - expect(second.stdout).toMatch(/thumbnails:\s+skipped/) - expect(second.stdout).toMatch(/nfo:\s+skipped/) + expect(second.stdout).toMatch(/poster –/) + expect(second.stdout).toMatch(/fanart –/) + expect(second.stdout).toMatch(/thumbnail –/) + expect(second.stdout).toMatch(/nfo –/) }, ) @@ -149,19 +130,23 @@ describe('smm scrape CLI e2e', () => { 'partial skip: pre-seeded poster.jpg leaves poster skipped and completes other tasks', { timeout: SCRAPE_TIMEOUT_MS }, async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'ScrapePartial 123123') + const testFolder: TestFolder = { + ...folder1, + folderName: 'ScrapePartial 123123', + files: [], + } + const folder = await createAndImportInitializedFolder(mediaDir, testFolder) const path = folder.path! await recognizeAndApply(path) - removePlaceholderEpisodeThumbnails(path) writeFileSync(join(path, 'poster.jpg'), '') - const scraped = await smm(['scrape', path]) + const scraped = await smm(['scrape', path, '--wait']) expect(scraped.code, scraped.stderr || scraped.stdout).toBe(0) - expect(scraped.stdout).toMatch(/poster:\s+skipped/) - expect(scraped.stdout).toMatch(/fanart:\s+completed/) - expect(scraped.stdout).toMatch(/thumbnails:\s+completed/) - expect(scraped.stdout).toMatch(/nfo:\s+completed/) + expect(scraped.stdout).toMatch(/poster –/) + expect(scraped.stdout).toMatch(/fanart ✓/) + expect(scraped.stdout).toMatch(/thumbnail ✓/) + expect(scraped.stdout).toMatch(/nfo ✓/) expect(findFileWithPrefix(path, 'fanart')).toBeTruthy() expect(existsSync(join(path, 'tvshow.nfo'))).toBe(true) @@ -176,33 +161,27 @@ describe('smm scrape CLI e2e', () => { expect(result.stderr).toMatch(/not managed by SMM/i) }) - it('rejects movie folder (not a TV show)', async () => { + it('starts scrape job for movie folder (Core accepts movie × TMDB)', async () => { const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { mediaMetadata: { type: 'movie-folder', mediaFiles: [], - movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, + movie: { database: 'TMDB', id: '116', name: 'The Dark Knight' }, }, }) const result = await smm(['scrape', folder.path!]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/not a TV show/i) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout.trim()).toMatch(/^[0-9a-z]+-[0-9a-z]+$/i) }) - it('rejects TV show with non-TMDB database', async () => { + it('starts scrape job for TV show with TVDB database', async () => { const folder = await createAndImportInitializedFolder( mediaDir, { ...tvShowFolder, folderName: 'TvdbScrape 123123' }, - { - updateMediaMetadata: (mm) => - withWindowsSafeTvShowName({ - ...mm, - tvShow: mm.tvShow ? { ...mm.tvShow, database: 'TVDB' } : mm.tvShow, - }), - }, + { templateFileName: '我推的孩子.metadata.json' }, ) const result = await smm(['scrape', folder.path!]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/must use TMDB database/i) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout.trim()).toMatch(/^[0-9a-z]+-[0-9a-z]+$/i) }) }) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 6463a906..92a81243 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -94,7 +94,7 @@ describe("Core", () => { const job = core.getJob(id); expect(job?.status).toBe("succeeded"); - expect(job?.progress).toBe(100); + expect(job?.kind === "import" && job.progress).toBe(100); const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); expect(savedConfig.folders).toContain("/m/My.Music"); @@ -963,16 +963,32 @@ describe("scrapeFolder", () => { }); } - it("downloads poster when artifacts are missing", async () => { + async function waitForScrapeJob(core: Core, id: string, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const job = core.getJob(id); + if (job && job.kind === "scrape" && job.status !== "pending" && job.status !== "running") { + return job; + } + if (Date.now() > deadline) throw new Error(`Timed out waiting for scrape job ${id}`); + await new Promise((r) => setTimeout(r, 10)); + } + } + + it("returns a scrape job id and completes poster when artifacts are missing", async () => { const fakeBytes = new Uint8Array([0xff, 0xd8, 0xff]); const fs = seed(); const network = scrapeNetwork(fakeBytes); const core = new Core({ fs, network, appDataDir }); - const result = await core.scrapeFolder("/m/Show"); + const { id } = await core.scrapeFolder("/m/Show"); + expect(id).toBeTruthy(); - expect(result.mediaFolderPath).toBe("/m/Show"); - expect(result.tasks.poster).toEqual({ status: "completed" }); + const job = await waitForScrapeJob(core, id); + expect(job.kind).toBe("scrape"); + expect(job.folderPath).toBe("/m/Show"); + expect(job.status).toBe("succeeded"); + expect(job.tasks.poster).toEqual({ status: "completed" }); expect(fs.binaryFiles.get("/m/Show/poster.jpg")).toEqual(fakeBytes); expect(network.fetch).toHaveBeenCalled(); }); @@ -983,9 +999,10 @@ describe("scrapeFolder", () => { const network = scrapeNetwork(new Uint8Array([0xff])); const core = new Core({ fs, network, appDataDir }); - const result = await core.scrapeFolder("/m/Show"); + const { id } = await core.scrapeFolder("/m/Show"); + const job = await waitForScrapeJob(core, id); - expect(result.tasks.poster).toEqual({ status: "skipped" }); + expect(job.tasks.poster).toEqual({ status: "skipped" }); expect(fs.binaryFiles.get("/m/Show/poster.jpg")).toEqual(existingPoster); const imageFetches = vi.mocked(network.fetch).mock.calls.filter(([url]) => String(url).includes("poster.jpg"), @@ -993,21 +1010,21 @@ describe("scrapeFolder", () => { expect(imageFetches).toHaveLength(0); }); - it("rejects unmanaged folders", async () => { + it("rejects unmanaged folders before creating a job", async () => { const fs = seed(); const core = new Core({ fs, network: scrapeNetwork(new Uint8Array([1])), appDataDir }); await expect(core.scrapeFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); }); - it("rejects non-TMDB metadata", async () => { + it("rejects non-TMDB/TVDB metadata before creating a job", async () => { const fs = scrapeInMemoryFs({ [userConfigPath(appDataDir)]: configWith([folder]), [metadataCachePath(appDataDir, folder)]: JSON.stringify({ ...tvMetadata, - tvShow: { id: "1", database: "TVDB", name: "Show", seasons: [] }, + tvShow: { id: "1", database: "OTHER", name: "Show", seasons: [] }, }), }); const core = new Core({ fs, network: scrapeNetwork(new Uint8Array([1])), appDataDir }); - await expect(core.scrapeFolder("/m/Show")).rejects.toThrow(/must use TMDB database/); + await expect(core.scrapeFolder("/m/Show")).rejects.toThrow(/Unsupported media database/); }); }); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 091b0818..2cc9b2cc 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -15,14 +15,17 @@ import { readPlan, type Plan } from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; import { - scrapeFolderPipeline, + prepareScrapeFolder, + runPreparedScrape, + type PreparedScrape, + type ScrapeFolderDeps, type ScrapeFolderOptions, } from "./pipeline/scrape/scrapeFolder"; import type { ScrapeFolderResult } from "./pipeline/scrape/types"; import type { RenameRuleName } from "./pipeline/renameRules"; import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; -import type { ImportJob } from "./jobs/types"; +import { initialScrapeTasks, type ImportJob, type Job } from "./jobs/types"; export type { RenameFolderArgs, ScrapeFolderOptions, ScrapeFolderResult }; @@ -46,6 +49,10 @@ export interface ImportFolderHandle { id: string; } +export interface ScrapeFolderHandle { + id: string; +} + export interface ImportFolderOptions { /** When true, only register the path in UserConfig.folders; skip recognition and metadata. */ skipInit?: boolean; @@ -79,6 +86,7 @@ export class Core { importFolder(path: string, type: FolderType, options?: ImportFolderOptions): ImportFolderHandle { const folderPath = this.normalizePosix(path); const job = this.jobs.create({ + kind: "import", folderPath, type, status: "running", @@ -93,7 +101,7 @@ export class Core { return { id: job.id }; } - getJob(id: string): ImportJob | undefined { + getJob(id: string): Job | undefined { return this.jobs.get(id); } @@ -206,23 +214,76 @@ export class Core { }); } - async scrapeFolder(path: string, options?: ScrapeFolderOptions): Promise { - return scrapeFolderPipeline(path, options, { + async scrapeFolder(path: string, options?: ScrapeFolderOptions): Promise { + const scrapeDeps = this.createScrapeDeps(); + const prepared = await prepareScrapeFolder(path, options, scrapeDeps); + const job = this.jobs.create({ + kind: "scrape", + folderPath: prepared.posixPath, + status: "running", + tasks: initialScrapeTasks(), + }); + void this.runScrape(job.id, prepared, scrapeDeps); + return { id: job.id }; + } + + private normalizePosix(path: string): string { + try { + return Path.posix(path); + } catch { + return path; + } + } + + private createScrapeDeps(): ScrapeFolderDeps { + return { fs: this.fs, network: this.network, appDataDir: this.appDataDir, userConfig: this.userConfig, - normalizePosix: (p) => this.normalizePosix(p), + normalizePosix: (p: string) => this.normalizePosix(p), discover: this.discover, reverseProxyUrl: this.reverseProxyUrl, - }); + }; } - private normalizePosix(path: string): string { + private async runScrape( + jobId: string, + prepared: PreparedScrape, + deps: ScrapeFolderDeps, + ): Promise { try { - return Path.posix(path); - } catch { - return path; + await runPreparedScrape(prepared, deps, { + onTaskStart: (taskId) => { + const current = this.jobs.get(jobId); + if (current?.kind !== "scrape") return; + this.jobs.update(jobId, { + tasks: { ...current.tasks, [taskId]: { status: "running" } }, + }); + }, + onTaskDone: (taskId, result) => { + const current = this.jobs.get(jobId); + if (current?.kind !== "scrape") return; + this.jobs.update(jobId, { + tasks: { + ...current.tasks, + [taskId]: { + status: result.status, + ...(result.error !== undefined ? { error: result.error } : {}), + }, + }, + }); + }, + }); + const finalJob = this.jobs.get(jobId); + if (finalJob?.kind !== "scrape") return; + const anyFailed = Object.values(finalJob.tasks).some((t) => t.status === "failed"); + this.jobs.update(jobId, { status: anyFailed ? "failed" : "succeeded" }); + } catch (error) { + this.jobs.update(jobId, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); } } diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts index 994d9f20..63d5f0cd 100644 --- a/apps/core/src/clients/TvdbClient.ts +++ b/apps/core/src/clients/TvdbClient.ts @@ -1,5 +1,6 @@ import { TVDBv4, type TVDBv4SearchResult } from "@smm/tvdb4"; import type { + TVDBv4ArtworkTypeRecord, TVDBv4Season, TVDBv4SeriesExtendedResponse, TVDBv4SeriesSeasonsExtendedResponse, @@ -133,6 +134,50 @@ export class TvdbClient { } return m; } + + async getSeriesExtended(seriesId: number): Promise { + const resp = await this.client.seriesExtendedById(seriesId); + return resp.status === "success" ? (resp.data as TVDBv4SeriesExtendedResponse) : undefined; + } + + async getSeasonExtended(seasonId: number): Promise { + const resp = await this.client.seasonExtendedById(seasonId); + return resp.status === "success" ? (resp.data as TVDBv4SeriesSeasonsExtendedResponse) : undefined; + } + + async getMovieExtended(movieId: number): Promise | undefined> { + const resp = await this.client.movieExtendedById(movieId); + return resp.status === "success" ? (resp.data as Record) : undefined; + } + + async getArtworkTypes(): Promise { + const resp = await this.client.getArtworkTypes(); + return resp.status === "success" ? resp.data : undefined; + } + + async getSeriesTranslation( + seriesId: number, + langCode: string, + ): Promise | undefined> { + const resp = await this.client.seriesTranslationByLangCode(seriesId, langCode); + return resp.status === "success" ? resp.data : undefined; + } + + async getEpisodeTranslation( + episodeId: number, + langCode: string, + ): Promise | undefined> { + const resp = await this.client.episodeTranslationByLangCode(episodeId, langCode); + return resp.status === "success" ? resp.data : undefined; + } + + async getMovieTranslation( + movieId: number, + langCode: string, + ): Promise | undefined> { + const resp = await this.client.movieTranslationByLangCode(movieId, langCode); + return resp.status === "success" ? resp.data : undefined; + } } export type { TVDBv4Season }; diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index f8bd1554..5e3bcd70 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -4,6 +4,7 @@ export { type ImportFolderHandle, type ImportFolderOptions, type RenameFolderArgs, + type ScrapeFolderHandle, type ScrapeFolderOptions, type ScrapeFolderResult, } from "./Core"; @@ -39,5 +40,13 @@ export type { ScrapeTaskResult, } from "./pipeline/scrape/types"; export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; -export type { ImportJob, JobStatus, JobStage } from "./jobs/types"; +export type { + ImportJob, + ScrapeJob, + Job, + JobStatus, + JobStage, + ScrapeJobTask, + ScrapeTaskRuntimeStatus, +} from "./jobs/types"; export type { FolderType } from "@smm/core"; diff --git a/apps/core/src/jobs/jobStore.test.ts b/apps/core/src/jobs/jobStore.test.ts index c005b520..04b347e2 100644 --- a/apps/core/src/jobs/jobStore.test.ts +++ b/apps/core/src/jobs/jobStore.test.ts @@ -5,6 +5,7 @@ describe("JobStore", () => { it("creates a job with id and timestamps", () => { const store = new JobStore(); const job = store.create({ + kind: "import", folderPath: "/m/My.Show", type: "tvshow", status: "running", @@ -13,6 +14,7 @@ describe("JobStore", () => { }); expect(job.id).toBeTruthy(); + expect(job.kind).toBe("import"); expect(job.createdAt).toBeGreaterThan(0); expect(job.updatedAt).toBeGreaterThanOrEqual(job.createdAt); expect(store.get(job.id)?.folderPath).toBe("/m/My.Show"); @@ -20,7 +22,14 @@ describe("JobStore", () => { it("update patches fields and bumps updatedAt", async () => { const store = new JobStore(); - const job = store.create({ folderPath: "/m", type: "movie", status: "running", stage: null, progress: 0 }); + const job = store.create({ + kind: "import", + folderPath: "/m", + type: "movie", + status: "running", + stage: null, + progress: 0, + }); const firstUpdatedAt = job.updatedAt; await new Promise((r) => setTimeout(r, 5)); @@ -28,7 +37,7 @@ describe("JobStore", () => { const updated = store.get(job.id); expect(updated?.status).toBe("succeeded"); - expect(updated?.progress).toBe(100); + expect(updated?.kind === "import" && updated.progress).toBe(100); expect(updated?.updatedAt).toBeGreaterThan(firstUpdatedAt); }); @@ -39,7 +48,14 @@ describe("JobStore", () => { it("get returns a snapshot (mutating it does not affect the store)", () => { const store = new JobStore(); - const job = store.create({ folderPath: "/m", type: "music", status: "running", stage: null, progress: 0 }); + const job = store.create({ + kind: "import", + folderPath: "/m", + type: "music", + status: "running", + stage: null, + progress: 0, + }); const snapshot = store.get(job.id); snapshot!.status = "failed"; expect(store.get(job.id)?.status).toBe("running"); diff --git a/apps/core/src/jobs/jobStore.ts b/apps/core/src/jobs/jobStore.ts index dda08b4a..3afe64c0 100644 --- a/apps/core/src/jobs/jobStore.ts +++ b/apps/core/src/jobs/jobStore.ts @@ -1,4 +1,4 @@ -import type { ImportJob } from "./types"; +import type { ImportJob, Job, ScrapeJob } from "./types"; let seq = 0; @@ -7,24 +7,29 @@ export function nextJobId(): string { return `${Date.now().toString(36)}-${(seq++).toString(36)}`; } +type ImportJobInit = Omit; +type ScrapeJobInit = Omit; + export class JobStore { - private readonly jobs = new Map(); + private readonly jobs = new Map(); - create(init: Omit): ImportJob { + create(init: ImportJobInit): ImportJob; + create(init: ScrapeJobInit): ScrapeJob; + create(init: ImportJobInit | ScrapeJobInit): Job { const now = Date.now(); - const job: ImportJob = { id: nextJobId(), createdAt: now, updatedAt: now, ...init }; + const job = { id: nextJobId(), createdAt: now, updatedAt: now, ...init } as Job; this.jobs.set(job.id, job); return job; } - update(id: string, patch: Partial): void { + update(id: string, patch: Partial | Partial): void { const job = this.jobs.get(id); if (job === undefined) return; Object.assign(job, patch, { updatedAt: Date.now() }); } - get(id: string): ImportJob | undefined { + get(id: string): Job | undefined { const job = this.jobs.get(id); - return job === undefined ? undefined : { ...job }; + return job === undefined ? undefined : structuredClone(job); } } diff --git a/apps/core/src/jobs/types.ts b/apps/core/src/jobs/types.ts index b96e0759..73028e2c 100644 --- a/apps/core/src/jobs/types.ts +++ b/apps/core/src/jobs/types.ts @@ -1,9 +1,11 @@ import type { FolderType } from "@smm/core"; +import type { ScrapeTaskId } from "../pipeline/scrape/types"; export type JobStatus = "pending" | "running" | "succeeded" | "failed" | "aborted"; export type JobStage = "config" | "metadata" | "listFiles" | "recognize" | "episodes" | "persist" | null; export interface ImportJob { + kind: "import"; id: string; folderPath: string; type: FolderType; @@ -16,3 +18,37 @@ export interface ImportJob { createdAt: number; updatedAt: number; } + +export type ScrapeTaskRuntimeStatus = + | "pending" + | "running" + | "skipped" + | "completed" + | "failed"; + +export interface ScrapeJobTask { + status: ScrapeTaskRuntimeStatus; + error?: string; +} + +export interface ScrapeJob { + kind: "scrape"; + id: string; + folderPath: string; + status: JobStatus; + tasks: Record; + error?: string; + createdAt: number; + updatedAt: number; +} + +export type Job = ImportJob | ScrapeJob; + +export function initialScrapeTasks(): Record { + return { + poster: { status: "pending" }, + fanart: { status: "pending" }, + thumbnails: { status: "pending" }, + nfo: { status: "pending" }, + }; +} diff --git a/apps/core/src/pipeline/scrape/buildMovieNfoTmdb.ts b/apps/core/src/pipeline/scrape/buildMovieNfoTmdb.ts new file mode 100644 index 00000000..8c934049 --- /dev/null +++ b/apps/core/src/pipeline/scrape/buildMovieNfoTmdb.ts @@ -0,0 +1,66 @@ +import type { TmdbMovieDetails } from "@smm/core"; +import { getTmdbImageUrl } from "./tmdbImageUrl"; +import type { MovieNFO } from "./nfoTypes"; + +export function buildMovieNfoTmdb(tmdbMovieDetails: TmdbMovieDetails): MovieNFO { + const thumbs: MovieNFO["thumbs"] = []; + const poster = getTmdbImageUrl(tmdbMovieDetails.poster_path, "original"); + if (poster) { + thumbs.push({ url: poster, aspect: "poster" }); + } + const fanart = getTmdbImageUrl(tmdbMovieDetails.backdrop_path, "original"); + const imdbId = tmdbMovieDetails.imdb_id ?? undefined; + const uniqueIds: MovieNFO["uniqueIds"] = [ + { default: true, type: "tmdb", value: String(tmdbMovieDetails.id) }, + ]; + if (imdbId) { + uniqueIds.push({ default: false, type: "imdb", value: imdbId }); + } + + return { + title: tmdbMovieDetails.title, + originalTitle: tmdbMovieDetails.original_title, + sortTitle: tmdbMovieDetails.title, + year: Number.parseInt(tmdbMovieDetails.release_date?.slice(0, 4) ?? "", 10) || undefined, + ratings: [ + { + default: true, + max: 10, + name: "themoviedb", + value: tmdbMovieDetails.vote_average, + votes: tmdbMovieDetails.vote_count, + }, + ], + userRating: 0, + top250: 0, + set: tmdbMovieDetails.belongs_to_collection?.name + ? { + name: tmdbMovieDetails.belongs_to_collection.name, + overview: tmdbMovieDetails.belongs_to_collection.name, + } + : undefined, + plot: tmdbMovieDetails.overview, + outline: tmdbMovieDetails.overview, + tagline: tmdbMovieDetails.tagline ?? undefined, + runtime: tmdbMovieDetails.runtime ?? undefined, + thumbs: thumbs.length > 0 ? thumbs : undefined, + fanartThumbs: fanart ? [fanart] : undefined, + id: imdbId ?? String(tmdbMovieDetails.id), + imdbid: imdbId, + tmdbid: String(tmdbMovieDetails.id), + uniqueIds, + countries: (tmdbMovieDetails.production_countries ?? []).map((c) => c.name).filter(Boolean), + status: tmdbMovieDetails.status || undefined, + premiered: tmdbMovieDetails.release_date || undefined, + watched: false, + playcount: 0, + genres: (tmdbMovieDetails.genres ?? []).map((g) => g.name).filter(Boolean), + studios: (tmdbMovieDetails.production_companies ?? []).map((c) => c.name).filter(Boolean), + languages: + (tmdbMovieDetails.spoken_languages ?? []) + .map((l) => l.name || l.english_name) + .filter(Boolean) + .join(" / ") || undefined, + dateadded: new Date().toISOString().slice(0, 19).replace("T", " "), + }; +} diff --git a/apps/core/src/pipeline/scrape/buildNfoTvdb.ts b/apps/core/src/pipeline/scrape/buildNfoTvdb.ts new file mode 100644 index 00000000..76716236 --- /dev/null +++ b/apps/core/src/pipeline/scrape/buildNfoTvdb.ts @@ -0,0 +1,164 @@ +import type { + TVDBv4MovieBaseRecord, + TVDBv4SeriesExtendedResponse, + TVDBv4SeriesSeasonsExtendedResponse, +} from "@smm/tvdb4/types"; +import type { EpisodeNfo, MovieNFO, TvShowNFO } from "./nfoTypes"; + +type TvdbSeriesDetails = TVDBv4SeriesExtendedResponse; +type TvdbSeasonDetails = TVDBv4SeriesSeasonsExtendedResponse; +type TvdbEpisodeDetails = TVDBv4SeriesSeasonsExtendedResponse["episodes"][number]; + +function tvdbGetString(data: Record, key: string): string | undefined { + const value = data[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function tvdbGetNumber(data: Record, key: string): number | undefined { + const value = data[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +export function buildTvShowNfoByTVDB( + tvdbSeries: TvdbSeriesDetails, + tvdbSeasons: TvdbSeasonDetails[], + resolvedSeriesText?: { title?: string; overview?: string }, +): TvShowNFO { + const getSeasonNumber = (season: TvdbSeasonDetails): number | undefined => + season.episodes?.find((ep) => Number.isFinite(ep.seasonNumber))?.seasonNumber; + + const thumbs: TvShowNFO["thumbs"] = []; + const namedSeasons: TvShowNFO["namedSeasons"] = (tvdbSeasons ?? []).map((season) => ({ + number: getSeasonNumber(season), + name: "", + })); + + if (tvdbSeries.image) { + thumbs.push({ url: tvdbSeries.image, aspect: "poster" }); + } + for (const season of tvdbSeasons ?? []) { + const seasonNumber = getSeasonNumber(season); + if (season.image) { + thumbs.push({ + url: season.image, + aspect: "poster", + season: seasonNumber, + type: "season", + }); + } + } + + const fanartThumb = (tvdbSeries.artworks ?? []) + .filter((art) => typeof art.image === "string" && art.image.length > 0) + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0]?.image; + + return { + id: String(tvdbSeries.id), + title: resolvedSeriesText?.title || tvdbSeries.name, + originalTitle: resolvedSeriesText?.title || tvdbSeries.name, + showTitle: resolvedSeriesText?.title || tvdbSeries.name, + year: Number.parseInt(tvdbSeries.year ?? "", 10) || undefined, + top250: 0, + ratings: + tvdbSeries.score > 0 + ? [{ default: true, max: 10, name: "tvdb", value: tvdbSeries.score }] + : undefined, + userRating: 0, + outline: resolvedSeriesText?.overview || tvdbSeries.overview, + plot: resolvedSeriesText?.overview || tvdbSeries.overview, + runtime: tvdbSeries.averageRuntime > 0 ? tvdbSeries.averageRuntime : undefined, + thumbs: thumbs.length > 0 ? thumbs : undefined, + namedSeasons: namedSeasons.length > 0 ? namedSeasons : undefined, + fanartThumbs: fanartThumb ? [fanartThumb] : undefined, + episodeguide: JSON.stringify({ tvdb: String(tvdbSeries.id) }), + tvdbid: String(tvdbSeries.id), + uniqueIds: [{ default: true, type: "tvdb", value: String(tvdbSeries.id) }], + premiered: tvdbSeries.firstAired, + status: tvdbSeries.status?.name, + watched: false, + playcount: 0, + countries: tvdbSeries.originalCountry ? [tvdbSeries.originalCountry] : undefined, + dateadded: new Date().toISOString().slice(0, 19).replace("T", " "), + }; +} + +export function buildTvShowEpisodeNfoByTVDB( + tvdbSeries: TvdbSeriesDetails, + _tvdbSeason: TvdbSeasonDetails, + tvdbEpisode: TvdbEpisodeDetails, + episodeTranslationData?: Record, +): EpisodeNfo { + const translatedTitle = + typeof episodeTranslationData?.name === "string" && episodeTranslationData.name.trim().length > 0 + ? episodeTranslationData.name + : tvdbEpisode.name; + const translatedOverview = + typeof episodeTranslationData?.overview === "string" && + episodeTranslationData.overview.trim().length > 0 + ? episodeTranslationData.overview + : tvdbEpisode.overview; + + return { + id: String(tvdbEpisode.id), + title: translatedTitle ?? undefined, + showTitle: tvdbSeries.name, + season: tvdbEpisode.seasonNumber, + episode: tvdbEpisode.number, + uniqueIds: [{ default: true, type: "tvdb", value: String(tvdbEpisode.id) }], + plot: translatedOverview ?? undefined, + runtime: tvdbEpisode.runtime ?? undefined, + thumb: tvdbEpisode.image, + aired: tvdbEpisode.aired ?? undefined, + watched: false, + playcount: 0, + dateadded: new Date().toISOString().slice(0, 19).replace("T", " "), + }; +} + +export function buildMovieNfoByTVDB( + tvdbMovie: TVDBv4MovieBaseRecord | Record, + resolvedMovieText?: { title?: string; overview?: string }, +): MovieNFO { + const data = tvdbMovie as Record; + const title = resolvedMovieText?.title || tvdbGetString(data, "name"); + const overview = resolvedMovieText?.overview || tvdbGetString(data, "overview"); + const image = tvdbGetString(data, "image"); + const score = tvdbGetNumber(data, "score"); + const yearText = tvdbGetString(data, "year"); + const year = yearText ? Number.parseInt(yearText, 10) || undefined : undefined; + const releaseDate = tvdbGetString(data, "releaseDate") ?? tvdbGetString(data, "first_release"); + const runtime = tvdbGetNumber(data, "runtime"); + const statusText = + typeof data.status === "object" && data.status !== null + ? tvdbGetString(data.status as Record, "name") + : undefined; + const id = data.id; + + return { + title, + originalTitle: title, + year, + ratings: + score && score > 0 ? [{ default: true, max: 10, name: "tvdb", value: score }] : undefined, + userRating: 0, + top250: 0, + plot: overview, + outline: overview, + runtime: runtime && runtime > 0 ? runtime : undefined, + thumbs: image ? [{ url: image, aspect: "poster" }] : undefined, + id: id !== undefined ? String(id) : "", + tvdbid: id !== undefined ? String(id) : undefined, + uniqueIds: + id !== undefined ? [{ default: true, type: "tvdb", value: String(id) }] : undefined, + status: statusText, + premiered: releaseDate, + watched: false, + playcount: 0, + dateadded: new Date().toISOString().slice(0, 19).replace("T", " "), + }; +} diff --git a/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts b/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts index 662b2150..ff2a9236 100644 --- a/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts +++ b/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts @@ -51,20 +51,37 @@ describe("checkScrapeCompletion", () => { }); }); - it("returns all false for non-TV metadata", async () => { - const fs = createFs(["/media/movie/poster.jpg"]); + it("checks movie poster/fanart/movie.nfo and forces thumbnails complete", async () => { + const fs = createFs([ + "/media/movie/poster.jpg", + "/media/movie/fanart.jpg", + "/media/movie/movie.nfo", + ]); const result = await checkScrapeCompletion( - { mediaFolderPath: "/media/movie", type: "movie-folder" }, + { mediaFolderPath: "/media/movie", type: "movie-folder", mediaFiles: [] }, fs, ); expect(result).toEqual({ - poster: false, - fanart: false, - thumbnails: false, - nfo: false, + poster: true, + fanart: true, + thumbnails: true, + nfo: true, }); - expect(fs.listFiles).not.toHaveBeenCalled(); + expect(fs.listFiles).toHaveBeenCalled(); + }); + + it("movie without movie.nfo leaves nfo incomplete but thumbnails still complete", async () => { + const fs = createFs(["/media/movie/poster.jpg"]); + const result = await checkScrapeCompletion( + { mediaFolderPath: "/media/movie", type: "movie-folder" }, + fs, + ); + + expect(result.poster).toBe(true); + expect(result.fanart).toBe(false); + expect(result.nfo).toBe(false); + expect(result.thumbnails).toBe(true); }); it("returns all false when listFiles throws", async () => { diff --git a/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts b/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts index 8022b1fc..68e26953 100644 --- a/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts +++ b/apps/core/src/pipeline/scrape/checkScrapeCompletion.ts @@ -77,12 +77,35 @@ function checkTvShowThumbnailsCompletion(files: string[], mediaMetadata: MediaMe return recognizedEpisodeCount > 0; } -/** Returns whether each scrape artifact already exists on disk (TV show folders only). */ +function checkMovieNfoCompletion(files: string[]): boolean { + return files.some((file) => basename(file) === "movie.nfo"); +} + +/** Returns whether each scrape artifact already exists on disk. */ export async function checkScrapeCompletion( mediaMetadata: MediaMetadata, fs: FsPort, ): Promise> { - if (!mediaMetadata.mediaFolderPath || mediaMetadata.type !== "tvshow-folder") { + if (!mediaMetadata.mediaFolderPath) { + return { ...DEFAULT_COMPLETION }; + } + + if (mediaMetadata.type === "movie-folder") { + try { + const files = (await fs.listFiles(mediaMetadata.mediaFolderPath)).map((p) => Path.posix(p)); + return { + poster: hasImageNamed(files, "poster"), + fanart: hasImageNamed(files, "fanart"), + nfo: checkMovieNfoCompletion(files), + // Movies have no thumbnail scrape (legacy UI TODO) — mark complete so orchestrator skips. + thumbnails: true, + }; + } catch { + return { ...DEFAULT_COMPLETION, thumbnails: true }; + } + } + + if (mediaMetadata.type !== "tvshow-folder") { return { ...DEFAULT_COMPLETION }; } diff --git a/apps/core/src/pipeline/scrape/nfoTypes.ts b/apps/core/src/pipeline/scrape/nfoTypes.ts index a0345ddf..700806bb 100644 --- a/apps/core/src/pipeline/scrape/nfoTypes.ts +++ b/apps/core/src/pipeline/scrape/nfoTypes.ts @@ -102,3 +102,39 @@ export interface EpisodeNfo { userNote?: string; episodeGroups?: Array<{ id?: string; name?: string; season?: number; episode?: number }>; } + +export interface MovieNFOSet { + name?: string; + overview?: string; +} + +export interface MovieNFO { + title?: string; + originalTitle?: string; + sortTitle?: string; + year?: number; + ratings?: TvShowNFORating[]; + userRating?: number; + top250?: number; + set?: MovieNFOSet; + plot?: string; + outline?: string; + tagline?: string; + runtime?: number; + thumbs?: NfoThumb[]; + fanartThumbs?: string[]; + id?: string; + imdbid?: string; + tmdbid?: string; + tvdbid?: string; + uniqueIds?: TvShowNFOUniqueId[]; + countries?: string[]; + status?: string; + premiered?: string; + watched?: boolean; + playcount?: number; + genres?: string[]; + studios?: string[]; + languages?: string; + dateadded?: string; +} diff --git a/apps/core/src/pipeline/scrape/nfoXml.ts b/apps/core/src/pipeline/scrape/nfoXml.ts index 8b09b60e..a7c91469 100644 --- a/apps/core/src/pipeline/scrape/nfoXml.ts +++ b/apps/core/src/pipeline/scrape/nfoXml.ts @@ -1,4 +1,4 @@ -import type { EpisodeNfo, TvShowNFO } from "./nfoTypes"; +import type { EpisodeNfo, MovieNFO, TvShowNFO } from "./nfoTypes"; interface XmlNode { tag: string; @@ -352,4 +352,87 @@ export function convertTvShowEpisodeNfoToXml(nfo: EpisodeNfo): string { return toXmlDocument("episodedetails", buildEpisodeChildren(nfo)); } -export type { EpisodeNfo, TvShowNFO } from "./nfoTypes"; +function buildMovieChildren(nfo: MovieNFO): XmlNode[] { + const children: XmlNode[] = []; + for (const node of [ + textElement("title", nfo.title), + textElement("originaltitle", nfo.originalTitle), + textElement("sorttitle", nfo.sortTitle), + textElement("year", nfo.year !== undefined ? String(nfo.year) : undefined), + textElement("plot", nfo.plot), + textElement("outline", nfo.outline), + textElement("tagline", nfo.tagline), + textElement("runtime", nfo.runtime !== undefined ? String(nfo.runtime) : undefined), + textElement("id", nfo.id), + textElement("imdbid", nfo.imdbid), + textElement("tmdbid", nfo.tmdbid), + textElement("tvdbid", nfo.tvdbid), + textElement("premiered", nfo.premiered), + textElement("status", nfo.status), + textElement("languages", nfo.languages), + textElement("dateadded", nfo.dateadded), + ]) { + if (node) children.push(node); + } + + if (nfo.ratings?.length) { + children.push({ + tag: "ratings", + children: nfo.ratings.map((r) => ({ + tag: "rating", + attrs: { + default: r.default !== undefined ? String(r.default) : undefined, + max: r.max !== undefined ? String(r.max) : undefined, + name: r.name, + }, + children: [ + ...(r.value !== undefined ? [{ tag: "value", text: String(r.value) }] : []), + ...(r.votes !== undefined ? [{ tag: "votes", text: String(r.votes) }] : []), + ], + })), + }); + } + + nfo.thumbs?.forEach((thumb) => { + if (!thumb.url) return; + children.push({ + tag: "thumb", + text: thumb.url, + attrs: { + aspect: thumb.aspect ?? undefined, + season: thumb.season !== undefined ? String(thumb.season) : undefined, + type: thumb.type, + }, + }); + }); + + if (nfo.fanartThumbs?.length) { + children.push({ + tag: "fanart", + children: nfo.fanartThumbs.map((url) => ({ tag: "thumb", text: url })), + }); + } + + nfo.uniqueIds?.forEach((uid) => { + children.push({ + tag: "uniqueid", + text: uid.value, + attrs: { + type: uid.type, + default: uid.default !== undefined ? String(uid.default) : undefined, + }, + }); + }); + + nfo.genres?.forEach((genre) => children.push({ tag: "genre", text: genre })); + nfo.studios?.forEach((studio) => children.push({ tag: "studio", text: studio })); + nfo.countries?.forEach((country) => children.push({ tag: "country", text: country })); + + return children; +} + +export function convertMovieNfoToXml(nfo: MovieNFO): string { + return toXmlDocument("movie", buildMovieChildren(nfo)); +} + +export type { EpisodeNfo, MovieNFO, TvShowNFO } from "./nfoTypes"; diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts index 75227dd8..2dbe4fa2 100644 --- a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts @@ -6,6 +6,7 @@ import type { NetworkPort } from "../../ports/NetworkPort"; import { DEFAULT_USER_CONFIG } from "../userConfig"; import { scrapeFanartTmdb } from "./scrapeFanartTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import type { TvdbClient } from "../../clients/TvdbClient"; const seriesDetails: TmdbSeriesDetails = { id: 123876, @@ -47,6 +48,7 @@ function createDeps( fs, network, tmdb: tmdb as TmdbClient, + tvdb: {} as TvdbClient, mediaMetadata: tvShowMetadata, language: "en-US", userConfig: DEFAULT_USER_CONFIG, diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts index 43cfb414..c083fd95 100644 --- a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts @@ -1,43 +1,122 @@ -import type { TmdbSeriesDetails } from "@smm/core"; +import type { MediaMetadata } from "@smm/core"; +import type { TVDBv4Artwork, TVDBv4ArtworkTypeRecord } from "@smm/tvdb4/types"; import { downloadScrapeImage } from "./downloadScrapeImage"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; import { fileExtensionFromUrl, - parseTmdbSeriesId, + parseNumericMediaId, scrapeErrorMessage, } from "./scrapeTaskDeps"; import { getTmdbImageUrl } from "./tmdbImageUrl"; import type { ScrapeTaskResult } from "./types"; import { joinPosix } from "../paths"; +function parseTvdbArtworks(record: unknown): TVDBv4Artwork[] { + if (!Array.isArray(record)) return []; + return record.filter((item): item is TVDBv4Artwork => { + if (typeof item !== "object" || item === null) return false; + const image = (item as { image?: unknown }).image; + return typeof image === "string" && image.length > 0; + }); +} + +function pickBestArtworkImage(artworks: TVDBv4Artwork[]): string | undefined { + const sorted = [...artworks].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return sorted[0]?.image; +} + +function pickBackgroundArtworkImage( + artworks: TVDBv4Artwork[], + artworkTypes: TVDBv4ArtworkTypeRecord[] | undefined, + recordType: "series" | "movie", +): string | undefined { + if (!artworks.length) return undefined; + + const backgroundTypeIds = (artworkTypes ?? []) + .filter((type) => type.recordType === recordType && type.name.toLowerCase().includes("background")) + .map((type) => type.id); + if (!backgroundTypeIds.length) return undefined; + + const candidates = artworks + .filter((art) => backgroundTypeIds.includes(art.type)) + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return candidates[0]?.image; +} + export async function resolveFanartUrl( + mediaMetadata: MediaMetadata, + language: string, + deps: Pick, +): Promise { + if (mediaMetadata.type === "tvshow-folder") { + const tvShow = mediaMetadata.tvShow; + const id = parseNumericMediaId(tvShow?.id); + if (!tvShow || id === undefined) return undefined; + + if (tvShow.database === "TMDB") { + const series = await deps.tmdb.getTvShowById(id, language); + return getTmdbImageUrl(series.backdrop_path, "original") ?? undefined; + } + if (tvShow.database === "TVDB") { + const series = await deps.tvdb.getSeriesExtended(id); + if (!series) return undefined; + const types = await deps.tvdb.getArtworkTypes(); + return ( + pickBackgroundArtworkImage(series.artworks ?? [], types, "series") || + pickBestArtworkImage(series.artworks ?? []) + ); + } + return undefined; + } + + if (mediaMetadata.type === "movie-folder") { + const movie = mediaMetadata.movie; + const id = parseNumericMediaId(movie?.id); + if (!movie || id === undefined) return undefined; + + if (movie.database === "TMDB") { + const details = await deps.tmdb.getMovieById(id, language); + return getTmdbImageUrl(details.backdrop_path, "original") ?? undefined; + } + if (movie.database === "TVDB") { + const record = await deps.tvdb.getMovieExtended(id); + if (!record) return undefined; + const artworks = parseTvdbArtworks(record.artworks); + if (!artworks.length) return undefined; + const types = await deps.tvdb.getArtworkTypes(); + return pickBackgroundArtworkImage(artworks, types, "movie") || pickBestArtworkImage(artworks); + } + } + + return undefined; +} + +/** @deprecated Prefer resolveFanartUrl(mediaMetadata, …). */ +export async function resolveFanartUrlTmdbTv( seriesId: number, language: string, - getTvShowById: (id: number, language: string) => Promise, + getTvShowById: ScrapeTaskDeps["tmdb"]["getTvShowById"], ): Promise { const series = await getTvShowById(seriesId, language); return getTmdbImageUrl(series.backdrop_path, "original") ?? undefined; } -/** Download TMDB TV fanart as `fanart.{ext}` in the media folder root. */ export async function scrapeFanartTmdb(deps: ScrapeTaskDeps): Promise { - const { fs, network, tmdb, mediaMetadata, language, userConfig } = deps; + return scrapeFanart(deps); +} + +export async function scrapeFanart(deps: ScrapeTaskDeps): Promise { + const { fs, network, mediaMetadata, language, userConfig } = deps; const folderPath = mediaMetadata.mediaFolderPath; - const seriesId = parseTmdbSeriesId(mediaMetadata); if (!folderPath) { return { status: "failed", error: "Missing media folder path" }; } - if (seriesId === undefined) { - return { status: "failed", error: "TV show TMDB metadata is required" }; - } try { - const fanartUrl = await resolveFanartUrl(seriesId, language, (id, lang) => - tmdb.getTvShowById(id, lang), - ); + const fanartUrl = await resolveFanartUrl(mediaMetadata, language, deps); if (!fanartUrl) { - return { status: "failed", error: "No TMDB fanart available" }; + return { status: "failed", error: "No fanart available" }; } const ext = fileExtensionFromUrl(fanartUrl); @@ -47,14 +126,7 @@ export async function scrapeFanartTmdb(deps: ScrapeTaskDeps): Promise string; discover?: DiscoverPort; reverseProxyUrl?: string | null; } +export interface ScrapeFolderProgress { + onTaskStart?: (taskId: ScrapeTaskId) => void; + onTaskDone?: (taskId: ScrapeTaskId, result: ScrapeTaskResult) => void; +} + const TASK_ORDER: ScrapeTaskId[] = ["poster", "fanart", "thumbnails", "nfo"]; const TASK_RUNNERS: Record< @@ -50,12 +57,19 @@ function isManaged(folders: string[], mediaFolderPath: string): boolean { ); } -/** Run TMDB TV scrape tasks sequentially; skip at orchestrator when artifacts exist. */ -export async function scrapeFolderPipeline( +export interface PreparedScrape { + posixPath: string; + language: string; + config: UserConfigData; + mediaMetadata: MediaMetadata; +} + +/** Throws if the folder cannot be scraped (no side effects beyond reads). */ +export async function prepareScrapeFolder( path: string, options: ScrapeFolderOptions | undefined, deps: ScrapeFolderDeps, -): Promise { +): Promise { const posixPath = deps.normalizePosix(path); const config = await deps.userConfig.read(); @@ -75,33 +89,67 @@ export async function scrapeFolderPipeline( throw new Error(`Media metadata not found: ${path}`); } - if (mediaMetadata.type !== "tvshow-folder") { - throw new Error(`Folder is not a TV show: ${path}`); - } - if (mediaMetadata.tvShow?.database !== "TMDB") { - throw new Error(`TV show must use TMDB database: ${path}`); + if (mediaMetadata.type === "tvshow-folder") { + const database = mediaMetadata.tvShow?.database; + if (database !== "TMDB" && database !== "TVDB") { + throw new Error(`Unsupported media database: ${database ?? "unknown"}`); + } + } else if (mediaMetadata.type === "movie-folder") { + const database = mediaMetadata.movie?.database; + if (database !== "TMDB" && database !== "TVDB") { + throw new Error(`Unsupported media database: ${database ?? "unknown"}`); + } + } else { + throw new Error(`Folder is not a TV show or movie: ${path}`); } const language = options?.language ?? config.preferMediaLanguage ?? "en-US"; + return { + posixPath, + language, + config, + mediaMetadata: { ...mediaMetadata, mediaFolderPath: posixPath }, + }; +} + +/** Run TMDB TV scrape tasks sequentially; skip at orchestrator when artifacts exist. */ +export async function scrapeFolderPipeline( + path: string, + options: ScrapeFolderOptions | undefined, + deps: ScrapeFolderDeps, + progress?: ScrapeFolderProgress, +): Promise { + const prepared = await prepareScrapeFolder(path, options, deps); + return runPreparedScrape(prepared, deps, progress); +} + +export async function runPreparedScrape( + prepared: PreparedScrape, + deps: ScrapeFolderDeps, + progress?: ScrapeFolderProgress, +): Promise { + const { posixPath, language, config, mediaMetadata } = prepared; + const tmdb = new TmdbClient(deps.network, { ...config.tmdb, discover: deps.discover, reverseProxyUrl: deps.reverseProxyUrl, }); + const tvdb = new TvdbClient(deps.network, { + ...config.tvdb, + discover: deps.discover, + reverseProxyUrl: deps.reverseProxyUrl, + }); - const normalizedMetadata: MediaMetadata = { - ...mediaMetadata, - mediaFolderPath: posixPath, - }; - - const completion = await checkScrapeCompletion(normalizedMetadata, deps.fs); + const completion = await checkScrapeCompletion(mediaMetadata, deps.fs); const taskDeps: ScrapeTaskDeps = { fs: deps.fs, network: deps.network, tmdb, - mediaMetadata: normalizedMetadata, + tvdb, + mediaMetadata, language, userConfig: config, reverseProxyUrl: deps.reverseProxyUrl ?? undefined, @@ -111,10 +159,15 @@ export async function scrapeFolderPipeline( for (const taskId of TASK_ORDER) { if (completion[taskId]) { - tasks[taskId] = { status: "skipped" }; + const skipped: ScrapeTaskResult = { status: "skipped" }; + tasks[taskId] = skipped; + progress?.onTaskDone?.(taskId, skipped); continue; } - tasks[taskId] = await TASK_RUNNERS[taskId](taskDeps); + progress?.onTaskStart?.(taskId); + const result = await TASK_RUNNERS[taskId](taskDeps); + tasks[taskId] = result; + progress?.onTaskDone?.(taskId, result); } return { diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts index b5771ef6..1e615bfa 100644 --- a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts @@ -99,6 +99,7 @@ function createDeps( fs, network: { fetch: vi.fn() }, tmdb: tmdb as TmdbClient, + tvdb: {} as import("../../clients/TvdbClient").TvdbClient, mediaMetadata, language: "en-US", userConfig: DEFAULT_USER_CONFIG, diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts index 345591ed..3e64cac1 100644 --- a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.ts @@ -1,11 +1,24 @@ -import type { TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import type { PreferMediaLanguage, TmdbSeasonDetails, TmdbSeriesDetails } from "@smm/core"; +import { mapToTvdbLangCode } from "../../clients/TvdbClient"; +import { buildMovieNfoTmdb } from "./buildMovieNfoTmdb"; +import { + buildMovieNfoByTVDB, + buildTvShowEpisodeNfoByTVDB, + buildTvShowNfoByTVDB, +} from "./buildNfoTvdb"; import { buildTvShowEpisodeNfo, buildTvShowNfo } from "./buildTvShowNfoTmdb"; import { + convertMovieNfoToXml, convertTvShowEpisodeNfoToXml, convertTvShowNfoToXml, } from "./nfoXml"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; -import { newFilePathWithExt, parseTmdbSeriesId, scrapeErrorMessage } from "./scrapeTaskDeps"; +import { + newFilePathWithExt, + parseNumericMediaId, + parseTmdbSeriesId, + scrapeErrorMessage, +} from "./scrapeTaskDeps"; import type { ScrapeTaskResult } from "./types"; import { joinPosix } from "../paths"; @@ -28,67 +41,231 @@ async function fetchTvShowSeasons( return { series, seasons }; } -/** Write `tvshow.nfo` and per-episode NFO files from TMDB metadata. */ -export async function scrapeNfoTmdb(deps: ScrapeTaskDeps): Promise { +function toPreferMediaLanguage(language: string): PreferMediaLanguage { + if (language === "zh-CN" || language === "ja-JP" || language === "en-US") { + return language; + } + return "en-US"; +} + +async function scrapeTvShowNfoTmdb(deps: ScrapeTaskDeps): Promise { const { fs, tmdb, mediaMetadata, language } = deps; - const folderPath = mediaMetadata.mediaFolderPath; + const folderPath = mediaMetadata.mediaFolderPath!; const seriesId = parseTmdbSeriesId(mediaMetadata); + if (seriesId === undefined) { + return { status: "failed", error: "TV show TMDB metadata is required" }; + } - if (!folderPath) { - return { status: "failed", error: "Missing media folder path" }; + const { series, seasons } = await fetchTvShowSeasons( + seriesId, + language, + (id, lang) => tmdb.getTvShowById(id, lang), + (id, season, lang) => tmdb.getTvSeasonById(id, season, lang), + ); + + let wroteAny = false; + let skippedAny = false; + + const tvShowNfoPath = joinPosix(folderPath, "tvshow.nfo"); + if (await fs.exists(tvShowNfoPath)) { + skippedAny = true; + } else { + await fs.writeTextFile(tvShowNfoPath, convertTvShowNfoToXml(buildTvShowNfo(series, seasons))); + wroteAny = true; } + + for (const mediaFile of mediaMetadata.mediaFiles ?? []) { + const { seasonNumber, episodeNumber, absolutePath } = mediaFile; + if (seasonNumber === undefined || episodeNumber === undefined) continue; + + const episodeNfoPath = newFilePathWithExt(absolutePath, ".nfo"); + if (await fs.exists(episodeNfoPath)) { + skippedAny = true; + continue; + } + + const tmdbSeason = seasons.find((season) => season.season_number === seasonNumber); + const tmdbEpisode = tmdbSeason?.episodes?.find( + (episode) => episode.episode_number === episodeNumber, + ); + if (!tmdbSeason || !tmdbEpisode) continue; + + await fs.writeTextFile( + episodeNfoPath, + convertTvShowEpisodeNfoToXml(buildTvShowEpisodeNfo(series, tmdbSeason, tmdbEpisode)), + ); + wroteAny = true; + } + + if (wroteAny) return { status: "completed" }; + if (skippedAny) return { status: "skipped" }; + return { status: "failed", error: "No linked episodes to write NFO files for" }; +} + +async function scrapeTvShowNfoTvdb(deps: ScrapeTaskDeps): Promise { + const { fs, tvdb, mediaMetadata, language } = deps; + const folderPath = mediaMetadata.mediaFolderPath!; + const seriesId = parseNumericMediaId(mediaMetadata.tvShow?.id); if (seriesId === undefined) { - return { status: "failed", error: "TV show TMDB metadata is required" }; + return { status: "failed", error: "TV show TVDB metadata is required" }; } - try { - const { series, seasons } = await fetchTvShowSeasons( - seriesId, - language, - (id, lang) => tmdb.getTvShowById(id, lang), - (id, season, lang) => tmdb.getTvSeasonById(id, season, lang), + const langCode = mapToTvdbLangCode(toPreferMediaLanguage(language)); + const series = await tvdb.getSeriesExtended(seriesId); + if (!series) { + return { status: "failed", error: `Failed to fetch TVDB series: ${seriesId}` }; + } + + const seasons = ( + await Promise.all((series.seasons ?? []).map((s) => tvdb.getSeasonExtended(s.id))) + ).filter((s): s is NonNullable => s !== undefined); + + const seriesTranslation = await tvdb.getSeriesTranslation(seriesId, langCode); + const resolvedSeriesText = { + title: + typeof seriesTranslation?.name === "string" && seriesTranslation.name.trim().length > 0 + ? seriesTranslation.name + : series.name, + overview: + typeof seriesTranslation?.overview === "string" && + seriesTranslation.overview.trim().length > 0 + ? seriesTranslation.overview + : series.overview, + }; + + let wroteAny = false; + let skippedAny = false; + + const tvShowNfoPath = joinPosix(folderPath, "tvshow.nfo"); + if (await fs.exists(tvShowNfoPath)) { + skippedAny = true; + } else { + await fs.writeTextFile( + tvShowNfoPath, + convertTvShowNfoToXml(buildTvShowNfoByTVDB(series, seasons, resolvedSeriesText)), ); + wroteAny = true; + } - let wroteAny = false; - let skippedAny = false; + for (const mediaFile of mediaMetadata.mediaFiles ?? []) { + const { seasonNumber, episodeNumber, absolutePath } = mediaFile; + if (seasonNumber === undefined || episodeNumber === undefined) continue; - const tvShowNfoPath = joinPosix(folderPath, "tvshow.nfo"); - if (await fs.exists(tvShowNfoPath)) { + const episodeNfoPath = newFilePathWithExt(absolutePath, ".nfo"); + if (await fs.exists(episodeNfoPath)) { skippedAny = true; - } else { - const tvShowNfo = buildTvShowNfo(series, seasons); - await fs.writeTextFile(tvShowNfoPath, convertTvShowNfoToXml(tvShowNfo)); - wroteAny = true; + continue; } - for (const mediaFile of mediaMetadata.mediaFiles ?? []) { - const { seasonNumber, episodeNumber, absolutePath } = mediaFile; - if (seasonNumber === undefined || episodeNumber === undefined) continue; - - const episodeNfoPath = newFilePathWithExt(absolutePath, ".nfo"); - if (await fs.exists(episodeNfoPath)) { - skippedAny = true; - continue; - } - - const tmdbSeason = seasons.find((season) => season.season_number === seasonNumber); - const tmdbEpisode = tmdbSeason?.episodes?.find( - (episode) => episode.episode_number === episodeNumber, - ); - if (!tmdbSeason || !tmdbEpisode) continue; - - const episodeNfo = buildTvShowEpisodeNfo(series, tmdbSeason, tmdbEpisode); - await fs.writeTextFile(episodeNfoPath, convertTvShowEpisodeNfoToXml(episodeNfo)); - wroteAny = true; + const tvdbSeason = seasons.find((s) => + (s.episodes ?? []).some((e) => e.seasonNumber === Number(seasonNumber)), + ); + const tvdbEpisode = tvdbSeason?.episodes?.find((e) => e.number === Number(episodeNumber)); + if (!tvdbSeason || !tvdbEpisode) continue; + + let episodeTranslation: Record | undefined; + try { + episodeTranslation = await tvdb.getEpisodeTranslation(tvdbEpisode.id, langCode); + } catch { + /* optional */ + } + + await fs.writeTextFile( + episodeNfoPath, + convertTvShowEpisodeNfoToXml( + buildTvShowEpisodeNfoByTVDB(series, tvdbSeason, tvdbEpisode, episodeTranslation), + ), + ); + wroteAny = true; + } + + if (wroteAny) return { status: "completed" }; + if (skippedAny) return { status: "skipped" }; + return { status: "failed", error: "No linked episodes to write NFO files for" }; +} + +async function scrapeMovieNfoTmdb(deps: ScrapeTaskDeps): Promise { + const { fs, tmdb, mediaMetadata, language } = deps; + const folderPath = mediaMetadata.mediaFolderPath!; + const movieId = parseNumericMediaId(mediaMetadata.movie?.id); + if (movieId === undefined) { + return { status: "failed", error: "Movie TMDB metadata is required" }; + } + + const movieNfoPath = joinPosix(folderPath, "movie.nfo"); + if (await fs.exists(movieNfoPath)) { + return { status: "skipped" }; + } + + const details = await tmdb.getMovieById(movieId, language); + await fs.writeTextFile(movieNfoPath, convertMovieNfoToXml(buildMovieNfoTmdb(details))); + return { status: "completed" }; +} + +async function scrapeMovieNfoTvdb(deps: ScrapeTaskDeps): Promise { + const { fs, tvdb, mediaMetadata, language } = deps; + const folderPath = mediaMetadata.mediaFolderPath!; + const movieId = parseNumericMediaId(mediaMetadata.movie?.id); + if (movieId === undefined) { + return { status: "failed", error: "Movie TVDB metadata is required" }; + } + + const movieNfoPath = joinPosix(folderPath, "movie.nfo"); + if (await fs.exists(movieNfoPath)) { + return { status: "skipped" }; + } + + const record = await tvdb.getMovieExtended(movieId); + if (!record) { + return { status: "failed", error: `Failed to fetch TVDB movie: ${movieId}` }; + } + + const langCode = mapToTvdbLangCode(toPreferMediaLanguage(language)); + let resolvedMovieText: { title?: string; overview?: string } | undefined; + try { + const translation = await tvdb.getMovieTranslation(movieId, langCode); + if (translation) { + resolvedMovieText = { + title: + typeof translation.name === "string" && translation.name.trim().length > 0 + ? translation.name + : undefined, + overview: + typeof translation.overview === "string" && translation.overview.trim().length > 0 + ? translation.overview + : undefined, + }; } + } catch { + /* optional */ + } - if (wroteAny) { - return { status: "completed" }; + await fs.writeTextFile( + movieNfoPath, + convertMovieNfoToXml(buildMovieNfoByTVDB(record, resolvedMovieText)), + ); + return { status: "completed" }; +} + +/** Write NFO files for TV|movie × TMDB|TVDB. */ +export async function scrapeNfoTmdb(deps: ScrapeTaskDeps): Promise { + const { mediaMetadata } = deps; + if (!mediaMetadata.mediaFolderPath) { + return { status: "failed", error: "Missing media folder path" }; + } + + try { + if (mediaMetadata.type === "tvshow-folder") { + if (mediaMetadata.tvShow?.database === "TMDB") return scrapeTvShowNfoTmdb(deps); + if (mediaMetadata.tvShow?.database === "TVDB") return scrapeTvShowNfoTvdb(deps); + return { status: "failed", error: "Unsupported TV database for NFO" }; } - if (skippedAny) { - return { status: "skipped" }; + if (mediaMetadata.type === "movie-folder") { + if (mediaMetadata.movie?.database === "TMDB") return scrapeMovieNfoTmdb(deps); + if (mediaMetadata.movie?.database === "TVDB") return scrapeMovieNfoTvdb(deps); + return { status: "failed", error: "Unsupported movie database for NFO" }; } - return { status: "failed", error: "No linked episodes to write NFO files for" }; + return { status: "failed", error: "Unsupported folder type for NFO" }; } catch (error) { return { status: "failed", error: scrapeErrorMessage(error) }; } diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts index c06639c1..31f5de43 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -6,6 +6,11 @@ import type { FsPort } from "../../ports/FsPort"; import { DEFAULT_USER_CONFIG } from "../userConfig"; import { resolvePosterUrl, scrapePosterTmdb } from "./scrapePosterTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; +import type { TvdbClient } from "../../clients/TvdbClient"; + +function emptyTvdb(): TvdbClient { + return {} as TvdbClient; +} const seriesDetails: TmdbSeriesDetails = { id: 123876, @@ -93,6 +98,7 @@ function createDeps( fs, network, tmdb: tmdb as TmdbClient, + tvdb: emptyTvdb(), mediaMetadata, language: "en-US", userConfig: DEFAULT_USER_CONFIG, @@ -101,7 +107,10 @@ function createDeps( describe("resolvePosterUrl", () => { it("resolves TMDB poster_path to an original-size CDN URL", async () => { - const result = await resolvePosterUrl(123876, "en-US", vi.fn().mockResolvedValue(seriesDetails)); + const result = await resolvePosterUrl(tvShowMetadata, "en-US", { + tmdb: { getTvShowById: vi.fn().mockResolvedValue(seriesDetails) } as unknown as TmdbClient, + tvdb: emptyTvdb(), + }); expect(result).toBe("https://image.tmdb.org/t/p/original/poster.jpg"); }); }); @@ -148,6 +157,6 @@ describe("scrapePosterTmdb", () => { const result = await scrapePosterTmdb(createDeps(fs, network, tmdb)); expect(result.status).toBe("failed"); - expect(result.error).toContain("No TMDB poster"); + expect(result.error).toContain("No poster available"); }); }); diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts index 9b4b07dd..ee85f4fa 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts @@ -1,43 +1,104 @@ -import type { TmdbSeriesDetails } from "@smm/core"; +import type { MediaMetadata } from "@smm/core"; +import type { TVDBv4Artwork, TVDBv4SeriesExtendedResponse } from "@smm/tvdb4/types"; import { downloadScrapeImage } from "./downloadScrapeImage"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; import { fileExtensionFromUrl, - parseTmdbSeriesId, + parseNumericMediaId, scrapeErrorMessage, } from "./scrapeTaskDeps"; import { getTmdbImageUrl } from "./tmdbImageUrl"; import type { ScrapeTaskResult } from "./types"; import { joinPosix } from "../paths"; +function toImageUrl(record: unknown): string | undefined { + return typeof record === "string" && record.trim().length > 0 ? record : undefined; +} + +function parseTvdbArtworks(record: unknown): TVDBv4Artwork[] { + if (!Array.isArray(record)) return []; + return record.filter((item): item is TVDBv4Artwork => { + if (typeof item !== "object" || item === null) return false; + const image = (item as { image?: unknown }).image; + return typeof image === "string" && image.length > 0; + }); +} + +function pickBestArtworkImage(artworks: TVDBv4Artwork[]): string | undefined { + const sorted = [...artworks].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + return sorted[0]?.image; +} + export async function resolvePosterUrl( + mediaMetadata: MediaMetadata, + language: string, + deps: Pick, +): Promise { + if (mediaMetadata.type === "tvshow-folder") { + const tvShow = mediaMetadata.tvShow; + const id = parseNumericMediaId(tvShow?.id); + if (!tvShow || id === undefined) return undefined; + + if (tvShow.database === "TMDB") { + const series = await deps.tmdb.getTvShowById(id, language); + return getTmdbImageUrl(series.poster_path, "original") ?? undefined; + } + if (tvShow.database === "TVDB") { + const series = await deps.tvdb.getSeriesExtended(id); + if (!series) return undefined; + return toImageUrl(series.image) || pickBestArtworkImage(series.artworks ?? []); + } + return undefined; + } + + if (mediaMetadata.type === "movie-folder") { + const movie = mediaMetadata.movie; + const id = parseNumericMediaId(movie?.id); + if (!movie || id === undefined) return undefined; + + if (movie.database === "TMDB") { + const details = await deps.tmdb.getMovieById(id, language); + return getTmdbImageUrl(details.poster_path, "original") ?? undefined; + } + if (movie.database === "TVDB") { + const record = await deps.tvdb.getMovieExtended(id); + if (!record) return undefined; + const baseImage = toImageUrl(record.image); + if (baseImage) return baseImage; + return pickBestArtworkImage(parseTvdbArtworks(record.artworks)); + } + } + + return undefined; +} + +/** @deprecated Prefer resolvePosterUrl(mediaMetadata, …) — kept for existing TMDB-TV unit tests. */ +export async function resolvePosterUrlTmdbTv( seriesId: number, language: string, - getTvShowById: (id: number, language: string) => Promise, + getTvShowById: ScrapeTaskDeps["tmdb"]["getTvShowById"], ): Promise { const series = await getTvShowById(seriesId, language); return getTmdbImageUrl(series.poster_path, "original") ?? undefined; } -/** Download TMDB TV poster as `poster.{ext}` in the media folder root. */ +/** Download poster as `poster.{ext}` for TV|movie × TMDB|TVDB. */ export async function scrapePosterTmdb(deps: ScrapeTaskDeps): Promise { - const { fs, network, tmdb, mediaMetadata, language, userConfig } = deps; + return scrapePoster(deps); +} + +export async function scrapePoster(deps: ScrapeTaskDeps): Promise { + const { fs, network, mediaMetadata, language, userConfig } = deps; const folderPath = mediaMetadata.mediaFolderPath; - const seriesId = parseTmdbSeriesId(mediaMetadata); if (!folderPath) { return { status: "failed", error: "Missing media folder path" }; } - if (seriesId === undefined) { - return { status: "failed", error: "TV show TMDB metadata is required" }; - } try { - const posterUrl = await resolvePosterUrl(seriesId, language, (id, lang) => - tmdb.getTvShowById(id, lang), - ); + const posterUrl = await resolvePosterUrl(mediaMetadata, language, deps); if (!posterUrl) { - return { status: "failed", error: "No TMDB poster available" }; + return { status: "failed", error: "No poster available" }; } const ext = fileExtensionFromUrl(posterUrl); @@ -47,16 +108,11 @@ export async function scrapePosterTmdb(deps: ScrapeTaskDeps): Promise { - const { fs, network, tmdb, mediaMetadata, language, userConfig } = deps; - const seriesId = parseTmdbSeriesId(mediaMetadata); +async function getEpisodeStillPathsFromTvdb( + seriesId: number, + tvdb: ScrapeTaskDeps["tvdb"], +): Promise { + const artworkTypes = await tvdb.getArtworkTypes(); + const screencapTypeId = artworkTypes?.find((type) => type.name === "16:9 Screencap")?.id ?? 11; + const series = await tvdb.getSeriesExtended(seriesId); + const stillPaths: EpisodeStillPath[] = []; - if (seriesId === undefined) { - return { status: "failed", error: "TV show TMDB metadata is required" }; + for (const season of series?.seasons ?? []) { + const tvdbSeason = await tvdb.getSeasonExtended(season.id); + for (const episode of tvdbSeason?.episodes ?? []) { + if (episode.image === undefined) continue; + if (episode.imageType !== screencapTypeId) continue; + stillPaths.push({ + season: season.number, + episode: episode.number, + stillUrl: episode.image, + }); + } } + return stillPaths; +} + +async function downloadStills( + deps: ScrapeTaskDeps, + stillPaths: EpisodeStillPath[], +): Promise { + const { fs, network, mediaMetadata, userConfig } = deps; const mediaFiles = (mediaMetadata.mediaFiles ?? []).filter( (file) => file.seasonNumber !== undefined && file.episodeNumber !== undefined, ); - if (mediaFiles.length === 0) { - return { status: "skipped" }; - } - try { - const stillPaths = await getEpisodeStillPathsFromTmdb( - seriesId, - language, - (id, lang) => tmdb.getTvShowById(id, lang), - (id, season, lang) => tmdb.getTvSeasonById(id, season, lang), + let downloaded = 0; + let skipped = 0; + + for (const mediaFile of mediaFiles) { + const still = stillPaths.find( + (path) => + path.season === mediaFile.seasonNumber && path.episode === mediaFile.episodeNumber, ); + if (!still) continue; - let downloaded = 0; - let skipped = 0; + const thumbPath = newFilePathWithExt(mediaFile.absolutePath, extname(still.stillUrl)); + if (await fs.exists(thumbPath)) { + skipped += 1; + continue; + } - for (const mediaFile of mediaFiles) { - const still = stillPaths.find( - (path) => - path.season === mediaFile.seasonNumber && - path.episode === mediaFile.episodeNumber, - ); - if (!still) continue; + await downloadScrapeImage( + mediaMetadata, + still.stillUrl, + thumbPath, + userConfig, + fs, + network, + ); + downloaded += 1; + } - const thumbPath = newFilePathWithExt( - mediaFile.absolutePath, - extname(still.stillUrl), - ); + if (downloaded > 0) return { status: "completed" }; + if (skipped > 0) return { status: "skipped" }; + return { status: "failed", error: "No episode stills available for linked files" }; +} - if (await fs.exists(thumbPath)) { - skipped += 1; - continue; - } +/** Download episode stills (TV) or skip (movie — legacy UI TODO). */ +export async function scrapeThumbnailsTmdb(deps: ScrapeTaskDeps): Promise { + const { tmdb, tvdb, mediaMetadata, language } = deps; + + if (mediaMetadata.type === "movie-folder") { + return { status: "skipped" }; + } + + if (mediaMetadata.type !== "tvshow-folder") { + return { status: "failed", error: "Unsupported folder type for thumbnails" }; + } + + const mediaFiles = (mediaMetadata.mediaFiles ?? []).filter( + (file) => file.seasonNumber !== undefined && file.episodeNumber !== undefined, + ); + if (mediaFiles.length === 0) { + return { status: "skipped" }; + } - await downloadScrapeImage( - mediaMetadata, - still.stillUrl, - thumbPath, - userConfig, - fs, - network, + try { + if (mediaMetadata.tvShow?.database === "TMDB") { + const seriesId = parseTmdbSeriesId(mediaMetadata); + if (seriesId === undefined) { + return { status: "failed", error: "TV show TMDB metadata is required" }; + } + const stillPaths = await getEpisodeStillPathsFromTmdb( + seriesId, + language, + (id, lang) => tmdb.getTvShowById(id, lang), + (id, season, lang) => tmdb.getTvSeasonById(id, season, lang), ); - downloaded += 1; + return downloadStills(deps, stillPaths); } - if (downloaded > 0) { - return { status: "completed" }; - } - if (skipped > 0) { - return { status: "skipped" }; + if (mediaMetadata.tvShow?.database === "TVDB") { + const seriesId = parseNumericMediaId(mediaMetadata.tvShow.id); + if (seriesId === undefined) { + return { status: "failed", error: "TV show TVDB metadata is required" }; + } + const stillPaths = await getEpisodeStillPathsFromTvdb(seriesId, tvdb); + return downloadStills(deps, stillPaths); } - return { status: "failed", error: "No TMDB episode stills available for linked files" }; + + return { status: "failed", error: "Unsupported TV database for thumbnails" }; } catch (error) { return { status: "failed", error: scrapeErrorMessage(error) }; } diff --git a/apps/ui/src/api/getJob.ts b/apps/ui/src/api/getJob.ts new file mode 100644 index 00000000..936f0b7d --- /dev/null +++ b/apps/ui/src/api/getJob.ts @@ -0,0 +1,76 @@ +import { apiFetch } from '@/lib/apiFetch' +import type { ScrapeTaskId } from '@/lib/scrapeDialog' + +export type JobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted' + +export type ScrapeTaskRuntimeStatus = + | 'pending' + | 'running' + | 'skipped' + | 'completed' + | 'failed' + +export interface ScrapeJobTask { + status: ScrapeTaskRuntimeStatus + error?: string +} + +export interface ScrapeJob { + kind: 'scrape' + id: string + folderPath: string + status: JobStatus + tasks: Record + error?: string + createdAt: number + updatedAt: number +} + +export interface ImportJob { + kind: 'import' + id: string + folderPath: string + type: string + status: JobStatus + stage: string | null + progress: number + recognizedTitle?: string + error?: string + createdAt: number + updatedAt: number +} + +export type Job = ImportJob | ScrapeJob + +export interface GetJobResponseBody { + data?: Job + error?: string +} + +/** Poll Core job (`POST /api/get-job`). */ +export async function getJob(id: string, signal?: AbortSignal): Promise { + const resp = await apiFetch('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as GetJobResponseBody +} + +/** Throws on business error; returns job. */ +export async function getJobViaCore(id: string, signal?: AbortSignal): Promise { + const body = await getJob(id, signal) + if (body.error) { + throw new Error(body.error) + } + if (!body.data) { + throw new Error('Error Reason: Job not found') + } + return body.data +} diff --git a/apps/ui/src/api/scrapeV3.ts b/apps/ui/src/api/scrapeV3.ts new file mode 100644 index 00000000..6b6e7263 --- /dev/null +++ b/apps/ui/src/api/scrapeV3.ts @@ -0,0 +1,47 @@ +import { apiFetch } from '@/lib/apiFetch' + +export interface ScrapeFolderV3Params { + path: string + language?: string +} + +export interface ScrapeFolderV3ResponseBody { + data?: { id: string } + error?: string +} + +/** Layer-2 scrape via Core (`POST /api/scrape`). Used when SMM v3 is enabled. */ +export async function scrapeFolderV3( + params: ScrapeFolderV3Params, + signal?: AbortSignal, +): Promise { + const body: Record = { path: params.path } + if (params.language !== undefined && params.language.trim() !== '') { + body.language = params.language + } + + const resp = await apiFetch('/api/scrape', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as ScrapeFolderV3ResponseBody +} + +/** Throws on business error; returns job id. */ +export async function scrapeFolderViaCore(params: ScrapeFolderV3Params): Promise { + const data = await scrapeFolderV3(params) + if (data.error) { + throw new Error(data.error) + } + if (!data.data?.id) { + throw new Error('Error Reason: scrape job id missing') + } + return data.data.id +} diff --git a/apps/ui/src/components/dialogs/useScrapeDialog.ts b/apps/ui/src/components/dialogs/useScrapeDialog.ts index 6a8d5ac3..073ec6ed 100644 --- a/apps/ui/src/components/dialogs/useScrapeDialog.ts +++ b/apps/ui/src/components/dialogs/useScrapeDialog.ts @@ -14,9 +14,13 @@ import { INITIAL_SCRAPE_TASK_STATE, taskReducer, type ScrapeTaskId, + type ScrapeTaskStatus, type ScrapeTaskView, } from "@/lib/scrapeDialog" import { normalizeScrapeTaskError } from "@/lib/scrapeError" +import { isSmmV3Enabled } from "@/lib/localStorages" +import { scrapeFolderViaCore } from "@/api/scrapeV3" +import { getJobViaCore, type ScrapeJob, type ScrapeTaskRuntimeStatus } from "@/api/getJob" export interface UseScrapeDialogInput { isOpen: boolean @@ -35,6 +39,36 @@ export interface UseScrapeDialogResult { handleStart: () => Promise } +const POLL_INTERVAL_MS = 1000 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function mapCoreTaskStatus(status: ScrapeTaskRuntimeStatus): ScrapeTaskStatus { + if (status === "skipped") return "completed" + if (status === "pending" || status === "running" || status === "completed" || status === "failed") { + return status + } + return "pending" +} + +function jobTasksToAction( + job: ScrapeJob, +): Partial> { + const result: Partial> = + {} + for (const id of Object.keys(job.tasks) as ScrapeTaskId[]) { + const task = job.tasks[id] + if (!task) continue + result[id] = { + status: mapCoreTaskStatus(task.status), + failedReason: task.error, + } + } + return result +} + export function useScrapeDialog({ isOpen, onClose, @@ -103,7 +137,7 @@ export function useScrapeDialog({ onClose() }, [cancelDisabled, onClose]) - const handleStart = useCallback(async () => { + const handleStartLegacy = useCallback(async () => { if (!mediaMetadata) return if (allTasksDone) { onClose() @@ -151,6 +185,77 @@ export function useScrapeDialog({ onClose, ]) + const handleStartV3 = useCallback(async () => { + if (!mediaMetadata?.mediaFolderPath) return + if (allTasksDone) { + onClose() + return + } + if (state.isRunning) return + + dispatch({ type: "START_RUN" }) + const traceId = `ScrapeDialog-handleStartV3-${nextTraceId()}` + try { + const jobId = await scrapeFolderViaCore({ + path: mediaMetadata.mediaFolderPath, + language: userConfig.preferMediaLanguage, + }) + + for (;;) { + const job = await getJobViaCore(jobId) + if (job.kind !== "scrape") { + throw new Error(`Error Reason: unexpected job kind: ${job.kind}`) + } + dispatch({ type: "APPLY_JOB_TASKS", tasks: jobTasksToAction(job) }) + if ( + job.status === "succeeded" || + job.status === "failed" || + job.status === "aborted" + ) { + break + } + await sleep(POLL_INTERVAL_MS) + } + + await refreshMediaMetadata({ path: mediaMetadata.mediaFolderPath, traceId }) + } catch (error) { + const { messageKey, debugDetail } = normalizeScrapeTaskError(error) + console.error("[ScrapeDialog] v3 run failed:", debugDetail, error) + dispatch({ + type: "APPLY_JOB_TASKS", + tasks: Object.fromEntries( + state.tasks + .filter((task) => task.status === "pending" || task.status === "running") + .map((task) => [ + task.id, + { + status: "failed" as const, + failedReason: debugDetail.trim() ? messageKey : undefined, + }, + ]), + ), + }) + } finally { + dispatch({ type: "FINISH_RUN" }) + } + }, [ + mediaMetadata, + allTasksDone, + state.isRunning, + state.tasks, + userConfig.preferMediaLanguage, + refreshMediaMetadata, + onClose, + ]) + + const handleStart = useCallback(async () => { + if (isSmmV3Enabled()) { + await handleStartV3() + return + } + await handleStartLegacy() + }, [handleStartV3, handleStartLegacy]) + return { tasks: state.tasks, isRunning: state.isRunning, diff --git a/apps/ui/src/lib/scrapeDialog/scrapeDialog.test.ts b/apps/ui/src/lib/scrapeDialog/scrapeDialog.test.ts index 798cd989..8b97334e 100644 --- a/apps/ui/src/lib/scrapeDialog/scrapeDialog.test.ts +++ b/apps/ui/src/lib/scrapeDialog/scrapeDialog.test.ts @@ -78,6 +78,34 @@ describe("ScrapeDialog state reducer", () => { const finished = taskReducer(failed, { type: "FINISH_RUN" }) expect(finished.isRunning).toBe(false) }) + + it("applies job task statuses from Core poll", () => { + const base = { + tasks: [ + { id: "poster" as const, status: "pending" as const }, + { id: "fanart" as const, status: "pending" as const }, + { id: "thumbnails" as const, status: "pending" as const }, + { id: "nfo" as const, status: "pending" as const }, + ], + isRunning: true, + } + const applied = taskReducer(base, { + type: "APPLY_JOB_TASKS", + tasks: { + poster: { status: "completed" }, + fanart: { status: "running" }, + thumbnails: { status: "completed" }, + nfo: { status: "failed", failedReason: "scrape.errors.tmdbUnavailable" }, + }, + }) + expect(applied.tasks.find((t) => t.id === "poster")?.status).toBe("completed") + expect(applied.tasks.find((t) => t.id === "fanart")?.status).toBe("running") + expect(applied.tasks.find((t) => t.id === "thumbnails")?.status).toBe("completed") + expect(applied.tasks.find((t) => t.id === "nfo")?.status).toBe("failed") + expect(applied.tasks.find((t) => t.id === "nfo")?.failedReason).toBe( + "scrape.errors.tmdbUnavailable", + ) + }) }) describe("ScrapeDialog selectors", () => { diff --git a/apps/ui/src/lib/scrapeDialog/taskReducer.ts b/apps/ui/src/lib/scrapeDialog/taskReducer.ts index 8579d943..002c2287 100644 --- a/apps/ui/src/lib/scrapeDialog/taskReducer.ts +++ b/apps/ui/src/lib/scrapeDialog/taskReducer.ts @@ -35,6 +35,19 @@ export function taskReducer(state: ScrapeTaskState, action: ScrapeTaskAction): S : task, ), } + case "APPLY_JOB_TASKS": + return { + ...state, + tasks: state.tasks.map((task) => { + const next = action.tasks[task.id] + if (!next) return task + return { + ...task, + status: next.status, + failedReason: next.failedReason, + } + }), + } case "START_RUN": return { ...state, isRunning: true } case "FINISH_RUN": diff --git a/apps/ui/src/lib/scrapeDialog/types.ts b/apps/ui/src/lib/scrapeDialog/types.ts index 61a6490d..a9e86966 100644 --- a/apps/ui/src/lib/scrapeDialog/types.ts +++ b/apps/ui/src/lib/scrapeDialog/types.ts @@ -21,6 +21,10 @@ export type ScrapeTaskAction = | { type: "MARK_RUNNING"; id: ScrapeTaskId } | { type: "MARK_COMPLETED"; id: ScrapeTaskId } | { type: "MARK_FAILED"; id: ScrapeTaskId; reason?: string } + | { + type: "APPLY_JOB_TASKS" + tasks: Partial> + } | { type: "START_RUN" } | { type: "FINISH_RUN" } diff --git a/docs/dev/scrape.md b/docs/dev/scrape.md new file mode 100644 index 00000000..fc68ebd9 --- /dev/null +++ b/docs/dev/scrape.md @@ -0,0 +1,404 @@ +# Scrape (HTTP job API + Core parity) + +This design document is the **golden source** for the v3 scrape feature: Core scrape pipeline (legacy UI parity), Internal HTTP job API, and Layer 1 clients (Web UI / CLI / AI / MCP). + +Related earlier Core note: [2026-08-19-core-scrape-folder-design.md](../superpowers/specs/2026-08-19-core-scrape-folder-design.md) (initial TV+TMDB only — **superseded** by the matrix below for product scope). + +## 1. Background + +Legacy ScrapeDialog runs up to four tasks via UI mutations (`useScrapePosterMutation`, `useScrapeFanartMutation`, `useScrapeThumbnailMutation`, `useScrapeNfoMutation`) over **TV show | movie** × **TMDB | TVDB**. + +Core already has a job-based `scrapeFolder` + CLI (`smm scrape` / `smm job`) for **TV + TMDB only**. This design expands Core to **full legacy parity**, then exposes HTTP + UI under `smm.v3.enabled`. + +| Task | Output | +|------|--------| +| `poster` | `poster.{ext}` in media folder root | +| `fanart` | `fanart.{ext}` in media folder root | +| `thumbnails` | TV only: per-episode still beside each linked video | +| `nfo` | TV: `tvshow.nfo` + `{videoStem}.nfo`; Movie: `movie.nfo` | + +### 1.1 Legacy parity matrix (must match UI) + +| Task | TV+TMDB | TV+TVDB | Movie+TMDB | Movie+TVDB | +|------|---------|---------|------------|------------| +| poster | series `poster_path` | series `image` / best artwork | movie `poster_path` | movie image / artworks | +| fanart | series backdrop | background artwork type (else best) | movie backdrop | background / best | +| thumbnails | episode stills | episode images (season extended) | **omit / skip** (UI TODO) | **omit / skip** | +| nfo | tvshow + episode nfos | same via TVDB builders + translations | `movie.nfo` | `movie.nfo` via TVDB | + +Source of truth for resolvers/builders (port into Core, do not call UI): + +- Poster/fanart URL: `apps/ui/src/hooks/useScrapePosterMutation.ts`, `useScrapeFanartMutation.ts` +- Thumbnails: `useDownloadThumbnailFromTMDB` / `useDownloadThumbnailFromTVDB` +- NFO: `useScrapeNfoMutation.ts` + `useHandleScrapeStart.ts` builders + `apps/ui/src/lib/nfo` + +### 1.2 Delivery phases + +1. **Core parity** — done (TV + movie × TMDB + TVDB; movie skips `thumbnails`) +2. **HTTP** — done (`POST /api/scrape` + scrape shape on `POST /api/get-job`) +3. **UI v3** — done (`isSmmV3Enabled()` → job start + poll; legacy mutations when v3 off) + +## 2. Architecture + +### 2.1 Project Level Architecture + +``` +Layer 1: Web UI / CLI / AI / MCP + │ + │ POST /api/scrape + │ POST /api/get-job + ▼ +Layer 3: apps/cli Hono routes (thin) + │ + │ Core.scrapeFolder / Core.getJob + ▼ +Layer 2: apps/core + JobStore (ImportJob | ScrapeJob) + scrapeFolderPipeline → task runners dispatch on type × database + TmdbClient + TvdbClient (extended for scrape reads) +``` + +Per [refactoring.md](../../refactoring.md): HTTP is the Internal API channel into Core; business rules stay in Core. + +### 2.2 App Level Architecture + +| Piece | Role | +|-------|------| +| `Core.scrapeFolder(path, options?)` | Await prerequisites; create `ScrapeJob`; run tasks in background; return `Promise<{ id }>` | +| `Core.getJob(id)` | Return `ImportJob \| ScrapeJob` | +| `JobStore` | In-memory store for both job kinds | +| `prepareScrapeFolder` | Managed + metadata; allow `tvshow-folder` \| `movie-folder` with `database` in `{TMDB, TVDB}` | +| `checkScrapeCompletion` | TV: poster/fanart/thumbnails/nfo; Movie: poster/fanart/nfo (`movie.nfo`); thumbnails always “complete” for movies so orchestrator skips | +| Task runners | Single entry per task id; internal switch on type×database (Approach 1) | +| `TvdbClient` | Add scrape helpers: `getSeriesExtended`, `getSeasonExtended`, `getMovieExtended`, `getArtworkTypes`, translation getters as needed | +| `POST /api/scrape` | Validate body → Core → `{ data: { id } }` or `{ error }` | +| `POST /api/get-job` | Existing route; `data` is discriminated job union | +| UI `useScrapeDialog` | v3 ON → job poll path; v3 OFF → existing four mutations | + +### 2.3 Key Design + +#### Decisions + +| Topic | Choice | +|-------|--------| +| Response envelope | `{ data?, error? }`, HTTP **200** (same as import-folder / get-job) | +| Job poll | Reuse `POST /api/get-job` | +| Job model | Discriminated union: `kind: "import" \| "scrape"` | +| Overall job status | `failed` if **any** task is `failed`; else `succeeded` when finished | +| Prerequisites | Fail **before** creating a job → `{ error }`, no id | +| Media / DB scope | **TV + movie**, **TMDB + TVDB** (legacy parity) | +| Movie thumbnails | Skip (`skipped` / not run) — match ScrapeDialog (UI still TODO) | +| Port style | One runner per task id with type×database dispatch (not separate pipelines) | +| UI v3 | Job start + poll; no per-task Core HTTP | + +#### Prerequisites (`prepareScrapeFolder`) + +Allow when: + +- Folder is managed and metadata exists +- `type` is `tvshow-folder` or `movie-folder` +- Corresponding `tvShow.database` or `movie.database` is `TMDB` or `TVDB` + +Reject (throw, no job) otherwise, e.g. music folder or unknown database. + +#### Task orchestration + +1. Resolve task list: movies use `[poster, fanart, nfo]` conceptually; job still has `thumbnails` key set to `skipped` immediately (stable job shape for UI/CLI). +2. For each remaining task: skip if completion says done; else run dispatcher; per-task failure does not abort others. +3. Terminal job status: any `failed` → job `failed`; else `succeeded`. + +#### Core signatures + +```ts +export type Job = ImportJob | ScrapeJob + +export interface ImportJob { + kind: "import" + id: string + folderPath: string + type: FolderType + status: JobStatus + stage: JobStage + progress: number + recognizedTitle?: string + error?: string + createdAt: number + updatedAt: number +} + +export type ScrapeTaskRuntimeStatus = + | "pending" + | "running" + | "skipped" + | "completed" + | "failed" + +export interface ScrapeJobTask { + status: ScrapeTaskRuntimeStatus + error?: string +} + +export interface ScrapeJob { + kind: "scrape" + id: string + folderPath: string + status: JobStatus // pending | running | succeeded | failed | aborted + tasks: Record<"poster" | "fanart" | "thumbnails" | "nfo", ScrapeJobTask> + error?: string + createdAt: number + updatedAt: number +} + +export interface ScrapeFolderHandle { + id: string +} + +// Async so prerequisites can fail before JobStore.create. +Core.scrapeFolder(path: string, options?: ScrapeFolderOptions): Promise +``` + +#### Runtime flow + +1. Normalize path; **await** validate (managed, metadata, TV|movie + TMDB|TVDB). On failure: throw → HTTP `{ error }`; **no job**. +2. Create `ScrapeJob` with `status: "running"`, all tasks `pending`; return `{ id }` immediately after create. +3. Background (`void`): for each task in `[poster, fanart, thumbnails, nfo]` sequentially: + - Movies: mark `thumbnails` `skipped` without calling a runner. + - Else set task `running` (or `skipped` if completion check says so) via `JobStore.update`. + - Await task runner (dispatches on type×database); set `completed` / `failed` (+ `error`). +4. Terminal: `status = any task failed ? "failed" : "succeeded"`. +5. Do not block the HTTP request on step 3–4. + +#### CLI + +``` +smm scrape [--language ] [--wait] +``` + +- Always prints the **job id** on the first line. +- Without `--wait`: returns immediately after the job is created (exit 0 on start success). +- With `--wait`: polls until the job is terminal, then prints four task lines with icons, exit `1` if job `status` is not `succeeded`. + +``` +smm job +``` + +- For scrape jobs, prints the same four icon lines (no job id). +- For import jobs, prints the job JSON. + +**Icon lines** (display label `thumbnail` maps to Core task `thumbnails`): + +| Status | Icon | +|--------|------| +| pending | `○` | +| running | `◐` | +| skipped | `–` | +| completed | `✓` | +| failed | `✗` | + +Example (`--wait` after success): + +``` + +poster ✓ +fanart ✓ +thumbnail ✓ +nfo ✓ +``` + +## 3. HTTP API + +All endpoints: **HTTP 200**. Clients use `error` for business failure and `data` for success. Error string format: `Error Reason: `. + +### 3.1 `POST /api/scrape` + +Starts a scrape job. + +**Request** + +```ts +interface ScrapeRequestBody { + /** Absolute media folder path (platform or POSIX; Core normalizes). */ + path: string + /** Optional; defaults to userConfig.preferMediaLanguage. */ + language?: string +} +``` + +Prefer field name `path` (aligned with `import-folder`). Legacy UI type `mediaFolderPath` is out of scope for this milestone’s route. + +**Success** + +```json +{ + "data": { "id": "550e8400-e29b-41d4-a716-446655440000" } +} +``` + +**Start failure** (no job created) + +```json +{ + "error": "Error Reason: /abs/path is not managed by SMM" +} +``` + +| Condition | Detail after `Error Reason: ` | +|-----------|-------------------------------| +| Missing / empty `path` | `path is required` | +| Unmanaged folder | `{path} is not managed by SMM` | +| Missing / corrupt metadata | `Media metadata not found: {path}` | +| Unsupported folder type | `Folder is not a TV show or movie: {path}` | +| Unsupported database | `Unsupported media database: {database}` (only `TMDB` / `TVDB`) | + +Unexpected route exceptions also return `{ error: "Error Reason: …" }` with HTTP 200 (same as import-folder). + +### 3.2 `POST /api/get-job` + +Unchanged path and request shape. + +**Request** + +```json +{ "id": "" } +``` + +**Success — scrape** + +```json +{ + "data": { + "kind": "scrape", + "id": "550e8400-e29b-41d4-a716-446655440000", + "folderPath": "/abs/media/folder", + "status": "running", + "tasks": { + "poster": { "status": "completed" }, + "fanart": { "status": "running" }, + "thumbnails": { "status": "pending" }, + "nfo": { "status": "pending" } + }, + "createdAt": 0, + "updatedAt": 0 + } +} +``` + +**Success — import** (existing fields + discriminator) + +```json +{ + "data": { + "kind": "import", + "id": "…", + "folderPath": "…", + "type": "tvshow", + "status": "running", + "stage": "recognize", + "progress": 40, + "createdAt": 0, + "updatedAt": 0 + } +} +``` + +**Errors** + +| Condition | Response | +|-----------|----------| +| Missing `id` | `{ "error": "Error Reason: id is required" }` | +| Unknown id | `{ "error": "Error Reason: Job not found" }` | + +**Terminal scrape semantics** + +- Job `status`: `succeeded` or `failed` (or `aborted` if added later). +- `failed` iff at least one task has `status: "failed"`. +- Finished task statuses: `skipped` | `completed` | `failed`. +- Failed tasks include `error` (human-readable). +- Optional job-level `error` is a summary only; clients should prefer per-task `error`. + +**Polling** + +Clients poll every ~1s until `status` is terminal (`succeeded` | `failed` | `aborted`). + +## 4. User Stories + +### 4.1 Start scrape and observe progress + +* **Given** a managed TV folder with TMDB metadata +* **When** the client `POST /api/scrape` with `{ "path": "…" }` +* **Then** the response is `{ "data": { "id": "…" } }` +* **And** polling `POST /api/get-job` shows `kind: "scrape"` with tasks moving `pending` → `running` → `completed`/`skipped` +* **And** the job ends with `status: "succeeded"` when all tasks succeed or skip + +```mermaid +sequenceDiagram + participant C as Client + participant H as HTTP + participant Core as Core + + C->>H: POST /api/scrape { path } + H->>Core: scrapeFolder(path) + Core-->>H: { id } + H-->>C: { data: { id } } + + loop poll ~1s + C->>H: POST /api/get-job { id } + H->>Core: getJob(id) + Core-->>H: ScrapeJob + H-->>C: { data: ScrapeJob } + end +``` + +### 4.2 Prerequisite failure at start + +* **Given** a path that is not managed by SMM +* **When** the client `POST /api/scrape` +* **Then** the response is `{ "error": "Error Reason: … is not managed by SMM" }` +* **And** no job id is returned + +### 4.3 Partial task failure + +* **Given** scrape starts successfully +* **When** one task fails (e.g. fanart network error) and others complete/skip +* **Then** that task has `status: "failed"` and `error` +* **And** remaining tasks still run +* **And** job `status` is `failed` + +### 4.4 Movie scrape skips thumbnails + +* **Given** a managed movie folder with TMDB or TVDB metadata +* **When** scrape runs to completion +* **Then** `thumbnails` is `skipped` +* **And** poster / fanart / nfo still run + +### 4.5 UI v3 ScrapeDialog + +* **Given** `smm.v3.enabled` is true +* **When** the user starts scrape in ScrapeDialog +* **Then** UI calls `POST /api/scrape` and polls `POST /api/get-job` +* **And** dialog task rows mirror job `tasks` +* **And** when v3 is false, the legacy four mutations run unchanged + +## 5. Out of scope + +- Movie thumbnail scraping (legacy UI also TODO) +- `--force` overwrite +- Plan-based scrape (`try-to-scrape` / `apply`) +- ProblemDetails / non-200 business status codes +- New REST path `/api/job/{id}` +- Removing legacy UI mutations while v3 is off + +## 6. Testing strategy + +- **Core unit:** prepare accepts TV/movie × TMDB/TVDB; rejects music / unknown DB; movie marks thumbnails skipped; poster/fanart/nfo dispatch smoke with mocks. +- **Core integration:** existing TV+TMDB e2e remains; add/adapt cases for TV+TVDB and movie when fixtures exist. +- **HTTP route tests:** scrape success returns id; start errors; get-job scrape shape. +- **CLI e2e:** `--wait` icon lines; reject unmanaged; update “not a TV show” / TVDB reject tests once Core accepts those folders. +- **UI (phase 3):** ScrapeDialog v3 branch polls job; v3 off keeps mutations. + +## 7. Compatibility notes + +- Existing `ImportJob` consumers must accept `kind: "import"` (additive field). +- `Core.scrapeFolder` is job-based (`Promise<{ id }>`); CLI already polls. +- CLI e2e that expected reject for movie / non-TMDB TV must flip to success (or assert artifacts) after Core parity. +- Legacy `ScrapeRequestBody.mediaFolderPath` remains until UI fully migrates; new route uses `path` + `{ data: { id } }`. diff --git a/docs/superpowers/plans/2026-08-20-core-scrape-parity.md b/docs/superpowers/plans/2026-08-20-core-scrape-parity.md new file mode 100644 index 00000000..264847ce --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-core-scrape-parity.md @@ -0,0 +1,52 @@ +# Core Scrape Parity (TV/Movie × TMDB/TVDB) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Expand `apps/core` scrape to match legacy UI (TV|movie × TMDB|TVDB); movies skip thumbnails. + +**Architecture:** One runner per task id with type×database dispatch. Golden source: `docs/dev/scrape.md`. + +**Tech Stack:** TypeScript, Vitest, TmdbClient, TvdbClient, FsPort, NetworkPort. + +## Global Constraints + +- Port logic from UI hooks; do not import `apps/ui`. +- Job shape unchanged (`poster|fanart|thumbnails|nfo`). +- Movie: `thumbnails` → `skipped`. +- TDD; red-green for Core tests. + +--- + +### Task 1: Prerequisites + completion + +**Files:** `prepareScrapeFolder` / `checkScrapeCompletion` + tests; CLI e2e reject cases. + +- [ ] Allow tvshow|movie × TMDB|TVDB +- [ ] Movie completion: poster/fanart/`movie.nfo`; thumbnails=true (force skip) +- [ ] Update Core/CLI tests that rejected movie/TVDB + +### Task 2: Poster + fanart dispatch + +**Files:** `scrapePoster.ts` / `scrapeFanart.ts` (or extend existing); TvdbClient helpers; tests. + +- [ ] Port UI `resolvePosterUrl` / `resolveFanartUrl` +- [ ] Wire TvdbClient extended/artwork-types APIs + +### Task 3: Thumbnails + +- [ ] TV+TVDB episode images +- [ ] Movie: skip in orchestrator + +### Task 4: NFO + +- [ ] Port movie TMDB/TVDB + TV TVDB builders/XML into Core +- [ ] Keep existing TV TMDB path + +### Task 5: Verify + +- [ ] Core unit tests green +- [ ] CLI scrape e2e (TV TMDB + updated rejects) + +--- + +After Core parity: HTTP `POST /api/scrape` + UI v3 (separate follow-up). diff --git a/refactoring.md b/refactoring.md new file mode 100644 index 00000000..ff2f0001 --- /dev/null +++ b/refactoring.md @@ -0,0 +1,205 @@ +# Refatoring of 3-layers app + +Current implementation mix business logic into UI layer, makes it very different to test and extend. + +This refactoring aims to extract the core logic into below model: + +``` +Layer 1: Web UI, AI tools, MCP tools, external HTTP API +Layer 2: Core +Layer 3: internal HTTP API +``` + +Layer 1 is presentation layer which holds the UI, don't care of any business logic. +Using TanStack Query and socket.io to sync states + +Layer 2 is headless business logic layer built in TypeScript. +Use adapter to support both browser runtime or node.js runtime(Electron or Node.js) + +Layer 3 is infrastructure layer to support basic function like fs or logging. + + +## 目标架构 + +三层的依赖方向自上而下:**Layer 1 → Layer 2 → Layer 3**。上层依赖下层的接口,下层不感知上层的存在。 + +每一层只关心一件事: + +| 层 | 名称 | 关心的问题 | +|----|------|-----------| +| Layer 1 | 表现层 (Presentation) | 如何与用户 / AI / 外部系统交互 | +| Layer 2 | 核心层 (Core) | 业务规则与流程是什么 | +| Layer 3 | 基础设施层 (Infrastructure) | 在具体宿主机上如何做到 fs / logging / 进程 / 网络 | + +### Layer 1: 表现层 (Presentation) + +**职责**:捕捉用户 / AI / 外部系统的意图,把它翻译成 Core 命令;渲染 Core 暴露的状态。不含任何业务编排、识别、重命名、文件扫描逻辑。 + +**内容清单**: +- **Web UI** (`apps/ui`):组件、hooks、Zustand(仅保存纯 UI 状态,如选中项、对话框开关)、TanStack Query(服务端状态缓存)、API client +- **AI tools / MCP**:把 LLM 的工具调用翻译成 Core 命令,并渲染工具返回结果 +- **External HTTP API**:面向应用外部的公开契约,委托给 Core +- **Socket.IO 客户端**:订阅 Core 推送的状态变化 + +**状态同步**(Layer 1 获取状态的两个手段,二者配合): +- **拉取 (pull)**:TanStack Query 定时轮询状态查询接口(如每 1s 轮询一次初始化状态) +- **推送 (push)**:Socket.IO 事件使对应 query key 失效 / 直接写入缓存,立即触发重渲染 + +### Layer 2: 核心层 (Core) + +**职责**:全部业务逻辑,以 headless TypeScript 实现,与运行时和框架完全解耦。 + +**内容清单**: +- **领域模型**:MediaFolder、MediaMetadata、Episode、RenameOperation、Plan、Job 等 +- **用例 (use-cases) / 编排**:初始化媒体文件夹、识别媒体文件夹、识别剧集、重命名计划、下载 / 转码任务编排 +- **纯逻辑**:识别流水线 (recognition pipeline)、重命名规则 (rename rules)、NFO 解析、剧集匹配算法 +- **Ports(端口)定义**:`fs`、`logger`、`subprocess`、`env`、`network`、`clock`、`db` —— 只声明接口,不实现 +- **Domain events**:业务状态变化以事件形式广播,供表现层订阅 + +**约束**: +- 不 import 任何平台 API(`node:fs`、`process`、`window`、`navigator`…),只能通过注入的适配器访问能力 +- 框架无关:不依赖 Hono / Express / React / Zod 之外的运行时框架 +- 可单测:给定输入 + mock 适配器,断言用例输出与副作用调用 +- 可运行在任何宿主:Node.js / Bun 后端、Electron 主进程、浏览器 Web Worker + +### Layer 3: 基础设施层 (Infrastructure) + +**职责**:在具体宿主机上实现 Core 声明的 Ports,并承载统一的应用内部 HTTP 服务。 + +**内容清单**: +- **Ports 的具体实现**:文件系统访问、pino 日志、yt-dlp / ffmpeg 子进程、TMDB / TVDB 网络请求、数据库 +- **Internal HTTP API**:应用内部命令入口与状态查询的统一 HTTP 服务(即当前 `packages/core-routes` 与 `apps/cli` 的路由) +- **Socket.IO 服务端**:向表现层推送状态 +- **宿主进程**:`apps/cli`(独立后端)、`apps/electron`(主进程内嵌)、`apps/ohos` + +**Internal vs External HTTP API 的区别**: +- **External HTTP API**(Layer 1 表现面之一)面向应用之外,是公开契约 +- **Internal HTTP API**(Layer 3)面向应用之内,是 UI、MCP、外部 API 触发 Core 与访问基础设施的统一通道。两者可能共享同一物理服务器,区别在于用途与暴露范围 + +## 通信模型 + +### 命令流(command) + +``` +Web UI / AI(MCP) / External API + │ HTTP POST /api/... + ▼ +Internal HTTP API (Layer 3) + │ 委托用例 + ▼ +Core use-case (Layer 2) + │ 通过注入的适配器 + ▼ +Infrastructure: fs / logging / subprocess / network / db +``` + +### 状态流(state) + +``` +Core 用例变更状态 + │ 持久化 + 广播 domain event + ▼ +Internal HTTP API 持久化到磁盘 / db + │ + ├─ Socket.IO push(立即失效 query 缓存) + └─ GET 状态接口(供 TanStack Query 轮询兜底) + ▼ +Web UI 重渲染 +``` + +## 示例: 媒体文件夹初始化 + +媒体文件夹初始化是当前实现最典型的反面教材。 + +`apps\ui\src\hooks\initialization\useInitializeImportedMediaFolder.ts` 把整套初始化流程(更新用户配置、识别媒体文件夹、识别剧集、保存元数据、任务状态管理)全部实现于 UI 侧,使得该流程几乎不可能被外部 HTTP API 或 MCP 用户触发,也难以单元测试。 + +**Current Implementation** +1. User import folder +2. UI update user config file +3. UI start initialization process +4. UI update UI states accordingly + +**Target Implementation** +1. User import folder +2. UI call "POST /api/importFolder" +3. Core (sitting in backend side) update user config file +4. Core start initialization process +5. UI sycn initialization state by TanStack Query (fetch state every 1s) + +**Target 详细时序** + +``` +UI Internal HTTP API Core Infrastructure + │ POST /api/importFolder │ │ │ + ├───────────────────────────────►│ │ │ + │ │ importFolder(folder) │ │ + │ ├────────────────────────►│ │ + │ │ │ fs.write(userConfig) │ + │ │ ├────────────────────────────────►│ + │ │ │ 识别流水线 (tvshow/movie/music) │ + │ │ ├────────────────────────────────►│ (TMDB/TVDB/NFO/ffprobe) + │ │ │ metadata 持久化 + 广播事件 │ + │ │ ├────────────────────────────────►│ + │ │ socket.io: folder.status=initializing/ok ◄───────────┤ + │ ◄─────────────────────────────┤ │ │ + │ GET /api/mediaFolder/:path/status (每 1s 轮询兜底) │ │ + │ ├─────────────────────────────►│ │ │ + │ ◄─────────────────────────────┤ │ │ + │ TanStack Query 更新 → 重渲染 │ │ │ +``` + +**现状代码位置 vs 目标代码位置** + +| 逻辑 | 现状 | 目标 | +|------|------|------| +| 初始化编排 | `apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts` | Layer 2 (Core use-case) | +| 识别流水线 | `apps/ui/src/lib/mediaFolderRecognitionPipeline.ts` | Layer 2 | +| 识别媒体文件夹 | `apps/ui/src/lib/recognizeMediaFolder.ts`、`recognizeMediaFolderByTmdbIdInFolderName.ts`、`recognizeMediaFolderByTvdbIdInFolderName.ts`、`tryToRecognizeMediaFolderBySearchingFolderNameInTMDB.ts`、`tryToRecognizeMediaFolderBySearchingFolderNameInTVDB.ts` | Layer 2 | +| 识别剧集 | `apps/ui/src/lib/recognizeEpisodes.ts` | Layer 2 | +| NFO 解析 | `apps/ui/src/lib/nfo.ts` | Layer 2 | +| 重命名规则 | `apps/ui/src/lib/renameRules.ts` | Layer 2 | +| 任务工厂 | `apps/ui/src/lib/*JobFactory.ts` | Layer 2 | +| 音乐目录初始化 | `apps/ui/src/lib/initializeMusicFolder.ts`、`music.ts` | Layer 2 | +| 状态同步 | UI Zustand + TanStack Query | 不变(表现层职责) | +| fs / 子进程 / 日志 | `apps/cli` + `packages/core-routes` | Layer 3(host 适配器) | + +## 与现有 monorepo 的映射 + +| 现有包 / 应用 | 目标层 | 演进方向 | +|---------------|--------|----------| +| `packages/core` | Layer 2 | 扩展为 headless 业务逻辑层:引入 Ports 定义,新增 use-cases 与编排,沉淀领域模型 | +| `packages/core-routes` | Layer 3 | 已是框架无关的通用 HTTP 路由(含 auth / allowlist / socket.io),作为 Internal HTTP API 的核心 | +| `apps/cli` | Layer 3 宿主 | 独立后端:提供基础设施实现(fs / yt-dlp / ffmpeg / pino)+ MCP 服务 + Socket.IO | +| `apps/electron` | Layer 3 宿主 | 主进程内嵌 Core + Core-routes,提供 Node.js 适配器 | +| `apps/ohos` | Layer 3 宿主 | 提供 HarmonyOS 适配器 | +| `apps/ui` | Layer 1 | 只保留表现层:组件、hooks、Zustand(纯 UI 状态)、TanStack Query、API client | +| MCP(`apps/cli/src/mcp`、`packages/core-routes/src/mcp`) | Layer 1 表现面 | 将 LLM 工具调用翻译成 Core 命令,不再内联业务逻辑 | + +## 迁移路径 + +分四阶段,每阶段保持可构建、可测试。 + +**Phase 1: 提取纯逻辑** +- 将 `apps/ui/src/lib` 中无 React 依赖的纯逻辑(nfo、renameRules、recognizeEpisodes、识别流水线等)移入 `packages/core` +- 在 core 中定义 Ports 接口(fs / logger / subprocess / network / db) +- 为纯逻辑补齐单元测试(core 已有 vitest) + +**Phase 2: 编排用例下沉** +- 初始化 / 识别 / 重命名 / 任务编排作为 use-cases 移入 core,依赖注入适配器 +- UI hooks 改为调用 HTTP API + TanStack Query,删除本地编排代码 + +**Phase 3: Internal HTTP API 补齐** +- 新增 `POST /api/importFolder` 等命令路由,将 HTTP 请求映射到 Core 用例 +- 状态通过 Socket.IO 推送 + 轮询查询接口暴露 + +**Phase 4: 宿主适配器收敛** +- 各宿主(cli / electron / ohos)只保留基础设施实现与启动代码 +- UI 不再包含任何业务逻辑,只剩表现层 + +## 测试策略 + +| 层 | 测试方式 | 说明 | +|----|----------|------| +| Layer 2 (core) | 单元测试 (vitest) | mock 适配器,断言用例输出与副作用调用 | +| Layer 3 | 路由级集成测试 | 现有 `*.test.ts` 风格,验证 HTTP 契约与 auth / allowlist | +| Layer 1 | 组件测试 + e2e | `apps/e2e` (WebdriverIO) 保持不变,验证端到端行为 | diff --git "a/test/templates/mediaMetadatas/\345\244\251\344\275\277\351\231\215\344\270\264\345\210\260\346\210\221\350\272\253\350\276\271.metadata.json" "b/test/templates/mediaMetadatas/\345\244\251\344\275\277\351\231\215\344\270\264\345\210\260\346\210\221\350\272\253\350\276\271.metadata.json" index b19e2bce..3d763010 100644 --- "a/test/templates/mediaMetadatas/\345\244\251\344\275\277\351\231\215\344\270\264\345\210\260\346\210\221\350\272\253\350\276\271.metadata.json" +++ "b/test/templates/mediaMetadatas/\345\244\251\344\275\277\351\231\215\344\270\264\345\210\260\346\210\221\350\272\253\350\276\271.metadata.json" @@ -21,7 +21,7 @@ ], "tvShow": { "id": "84666", - "name": "WATATEN!: an Angel Flew Down to Me", + "name": "WATATEN an Angel Flew Down to Me", "database": "TMDB", "airDate": "2019-01-08", "seasons": [ From 9d0abf70193ae20ac5fbb12ec86cf10624175a39 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 20 Aug 2026 15:54:16 +0800 Subject: [PATCH 091/214] feat(cli): print matched mediaFiles tree in smm show Show TV episodes and movie paths under the folder summary so CLI users can inspect recognition without dumping full metadata JSON. Co-authored-by: Cursor --- apps/cli/src/cli/folderDisplay.test.ts | 172 +++++++++++++++++++++++++ apps/cli/src/cli/folderDisplay.ts | 162 ++++++++++++++++++++++- apps/cli/src/route/ShowFolder.ts | 9 +- apps/cli/test/cli.e2e.ts | 3 + 4 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/cli/folderDisplay.test.ts diff --git a/apps/cli/src/cli/folderDisplay.test.ts b/apps/cli/src/cli/folderDisplay.test.ts new file mode 100644 index 00000000..d5c56ada --- /dev/null +++ b/apps/cli/src/cli/folderDisplay.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { Path } from '@core/path' +import type { MediaMetadata } from '@smm/core' +import { + formatMediaFilesTree, + formatShowFolder, + relativeMediaFilePath, + toShowFolderApiResult, +} from './folderDisplay' + +describe('relativeMediaFilePath', () => { + it('keeps already-relative paths and normalizes separators', () => { + const rel = relativeMediaFilePath('/media/Show', 'Season 01/S01E01.mp4') + expect(rel).toBe(Path.isWindows() ? 'Season 01\\S01E01.mp4' : 'Season 01/S01E01.mp4') + }) + + it('strips the media folder prefix from absolute paths', () => { + const rel = relativeMediaFilePath('/media/Show', '/media/Show/Season 01/S01E01.mp4') + expect(rel).toBe(Path.isWindows() ? 'Season 01\\S01E01.mp4' : 'Season 01/S01E01.mp4') + }) +}) + +describe('formatMediaFilesTree', () => { + it('prints only matched TV episodes grouped by season', () => { + const mm: MediaMetadata = { + mediaFolderPath: '/media/Kill Me Baby', + type: 'tvshow-folder', + tvShow: { + database: 'TMDB', + id: '1', + name: 'Kill Me Baby', + seasons: [ + { + season: 0, + name: 'Specials', + episodes: [{ season: 0, episode: 1, name: 'OVA' }], + }, + { + season: 1, + name: 'Season 1', + episodes: [ + { season: 1, episode: 1, name: 'Pilot' }, + { season: 1, episode: 2, name: 'Second' }, + { season: 1, episode: 3, name: 'Unmatched' }, + ], + }, + ], + }, + mediaFiles: [ + { + absolutePath: '/media/Kill Me Baby/Season 01/S01E02.mp4', + seasonNumber: 1, + episodeNumber: 2, + }, + { + absolutePath: 'Season 00/S00E01.mp4', + seasonNumber: 0, + episodeNumber: 1, + }, + { + absolutePath: '/media/Kill Me Baby/Season 01/S01E01.mp4', + seasonNumber: 1, + episodeNumber: 1, + }, + { + absolutePath: '/media/Kill Me Baby/orphan.mp4', + }, + ], + } + + const lines = formatMediaFilesTree(mm) + const sep = Path.isWindows() ? '\\' : '/' + expect(lines).toEqual([ + 'Season 0: Specials', + ' S00E01 OVA', + ` Season 00${sep}S00E01.mp4`, + 'Season 1: Season 1', + ' S01E01 Pilot', + ` Season 01${sep}S01E01.mp4`, + ' S01E02 Second', + ` Season 01${sep}S01E02.mp4`, + ]) + expect(lines.join('\n')).not.toContain('Unmatched') + expect(lines.join('\n')).not.toContain('orphan') + }) + + it('prints movie media files as relative paths', () => { + const mm: MediaMetadata = { + mediaFolderPath: '/media/Dark Knight', + type: 'movie-folder', + movie: { database: 'TMDB', id: '1', name: 'The Dark Knight' }, + mediaFiles: [{ absolutePath: '/media/Dark Knight/movie.mkv' }], + } + expect(formatMediaFilesTree(mm)).toEqual([' movie.mkv']) + }) + + it('returns empty for music folders', () => { + const mm: MediaMetadata = { + type: 'music-folder', + mediaFiles: [{ absolutePath: 'track.mp3' }], + } + expect(formatMediaFilesTree(mm)).toEqual([]) + }) +}) + +describe('formatShowFolder', () => { + it('appends the mediaFiles tree after the summary when metadata is present', () => { + const lines = formatShowFolder({ + path: 'C:\\media\\Show', + status: 'ok', + type: 'tvshow-folder', + title: 'Demo', + metadata: { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + tvShow: { + database: 'TMDB', + id: '1', + name: 'Demo', + seasons: [ + { + season: 1, + name: 'Season 1', + episodes: [{ season: 1, episode: 1, name: 'Pilot' }], + }, + ], + }, + mediaFiles: [ + { absolutePath: 'S01E01.mkv', seasonNumber: 1, episodeNumber: 1 }, + ], + }, + }) + expect(lines[0]).toBe('Path: C:\\media\\Show') + expect(lines).toContain('Title: Demo') + expect(lines).toContain('') + expect(lines).toContain('Season 1: Season 1') + expect(lines).toContain(' S01E01 Pilot') + }) + + it('omits the tree when status is not ok or metadata is absent', () => { + expect( + formatShowFolder({ path: '/x', status: 'folder_not_found' }).join('\n'), + ).not.toContain('Season') + expect( + formatShowFolder({ + path: '/x', + status: 'ok', + type: 'tvshow-folder', + title: 'Demo', + }).join('\n'), + ).not.toContain('Season') + }) +}) + +describe('toShowFolderApiResult', () => { + it('strips metadata from the HTTP payload', () => { + expect( + toShowFolderApiResult({ + path: '/media/Show', + status: 'ok', + type: 'tvshow-folder', + title: 'Demo', + metadata: { type: 'tvshow-folder', mediaFiles: [] }, + }), + ).toEqual({ + path: '/media/Show', + status: 'ok', + type: 'tvshow-folder', + title: 'Demo', + }) + }) +}) diff --git a/apps/cli/src/cli/folderDisplay.ts b/apps/cli/src/cli/folderDisplay.ts index 36c535c4..2508b6f5 100644 --- a/apps/cli/src/cli/folderDisplay.ts +++ b/apps/cli/src/cli/folderDisplay.ts @@ -1,6 +1,6 @@ import { stat } from 'fs/promises' import { Path } from '@core/path' -import type { MediaMetadata } from '@smm/core' +import type { MediaFileMetadata, MediaMetadata } from '@smm/core' import { getCore } from '../core/getCore' function normalizePosixSafe(path: string): string { @@ -35,6 +35,7 @@ export type ShowFolderStatus = | 'folder_not_found' | 'error_loading_metadata' +/** Public API / HTTP payload — no metadata blob. */ export interface ShowFolderResult { path: string status: ShowFolderStatus @@ -42,12 +43,26 @@ export interface ShowFolderResult { title?: string } +/** CLI formatting may include full metadata for mediaFiles tree. */ +export interface ShowFolderCliResult extends ShowFolderResult { + metadata?: MediaMetadata +} + +export function toShowFolderApiResult(result: ShowFolderCliResult): ShowFolderResult { + return { + path: result.path, + status: result.status, + ...(result.type !== undefined ? { type: result.type } : {}), + ...(result.title !== undefined ? { title: result.title } : {}), + } +} + function titleFromMetadata(mm: MediaMetadata): string | undefined { return mm.tvShow?.name ?? mm.movie?.name } export async function resolveShowFolder(folder: string): Promise< - | { ok: true; result: ShowFolderResult } + | { ok: true; result: ShowFolderCliResult } | { ok: false; error: string } > { if (!(await isFolderImported(folder))) { @@ -77,11 +92,145 @@ export async function resolveShowFolder(folder: string): Promise< status: 'ok', type: mm.type, ...(title !== undefined ? { title } : {}), + metadata: mm, }, } } -export function formatShowFolder(result: ShowFolderResult): string[] { +function looksAbsolutePath(filePath: string): boolean { + return ( + filePath.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(filePath) || + filePath.startsWith('\\\\') + ) +} + +function toPlatformRelative(rel: string): string { + const normalized = rel.replace(/^[\\/]+/, '').replace(/\\/g, '/') + return Path.isWindows() ? normalized.split('/').join('\\') : normalized +} + +/** Relative path from media folder for CLI display. */ +export function relativeMediaFilePath( + mediaFolderPath: string | undefined, + filePath: string, +): string { + if (!looksAbsolutePath(filePath)) { + return toPlatformRelative(filePath) + } + if (mediaFolderPath === undefined || mediaFolderPath.trim() === '') { + return Path.toPlatformPath(filePath) + } + try { + const folderPosix = Path.posix(mediaFolderPath) + const filePosix = Path.posix(filePath) + const prefix = folderPosix.endsWith('/') ? folderPosix : `${folderPosix}/` + if (filePosix === folderPosix) { + return '.' + } + if (filePosix.startsWith(prefix)) { + return toPlatformRelative(filePosix.slice(prefix.length)) + } + return Path.fromAbsolutePath(filePosix, folderPosix).platformRelPath() + } catch { + return Path.toPlatformPath(filePath) + } +} + +function padEp(n: number): string { + return String(n).padStart(2, '0') +} + +/** Indent for episode lines (one 4-space level). */ +const EPISODE_INDENT = ' ' +/** + * Path lines align under the episode title (after ` SxxExx `). + * SxxExx is always 6 chars with zero-padded season/episode. + */ +const PATH_INDENT = `${EPISODE_INDENT}${' '.repeat(6 + 1)}` + +function lookupSeasonTitle(mm: MediaMetadata, seasonNumber: number): string | undefined { + return mm.tvShow?.seasons.find((s) => s.season === seasonNumber)?.name +} + +function lookupEpisodeTitle( + mm: MediaMetadata, + seasonNumber: number, + episodeNumber: number, +): string | undefined { + const season = mm.tvShow?.seasons.find((s) => s.season === seasonNumber) + return season?.episodes.find((e) => e.episode === episodeNumber)?.name +} + +function formatTvShowMediaFiles(mm: MediaMetadata): string[] { + const matched = (mm.mediaFiles ?? []).filter( + (f): f is MediaFileMetadata & { seasonNumber: number; episodeNumber: number } => + f.seasonNumber !== undefined && f.episodeNumber !== undefined, + ) + if (matched.length === 0) return [] + + matched.sort((a, b) => { + if (a.seasonNumber !== b.seasonNumber) return a.seasonNumber - b.seasonNumber + if (a.episodeNumber !== b.episodeNumber) return a.episodeNumber - b.episodeNumber + return a.absolutePath.localeCompare(b.absolutePath) + }) + + const lines: string[] = [] + let lastSeason: number | undefined + let lastEpisodeKey: string | undefined + + for (const file of matched) { + const { seasonNumber, episodeNumber } = file + if (lastSeason !== seasonNumber) { + const seasonTitle = lookupSeasonTitle(mm, seasonNumber) + lines.push( + seasonTitle !== undefined && seasonTitle !== '' + ? `Season ${seasonNumber}: ${seasonTitle}` + : `Season ${seasonNumber}:`, + ) + lastSeason = seasonNumber + lastEpisodeKey = undefined + } + + const epKey = `${seasonNumber}:${episodeNumber}` + if (lastEpisodeKey !== epKey) { + const epTitle = lookupEpisodeTitle(mm, seasonNumber, episodeNumber) + const code = `S${padEp(seasonNumber)}E${padEp(episodeNumber)}` + lines.push( + epTitle !== undefined && epTitle !== '' + ? `${EPISODE_INDENT}${code} ${epTitle}` + : `${EPISODE_INDENT}${code}`, + ) + lastEpisodeKey = epKey + } + + lines.push( + `${PATH_INDENT}${relativeMediaFilePath(mm.mediaFolderPath, file.absolutePath)}`, + ) + } + + return lines +} + +function formatMovieMediaFiles(mm: MediaMetadata): string[] { + const files = mm.mediaFiles ?? [] + if (files.length === 0) return [] + return files.map( + (f) => ` ${relativeMediaFilePath(mm.mediaFolderPath, f.absolutePath)}`, + ) +} + +export function formatMediaFilesTree(mm: MediaMetadata): string[] { + if (mm.type === 'tvshow-folder') { + return formatTvShowMediaFiles(mm) + } + if (mm.type === 'movie-folder') { + return formatMovieMediaFiles(mm) + } + return [] +} + +export function formatShowFolder(result: ShowFolderCliResult): string[] { const lines = [`Path: ${result.path}`, `Status: ${result.status}`] if (result.type !== undefined) { lines.push(`Type: ${result.type}`) @@ -89,6 +238,13 @@ export function formatShowFolder(result: ShowFolderResult): string[] { if (result.title !== undefined) { lines.push(`Title: ${result.title}`) } + if (result.metadata !== undefined) { + const tree = formatMediaFilesTree(result.metadata) + if (tree.length > 0) { + lines.push('') + lines.push(...tree) + } + } return lines } diff --git a/apps/cli/src/route/ShowFolder.ts b/apps/cli/src/route/ShowFolder.ts index d453cf68..387f20cb 100644 --- a/apps/cli/src/route/ShowFolder.ts +++ b/apps/cli/src/route/ShowFolder.ts @@ -1,7 +1,10 @@ import type { Hono } from 'hono' -import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' -import { resolveShowFolder, type ShowFolderResult } from '../cli/folderDisplay' +import { + resolveShowFolder, + toShowFolderApiResult, + type ShowFolderResult, +} from '../cli/folderDisplay' export interface ShowFolderResponseBody { data?: ShowFolderResult @@ -30,7 +33,7 @@ export function handleShowFolder(app: Hono): void { const err: ShowFolderResponseBody = { error: `Error Reason: ${resolved.error}` } return c.json(err, 200) } - const ok: ShowFolderResponseBody = { data: resolved.result } + const ok: ShowFolderResponseBody = { data: toShowFolderApiResult(resolved.result) } return c.json(ok, 200) } catch (error) { logger.error({ error }, '[POST /api/show-folder] route error') diff --git a/apps/cli/test/cli.e2e.ts b/apps/cli/test/cli.e2e.ts index b1206119..a6fde991 100644 --- a/apps/cli/test/cli.e2e.ts +++ b/apps/cli/test/cli.e2e.ts @@ -78,6 +78,8 @@ describe('smm CLI e2e', () => { expect(tvShow.stdout).toContain('Type: tvshow-folder') expect(tvShow.stdout).toMatch(/Title:\s+.+/) expect(tvShow.stdout).toMatch(/天使降临到我身边|WATATEN|Angel Flew Down/i) + expect(tvShow.stdout).toMatch(/Season \d+:/) + expect(tvShow.stdout).toMatch(/S\d{2}E\d{2}/) const tvMeta = await smm(['metadata', tv.path!]) expect(tvMeta.code).toBe(0) @@ -93,6 +95,7 @@ describe('smm CLI e2e', () => { expect(movieShow.stdout).toContain('Type: movie-folder') expect(movieShow.stdout).toMatch(/Title:\s+.+/) expect(movieShow.stdout).toMatch(/Dark Knight|黑暗骑士/i) + expect(movieShow.stdout).toMatch(/\n {4}\S+/) const movieMeta = await smm(['metadata', movie.path!]) expect(movieMeta.code).toBe(0) From f0e0bdaa872b110be85463f82918a8603589aaa0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 20 Aug 2026 17:17:07 +0800 Subject: [PATCH 092/214] feat(core): add renameEpisodeFile with shared rename preflight Port TV episode context-menu rename into Core (associates + metadata), expose HTTP and UI v3 paths, and reuse path/FS validation across rename callers. Co-authored-by: Cursor --- apps/cli/server.ts | 2 + apps/cli/src/route/RenameEpisodeFile.test.ts | 155 +++++ apps/cli/src/route/RenameEpisodeFile.ts | 94 +++ apps/cli/src/tools/renameFilesInBatch.ts | 69 +-- .../validateRenameOperations.test.ts | 284 +++++----- apps/core/src/Core.ts | 25 +- .../src/adapters/network/NetworkFsAdapter.ts | 5 + .../core/src/adapters/node/NodejsFsAdapter.ts | 9 + .../expandAssociatedFileRenames.test.ts | 33 ++ .../pipeline/expandAssociatedFileRenames.ts | 42 ++ .../src/pipeline/renameEpisodeFile.test.ts | 533 ++++++++++++++++++ apps/core/src/pipeline/renameEpisodeFile.ts | 153 +++++ apps/core/src/ports/FsPort.ts | 5 + apps/ui/src/api/renameEpisodeFile.ts | 52 ++ .../src/components/tv/TvShowEpisodeTable.tsx | 28 +- apps/ui/src/components/tv/TvShowPanel.tsx | 1 + apps/ui/src/hooks/useRenameVideoFileFlow.ts | 75 ++- docs/dev/rename-episode-file.md | 334 +++++++++++ packages/core-routes/src/index.ts | 1 + .../src/nodeRenameFileExistenceProbe.ts | 37 ++ .../core-routes/src/renameFilesValidation.ts | 154 +---- .../src/validateRenameOperations.ts | 106 +--- packages/core/validations/rename/index.ts | 7 + .../rename/validateRenameFileExistence.ts | 63 +++ .../rename/validateRenameOperations.test.ts | 81 +++ .../rename/validateRenameOperations.ts | 66 +++ 26 files changed, 1954 insertions(+), 460 deletions(-) create mode 100644 apps/cli/src/route/RenameEpisodeFile.test.ts create mode 100644 apps/cli/src/route/RenameEpisodeFile.ts create mode 100644 apps/core/src/pipeline/expandAssociatedFileRenames.test.ts create mode 100644 apps/core/src/pipeline/expandAssociatedFileRenames.ts create mode 100644 apps/core/src/pipeline/renameEpisodeFile.test.ts create mode 100644 apps/core/src/pipeline/renameEpisodeFile.ts create mode 100644 apps/ui/src/api/renameEpisodeFile.ts create mode 100644 docs/dev/rename-episode-file.md create mode 100644 packages/core-routes/src/nodeRenameFileExistenceProbe.ts create mode 100644 packages/core/validations/rename/validateRenameFileExistence.ts create mode 100644 packages/core/validations/rename/validateRenameOperations.test.ts create mode 100644 packages/core/validations/rename/validateRenameOperations.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 051873b4..ff77ab82 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -15,6 +15,7 @@ import { handleWriteFile } from './src/route/WriteFile'; import { handleRenameFiles } from './src/route/RenameFiles'; import { handleRenameFolder } from './src/route/RenameFolder'; import { handleRenameFolderV3 } from './src/route/RenameFolderV3'; +import { handleRenameEpisodeFile } from './src/route/RenameEpisodeFile'; import { handleGetEpisodesRoute } from './src/route/getEpisodes'; import { handleListFilesInMediaFolderRoute } from './src/route/listFilesInMediaFolder'; import { handleValidateRenameOperationsRoute } from './src/route/validateRenameOperations'; @@ -256,6 +257,7 @@ export class Server { handleRenameFiles(this.app); handleRenameFolder(this.app); handleRenameFolderV3(this.app); + handleRenameEpisodeFile(this.app); handleGetEpisodesRoute(this.app); handleListFilesInMediaFolderRoute(this.app); handleValidateRenameOperationsRoute(this.app); diff --git a/apps/cli/src/route/RenameEpisodeFile.test.ts b/apps/cli/src/route/RenameEpisodeFile.test.ts new file mode 100644 index 00000000..c28c172e --- /dev/null +++ b/apps/cli/src/route/RenameEpisodeFile.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { Path } from '@core/path' +import { handleRenameEpisodeFile } from './RenameEpisodeFile' +import { resetCoreForTests } from '../core/getCore' + +vi.mock('@/utils/socketIO', () => ({ + broadcast: vi.fn(), +})) + +describe('POST /api/rename-episode-file', () => { + let userDataDir: string + let mediaDir: string + let mediaFolder: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-rename-episode-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-rename-episode-media-')) + mediaFolder = join(mediaDir, 'Show') + mkdirSync(mediaFolder) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleRenameEpisodeFile(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + async function post(body: unknown) { + return app.request('/api/rename-episode-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + function seedImportedTvShow() { + const video = join(mediaFolder, 'S01E01.mp4') + const srt = join(mediaFolder, 'S01E01.srt') + writeFileSync(video, 'v') + writeFileSync(srt, 's') + + const folderPosix = Path.posix(mediaFolder) + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ + folders: [mediaFolder], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: 'plex', + }), + 'utf-8', + ) + const cacheName = folderPosix.replace(/[/\\:?*|<>"]/g, '_') + mkdirSync(join(userDataDir, 'metadata'), { recursive: true }) + writeFileSync( + join(userDataDir, 'metadata', `${cacheName}.json`), + JSON.stringify({ + mediaFolderPath: folderPosix, + type: 'tvshow-folder', + mediaFiles: [ + { + absolutePath: Path.posix(video), + seasonNumber: 1, + episodeNumber: 1, + }, + ], + tvShow: { + database: 'TMDB', + id: '1', + name: 'Demo', + seasons: [ + { + season: 1, + name: 'Season 1', + episodes: [{ season: 1, episode: 1, name: 'Pilot' }], + }, + ], + }, + }), + 'utf-8', + ) + resetCoreForTests() + return { video, srt } + } + + it('returns Error Reason when mediaFolder is missing', async () => { + const res = await post({ from: '/a', to: '/b' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: mediaFolder is required/) + }) + + it('renames episode and associate via Core', async () => { + const { video, srt } = seedImportedTvShow() + const to = join(mediaFolder, 'S01E01_renamed.mp4') + + const res = await post({ mediaFolder, from: video, to }) + expect(res.status).toBe(200) + const json = (await res.json()) as { + data?: { succeeded: Array<{ from: string; to: string }> } + error?: string + } + expect(json.error).toBeUndefined() + expect(json.data?.succeeded.length).toBeGreaterThanOrEqual(2) + + expect(existsSync(to)).toBe(true) + expect(existsSync(video)).toBe(false) + expect(existsSync(join(mediaFolder, 'S01E01_renamed.srt'))).toBe(true) + expect(existsSync(srt)).toBe(false) + + const folderPosix = Path.posix(mediaFolder) + const cacheName = folderPosix.replace(/[/\\:?*|<>"]/g, '_') + const mm = JSON.parse( + readFileSync(join(userDataDir, 'metadata', `${cacheName}.json`), 'utf-8'), + ) as { mediaFiles: Array<{ absolutePath: string }> } + expect(mm.mediaFiles[0]?.absolutePath).toBe(Path.posix(to)) + }) + + it('rejects unlinked files', async () => { + seedImportedTvShow() + const orphan = join(mediaFolder, 'orphan.mp4') + writeFileSync(orphan, 'x') + + const res = await post({ + mediaFolder, + from: orphan, + to: join(mediaFolder, 'orphan2.mp4'), + }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/not a linked episode/i) + }) +}) diff --git a/apps/cli/src/route/RenameEpisodeFile.ts b/apps/cli/src/route/RenameEpisodeFile.ts new file mode 100644 index 00000000..ba81741e --- /dev/null +++ b/apps/cli/src/route/RenameEpisodeFile.ts @@ -0,0 +1,94 @@ +import type { Hono } from 'hono' +import { Path } from '@core/path' +import { getCore } from '../core/getCore' +import { broadcast } from '@/utils/socketIO' +import { logger } from '../../lib/logger' + +export interface RenameEpisodeFileRequestBody { + mediaFolder: string + from: string + to: string +} + +export interface RenameEpisodeFileResponseBody { + data?: { + succeeded: Array<{ from: string; to: string }> + failed: Array<{ path: string; error: string }> + } + error?: string +} + +/** + * Layer-2 rename: POST /api/rename-episode-file → Core.renameEpisodeFile. + * Broadcasts mediaMetadataUpdated on success (parity with /api/renameFiles). + */ +export function handleRenameEpisodeFile(app: Hono): void { + app.post('/api/rename-episode-file', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + + const mediaFolder = + typeof body === 'object' && body !== null && 'mediaFolder' in body + ? (body as { mediaFolder: unknown }).mediaFolder + : undefined + const from = + typeof body === 'object' && body !== null && 'from' in body + ? (body as { from: unknown }).from + : undefined + const to = + typeof body === 'object' && body !== null && 'to' in body + ? (body as { to: unknown }).to + : undefined + + if (typeof mediaFolder !== 'string' || mediaFolder.trim() === '') { + const err: RenameEpisodeFileResponseBody = { + error: 'Error Reason: mediaFolder is required', + } + return c.json(err, 200) + } + if (typeof from !== 'string' || from.trim() === '') { + const err: RenameEpisodeFileResponseBody = { + error: 'Error Reason: from is required', + } + return c.json(err, 200) + } + if (typeof to !== 'string' || to.trim() === '') { + const err: RenameEpisodeFileResponseBody = { + error: 'Error Reason: to is required', + } + return c.json(err, 200) + } + + const clientId = c.req.header('clientId') + const result = await getCore().renameEpisodeFile({ + mediaFolderPath: mediaFolder, + from, + to, + }) + + if (result.succeeded.length > 0) { + broadcast({ + clientId: clientId ?? undefined, + event: 'mediaMetadataUpdated', + data: { + folderPath: Path.posix(mediaFolder), + }, + }) + } + + const ok: RenameEpisodeFileResponseBody = { data: result } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/rename-episode-file] route error') + const err: RenameEpisodeFileResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/src/tools/renameFilesInBatch.ts b/apps/cli/src/tools/renameFilesInBatch.ts index cd3d413b..46a5ed28 100644 --- a/apps/cli/src/tools/renameFilesInBatch.ts +++ b/apps/cli/src/tools/renameFilesInBatch.ts @@ -1,12 +1,12 @@ import { z } from 'zod/v3'; +import { stat } from 'node:fs/promises'; import { Path } from '@core/path'; import type { MediaMetadata, RenameValidationResult } from '@core/types'; import { updateMediaMetadataAfterRename } from '@core/mediaMetadata'; -import { validateRenameOperationsSync } from '@core/validations/rename/validateRenameOperationsSync'; +import { validateRenameOperations as validateRenameOperationsShared } from '@core/validations/rename/validateRenameOperations'; +import type { RenameFileExistenceProbe } from '@core/validations/rename/validateRenameFileExistence'; import { metadataCacheFilePath } from '../route/mediaMetadata/utils'; import { executeBatchRenameOperations, updateMediaMetadataAndBroadcast } from '../utils/renameFileUtils'; -import { validateSourceFileExist } from '../validations/validateSourceFileExist'; -import { validateDestFileNotExist } from '../validations/validateDestFileNotExist'; import pino from 'pino'; import { askForRenameFilesConfirmation } from '@/events/askForRenameFilesConfirmation'; @@ -23,13 +23,23 @@ export interface ValidationResult { validatedRenames: RenameFile[]; } - +function createCliRenameFileExistenceProbe(): RenameFileExistenceProbe { + return { + async isFile(posixPath: string): Promise { + try { + const stats = await stat(Path.toPlatformPath(posixPath)); + return stats.isFile(); + } catch { + return false; + } + }, + }; +} /** - * Validate rename operations using validation functions from validations folder + * Validate rename operations using shared path rules + FS existence probe. * @param files Array of rename operations to validate * @param folderPathInPosix The media folder path in POSIX format - * @param filesystemFiles Array of files currently in the filesystem (unused, kept for compatibility) * @returns RenameValidationResult with isValid flag, errors array, and validated renames */ export async function validateRenameOperations( @@ -51,42 +61,23 @@ export async function validateRenameOperations( }); } - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [], - }; - } - - const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); - const errors = [...syncResult.errors]; - - const sourceExistResult = await validateSourceFileExist(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - logger.warn({ from: missingFile }, '[tool][renameFilesInBatch] Source file not found'); - errors.push(`Source file "${missingFile}" does not exist in the media folder`); - } - } - - const destNotExistResult = await validateDestFileNotExist(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - logger.warn({ to: existingFile }, '[tool][renameFilesInBatch] Target file already exists in filesystem'); - errors.push(`Target file "${existingFile}" already exists in the filesystem`); + const result = await validateRenameOperationsShared( + normalizedTasks, + folderPathInPosix, + createCliRenameFileExistenceProbe(), + ); + + if (!result.isValid) { + for (const error of result.errors) { + if (error.includes('does not exist')) { + logger.warn({ error }, '[tool][renameFilesInBatch] Source file not found'); + } else if (error.includes('already exists')) { + logger.warn({ error }, '[tool][renameFilesInBatch] Target file already exists in filesystem'); + } } } - if (errors.length > 0) { - return { - isValid: false, - errors, - validatedRenames: [], - }; - } - - return syncResult; + return result; } export { updateMediaMetadataAfterRename }; diff --git a/apps/cli/src/validations/validateRenameOperations.test.ts b/apps/cli/src/validations/validateRenameOperations.test.ts index 9f233ce6..dba33021 100644 --- a/apps/cli/src/validations/validateRenameOperations.test.ts +++ b/apps/cli/src/validations/validateRenameOperations.test.ts @@ -1,238 +1,244 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { validateRenameOperations } from '../tools/renameFilesInBatch'; -import { getMediaFolder } from '../utils/getMediaFolder'; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Path } from '@core/path' +import { getMediaFolder } from '../utils/getMediaFolder' + +vi.mock('node:fs/promises', () => ({ + stat: vi.fn(), +})) // Mock transitive dependencies that require runtime-only modules vi.mock('tasks/HelloTask', () => ({ getAppDataDir: () => '/tmp/smm-test', -})); +})) vi.mock('../events/askForRenameFilesConfirmation', () => ({ askForRenameFilesConfirmation: vi.fn(async () => true), -})); +})) vi.mock('../utils/renameFileUtils', () => ({ executeBatchRenameOperations: vi.fn(async () => ({ success: true, successfulRenames: [] })), updateMediaMetadataAndBroadcast: vi.fn(async () => ({ success: true })), -})); - -// Mock filesystem checks so tests run without real files -vi.mock('../validations/validateSourceFileExist', () => ({ - validateSourceFileExist: vi.fn(async (_files: Array<{ from: string; to: string }>) => ({ - isValid: true, - missingFiles: [], - })), -})); +})) -vi.mock('../validations/validateDestFileNotExist', () => ({ - validateDestFileNotExist: vi.fn(async (_files: Array<{ from: string; to: string }>) => ({ - isValid: true, - existingFiles: [], - })), -})); +import { stat } from 'node:fs/promises' +import { validateRenameOperations } from '../tools/renameFilesInBatch' -import { validateSourceFileExist } from '../validations/validateSourceFileExist'; -import { validateDestFileNotExist } from '../validations/validateDestFileNotExist'; +const mockStat = vi.mocked(stat) +const existingFiles = new Set() -const mockValidateSourceFileExist = vi.mocked(validateSourceFileExist); -const mockValidateDestFileNotExist = vi.mocked(validateDestFileNotExist); +function seedExisting(...paths: string[]) { + existingFiles.clear() + for (const p of paths) { + existingFiles.add(Path.posix(p)) + existingFiles.add(Path.toPlatformPath(p)) + } +} describe('validateRenameOperations', () => { - const mediaFolder = '/home/user/media'; + const mediaFolder = '/home/user/media' beforeEach(() => { - vi.clearAllMocks(); - mockValidateSourceFileExist.mockResolvedValue({ isValid: true, missingFiles: [] }); - mockValidateDestFileNotExist.mockResolvedValue({ isValid: true, existingFiles: [] }); - }); + vi.clearAllMocks() + existingFiles.clear() + mockStat.mockImplementation(async (filePath) => { + const key = String(filePath) + if (existingFiles.has(key) || existingFiles.has(Path.posix(key))) { + return { isFile: () => true } as Awaited> + } + throw Object.assign(new Error(`ENOENT: ${key}`), { code: 'ENOENT' }) + }) + }) it('should return isValid true and empty arrays for empty input', async () => { - const result = await validateRenameOperations([], mediaFolder); - expect(result.isValid).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.validatedRenames).toHaveLength(0); - }); + const result = await validateRenameOperations([], mediaFolder) + expect(result.isValid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.validatedRenames).toHaveLength(0) + }) it('should return isValid true for valid rename operations', async () => { + seedExisting('/home/user/media/ep1.mp4') const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(true); - expect(result.errors).toHaveLength(0); - expect(result.validatedRenames).toHaveLength(1); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.validatedRenames).toHaveLength(1) expect(result.validatedRenames[0]).toEqual({ from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4', - }); - }); + }) + }) it('should normalize Windows paths to POSIX in validatedRenames', async () => { + seedExisting('C:\\Users\\media\\ep1.mp4') const files = [ { from: 'C:\\Users\\media\\ep1.mp4', to: 'C:\\Users\\media\\S01E01.mp4' }, - ]; - const result = await validateRenameOperations(files, '/C/Users/media'); - expect(result.isValid).toBe(true); - // normalized to posix - expect(result.validatedRenames[0]!.from).toMatch(/\//); - }); + ] + const result = await validateRenameOperations(files, '/C/Users/media') + expect(result.isValid).toBe(true) + expect(result.validatedRenames[0]!.from).toMatch(/\//) + }) it('should return error when source file does not exist', async () => { - mockValidateSourceFileExist.mockResolvedValue({ - isValid: false, - missingFiles: ['/home/user/media/missing.mp4'], - }); const files = [ { from: '/home/user/media/missing.mp4', to: '/home/user/media/S01E01.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('does not exist'))).toBe(true); - expect(result.validatedRenames).toHaveLength(0); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes('does not exist'))).toBe(true) + expect(result.validatedRenames).toHaveLength(0) + }) it('should return error when destination file already exists', async () => { - mockValidateDestFileNotExist.mockResolvedValue({ - isValid: false, - existingFiles: ['/home/user/media/S01E01.mp4'], - }); + seedExisting('/home/user/media/ep1.mp4', '/home/user/media/S01E01.mp4') const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('already exists'))).toBe(true); - expect(result.validatedRenames).toHaveLength(0); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes('already exists'))).toBe(true) + expect(result.validatedRenames).toHaveLength(0) + }) it('should return error for duplicate source files', async () => { + seedExisting('/home/user/media/ep1.mp4') const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E02.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('appears multiple times'))).toBe(true); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes('appears multiple times'))).toBe(true) + }) it('should return error for duplicate destination files', async () => { + seedExisting('/home/user/media/ep1.mp4', '/home/user/media/ep2.mp4') const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, { from: '/home/user/media/ep2.mp4', to: '/home/user/media/S01E01.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('appears multiple times'))).toBe(true); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes('appears multiple times'))).toBe(true) + }) it('should exclude identical source/dest from validatedRenames (treated as chaining conflict)', async () => { - // When from === to, the chaining conflict check fires (to is in sourcePaths), - // producing an error. The task is excluded from validatedRenames in either case. + seedExisting('/home/user/media/ep1.mp4') const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/ep1.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.validatedRenames).toHaveLength(0); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.validatedRenames).toHaveLength(0) + }) it('should return error for paths outside media folder', async () => { + seedExisting('/other/ep1.mp4') const files = [ { from: '/other/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('outside the media folder'))).toBe(true); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes('outside the media folder'))).toBe(true) + }) it('should return error for chaining conflicts', async () => { + seedExisting('/home/user/media/a.mp4', '/home/user/media/b.mp4') const files = [ { from: '/home/user/media/a.mp4', to: '/home/user/media/b.mp4' }, { from: '/home/user/media/b.mp4', to: '/home/user/media/c.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('cannot chain renames'))).toBe(true); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(false) + expect(result.errors.some((e) => e.includes('cannot chain renames'))).toBe(true) + }) it('should handle multiple valid operations in a batch', async () => { + seedExisting( + '/home/user/media/ep1.mp4', + '/home/user/media/ep2.mp4', + '/home/user/media/ep3.mp4', + ) const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, { from: '/home/user/media/ep2.mp4', to: '/home/user/media/S01E02.mp4' }, { from: '/home/user/media/ep3.mp4', to: '/home/user/media/S01E03.mp4' }, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.isValid).toBe(true); - expect(result.validatedRenames).toHaveLength(3); - }); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.isValid).toBe(true) + expect(result.validatedRenames).toHaveLength(3) + }) it('should skip undefined/null operations in the batch', async () => { + seedExisting('/home/user/media/ep1.mp4') const files = [ { from: '/home/user/media/ep1.mp4', to: '/home/user/media/S01E01.mp4' }, undefined as any, - ]; - const result = await validateRenameOperations(files, mediaFolder); - expect(result.validatedRenames).toHaveLength(1); - }); -}); + ] + const result = await validateRenameOperations(files, mediaFolder) + expect(result.validatedRenames).toHaveLength(1) + }) +}) describe('getMediaFolder', () => { describe('Exact match scenarios', () => { it('should return folder when filePath exactly matches a folder', () => { - const result = getMediaFolder('/home/user/media', ['/home/user/media', '/home/user/documents']); - expect(result).toBe('/home/user/media'); - }); + const result = getMediaFolder('/home/user/media', ['/home/user/media', '/home/user/documents']) + expect(result).toBe('/home/user/media') + }) it('should return folder when filePath exactly matches a Windows path', () => { - const result = getMediaFolder('C:\\Media\\Movies', ['C:\\Media\\Movies', 'C:\\Media\\TV']); - expect(result).toBe('C:\\Media\\Movies'); - }); - }); + const result = getMediaFolder('C:\\Media\\Movies', ['C:\\Media\\Movies', 'C:\\Media\\TV']) + expect(result).toBe('C:\\Media\\Movies') + }) + }) describe('File inside folder scenarios', () => { it('should return folder when file is inside a POSIX folder', () => { - const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user/media', '/home/user/documents']); - expect(result).toBe('/home/user/media'); - }); + const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user/media', '/home/user/documents']) + expect(result).toBe('/home/user/media') + }) it('should return folder when file is deeply nested', () => { - const result = getMediaFolder('/home/user/media/tv/show/season1/ep1.mp4', ['/home/user/media']); - expect(result).toBe('/home/user/media'); - }); + const result = getMediaFolder('/home/user/media/tv/show/season1/ep1.mp4', ['/home/user/media']) + expect(result).toBe('/home/user/media') + }) it('should return folder when file is inside a Windows folder', () => { - const result = getMediaFolder('C:\\Media\\Movies\\movie.mp4', ['C:\\Media\\Movies', 'C:\\Media\\TV']); - expect(result).toBe('C:\\Media\\Movies'); - }); - }); + const result = getMediaFolder('C:\\Media\\Movies\\movie.mp4', ['C:\\Media\\Movies', 'C:\\Media\\TV']) + expect(result).toBe('C:\\Media\\Movies') + }) + }) describe('No match scenarios', () => { it('should return null when file is not in any folder', () => { - const result = getMediaFolder('/home/user/other/file.txt', ['/home/user/media', '/home/user/documents']); - expect(result).toBeNull(); - }); + const result = getMediaFolder('/home/user/other/file.txt', ['/home/user/media', '/home/user/documents']) + expect(result).toBeNull() + }) it('should return null when folderPaths is empty', () => { - const result = getMediaFolder('/home/user/media/file.mp4', []); - expect(result).toBeNull(); - }); - }); + const result = getMediaFolder('/home/user/media/file.mp4', []) + expect(result).toBeNull() + }) + }) describe('Trailing slash scenarios', () => { it('should handle folder path with trailing slash', () => { - const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user/media/', '/home/user/documents']); - expect(result).toBe('/home/user/media/'); - }); + const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user/media/', '/home/user/documents']) + expect(result).toBe('/home/user/media/') + }) it('should handle folder path without trailing slash', () => { - const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user/media', '/home/user/documents']); - expect(result).toBe('/home/user/media'); - }); - }); + const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user/media', '/home/user/documents']) + expect(result).toBe('/home/user/media') + }) + }) describe('First-match behavior', () => { it('should return first matching folder', () => { - const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user', '/home/user/media']); - expect(result).toBe('/home/user'); - }); - }); -}); + const result = getMediaFolder('/home/user/media/movie.mp4', ['/home/user', '/home/user/media']) + expect(result).toBe('/home/user') + }) + }) +}) diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 2cc9b2cc..2e79ffbb 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -10,6 +10,11 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; +import { + renameEpisodeFilePipeline, + type RenameEpisodeFileInput, + type RenameEpisodeFileResult, +} from "./pipeline/renameEpisodeFile"; import { applyPlanPipeline } from "./pipeline/applyPlan"; import { readPlan, type Plan } from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; @@ -27,7 +32,13 @@ import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import { initialScrapeTasks, type ImportJob, type Job } from "./jobs/types"; -export type { RenameFolderArgs, ScrapeFolderOptions, ScrapeFolderResult }; +export type { + RenameFolderArgs, + RenameEpisodeFileInput, + RenameEpisodeFileResult, + ScrapeFolderOptions, + ScrapeFolderResult, +}; export interface CoreOptions { fs: FsPort; @@ -180,6 +191,18 @@ export class Core { }); } + /** Rename a linked TV episode file and same-stem associates; updates metadata. */ + async renameEpisodeFile(input: RenameEpisodeFileInput): Promise { + return renameEpisodeFilePipeline(input, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (path) => this.normalizePosix(path), + getMediaMetadata: (folder) => this.getMediaMetadata(folder), + setMetadata: (mm) => this.setMetadata(mm), + }); + } + async tryToRecognizeFolder(path: string): Promise { return tryToRecognizeFolderPipeline(path, { fs: this.fs, diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index 417db642..b1988060 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -52,6 +52,11 @@ export class NetworkFsAdapter implements FsPort { return !(json.error !== undefined && json.error.startsWith("File Not Found")); } + /** Best-effort: readFile success implies a readable file (not a directory listing). */ + async isFile(path: string): Promise { + return this.exists(path); + } + async listFiles(dir: string): Promise { const json = await this.post<{ error?: string; diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts index f02551e2..89707515 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -33,6 +33,15 @@ export class NodejsFsAdapter implements FsPort { } } + async isFile(path: string): Promise { + try { + const stats = await fsp.stat(Path.toPlatformPath(path)); + return stats.isFile(); + } catch { + return false; + } + } + async listFiles(dir: string): Promise { const root = Path.toPlatformPath(dir); const out: string[] = []; diff --git a/apps/core/src/pipeline/expandAssociatedFileRenames.test.ts b/apps/core/src/pipeline/expandAssociatedFileRenames.test.ts new file mode 100644 index 00000000..5b2557ea --- /dev/null +++ b/apps/core/src/pipeline/expandAssociatedFileRenames.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { expandAssociatedFileRenames } from "./expandAssociatedFileRenames"; + +describe("expandAssociatedFileRenames", () => { + it("renames same-stem associates in place", () => { + expect( + expandAssociatedFileRenames( + "/m/Show/S01E01.mp4", + "/m/Show/S01E01_renamed.mp4", + [ + "/m/Show/S01E01.mp4", + "/m/Show/S01E01.srt", + "/m/Show/S01E01.en.srt", + "/m/Show/S01E01.ass", + "/m/Show/other.mkv", + ], + ), + ).toEqual([ + { from: "/m/Show/S01E01.srt", to: "/m/Show/S01E01_renamed.srt" }, + { from: "/m/Show/S01E01.en.srt", to: "/m/Show/S01E01_renamed.en.srt" }, + { from: "/m/Show/S01E01.ass", to: "/m/Show/S01E01_renamed.ass" }, + ]); + }); + + it("returns empty when stems are equal", () => { + expect( + expandAssociatedFileRenames("/m/Show/S01E01.mp4", "/m/Show/Season 01/S01E01.mp4", [ + "/m/Show/S01E01.mp4", + "/m/Show/S01E01.srt", + ]), + ).toEqual([]); + }); +}); diff --git a/apps/core/src/pipeline/expandAssociatedFileRenames.ts b/apps/core/src/pipeline/expandAssociatedFileRenames.ts new file mode 100644 index 00000000..c3d01a6e --- /dev/null +++ b/apps/core/src/pipeline/expandAssociatedFileRenames.ts @@ -0,0 +1,42 @@ +import { basename, dirname, extname, joinPosix } from "./paths"; + +/** + * Port of UI `computeAssociatedFileRenames`: same-stem siblings stay in their + * current directory; only the basename stem changes. + * + * Paths are POSIX. Does not include the episode file itself. + */ +export function expandAssociatedFileRenames( + episodeOldPath: string, + episodeNewPath: string, + allFilesInFolder: string[], +): Array<{ from: string; to: string }> { + const oldBasename = basename(episodeOldPath); + const oldExt = extname(oldBasename); + const oldStem = oldBasename.slice(0, oldBasename.length - oldExt.length); + + const newBasename = basename(episodeNewPath); + const newExt = extname(newBasename); + const newStem = newBasename.slice(0, newBasename.length - newExt.length); + + if (!oldStem || !newStem || oldStem === newStem) { + return []; + } + + const renames: Array<{ from: string; to: string }> = []; + for (const filePath of allFilesInFolder) { + if (filePath === episodeOldPath) continue; + + const assocBasename = basename(filePath); + if (assocBasename === oldStem || assocBasename.startsWith(oldStem + ".")) { + const suffix = assocBasename.slice(oldStem.length); + const newAssocBasename = newStem + suffix; + const assocDir = dirname(filePath); + renames.push({ + from: filePath, + to: joinPosix(assocDir, newAssocBasename), + }); + } + } + return renames; +} diff --git a/apps/core/src/pipeline/renameEpisodeFile.test.ts b/apps/core/src/pipeline/renameEpisodeFile.test.ts new file mode 100644 index 00000000..1c9e9e0e --- /dev/null +++ b/apps/core/src/pipeline/renameEpisodeFile.test.ts @@ -0,0 +1,533 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath, userConfigPath } from "./paths"; +import { renameEpisodeFilePipeline } from "./renameEpisodeFile"; +import { UserConfig } from "./userConfig"; + +const appDataDir = "/app"; +const folder = "/m/Show"; + +function configWith(folders: string[]): string { + return JSON.stringify({ + folders, + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }); +} + +function tvMetadata(overrides: Partial = {}): MediaMetadata { + return { + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [ + { + absolutePath: "/m/Show/S01E01.mp4", + seasonNumber: 1, + episodeNumber: 1, + }, + ], + tvShow: { + database: "TMDB", + id: "1", + name: "Demo", + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [{ season: 1, episode: 1, name: "Pilot" }], + }, + ], + }, + ...overrides, + }; +} + +function createFs(seed: Record): FsPort & { + textFiles: Map; +} { + const textFiles = new Map(Object.entries(seed)); + return { + textFiles, + readTextFile: vi.fn(async (path: string) => { + const v = textFiles.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + textFiles.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => textFiles.has(path)), + listFiles: vi.fn(async (dir: string) => + [...textFiles.keys()].filter((k) => k.startsWith(dir + "/") && !k.endsWith("/")), + ), + deleteFile: vi.fn(async (path: string) => { + textFiles.delete(path); + }), + rename: vi.fn(async (from: string, to: string) => { + const content = textFiles.get(from); + if (content === undefined) throw new Error("ENOENT: " + from); + textFiles.delete(from); + textFiles.set(to, content); + }), + mkdir: vi.fn(async () => {}), + }; +} + +function managedFs(extraFiles: Record = {}, mm: MediaMetadata = tvMetadata()) { + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + ...extraFiles, + }); + return { fs, userConfig: new UserConfig(fs, appDataDir), mm }; +} + +function baseDeps( + fs: FsPort, + userConfig: UserConfig, + mm: MediaMetadata | null, + setMetadata: (next: MediaMetadata) => Promise = async () => {}, +) { + return { + fs, + appDataDir, + userConfig, + normalizePosix: (p: string) => p, + getMediaMetadata: async () => mm, + setMetadata, + }; +} + +describe("renameEpisodeFilePipeline", () => { + it("renames episode + associates and updates metadata", async () => { + const mm = tvMetadata(); + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/S01E01.mp4": "video", + "/m/Show/S01E01.srt": "sub", + "/m/Show/S01E01.en.srt": "en", + }); + const userConfig = new UserConfig(fs, appDataDir); + + const result = await renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + { + fs, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async (next) => { + await fs.writeTextFile(metadataCachePath(appDataDir, folder), JSON.stringify(next)); + }, + }, + ); + + expect(result.failed).toEqual([]); + expect(result.succeeded).toEqual([ + { from: "/m/Show/S01E01.mp4", to: "/m/Show/S01E01_renamed.mp4" }, + { from: "/m/Show/S01E01.srt", to: "/m/Show/S01E01_renamed.srt" }, + { from: "/m/Show/S01E01.en.srt", to: "/m/Show/S01E01_renamed.en.srt" }, + ]); + expect(fs.textFiles.has("/m/Show/S01E01_renamed.mp4")).toBe(true); + expect(fs.textFiles.has("/m/Show/S01E01.mp4")).toBe(false); + + const saved = JSON.parse( + fs.textFiles.get(metadataCachePath(appDataDir, folder))!, + ) as MediaMetadata; + expect(saved.mediaFiles?.[0]?.absolutePath).toBe("/m/Show/S01E01_renamed.mp4"); + }); + + it("rejects unlinked files", async () => { + const mm = tvMetadata(); + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/orphan.mp4": "x", + }); + const userConfig = new UserConfig(fs, appDataDir); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/orphan.mp4", + to: "/m/Show/orphan2.mp4", + }, + { + fs, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async () => {}, + }, + ), + ).rejects.toThrow(/not a linked episode/i); + }); + + it("rejects movie folders", async () => { + const mm = tvMetadata({ type: "movie-folder", mediaFiles: [{ absolutePath: "/m/Show/movie.mkv" }] }); + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/movie.mkv": "x", + }); + const userConfig = new UserConfig(fs, appDataDir); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/movie.mkv", + to: "/m/Show/movie2.mkv", + }, + { + fs, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async () => {}, + }, + ), + ).rejects.toThrow(/not a TV show/i); + }); + + it("rejects unmanaged folders", async () => { + const mm = tvMetadata(); + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/S01E01.mp4": "video", + }); + const userConfig = new UserConfig(fs, appDataDir); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + { + fs, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async () => {}, + }, + ), + ).rejects.toThrow(/not managed by SMM/i); + }); + + it("refuses when destination already exists (no renames)", async () => { + const mm = tvMetadata(); + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/S01E01.mp4": "video", + "/m/Show/S01E01_renamed.mp4": "already", + }); + const userConfig = new UserConfig(fs, appDataDir); + const rename = vi.fn(fs.rename); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + { + fs: { ...fs, rename }, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async () => {}, + }, + ), + ).rejects.toThrow(/already exists/i); + + expect(rename).not.toHaveBeenCalled(); + expect(fs.textFiles.has("/m/Show/S01E01.mp4")).toBe(true); + }); + + it("refuses when source is missing (no renames)", async () => { + const mm = tvMetadata(); + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([folder]), + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + }); + const userConfig = new UserConfig(fs, appDataDir); + const rename = vi.fn(fs.rename); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + { + fs: { ...fs, rename }, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async () => {}, + }, + ), + ).rejects.toThrow(/does not exist/i); + + expect(rename).not.toHaveBeenCalled(); + }); + + it("rejects empty mediaFolder / from / to", async () => { + const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + const deps = baseDeps(fs, userConfig, mm); + + await expect( + renameEpisodeFilePipeline( + { mediaFolderPath: " ", from: "/m/Show/S01E01.mp4", to: "/m/Show/x.mp4" }, + deps, + ), + ).rejects.toThrow(/mediaFolder is required/i); + + await expect( + renameEpisodeFilePipeline( + { mediaFolderPath: folder, from: "", to: "/m/Show/x.mp4" }, + deps, + ), + ).rejects.toThrow(/from is required/i); + + await expect( + renameEpisodeFilePipeline( + { mediaFolderPath: folder, from: "/m/Show/S01E01.mp4", to: " " }, + deps, + ), + ).rejects.toThrow(/to is required/i); + }); + + it("rejects when from and to are the same path", async () => { + const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01.mp4", + }, + baseDeps(fs, userConfig, mm), + ), + ).rejects.toThrow(/from and to must differ/i); + }); + + it("rejects when media metadata is missing", async () => { + const { fs, userConfig } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + baseDeps(fs, userConfig, null), + ), + ).rejects.toThrow(/Media metadata not found/i); + }); + + it("rejects when from is outside the media folder", async () => { + const mm = tvMetadata({ + mediaFiles: [ + { + absolutePath: "/other/S01E01.mp4", + seasonNumber: 1, + episodeNumber: 1, + }, + ], + }); + const { fs, userConfig } = managedFs({ "/other/S01E01.mp4": "video" }, mm); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/other/S01E01.mp4", + to: "/m/Show/S01E01.mp4", + }, + baseDeps(fs, userConfig, mm), + ), + ).rejects.toThrow(/outside media folder/i); + }); + + it("rejects when to is outside the media folder", async () => { + const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/other/S01E01.mp4", + }, + baseDeps(fs, userConfig, mm), + ), + ).rejects.toThrow(/outside media folder/i); + }); + + it("rejects mediaFiles entry missing season or episode numbers", async () => { + const mm = tvMetadata({ + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mp4", seasonNumber: 1 }], + }); + const { fs, userConfig } = managedFs({ "/m/Show/S01E01.mp4": "video" }, mm); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + baseDeps(fs, userConfig, mm), + ), + ).rejects.toThrow(/not a linked episode/i); + }); + + it("renames only the primary file when there are no associates", async () => { + const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + const setMetadata = vi.fn(async () => {}); + + const result = await renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + baseDeps(fs, userConfig, mm, setMetadata), + ); + + expect(result.failed).toEqual([]); + expect(result.succeeded).toEqual([ + { from: "/m/Show/S01E01.mp4", to: "/m/Show/S01E01_renamed.mp4" }, + ]); + expect(setMetadata).toHaveBeenCalledOnce(); + }); + + it("refuses when an associate destination already exists (no renames)", async () => { + const { fs, userConfig, mm } = managedFs({ + "/m/Show/S01E01.mp4": "video", + "/m/Show/S01E01.srt": "sub", + "/m/Show/S01E01_renamed.srt": "taken", + }); + const rename = vi.fn(fs.rename); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + baseDeps({ ...fs, rename }, userConfig, mm), + ), + ).rejects.toThrow(/already exists/i); + + expect(rename).not.toHaveBeenCalled(); + expect(fs.textFiles.has("/m/Show/S01E01.mp4")).toBe(true); + expect(fs.textFiles.has("/m/Show/S01E01.srt")).toBe(true); + }); + + it("creates parent dir when renaming into a subdirectory", async () => { + const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + + const result = await renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/Season 01/S01E01.mp4", + }, + baseDeps(fs, userConfig, mm), + ); + + expect(result.failed).toEqual([]); + expect(result.succeeded).toEqual([ + { from: "/m/Show/S01E01.mp4", to: "/m/Show/Season 01/S01E01.mp4" }, + ]); + expect(fs.mkdir).toHaveBeenCalledWith("/m/Show/Season 01"); + expect(fs.textFiles.has("/m/Show/Season 01/S01E01.mp4")).toBe(true); + }); + + it("records partial failures and still updates metadata for succeeded renames", async () => { + const { fs, userConfig, mm } = managedFs({ + "/m/Show/S01E01.mp4": "video", + "/m/Show/S01E01.srt": "sub", + }); + const setMetadata = vi.fn(async () => {}); + const rename = vi.fn(async (from: string, to: string) => { + if (from.endsWith(".srt")) { + throw new Error("EACCES: subtitle locked"); + } + const content = fs.textFiles.get(from); + if (content === undefined) throw new Error("ENOENT: " + from); + fs.textFiles.delete(from); + fs.textFiles.set(to, content); + }); + + const result = await renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + baseDeps({ ...fs, rename }, userConfig, mm, setMetadata), + ); + + expect(result.succeeded).toEqual([ + { from: "/m/Show/S01E01.mp4", to: "/m/Show/S01E01_renamed.mp4" }, + ]); + expect(result.failed).toEqual([ + { path: "/m/Show/S01E01.srt", error: "EACCES: subtitle locked" }, + ]); + expect(setMetadata).toHaveBeenCalledOnce(); + const saved = setMetadata.mock.calls[0]![0] as MediaMetadata; + expect(saved.mediaFiles?.[0]?.absolutePath).toBe("/m/Show/S01E01_renamed.mp4"); + }); + + it("does not call setMetadata when every rename fails", async () => { + const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); + const setMetadata = vi.fn(async () => {}); + const rename = vi.fn(async () => { + throw new Error("EIO: disk error"); + }); + + const result = await renameEpisodeFilePipeline( + { + mediaFolderPath: folder, + from: "/m/Show/S01E01.mp4", + to: "/m/Show/S01E01_renamed.mp4", + }, + baseDeps({ ...fs, rename }, userConfig, mm, setMetadata), + ); + + expect(result.succeeded).toEqual([]); + expect(result.failed).toEqual([ + { path: "/m/Show/S01E01.mp4", error: "EIO: disk error" }, + ]); + expect(setMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/core/src/pipeline/renameEpisodeFile.ts b/apps/core/src/pipeline/renameEpisodeFile.ts new file mode 100644 index 00000000..9c35664d --- /dev/null +++ b/apps/core/src/pipeline/renameEpisodeFile.ts @@ -0,0 +1,153 @@ +import { Path } from "@core/path"; +import { updateMediaMetadataAfterRename } from "@core/mediaMetadata"; +import type { MediaMetadata } from "@smm/core"; +import { validateRenameOperations } from "@core/validations/rename/validateRenameOperations"; +import type { RenameFileExistenceProbe } from "@core/validations/rename/validateRenameFileExistence"; +import type { FsPort } from "../ports/FsPort"; +import { expandAssociatedFileRenames } from "./expandAssociatedFileRenames"; +import { mediaFilePathEqual } from "./mediaFilePathEqual"; +import { dirname } from "./paths"; +import type { UserConfig } from "./userConfig"; + +function renameFileExistenceProbe(fs: FsPort): RenameFileExistenceProbe { + return { + isFile: async (path: string): Promise => { + if (fs.isFile) { + return fs.isFile(path); + } + return fs.exists(path); + }, + }; +} + +export interface RenameEpisodeFileInput { + mediaFolderPath: string; + from: string; + to: string; +} + +export interface RenameEpisodeFileResult { + succeeded: Array<{ from: string; to: string }>; + failed: Array<{ path: string; error: string }>; +} + +export interface RenameEpisodeFileDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + getMediaMetadata: (folder: string) => Promise; + setMetadata: (mm: MediaMetadata) => Promise; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +function isUnderFolder(folderPosix: string, filePosix: string): boolean { + const prefix = folderPosix.endsWith("/") ? folderPosix : `${folderPosix}/`; + return filePosix === folderPosix || filePosix.startsWith(prefix); +} + +function isLinkedEpisode(mm: MediaMetadata, fromPosix: string): boolean { + return (mm.mediaFiles ?? []).some( + (f) => + mediaFilePathEqual(f.absolutePath, fromPosix) && + f.seasonNumber !== undefined && + f.episodeNumber !== undefined, + ); +} + +/** Rename a linked TV episode file (+ same-stem associates). Throws on prerequisite failure. */ +export async function renameEpisodeFilePipeline( + input: RenameEpisodeFileInput, + deps: RenameEpisodeFileDeps, +): Promise { + const mediaFolderRaw = input.mediaFolderPath?.trim() ?? ""; + const fromRaw = input.from?.trim() ?? ""; + const toRaw = input.to?.trim() ?? ""; + + if (mediaFolderRaw === "") { + throw new Error("mediaFolder is required"); + } + if (fromRaw === "") { + throw new Error("from is required"); + } + if (toRaw === "") { + throw new Error("to is required"); + } + + const folderPosix = deps.normalizePosix(mediaFolderRaw); + const fromPosix = deps.normalizePosix(fromRaw); + const toPosix = deps.normalizePosix(toRaw); + + if (fromPosix === toPosix) { + throw new Error("from and to must differ"); + } + + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], mediaFolderRaw)) { + throw new Error(`${folderPosix} is not managed by SMM`); + } + + const mm = await deps.getMediaMetadata(folderPosix); + if (!mm) { + throw new Error(`Media metadata not found: ${mediaFolderRaw}`); + } + if (mm.type !== "tvshow-folder") { + throw new Error(`Folder is not a TV show: ${mediaFolderRaw}`); + } + if (!isLinkedEpisode(mm, fromPosix)) { + throw new Error(`File is not a linked episode: ${fromRaw}`); + } + if (!isUnderFolder(folderPosix, fromPosix)) { + throw new Error(`Path is outside media folder: ${fromRaw}`); + } + if (!isUnderFolder(folderPosix, toPosix)) { + throw new Error(`Path is outside media folder: ${toRaw}`); + } + + const localFiles = (await deps.fs.listFiles(folderPosix)).map((p) => Path.posix(p)); + const associates = expandAssociatedFileRenames(fromPosix, toPosix, localFiles); + const renameList: Array<{ from: string; to: string }> = [ + { from: fromPosix, to: toPosix }, + ...associates, + ]; + + const validation = await validateRenameOperations( + renameList, + folderPosix, + renameFileExistenceProbe(deps.fs), + ); + if (!validation.isValid) { + throw new Error(validation.errors.join("; ")); + } + + const succeeded: Array<{ from: string; to: string }> = []; + const failed: Array<{ path: string; error: string }> = []; + + for (const pair of renameList) { + try { + await deps.fs.mkdir(dirname(pair.to)); + await deps.fs.rename(pair.from, pair.to); + succeeded.push(pair); + } catch (error) { + failed.push({ + path: pair.from, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (succeeded.length > 0) { + const updated = updateMediaMetadataAfterRename(mm, succeeded); + await deps.setMetadata({ ...updated, mediaFolderPath: folderPosix }); + } + + return { succeeded, failed }; +} diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts index b0246d0c..e9e01ace 100644 --- a/apps/core/src/ports/FsPort.ts +++ b/apps/core/src/ports/FsPort.ts @@ -5,6 +5,11 @@ export interface FsPort { /** Write raw bytes; parent directories are created if needed. Paths are POSIX. */ writeBinaryFile(path: string, data: Uint8Array): Promise; exists(path: string): Promise; + /** + * True iff the path exists and is a regular file (not a directory). + * Optional for transitional mocks; prefer implementing for rename preflight. + */ + isFile?(path: string): Promise; /** Recursively list all files under `dir` (not directories). */ listFiles(dir: string): Promise; /** Delete a file; missing files count as success (idempotent). */ diff --git a/apps/ui/src/api/renameEpisodeFile.ts b/apps/ui/src/api/renameEpisodeFile.ts new file mode 100644 index 00000000..dd92fc1a --- /dev/null +++ b/apps/ui/src/api/renameEpisodeFile.ts @@ -0,0 +1,52 @@ +import { apiFetch } from '@/lib/apiFetch' + +export interface RenameEpisodeFileParams { + mediaFolder: string + from: string + to: string +} + +export interface RenameEpisodeFileResponseBody { + data?: { + succeeded: Array<{ from: string; to: string }> + failed: Array<{ path: string; error: string }> + } + error?: string +} + +/** Layer-2 episode rename via Core (`POST /api/rename-episode-file`). */ +export async function renameEpisodeFile( + params: RenameEpisodeFileParams, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/rename-episode-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mediaFolder: params.mediaFolder, + from: params.from, + to: params.to, + }), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as RenameEpisodeFileResponseBody +} + +/** Throws on business error or per-file failures. */ +export async function renameEpisodeFileViaCore( + params: RenameEpisodeFileParams, +): Promise { + const body = await renameEpisodeFile(params) + if (body.error) { + throw new Error(body.error) + } + const failed = body.data?.failed ?? [] + if (failed.length > 0) { + throw new Error(failed.map((f) => f.error).join(', ') || 'Rename failed') + } +} diff --git a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx index eb0c61ba..edacb8af 100644 --- a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx +++ b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx @@ -29,6 +29,7 @@ import { generateFfmpegScreenshots } from "@/api/ffmpeg" import { useFailedCommandLogsStore } from "@/stores/failedCommandLogsStore" import { useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" import { renameFiles } from "@/api/renameFiles" +import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" import { openFile } from "@/api/openFile" import { toast } from "sonner" import { useTranslation } from "@/lib/i18n" @@ -36,6 +37,7 @@ import { cn } from "@/lib/utils" import { computeAssociatedFileRenames } from "../episode-file" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" +import { isSmmV3Enabled } from "@/lib/localStorages" export interface TvShowEpisodeDividerRow { id: string @@ -895,15 +897,23 @@ export function TvShowEpisodeTable({ if (!selectedMediaMetadata?.mediaFolderPath || !row.videoFile) return try { const newAbsolutePath = join(selectedMediaMetadata.mediaFolderPath, newRelativePath) - const allMediaFiles = selectedMediaMetadata.files ?? [] - const assocRenames = computeAssociatedFileRenames(row.videoFile, newAbsolutePath, allMediaFiles) - await renameFiles({ - files: [ - { from: row.videoFile, to: newAbsolutePath }, - ...assocRenames, - ], - mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), - }) + if (isSmmV3Enabled()) { + await renameEpisodeFileViaCore({ + mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), + from: row.videoFile, + to: newAbsolutePath, + }) + } else { + const allMediaFiles = selectedMediaMetadata.files ?? [] + const assocRenames = computeAssociatedFileRenames(row.videoFile, newAbsolutePath, allMediaFiles) + await renameFiles({ + files: [ + { from: row.videoFile, to: newAbsolutePath }, + ...assocRenames, + ], + mediaFolder: Path.posix(selectedMediaMetadata.mediaFolderPath), + }) + } fetchMediaMetadata({ path: selectedMediaMetadata.mediaFolderPath }) toast.success(t('episodeFile.renameSuccess', { ns: 'components' })) } catch (error) { diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index 1f138bd1..c5fc234f 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -93,6 +93,7 @@ function TvShowPanel() { const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, files: mediaMetadata?.files ?? [], + mode: "episode", }) const [tableData, setTableData] = useState([]) diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.ts index 464fa6f9..7629cec7 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.ts @@ -4,9 +4,11 @@ import { Path } from "@core/path" import { useTranslation } from "@/lib/i18n" import { join, relative } from "@/lib/path" import { renameFiles } from "@/api/renameFiles" +import { renameEpisodeFileViaCore } from "@/api/renameEpisodeFile" import { useDialogs } from "@/providers/dialog-provider" import { computeAssociatedFileRenames } from "@/components/episode-file" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" +import { isSmmV3Enabled } from "@/lib/localStorages" import type { UIMediaFileDataRow } from "@/components/media/UIMediaFileTable" export interface UseRenameVideoFileFlowOptions { @@ -18,11 +20,17 @@ export interface UseRenameVideoFileFlowOptions { /** * Every file path (absolute) that belongs to the media folder. Used by * `computeAssociatedFileRenames` to find sibling subtitle / thumbnail / nfo - * files that share the video file's stem. + * files that share the video file's stem (legacy / movie path only). */ files: string[] /** - * Optional hook called after `renameFiles` succeeds and before + * When `"episode"` and `smm.v3.enabled`, confirm calls Core + * `renameEpisodeFile` (TV only). Default `"generic"` keeps legacy + * `/api/renameFiles` (movie panel). + */ + mode?: "episode" | "generic" + /** + * Optional hook called after rename succeeds and before * `fetchMediaMetadata`. Lets the panel refresh local state synchronously * (e.g. clear checked rows) before the server re-fetch lands. */ @@ -32,9 +40,9 @@ export interface UseRenameVideoFileFlowOptions { export interface RenameVideoFileFlow { /** * Open the rename dialog for `row` and, on confirm, rename the video file - * (and any associated files) via `renameFiles`, then refetch the media - * folder metadata. No-op when the row has no `videoFile` or the hook was - * constructed without a `mediaFolderPath`. + * (and any associated files), then refetch the media folder metadata. + * No-op when the row has no `videoFile` or the hook was constructed without + * a `mediaFolderPath`. */ onRenameContextMenuClick: (row: UIMediaFileDataRow) => void } @@ -43,22 +51,13 @@ export interface RenameVideoFileFlow { * Encapsulates the "rename the selected video file" right-click flow that * `TvShowPanel` and `MoviePanel` inject into `MediaFileTable`. * - * The flow: - * 1. Resolves the row's video path relative to the media folder and pre-fills - * the rename dialog with that relative path. - * 2. On confirm, calls `renameFiles` for the video file plus every associated - * file (same stem, any extension) in one batch. - * 3. Calls `onAfterRename` (if provided) to let the panel sync local state. - * 4. Refetches the media folder metadata so the table reflects the rename. - * 5. Toasts success / failure. - * - * Mirrors the long-standing `TvShowEpisodeTable` rename behavior; extracted - * into a hook so `MediaFileTable` can stay free of rename business logic. + * TV (`mode: "episode"`) + v3: `POST /api/rename-episode-file` → Core. + * Otherwise: client expands associates and calls `POST /api/renameFiles`. */ export function useRenameVideoFileFlow( options: UseRenameVideoFileFlowOptions, ): RenameVideoFileFlow { - const { mediaFolderPath, files, onAfterRename } = options + const { mediaFolderPath, files, mode = "generic", onAfterRename } = options const { t } = useTranslation(["components", "dialogs"]) const { renameFileDialog } = useDialogs() const [openRename] = renameFileDialog @@ -80,18 +79,26 @@ export function useRenameVideoFileFlow( if (!row.videoFile) return const newAbsolutePath = join(mediaFolderPath, newRelativePath) try { - const assocRenames = computeAssociatedFileRenames( - row.videoFile, - newAbsolutePath, - files, - ) - await renameFiles({ - files: [ - { from: row.videoFile, to: newAbsolutePath }, - ...assocRenames, - ], - mediaFolder: Path.posix(mediaFolderPath), - }) + if (mode === "episode" && isSmmV3Enabled()) { + await renameEpisodeFileViaCore({ + mediaFolder: Path.posix(mediaFolderPath), + from: row.videoFile, + to: newAbsolutePath, + }) + } else { + const assocRenames = computeAssociatedFileRenames( + row.videoFile, + newAbsolutePath, + files, + ) + await renameFiles({ + files: [ + { from: row.videoFile, to: newAbsolutePath }, + ...assocRenames, + ], + mediaFolder: Path.posix(mediaFolderPath), + }) + } await onAfterRename?.() await fetchMediaMetadata({ path: mediaFolderPath }) toast.success(t("episodeFile.renameSuccess")) @@ -113,7 +120,15 @@ export function useRenameVideoFileFlow( }, ) }, - [mediaFolderPath, files, onAfterRename, openRename, fetchMediaMetadata, t], + [ + mediaFolderPath, + files, + mode, + onAfterRename, + openRename, + fetchMediaMetadata, + t, + ], ) return { onRenameContextMenuClick } diff --git a/docs/dev/rename-episode-file.md b/docs/dev/rename-episode-file.md new file mode 100644 index 00000000..651c7726 --- /dev/null +++ b/docs/dev/rename-episode-file.md @@ -0,0 +1,334 @@ +# Rename Episode File (context-menu rename → Core) + +This design document is the **golden source** for porting the **TV episode context-menu “Rename”** flow into Layer 2 Core, then wiring every frontend through the Internal HTTP API. + +**Naming:** Core / HTTP / CLI use **`renameEpisodeFile`** only. Do **not** name this `renameVideoFile` — that is too broad. This API renames an **episode’s linked media file** (and same-stem associates) inside a **TV show** folder. It does **not** support renaming arbitrary video files (e.g. orphan files, music videos, or movie-panel videos). + +Sibling docs (different features — do not confuse): + +| Doc / feature | What it renames | +|---------------|-----------------| +| **This doc** | One **episode** file (+ same-stem associates) via right-click **重命名** | +| [rename-folder](../api/index.md) / `Core.renameFolder` | Entire **media folder** directory | +| `smm try-to-rename` / rule-based toolbar | Batch plex/emby **naming-rule** plans | +| AI `begin/add/end-rename-files-task` | AI-authored rename **plans** | +| Movie panel “Rename” | Separate legacy path (`useRenameVideoFileFlow` / `/api/renameFiles`) — **out of scope** for this Core API | + +Architecture baseline: [refactoring.md](../../refactoring.md). Pattern reference: [scrape.md](./scrape.md) (Core parity → HTTP → UI v3). + +## 1. Background + +### 1.1 Legacy UX (must preserve) + +In the **TV show** panel, the user right-clicks an episode row → **Rename** / **重命名**: + +1. A rename dialog opens, pre-filled with the episode file’s path **relative to the media folder** (e.g. `S01E01.mp4` or `Season 01\….mkv`). +2. Confirm is disabled while the value is unchanged; enabled when the user edits it. +3. On confirm: + - Rename the **episode file** to the new absolute path (media folder + new relative path). + - Rename every **associated file** that shares that file’s stem (e.g. `S01E01.srt`, `S01E01.en.srt`, `S01E01.ass`) to the new stem, **keeping each associate’s directory**. +4. Persist updated `mediaFiles` / `files` in metadata and refresh the UI. + +E2E coverage: `apps/e2e/common/tv/TVShow-RenameEpisodeFile.e2e.ts`. + +### 1.2 Scope boundary (important) + +| In scope | Out of scope | +|----------|----------------| +| `tvshow-folder` with episode linked in `mediaFiles` (`seasonNumber` + `episodeNumber`) | Movie folder / movie panel rename | +| Context-menu rename of that episode’s primary file + same-stem associates | Arbitrary path under the folder that is not an episode `mediaFiles` entry | +| | Rule-based / AI plan batch rename | +| | Folder rename | + +Core **must reject** requests where `from` is not the `absolutePath` of a TV episode entry in `mediaFiles`. This is what keeps the API from becoming a generic “rename any video” endpoint. + +### 1.3 Legacy implementation (business logic in UI) + +| Piece | Location | Role today | +|-------|----------|------------| +| Context menu entry | `TvShowPanel` → `MediaFileTable` extra menu; also `TvShowEpisodeTable` | Opens rename dialog for an episode row | +| Flow hook | `apps/ui/src/hooks/useRenameVideoFileFlow.ts` | Shared hook today (also used by Movie) — **TV v3 path** should call `renameEpisodeFile`; movie stays on legacy | +| Associate expansion | `computeAssociatedFileRenames` in `apps/ui/src/components/episode-file.tsx` | Stem-based sibling renames | +| HTTP | `POST /api/renameFiles` (`packages/core-routes`) | Generic batch rename + optional metadata update + broadcast | +| Metadata helper | `updateMediaMetadataAfterRename` in `packages/core/mediaMetadata.ts` | Already shared — keep / call from Core | + +Problem: associate discovery and orchestration live in **Layer 1**. Electron / OHOS / Web all depend on UI code; Core cannot be unit-tested as the source of truth; `/api/renameFiles` is a low-level batch API that still expects the client to build the full rename list and does not enforce “episode only”. + +### 1.4 Goal + +1. **Port** legacy **episode** rename orchestration into `apps/core` (FsPort + metadata), with episode identity checks. +2. Expose a **thin Internal HTTP** command that only validates and calls Core. +3. Point **TV** frontends (Web UI, Electron, OHOS — shared `apps/ui`) at that API under `smm.v3.enabled`. +4. Leave movie rename, rule-based / AI **plans**, and **folder** rename out of this workstream. + +### 1.5 Delivery phases + +1. **Core** — `Core.renameEpisodeFile` with episode check + associate expansion + disk + metadata +2. **HTTP** — `POST /api/rename-episode-file` → Core; Socket/metadata broadcast parity with today’s `/api/renameFiles` +3. **UI v3 (TV only)** — episode context-menu confirm calls the new API; no client-side `computeAssociatedFileRenames` when v3 is on +4. **Cleanup** — optional: dual paths for TV; keep generic `/api/renameFiles` for movie / plan / AI until those migrate + +## 2. Architecture + +### 2.1 Project Level Architecture + +``` +Layer 1: Web UI / Electron / OHOS / (optional CLI) + │ + │ POST /api/rename-episode-file (new, thin) + │ mediaMetadataUpdated (Socket.IO) — same as today + ▼ +Layer 3: apps/cli Hono (or core-routes adapter) + │ + │ Core.renameEpisodeFile(...) + ▼ +Layer 2: apps/core + assertEpisodeMediaFile (tvshow-folder + mediaFiles match) + expandAssociatedFileRenames (port of computeAssociatedFileRenames) + FsPort.mkdir / rename (batch) + updateMediaMetadataAfterRename + setMetadata +``` + +Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog relative path) and renders results; **no** associate math and **no** metadata rewrite in Layer 1 for the v3 path. + +### 2.2 App Level Architecture + +| Piece | Role | +|-------|------| +| `Core.renameEpisodeFile(input)` | Assert TV episode; expand associates; rename; update metadata; return result | +| `assertEpisodeMediaFile` | Folder is `tvshow-folder`; `from` matches a `mediaFiles` entry with season/episode | +| `expandAssociatedFileRenames` | Port of UI `computeAssociatedFileRenames` (stem + suffix; same directory) | +| `updateMediaMetadataAfterRename` | Existing pure helper — Core must apply it after successful disk renames | +| `POST /api/rename-episode-file` | Body → Core → `{ data }` / `{ error }`, HTTP 200 | +| Broadcast | After success: `mediaMetadataUpdated` for the folder (parity with `/api/renameFiles`) | +| UI TV context menu | v3 ON → new API with `{ mediaFolder, from, to }` only; v3 OFF → legacy client expand + `/api/renameFiles` | +| CLI (optional phase) | `smm rename-episode-file --from … --to …` for operators / debugging | + +### 2.3 Key Design + +#### Decisions + +| Topic | Choice | +|-------|--------| +| Naming | **`renameEpisodeFile`** / `/api/rename-episode-file` only — never `renameVideoFile` | +| Response envelope | `{ data?, error? }`, HTTP **200** (same as import / scrape / rename-folder) | +| Error string | `Error Reason: ` | +| Sync vs job | **Synchronous** command (small batch). No JobStore / plan. | +| Client payload | One primary **episode file** rename (`from` / `to`); Core expands associates | +| Associate semantics | **Legacy context-menu**: same directory, stem prefix match (`stem` or `stem.*`) — **not** rule-based season-folder move | +| Folder type | **`tvshow-folder` only** | +| Episode identity | `from` must equal a `mediaFiles[].absolutePath` that has `seasonNumber` and `episodeNumber` | +| Movie | **Out of scope** — keep legacy `/api/renameFiles` | +| Feature flag | `smm.v3.enabled` — mirror scrape / rename-folder | +| Existing `/api/renameFiles` | Keep for movie, rule-based, AI plan apply, and v3-off TV path | + +#### Prerequisites + +Fail before any disk write when: + +- `mediaFolder` is missing / empty +- Folder is not managed by SMM +- Metadata missing or `type !== "tvshow-folder"` +- `from` / `to` missing +- `from === to` (dialog already disables confirm; API should still reject) +- `from` is not under the media folder +- Destination `to` would escape the media folder +- **`from` is not a linked episode file** in `mediaFiles` (no matching entry with both `seasonNumber` and `episodeNumber`) + +#### Associate expansion (parity with UI) + +Port `computeAssociatedFileRenames(episodeOld, episodeNew, allFilesInFolder)`: + +1. Derive old/new **basename stems** (strip last extension). +2. If stems empty or equal → no associates. +3. For each path in the folder file list (excluding the episode file itself): if basename is `oldStem` or starts with `oldStem + '.'`, map to `dirname(assoc) + newStem + suffix`. +4. Primary list for disk ops: `[{ from: episodeOld, to: episodeNew }, ...associates]`. + +Core should obtain `allFilesInFolder` via `FsPort.listFiles(mediaFolder)` (or accept an optional list for tests). Do **not** rely on deprecated `metadata.files` alone. + +#### Runtime flow + +1. Normalize `mediaFolder`, `from`, `to` to POSIX internally; platform paths for FsPort as needed. +2. Validate managed + `tvshow-folder` + episode `mediaFiles` match + paths in folder. +3. `listFiles` → `expandAssociatedFileRenames`. +4. For each pair: ensure parent of `to` exists; `rename(from, to)`. +5. On full or partial success: `updateMediaMetadataAfterRename` for successful pairs; `setMetadata`; broadcast `mediaMetadataUpdated`. +6. Return `{ succeeded, failed }` (same shape spirit as today’s renameFiles `data`). + +Partial failure policy: match `/api/renameFiles` — report per-path failures; still update metadata for successful renames. + +#### Core signature + +```ts +export interface RenameEpisodeFileInput { + /** Absolute media folder path (platform or POSIX; Core normalizes). */ + mediaFolderPath: string + /** Absolute current episode file path (must be a linked mediaFiles entry). */ + from: string + /** Absolute desired episode file path (under the same media folder tree). */ + to: string +} + +export interface RenameEpisodeFileResult { + /** Successful renames (episode file + associates). */ + succeeded: Array<{ from: string; to: string }> + failed: Array<{ path: string; error: string }> +} + +Core.renameEpisodeFile(input: RenameEpisodeFileInput): Promise +``` + +Optional convenience: accept `toRelative` instead of absolute `to` and join inside Core — either is fine if HTTP and UI agree. + +#### Legacy sources to port (do not call UI from Core) + +- Associate logic: `apps/ui/src/components/episode-file.tsx` → `computeAssociatedFileRenames` +- Orchestration (TV): `useRenameVideoFileFlow` / `TvShowEpisodeTable` context menu — only the **episode** confirm path +- Metadata: `packages/core/mediaMetadata.ts` → `updateMediaMetadataAfterRename` +- Preflight: shared `packages/core/validations/rename` (`validateRenameOperations` + FS probe) — path rules, source must exist, dest must not; refuse entire batch before any rename + +Note: Core already has `findAssociatedFiles` for **rule-based** apply (extension allowlists + season moves). Context-menu rename must keep **`computeAssociatedFileRenames` semantics**, not silently switch to `findAssociatedFiles`. + +## 3. HTTP API + +All endpoints: **HTTP 200**. Clients use `error` for business failure and `data` for success. + +### 3.1 `POST /api/rename-episode-file` (new) + +**Request** + +```ts +interface RenameEpisodeFileRequestBody { + /** Absolute media folder path. */ + mediaFolder: string + /** Absolute current episode file path. */ + from: string + /** Absolute target episode file path. */ + to: string +} +``` + +**Success** + +```json +{ + "data": { + "succeeded": [ + { "from": "…/S01E01.mp4", "to": "…/S01E01_renamed.mp4" }, + { "from": "…/S01E01.srt", "to": "…/S01E01_renamed.srt" } + ], + "failed": [] + } +} +``` + +**Failure** + +```json +{ + "error": "Error Reason: …" +} +``` + +| Condition | Detail after `Error Reason: ` | +|-----------|-------------------------------| +| Missing fields | `mediaFolder is required` / `from is required` / `to is required` | +| Unmanaged folder | `{path} is not managed by SMM` | +| Not TV show | `Folder is not a TV show: {path}` | +| Not an episode file | `File is not a linked episode: {from}` | +| Path outside folder | `Path is outside media folder: …` | +| Validation / IO | Human-readable rename validation or fs error | + +Unexpected route exceptions also return `{ error: "Error Reason: …" }` with HTTP 200. + +After success, server broadcasts `mediaMetadataUpdated` for the folder (clients invalidate / refetch metadata queries). + +### 3.2 Existing `POST /api/renameFiles` + +Unchanged in phases 1–3. Still used by: + +- Movie panel rename +- Rule-based rename confirm (`applyRenameFilesPlanForTvShow`) +- AI rename confirm +- v3 **off** TV context-menu path + +## 4. User Stories + +### 4.1 Rename episode file + associates (happy path) + +* **Given** a managed TV folder with episode `S01E01` linked to `S01E01.mp4` and `S01E01.srt` +* **When** the user renames via context menu to `S01E01_renamed.mp4` +* **Then** both files are renamed on disk +* **And** metadata `mediaFiles` paths update +* **And** UI shows the new name after refetch / socket + +```mermaid +sequenceDiagram + participant U as UI + participant H as HTTP + participant Core as Core + + U->>U: dialog relative path + U->>H: POST /api/rename-episode-file { mediaFolder, from, to } + H->>Core: renameEpisodeFile(...) + Core->>Core: assert tvshow + episode mediaFiles + Core->>Core: listFiles + expand associates + Core->>Core: FsPort.rename batch + Core->>Core: setMetadata + broadcast + Core-->>H: { succeeded, failed } + H-->>U: { data } + U->>U: toast + refetch / query invalidate +``` + +### 4.2 Reject non-episode file + +* **Given** a managed TV folder +* **When** the client calls `renameEpisodeFile` with a `from` path that is not in `mediaFiles` as an episode +* **Then** response is `{ error: "Error Reason: File is not a linked episode: …" }` +* **And** no files are renamed + +### 4.3 v3 off keeps legacy path + +* **Given** `smm.v3.enabled` is false +* **When** the user uses TV context-menu Rename +* **Then** UI still expands associates and calls `POST /api/renameFiles` + +### 4.4 v3 on all TV frontends + +* **Given** Web, Electron, or OHOS with shared UI and v3 enabled +* **When** TV episode context-menu Rename confirms +* **Then** only `POST /api/rename-episode-file` → Core runs (no UI `computeAssociatedFileRenames`) + +### 4.5 Prerequisite / validation failure + +* **Given** an unmanaged path, movie folder, or `to` outside the media folder +* **When** the client calls `POST /api/rename-episode-file` +* **Then** response is `{ error: "Error Reason: …" }` +* **And** no files are renamed + +## 5. Out of scope + +- Media **folder** rename (`Core.renameFolder` / `/api/rename-folder`) +- **Movie** panel / movie-folder file rename (keep legacy) +- Renaming **arbitrary** files or videos not linked as episodes in `mediaFiles` +- Toolbar **plex/emby** rule-based batch rename / `try-to-rename` plans +- AI / MCP rename **plan** tools (`begin/add/end-rename-files-task`) +- Changing dialog UX (suggestions, i18n) beyond swapping the confirm API +- Replacing `/api/renameFiles` for plan apply / movie in this milestone +- Job-based progress UI for a single rename + +## 6. Testing strategy + +- **Core unit:** episode assert accepts linked `mediaFiles` entry and rejects unlinked paths / movie folders; stem associate expansion matches `computeAssociatedFileRenames` fixtures (`S01E01.en.srt` → new stem); rejects unmanaged / escape paths; metadata updated via `updateMediaMetadataAfterRename`. +- **HTTP route tests:** success returns `data.succeeded`; validation / not-episode / not-TV errors. +- **UI unit:** TV context-menu flow with v3 on posts `{ mediaFolder, from, to }` to `/api/rename-episode-file` only; v3 off keeps legacy; movie panel unchanged. +- **E2E:** `TVShow-RenameEpisodeFile.e2e.ts` must pass with v3 on against Core path (Web / Electron / OHOS / Docker as already tagged). + +## 7. Compatibility notes + +- Relative path editing stays in the dialog (Layer 1); Core receives absolute `from` / `to`. +- Do not conflate with `buildTvShowRenameListForPlan` (moves associates into season folders). +- `metadata.files` is legacy; prefer disk `listFiles` inside Core for associate discovery. +- Until movie / plan / AI flows migrate, `/api/rename-episode-file` and `/api/renameFiles` coexist. +- Feature flag: same `isSmmV3Enabled()` used by folder rename and scrape UI. +- Legacy hook name `useRenameVideoFileFlow` may remain until refactored; the **Core/HTTP contract** must still be `renameEpisodeFile`. diff --git a/packages/core-routes/src/index.ts b/packages/core-routes/src/index.ts index d99e7563..1e3a4565 100644 --- a/packages/core-routes/src/index.ts +++ b/packages/core-routes/src/index.ts @@ -13,6 +13,7 @@ export { rejectUnauthorized, } from "./auth.ts"; export { validatePathIsInAllowlist } from "./allowlist.ts"; +export { createNodeRenameFileExistenceProbe } from "./nodeRenameFileExistenceProbe.ts"; export { resolveMcpAdvertisedHost, resolveMcpBindAddress, diff --git a/packages/core-routes/src/nodeRenameFileExistenceProbe.ts b/packages/core-routes/src/nodeRenameFileExistenceProbe.ts new file mode 100644 index 00000000..aab69068 --- /dev/null +++ b/packages/core-routes/src/nodeRenameFileExistenceProbe.ts @@ -0,0 +1,37 @@ +import { stat } from "node:fs/promises"; +import { Path } from "@smm/core/path"; +import type { RenameFileExistenceProbe } from "@smm/core/validations/rename/validateRenameFileExistence"; + +function statWithTimeout( + filePath: string, + timeoutMs: number, +): Promise>> { + return Promise.race([ + stat(filePath), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`stat timeout for path: ${filePath}`)), + timeoutMs, + ), + ), + ]); +} + +/** + * Node/Bun probe for rename FS preflight (`node:fs` at the host boundary). + * Paths are POSIX; converted to platform paths before `stat`. + */ +export function createNodeRenameFileExistenceProbe( + timeoutMs: number = 1000, +): RenameFileExistenceProbe { + return { + async isFile(posixPath: string): Promise { + try { + const stats = await statWithTimeout(Path.toPlatformPath(posixPath), timeoutMs); + return stats.isFile(); + } catch { + return false; + } + }, + }; +} diff --git a/packages/core-routes/src/renameFilesValidation.ts b/packages/core-routes/src/renameFilesValidation.ts index beb85904..ffa6d53e 100644 --- a/packages/core-routes/src/renameFilesValidation.ts +++ b/packages/core-routes/src/renameFilesValidation.ts @@ -1,7 +1,6 @@ -import { stat } from "node:fs/promises"; -import { Path } from "@smm/core/path"; -import { validateRenameOperationsSync } from "@smm/core/validations/rename/validateRenameOperationsSync"; +import { validateRenameOperations as validateRenameOperationsShared } from "@smm/core/validations/rename/validateRenameOperations"; import type { RenameValidationResult } from "@smm/core/types"; +import { createNodeRenameFileExistenceProbe } from "./nodeRenameFileExistenceProbe.ts"; /** * A single rename operation: source path and target path. @@ -12,155 +11,16 @@ export interface RenameFile { } /** - * Validate a batch of rename operations. Mirrors the original - * `validateRenameOperations` from - * `apps/cli/src/tools/renameFilesInBatch.ts` but is self-contained - * (no `apps/cli` imports) so it works on both Bun (`apps/cli`) and - * Node.js (`apps/ohos` Electron main process). - * - * Validation consists of: - * 1. POSIX-normalising both `from` and `to` paths. - * 2. Calling the runtime-neutral - * {@link validateRenameOperationsSync} for syntax/structure - * checks. - * 3. Checking that each source file exists on disk via - * `node:fs/promises#stat`. - * 4. Checking that each target path is not an existing file - * (renaming onto an existing file is rejected). + * Validate a batch of rename operations. Self-contained for Bun (`apps/cli`) + * and Node (`apps/ohos` / Electron main): shared path rules + `node:fs` probe. */ export async function validateRenameOperations( files: RenameFile[], folderPathInPosix: string, ): Promise { - const normalizedTasks: RenameFile[] = []; - - for (let i = 0; i < files.length; i++) { - const renameOp = files[i]; - if (!renameOp) { - continue; - } - - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to), - }); - } - - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [], - }; - } - - const syncResult = validateRenameOperationsSync( - normalizedTasks, + return validateRenameOperationsShared( + files, folderPathInPosix, + createNodeRenameFileExistenceProbe(), ); - const errors = [...syncResult.errors]; - - const sourceExistResult = await validateSourceFileExist(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - errors.push( - `Source file "${missingFile}" does not exist in the media folder`, - ); - } - } - - const destNotExistResult = await validateDestFileNotExist(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - errors.push( - `Target file "${existingFile}" already exists in the filesystem`, - ); - } - } - - if (errors.length > 0) { - return { - isValid: false, - errors, - validatedRenames: [], - }; - } - - return syncResult; -} - -/** - * Returns true if every source file in `tasks` exists on disk and - * is a regular file (not a directory). - */ -async function validateSourceFileExist( - tasks: RenameFile[], -): Promise<{ isValid: boolean; missingFiles: string[] }> { - const missingFiles: string[] = []; - - for (const task of tasks) { - if (!task) continue; - - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await stat(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch { - // File doesn't exist or can't be accessed - missingFiles.push(task.from); - } - } - - return { - isValid: missingFiles.length === 0, - missingFiles, - }; -} - -/** - * Returns true if none of the destination paths in `tasks` is an - * existing file. Wraps `stat` in a 1-second timeout so the - * validation does not hang on unresponsive filesystems. - */ -async function validateDestFileNotExist( - tasks: RenameFile[], -): Promise<{ isValid: boolean; existingFiles: string[] }> { - const existingFiles: string[] = []; - - for (const task of tasks) { - if (!task) continue; - - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await statWithTimeout(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - // File doesn't exist or timeout — that's the desired state - continue; - } - } - - return { - isValid: existingFiles.length === 0, - existingFiles, - }; -} - -function statWithTimeout( - filePath: string, - timeoutMs: number = 1000, -): Promise>> { - return Promise.race([ - stat(filePath), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`stat timeout for path: ${filePath}`)), - timeoutMs, - ), - ), - ]); } diff --git a/packages/core-routes/src/validateRenameOperations.ts b/packages/core-routes/src/validateRenameOperations.ts index cf5d53d7..07b6861f 100644 --- a/packages/core-routes/src/validateRenameOperations.ts +++ b/packages/core-routes/src/validateRenameOperations.ts @@ -1,107 +1,23 @@ -import { stat } from "node:fs/promises"; -import { Path } from "@smm/core/path"; +import { validateRenameOperations as validateRenameOperationsShared } from "@smm/core/validations/rename/validateRenameOperations"; import type { RenameValidationResult } from "@smm/core/types"; -import { validateRenameOperationsSync } from "@smm/core/validations/rename/validateRenameOperationsSync"; +import { createNodeRenameFileExistenceProbe } from "./nodeRenameFileExistenceProbe.ts"; interface RenameFile { from: string; to: string; } -async function validateSourceFileExist( - tasks: RenameFile[], -): Promise<{ isValid: boolean; missingFiles: string[] }> { - const missingFiles: string[] = []; - - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await stat(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch { - missingFiles.push(task.from); - } - } - - return { - isValid: missingFiles.length === 0, - missingFiles, - }; -} - -async function validateDestFileNotExist( - tasks: RenameFile[], -): Promise<{ isValid: boolean; existingFiles: string[] }> { - const existingFiles: string[] = []; - - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await stat(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; - } - } - - return { - isValid: existingFiles.length === 0, - existingFiles, - }; -} - +/** + * Host-side rename preflight for Node/Bun (path rules + FS existence). + * Delegates to shared `@smm/core` validators with a `node:fs` probe. + */ export async function validateRenameOperations( files: RenameFile[], folderPathInPosix: string, ): Promise { - const normalizedTasks: RenameFile[] = []; - - for (const renameOp of files) { - if (!renameOp) { - continue; - } - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to), - }); - } - - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [], - }; - } - - const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); - const errors = [...syncResult.errors]; - - const sourceExistResult = await validateSourceFileExist(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - errors.push(`Source file "${missingFile}" does not exist in the media folder`); - } - } - - const destNotExistResult = await validateDestFileNotExist(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - errors.push(`Target file "${existingFile}" already exists in the filesystem`); - } - } - - if (errors.length > 0) { - return { - isValid: false, - errors, - validatedRenames: [], - }; - } - - return syncResult; + return validateRenameOperationsShared( + files, + folderPathInPosix, + createNodeRenameFileExistenceProbe(), + ); } diff --git a/packages/core/validations/rename/index.ts b/packages/core/validations/rename/index.ts index 264f7d73..777a5e9e 100644 --- a/packages/core/validations/rename/index.ts +++ b/packages/core/validations/rename/index.ts @@ -1,4 +1,5 @@ export type { RenameOperation } from './types' +export type { RenameFileExistenceProbe } from './validateRenameFileExistence' export { validateNoDuplicatedSourceFile } from './validateNoDuplicatedSourceFile' export { validateNoDuplicatedDestFile } from './validateNoDuplicatedDestFile' export { validateNoIdenticalSourceAndDestFile } from './validateNoIdenticalSourceAndDestFile' @@ -6,3 +7,9 @@ export { validateChainingConflicts } from './validateChainingConflicts' export { validatePathWithinMediaFolder } from './validatePathWithinMediaFolder' export { validateNoAbnormalPaths } from './validateNoAbnormalPaths' export { validateRenameOperationsSync } from './validateRenameOperationsSync' +export { + validateSourceFilesExist, + validateDestFilesNotExist, +} from './validateRenameFileExistence' +export { validateRenameOperations } from './validateRenameOperations' + diff --git a/packages/core/validations/rename/validateRenameFileExistence.ts b/packages/core/validations/rename/validateRenameFileExistence.ts new file mode 100644 index 00000000..22d68f30 --- /dev/null +++ b/packages/core/validations/rename/validateRenameFileExistence.ts @@ -0,0 +1,63 @@ +import type { RenameOperation } from "./types" + +/** + * Runtime-agnostic probe for rename FS preflight. + * Paths are expected in POSIX form (callers normalize first). + */ +export interface RenameFileExistenceProbe { + /** True iff the path exists and is a regular file. */ + isFile(path: string): Promise +} + +/** + * Every `from` must exist as a regular file. + * Mirrors legacy `/api/renameFiles` source existence preflight. + */ +export async function validateSourceFilesExist( + tasks: RenameOperation[], + probe: RenameFileExistenceProbe, +): Promise<{ isValid: boolean; missingFiles: string[] }> { + const missingFiles: string[] = [] + + for (const task of tasks) { + try { + if (!(await probe.isFile(task.from))) { + missingFiles.push(task.from) + } + } catch { + missingFiles.push(task.from) + } + } + + return { + isValid: missingFiles.length === 0, + missingFiles, + } +} + +/** + * No `to` may already exist as a regular file. + * Mirrors legacy `/api/renameFiles` destination preflight. + */ +export async function validateDestFilesNotExist( + tasks: RenameOperation[], + probe: RenameFileExistenceProbe, +): Promise<{ isValid: boolean; existingFiles: string[] }> { + const existingFiles: string[] = [] + + for (const task of tasks) { + try { + if (await probe.isFile(task.to)) { + existingFiles.push(task.to) + } + } catch { + // Unreadable / missing — treat as free destination + continue + } + } + + return { + isValid: existingFiles.length === 0, + existingFiles, + } +} diff --git a/packages/core/validations/rename/validateRenameOperations.test.ts b/packages/core/validations/rename/validateRenameOperations.test.ts new file mode 100644 index 00000000..8b9afbb6 --- /dev/null +++ b/packages/core/validations/rename/validateRenameOperations.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest" +import type { RenameFileExistenceProbe } from "./validateRenameFileExistence" +import { + validateDestFilesNotExist, + validateSourceFilesExist, +} from "./validateRenameFileExistence" +import { validateRenameOperations } from "./validateRenameOperations" + +function probeFromSet(files: Set): RenameFileExistenceProbe { + return { + isFile: async (path) => files.has(path), + } +} + +describe("validateSourceFilesExist / validateDestFilesNotExist", () => { + it("flags missing sources", async () => { + const probe = probeFromSet(new Set(["/m/a.mp4"])) + const result = await validateSourceFilesExist( + [ + { from: "/m/a.mp4", to: "/m/b.mp4" }, + { from: "/m/missing.mp4", to: "/m/c.mp4" }, + ], + probe, + ) + expect(result.isValid).toBe(false) + expect(result.missingFiles).toEqual(["/m/missing.mp4"]) + }) + + it("flags existing destinations", async () => { + const probe = probeFromSet(new Set(["/m/a.mp4", "/m/b.mp4"])) + const result = await validateDestFilesNotExist( + [{ from: "/m/a.mp4", to: "/m/b.mp4" }], + probe, + ) + expect(result.isValid).toBe(false) + expect(result.existingFiles).toEqual(["/m/b.mp4"]) + }) +}) + +describe("validateRenameOperations", () => { + const folder = "/m/Show" + + it("passes when paths are valid and FS state is free", async () => { + const probe = probeFromSet(new Set([`${folder}/S01E01.mp4`, `${folder}/S01E01.srt`])) + const result = await validateRenameOperations( + [ + { from: `${folder}/S01E01.mp4`, to: `${folder}/S01E01_renamed.mp4` }, + { from: `${folder}/S01E01.srt`, to: `${folder}/S01E01_renamed.srt` }, + ], + folder, + probe, + ) + expect(result.isValid).toBe(true) + expect(result.validatedRenames).toHaveLength(2) + }) + + it("refuses the whole batch when dest already exists", async () => { + const probe = probeFromSet( + new Set([`${folder}/S01E01.mp4`, `${folder}/S01E01_renamed.mp4`]), + ) + const result = await validateRenameOperations( + [{ from: `${folder}/S01E01.mp4`, to: `${folder}/S01E01_renamed.mp4` }], + folder, + probe, + ) + expect(result.isValid).toBe(false) + expect(result.validatedRenames).toEqual([]) + expect(result.errors.some((e) => e.includes("already exists"))).toBe(true) + }) + + it("refuses the whole batch when a path is outside the folder", async () => { + const probe = probeFromSet(new Set(["/other/a.mp4"])) + const result = await validateRenameOperations( + [{ from: "/other/a.mp4", to: `${folder}/b.mp4` }], + folder, + probe, + ) + expect(result.isValid).toBe(false) + expect(result.validatedRenames).toEqual([]) + }) +}) diff --git a/packages/core/validations/rename/validateRenameOperations.ts b/packages/core/validations/rename/validateRenameOperations.ts new file mode 100644 index 00000000..dbede1f9 --- /dev/null +++ b/packages/core/validations/rename/validateRenameOperations.ts @@ -0,0 +1,66 @@ +import { Path } from "../../path" +import type { RenameValidationResult } from "../../types" +import type { RenameOperation } from "./types" +import { + type RenameFileExistenceProbe, + validateDestFilesNotExist, + validateSourceFilesExist, +} from "./validateRenameFileExistence" +import { validateRenameOperationsSync } from "./validateRenameOperationsSync" + +/** + * Full rename preflight: path/batch rules + source exists + dest must not exist. + * + * On any failure, returns `validatedRenames: []` so callers can refuse the + * entire batch (legacy `/api/renameFiles` semantics). + */ +export async function validateRenameOperations( + files: RenameOperation[], + folderPathInPosix: string, + probe: RenameFileExistenceProbe, +): Promise { + const normalizedTasks: RenameOperation[] = [] + + for (const renameOp of files) { + if (!renameOp) continue + normalizedTasks.push({ + from: Path.posix(renameOp.from), + to: Path.posix(renameOp.to), + }) + } + + if (normalizedTasks.length === 0) { + return { + isValid: true, + errors: [], + validatedRenames: [], + } + } + + const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix) + const errors = [...syncResult.errors] + + const sourceExistResult = await validateSourceFilesExist(normalizedTasks, probe) + if (!sourceExistResult.isValid) { + for (const missingFile of sourceExistResult.missingFiles) { + errors.push(`Source file "${missingFile}" does not exist in the media folder`) + } + } + + const destNotExistResult = await validateDestFilesNotExist(normalizedTasks, probe) + if (!destNotExistResult.isValid) { + for (const existingFile of destNotExistResult.existingFiles) { + errors.push(`Target file "${existingFile}" already exists in the filesystem`) + } + } + + if (errors.length > 0) { + return { + isValid: false, + errors, + validatedRenames: [], + } + } + + return syncResult +} From fe09ce700288645bd9f260083b7c6b987a31f349 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 20 Aug 2026 18:44:39 +0800 Subject: [PATCH 093/214] feat: expose renameEpisodeFile via CLI, AI tool, and MCP Add smm rename-episode-file, shared tool schemas with confirmation, and wire Core into chat/MCP hosts; document the Layer-1 surfaces. Co-authored-by: Cursor --- apps/cli/public/locales/en/tools.json | 3 + apps/cli/public/locales/zh-CN/tools.json | 3 + .../cli/resolvePathUnderMediaFolder.test.ts | 21 ++ .../src/cli/resolvePathUnderMediaFolder.ts | 26 +++ apps/cli/src/cli/runCli.ts | 35 +++- apps/cli/src/mcp/mcp.ts | 4 + apps/cli/src/route/chatRoute.ts | 19 +- apps/cli/src/test/ai-tool-registry.test.ts | 1 + apps/ohos/src/core-routes-loader.ts | 1 + apps/ohos/src/http/mcp.ts | 17 +- apps/ui/src/ai/Assistant.tsx | 2 + apps/ui/src/ai/tools/RenameEpisodeFile.tsx | 85 ++++++++ apps/ui/src/ai/tools/index.ts | 1 + docs/dev/rename-episode-file.md | 143 +++++++++++-- packages/core-routes/src/chat.ts | 2 + packages/core-routes/src/mcp/createServer.ts | 6 + packages/core-routes/src/mcp/index.ts | 7 +- packages/core-routes/src/mcp/mcp.test.ts | 43 ++++ .../src/mcp/toolHandlers/renameEpisodeFile.ts | 118 +++++++++++ packages/core-routes/src/mcp/types.ts | 14 ++ packages/core-routes/src/tools/index.ts | 16 +- .../src/tools/renameEpisodeFile.test.ts | 35 ++++ .../src/tools/renameEpisodeFile.ts | 196 ++++++++++++++++++ packages/core/ai-tool/registry.ts | 2 + .../ai-tool/renameEpisodeFileConfirm.test.ts | 21 ++ .../core/ai-tool/renameEpisodeFileConfirm.ts | 19 ++ .../core/ai-tool/renameEpisodeFileResult.ts | 59 ++++++ .../core/types/ai-tools/renameEpisodeFile.ts | 61 ++++++ 28 files changed, 922 insertions(+), 38 deletions(-) create mode 100644 apps/cli/src/cli/resolvePathUnderMediaFolder.test.ts create mode 100644 apps/cli/src/cli/resolvePathUnderMediaFolder.ts create mode 100644 apps/ui/src/ai/tools/RenameEpisodeFile.tsx create mode 100644 packages/core-routes/src/mcp/toolHandlers/renameEpisodeFile.ts create mode 100644 packages/core-routes/src/tools/renameEpisodeFile.test.ts create mode 100644 packages/core-routes/src/tools/renameEpisodeFile.ts create mode 100644 packages/core/ai-tool/renameEpisodeFileConfirm.test.ts create mode 100644 packages/core/ai-tool/renameEpisodeFileConfirm.ts create mode 100644 packages/core/ai-tool/renameEpisodeFileResult.ts create mode 100644 packages/core/types/ai-tools/renameEpisodeFile.ts diff --git a/apps/cli/public/locales/en/tools.json b/apps/cli/public/locales/en/tools.json index 3f574253..0085a04f 100644 --- a/apps/cli/public/locales/en/tools.json +++ b/apps/cli/public/locales/en/tools.json @@ -17,6 +17,9 @@ "rename-folder": { "description": "Rename a media folder. This is a destructive operation - the folder will be renamed on disk and metadata cache will be updated." }, + "rename-episode-file": { + "description": "Rename a linked TV episode video file and same-stem associates (e.g. subtitles). TV episode files only — not folders, movies, or bulk rename plans." + }, "begin-rename-task": { "description": "Begin a rename files task for a media folder. Call this first, then addRenameFileToTask for each file, then endRenameFilesTask. The user will be asked to confirm before files are renamed. Returns a task ID to use with addRenameFileToTask and endRenameFilesTask." }, diff --git a/apps/cli/public/locales/zh-CN/tools.json b/apps/cli/public/locales/zh-CN/tools.json index d589d893..1b4a90a1 100644 --- a/apps/cli/public/locales/zh-CN/tools.json +++ b/apps/cli/public/locales/zh-CN/tools.json @@ -17,6 +17,9 @@ "rename-folder": { "description": "重命名媒体文件夹。这是一个破坏性操作 - 文件夹将在磁盘上重命名,元数据缓存将被更新。" }, + "rename-episode-file": { + "description": "重命名已关联的电视剧集视频文件及其同名关联文件(如字幕)。仅用于剧集文件,不可用于文件夹、电影或批量重命名计划。" + }, "begin-rename-task": { "description": "为媒体文件夹开始重命名文件任务。首先调用此工具,然后为每个文件调用 addRenameFileToTask,最后调用 endRenameFilesTask。用户将被要求确认后才会重命名文件。返回一个任务 ID,用于 addRenameFileToTask 和 endRenameFilesTask。" }, diff --git a/apps/cli/src/cli/resolvePathUnderMediaFolder.test.ts b/apps/cli/src/cli/resolvePathUnderMediaFolder.test.ts new file mode 100644 index 00000000..a0b9481e --- /dev/null +++ b/apps/cli/src/cli/resolvePathUnderMediaFolder.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { resolvePathUnderMediaFolder } from './resolvePathUnderMediaFolder' + +describe('resolvePathUnderMediaFolder', () => { + const folder = '/m/Show' + + it('keeps absolute POSIX paths', () => { + expect(resolvePathUnderMediaFolder(folder, '/m/Show/S01E01.mp4')).toBe( + '/m/Show/S01E01.mp4', + ) + }) + + it('joins relative paths under the folder', () => { + expect(resolvePathUnderMediaFolder(folder, 'S01E01.mp4')).toBe( + '/m/Show/S01E01.mp4', + ) + expect(resolvePathUnderMediaFolder(folder, './Season 01/S01E01.mp4')).toBe( + '/m/Show/Season 01/S01E01.mp4', + ) + }) +}) diff --git a/apps/cli/src/cli/resolvePathUnderMediaFolder.ts b/apps/cli/src/cli/resolvePathUnderMediaFolder.ts new file mode 100644 index 00000000..2b2b0bfe --- /dev/null +++ b/apps/cli/src/cli/resolvePathUnderMediaFolder.ts @@ -0,0 +1,26 @@ +import { Path } from '@core/path' + +/** + * Resolve CLI `--from` / `--to` against the media folder. + * Absolute paths (POSIX or Windows) are normalized; relative paths + * are joined under `folder`. + */ +export function resolvePathUnderMediaFolder( + folder: string, + filePath: string, +): string { + const trimmed = filePath.trim() + const folderPosix = Path.posix(folder) + + if ( + trimmed.startsWith('/') || + /^[A-Za-z]:[/\\]/.test(trimmed) || + trimmed.startsWith('\\\\') + ) { + return Path.posix(trimmed) + } + + const relative = trimmed.replace(/\\/g, '/').replace(/^\.\//, '') + const prefix = folderPosix.endsWith('/') ? folderPosix : `${folderPosix}/` + return Path.posix(`${prefix}${relative}`) +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 82164827..ffcb1033 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -14,6 +14,7 @@ import { isFolderImported, resolveShowFolder, } from './folderDisplay' +import { resolvePathUnderMediaFolder } from './resolvePathUnderMediaFolder' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] const TYPE_CHOICES = [...FOLDER_TYPES, 'anime'] as const @@ -52,7 +53,7 @@ const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 const SCRAPE_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `job`, `config`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `rename-episode-file`, `job`, `config`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -300,6 +301,38 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('rename-episode-file') + .description( + 'Rename a linked TV episode file (+ same-stem associates) via Core', + ) + .argument('', 'Imported TV show media folder path') + .requiredOption('--from ', 'Current episode file path (absolute or relative to folder)') + .requiredOption('--to ', 'Target episode file path (absolute or relative to folder)') + .action(async (folder: string, opts: { from: string; to: string }) => { + try { + const from = resolvePathUnderMediaFolder(folder, opts.from) + const to = resolvePathUnderMediaFolder(folder, opts.to) + const result = await getCore().renameEpisodeFile({ + mediaFolderPath: folder, + from, + to, + }) + for (const pair of result.succeeded) { + console.log(`${pair.from} → ${pair.to}`) + } + for (const fail of result.failed) { + console.error(`FAILED ${fail.path}: ${fail.error}`) + } + if (result.failed.length > 0) { + exitCode = 1 + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + program .command('job') .description('Show job status by id (scrape: four task icon lines; import: JSON)') diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 315f5ff0..e28bb7e1 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -8,6 +8,7 @@ import { IS_FOLDER_EXIST } from "@smm/core/types/ai-tools/isFolderExist"; import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { GET_MEDIA_METADATA } from "@smm/core/types/ai-tools/getMediaMetadata"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; +import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -44,6 +45,7 @@ const TOOL_NAME_KEYS = [ GET_MEDIA_METADATA, LIST_FILES_KEY, RENAME_FOLDER, + RENAME_EPISODE_FILE, BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, END_RENAME_FILES_TASK, @@ -123,6 +125,7 @@ async function loadLocalizedToolDescriptions(): Promise> */ async function buildMcpConfig(): Promise { const { getUserConfig } = await import("@/utils/config"); + const { getCore } = await import("@/core/getCore"); return { getUserConfig, appDataDir: getAppDataDir(), @@ -132,6 +135,7 @@ async function buildMcpConfig(): Promise { broadcast: (message) => broadcast(message as Parameters[0]), toolDescriptions: await loadLocalizedToolDescriptions(), + renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), logger: { debug: (obj, msg) => logger.debug(obj, msg), info: (obj, msg) => logger.info(obj, msg), diff --git a/apps/cli/src/route/chatRoute.ts b/apps/cli/src/route/chatRoute.ts index 249471b8..06798df8 100644 --- a/apps/cli/src/route/chatRoute.ts +++ b/apps/cli/src/route/chatRoute.ts @@ -1,6 +1,7 @@ -import type { Hono } from 'hono'; -import { doChat, type ChatConfig } from '@smm/core-routes'; -import { logger } from '../../lib/logger'; +import type { Hono } from 'hono' +import { doChat, type ChatConfig } from '@smm/core-routes' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' /** * Register a Hono shell for `POST /api/chat` that delegates to @@ -14,8 +15,10 @@ import { logger } from '../../lib/logger'; export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { app.post('/api/chat', async (c) => { try { - const response = await doChat(chatConfig, c.req.raw); - return response; + const response = await doChat(chatConfig, c.req.raw, { + renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), + }) + return response } catch (error) { logger.error( { error: error instanceof Error ? { @@ -24,14 +27,14 @@ export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { name: error.name, } : error }, 'Chat route error:', - ); + ) return c.json( { error: 'Failed to process chat request', details: error instanceof Error ? error.message : 'Unknown error', }, 500, - ); + ) } - }); + }) } diff --git a/apps/cli/src/test/ai-tool-registry.test.ts b/apps/cli/src/test/ai-tool-registry.test.ts index e54721c3..49195a8b 100644 --- a/apps/cli/src/test/ai-tool-registry.test.ts +++ b/apps/cli/src/test/ai-tool-registry.test.ts @@ -57,6 +57,7 @@ const CONSTANT_NAME_TO_TOOL_NAME: Record = { GET_MEDIA_FOLDERS: 'get-media-folders', LIST_FILES_IN_MEDIA_FOLDER: 'list-files-in-media-folder', RENAME_FOLDER: 'rename-folder', + RENAME_EPISODE_FILE: 'rename-episode-file', BEGIN_RENAME_FILES_TASK: 'begin-rename-files-task', ADD_RENAME_FILE_TO_TASK: 'add-rename-file-to-task', END_RENAME_FILES_TASK: 'end-rename-files-task', diff --git a/apps/ohos/src/core-routes-loader.ts b/apps/ohos/src/core-routes-loader.ts index cb0ac3b1..e3fc8b6e 100644 --- a/apps/ohos/src/core-routes-loader.ts +++ b/apps/ohos/src/core-routes-loader.ts @@ -69,6 +69,7 @@ export interface CoreRoutesModule { */ MCP_TOOL_NAMES: { readonly RENAME_FOLDER: "rename-folder" + readonly RENAME_EPISODE_FILE: "rename-episode-file" } } diff --git a/apps/ohos/src/http/mcp.ts b/apps/ohos/src/http/mcp.ts index 3e522e88..cbdbbe9f 100644 --- a/apps/ohos/src/http/mcp.ts +++ b/apps/ohos/src/http/mcp.ts @@ -78,7 +78,10 @@ export function getMcpHandler( // `apps/ohos/node_modules`). The constant is added by the // `MCP_TOOL_NAMES` export from `@smm/core-routes/mcp/index.ts`. const mcpToolNames = coreRoutesModule.MCP_TOOL_NAMES as - | { readonly RENAME_FOLDER: string } + | { + readonly RENAME_FOLDER: string + readonly RENAME_EPISODE_FILE?: string + } | undefined if (!mcpToolNames) { throw new Error( @@ -86,15 +89,19 @@ export function getMcpHandler( ) } + const disabledTools = [mcpToolNames.RENAME_FOLDER] + if (mcpToolNames.RENAME_EPISODE_FILE) { + disabledTools.push(mcpToolNames.RENAME_EPISODE_FILE) + } + return createMcpStreamableHttpHandler({ getUserConfig: options.getUserConfig, appDataDir: options.appDataDir, userDataDir: options.userDataDir, activatePersistedFileAccess: options.activatePersistedFileAccess, - // HarmonyOS cannot rename folders — the sandbox denies the - // operation regardless of `user_config.selectedFolder`, so - // the MCP `tools/list` response must not advertise the tool. - disabledTools: [mcpToolNames.RENAME_FOLDER], + // HarmonyOS sandbox cannot rename folders/files via MCP — omit + // destructive rename tools from tools/list. + disabledTools, acknowledge: async ( message: unknown, timeoutMs?: number, diff --git a/apps/ui/src/ai/Assistant.tsx b/apps/ui/src/ai/Assistant.tsx index 03927b31..c70dda3f 100644 --- a/apps/ui/src/ai/Assistant.tsx +++ b/apps/ui/src/ai/Assistant.tsx @@ -19,6 +19,7 @@ import { GetEpisodesTool, GetMediaMetadataTool, RenameFolderTool, + RenameEpisodeFileTool, BeginRecognizeTaskTool, AddRecognizedMediaFileTool, EndRecognizeTaskTool, @@ -314,6 +315,7 @@ function AssistantImpl() { + {/* Rename/recognize task tools run HTTP plan APIs in the renderer. On desktop `AssistantChatTransport`, the same tools are executed server-side in `doChat` — mounting them here would duplicate diff --git a/apps/ui/src/ai/tools/RenameEpisodeFile.tsx b/apps/ui/src/ai/tools/RenameEpisodeFile.tsx new file mode 100644 index 00000000..d1949525 --- /dev/null +++ b/apps/ui/src/ai/tools/RenameEpisodeFile.tsx @@ -0,0 +1,85 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + RENAME_EPISODE_FILE, + RENAME_EPISODE_FILE_CONFIRMATION_TITLE, + RENAME_EPISODE_FILE_DESCRIPTION, + renameEpisodeFileInputSchema, +} from '@core/types/ai-tools/renameEpisodeFile' +import { buildRenameEpisodeFileConfirmationMessage } from '@core/ai-tool/renameEpisodeFileConfirm' +import { + renameEpisodeFileCancelled, + renameEpisodeFileFailed, + renameEpisodeFileSucceeded, +} from '@core/ai-tool/renameEpisodeFileResult' +import { formatToolError, requireNonEmptyString, toolOk } from '@core/ai-tool/toolResult' +import { renameEpisodeFile } from '@/api/renameEpisodeFile' +import { requestConfirmation } from '../confirmationBridge' + +const renameEpisodeFileTool = tool({ + description: RENAME_EPISODE_FILE_DESCRIPTION, + parameters: renameEpisodeFileInputSchema, + execute: async ({ mediaFolder, from, to }) => { + const folderCheck = requireNonEmptyString(mediaFolder, 'mediaFolder') + if (typeof folderCheck !== 'string') { + return renameEpisodeFileFailed('', '', '', folderCheck.error) + } + const fromCheck = requireNonEmptyString(from, 'from') + if (typeof fromCheck !== 'string') { + return renameEpisodeFileFailed(folderCheck, '', '', fromCheck.error) + } + const toCheck = requireNonEmptyString(to, 'to') + if (typeof toCheck !== 'string') { + return renameEpisodeFileFailed(folderCheck, fromCheck, '', toCheck.error) + } + + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage( + fromCheck, + toCheck, + ) + + const confirmed = await requestConfirmation(confirmationMessage, { + title: RENAME_EPISODE_FILE_CONFIRMATION_TITLE, + }) + + if (!confirmed) { + return renameEpisodeFileCancelled(folderCheck, fromCheck, toCheck) + } + + try { + const result = await renameEpisodeFile({ + mediaFolder: folderCheck, + from: fromCheck, + to: toCheck, + }) + if (result.error) { + return renameEpisodeFileFailed( + folderCheck, + fromCheck, + toCheck, + result.error, + ) + } + return toolOk( + renameEpisodeFileSucceeded( + folderCheck, + fromCheck, + toCheck, + result.data?.succeeded ?? [], + result.data?.failed ?? [], + ), + ) + } catch (error) { + return renameEpisodeFileFailed( + folderCheck, + fromCheck, + toCheck, + formatToolError(error).error, + ) + } + }, +}) + +export const RenameEpisodeFileTool = makeAssistantTool({ + ...renameEpisodeFileTool, + toolName: RENAME_EPISODE_FILE, +}) diff --git a/apps/ui/src/ai/tools/index.ts b/apps/ui/src/ai/tools/index.ts index 1f967c85..c34bd03e 100644 --- a/apps/ui/src/ai/tools/index.ts +++ b/apps/ui/src/ai/tools/index.ts @@ -5,6 +5,7 @@ export { GetApplicationContextTool, type ApplicationContextData } from './GetApp export { IsFolderExistTool } from './IsFolderExist'; export { GetEpisodesTool } from './GetEpisodes'; export { RenameFolderTool } from './RenameFolder'; +export { RenameEpisodeFileTool } from './RenameEpisodeFile'; export { BeginRecognizeTaskTool } from './BeginRecognizeTask'; export { AddRecognizedMediaFileTool } from './AddRecognizedMediaFile'; export { EndRecognizeTaskTool, cleanupRecognizePlan } from './EndRecognizeTask'; diff --git a/docs/dev/rename-episode-file.md b/docs/dev/rename-episode-file.md index 651c7726..0c4592e0 100644 --- a/docs/dev/rename-episode-file.md +++ b/docs/dev/rename-episode-file.md @@ -1,20 +1,20 @@ # Rename Episode File (context-menu rename → Core) -This design document is the **golden source** for porting the **TV episode context-menu “Rename”** flow into Layer 2 Core, then wiring every frontend through the Internal HTTP API. +This design document is the **golden source** for porting the **TV episode context-menu “Rename”** flow into Layer 2 Core, then wiring Layer 1 clients through the Internal HTTP API (and thin Core callers). -**Naming:** Core / HTTP / CLI use **`renameEpisodeFile`** only. Do **not** name this `renameVideoFile` — that is too broad. This API renames an **episode’s linked media file** (and same-stem associates) inside a **TV show** folder. It does **not** support renaming arbitrary video files (e.g. orphan files, music videos, or movie-panel videos). +**Naming:** Core / HTTP / CLI / MCP / AI use **`renameEpisodeFile`** / **`rename-episode-file`** only. Do **not** name this `renameVideoFile` — that is too broad. This API renames an **episode’s linked media file** (and same-stem associates) inside a **TV show** folder. It does **not** support renaming arbitrary video files (e.g. orphan files, music videos, or movie-panel videos). Sibling docs (different features — do not confuse): | Doc / feature | What it renames | |---------------|-----------------| -| **This doc** | One **episode** file (+ same-stem associates) via right-click **重命名** | +| **This doc** | One **episode** file (+ same-stem associates) via right-click **重命名**, plus the same Core API exposed to **CLI / MCP / in-app AI** | | [rename-folder](../api/index.md) / `Core.renameFolder` | Entire **media folder** directory | | `smm try-to-rename` / rule-based toolbar | Batch plex/emby **naming-rule** plans | -| AI `begin/add/end-rename-files-task` | AI-authored rename **plans** | +| AI `begin/add/end-rename-files-task` | AI-authored **batch rename plans** (multi-file task) — **not** this single-episode tool | | Movie panel “Rename” | Separate legacy path (`useRenameVideoFileFlow` / `/api/renameFiles`) — **out of scope** for this Core API | -Architecture baseline: [refactoring.md](../../refactoring.md). Pattern reference: [scrape.md](./scrape.md) (Core parity → HTTP → UI v3). +Architecture baseline: [refactoring.md](../../refactoring.md). Pattern reference: [scrape.md](./scrape.md) (Core parity → HTTP → UI / CLI / AI / MCP); MCP/AI confirmation pattern: `rename-folder`. ## 1. Background @@ -59,50 +59,57 @@ Problem: associate discovery and orchestration live in **Layer 1**. Electron / O 1. **Port** legacy **episode** rename orchestration into `apps/core` (FsPort + metadata), with episode identity checks. 2. Expose a **thin Internal HTTP** command that only validates and calls Core. 3. Point **TV** frontends (Web UI, Electron, OHOS — shared `apps/ui`) at that API under `smm.v3.enabled`. -4. Leave movie rename, rule-based / AI **plans**, and **folder** rename out of this workstream. +4. Expose the **same Core capability** to **CLI**, **MCP**, and **in-app AI tool** (shared schemas; confirmation for MCP/AI). +5. Leave movie rename, rule-based / AI **batch plans**, and **folder** rename out of this workstream. ### 1.5 Delivery phases -1. **Core** — `Core.renameEpisodeFile` with episode check + associate expansion + disk + metadata +1. **Core** — `Core.renameEpisodeFile` with episode check + associate expansion + disk + metadata + shared rename preflight 2. **HTTP** — `POST /api/rename-episode-file` → Core; Socket/metadata broadcast parity with today’s `/api/renameFiles` 3. **UI v3 (TV only)** — episode context-menu confirm calls the new API; no client-side `computeAssociatedFileRenames` when v3 is on -4. **Cleanup** — optional: dual paths for TV; keep generic `/api/renameFiles` for movie / plan / AI until those migrate +4. **CLI** — `smm rename-episode-file` → `Core.renameEpisodeFile` (operators / scripting / debugging) +5. **MCP + AI tool** — shared `rename-episode-file` tool schemas; MCP handler + in-app assistant tool; **user confirmation** before disk write (mirror `rename-folder`) +6. **Cleanup** — optional: dual paths for TV; keep generic `/api/renameFiles` for movie / plan apply until those migrate ## 2. Architecture ### 2.1 Project Level Architecture ``` -Layer 1: Web UI / Electron / OHOS / (optional CLI) +Layer 1: Web UI / Electron / OHOS / CLI / in-app AI / MCP clients │ - │ POST /api/rename-episode-file (new, thin) + │ POST /api/rename-episode-file (UI v3, in-app AI) + │ Core.renameEpisodeFile(...) (CLI direct; MCP via host) │ mediaMetadataUpdated (Socket.IO) — same as today ▼ -Layer 3: apps/cli Hono (or core-routes adapter) +Layer 3: apps/cli Hono (or core-routes adapter) + MCP server │ │ Core.renameEpisodeFile(...) ▼ Layer 2: apps/core assertEpisodeMediaFile (tvshow-folder + mediaFiles match) expandAssociatedFileRenames (port of computeAssociatedFileRenames) + validateRenameOperations (path + FS preflight) FsPort.mkdir / rename (batch) updateMediaMetadataAfterRename + setMetadata ``` -Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog relative path) and renders results; **no** associate math and **no** metadata rewrite in Layer 1 for the v3 path. +Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog relative path) and renders results; **no** associate math and **no** metadata rewrite in Layer 1 for the v3 path. CLI / MCP / AI must **not** reimplement associate expansion — they pass primary `from` / `to` only. ### 2.2 App Level Architecture | Piece | Role | |-------|------| -| `Core.renameEpisodeFile(input)` | Assert TV episode; expand associates; rename; update metadata; return result | +| `Core.renameEpisodeFile(input)` | Assert TV episode; expand associates; preflight; rename; update metadata; return result | | `assertEpisodeMediaFile` | Folder is `tvshow-folder`; `from` matches a `mediaFiles` entry with season/episode | | `expandAssociatedFileRenames` | Port of UI `computeAssociatedFileRenames` (stem + suffix; same directory) | | `updateMediaMetadataAfterRename` | Existing pure helper — Core must apply it after successful disk renames | | `POST /api/rename-episode-file` | Body → Core → `{ data }` / `{ error }`, HTTP 200 | | Broadcast | After success: `mediaMetadataUpdated` for the folder (parity with `/api/renameFiles`) | | UI TV context menu | v3 ON → new API with `{ mediaFolder, from, to }` only; v3 OFF → legacy client expand + `/api/renameFiles` | -| CLI (optional phase) | `smm rename-episode-file --from … --to …` for operators / debugging | +| CLI | `smm rename-episode-file --from … --to …` → Core (no Socket confirmation) | +| MCP tool `rename-episode-file` | Same args as HTTP; **confirm** then Core (or HTTP); omit on hosts that cannot rename files | +| In-app AI tool `rename-episode-file` | Same schemas as MCP; UI confirmation bridge then `POST /api/rename-episode-file` | ### 2.3 Key Design @@ -178,7 +185,7 @@ export interface RenameEpisodeFileResult { Core.renameEpisodeFile(input: RenameEpisodeFileInput): Promise ``` -Optional convenience: accept `toRelative` instead of absolute `to` and join inside Core — either is fine if HTTP and UI agree. +Optional convenience: accept `toRelative` instead of absolute `to` and join inside Core — either is fine if HTTP and UI agree. CLI may accept relative `--from` / `--to` and resolve against `` before calling Core. #### Legacy sources to port (do not call UI from Core) @@ -189,6 +196,72 @@ Optional convenience: accept `toRelative` instead of absolute `to` and join insi Note: Core already has `findAssociatedFiles` for **rule-based** apply (extension allowlists + season moves). Context-menu rename must keep **`computeAssociatedFileRenames` semantics**, not silently switch to `findAssociatedFiles`. +#### CLI + +``` +smm rename-episode-file --from --to +``` + +| Arg / option | Meaning | +|--------------|---------| +| `` | Absolute media folder path (managed TV show folder) | +| `--from` | Current episode file path (absolute, or relative to ``) | +| `--to` | Target episode file path (absolute, or relative to ``) | + +Behavior: + +1. Resolve relative `--from` / `--to` against `` to absolute paths; normalize via the same helpers Core uses. +2. Call `Core.renameEpisodeFile({ mediaFolderPath, from, to })` directly (no HTTP, no confirmation prompt — operator owns the shell). +3. On success: print one line per `succeeded` pair (`from → to`), then any `failed` lines (`FAILED path: error`). +4. Exit `0` if Core returns and `failed.length === 0`; exit `1` if Core throws or any pair failed. +5. Do **not** invent associate paths in the CLI — Core expands them. + +Example (happy path, one associate): + +``` +/m/Show/S01E01.mp4 → /m/Show/S01E01_renamed.mp4 +/m/Show/S01E01.srt → /m/Show/S01E01_renamed.srt +``` + +Errors (unmanaged / not TV / not linked episode / preflight): print the Core error message to stderr and exit `1`. + +#### MCP tool + +Mirror **`rename-folder`**: one shared tool contract in `@smm/core/types/ai-tools/renameEpisodeFile` (name, description, Zod input/output), registered on the MCP server and reused by the in-app AI tool. + +| Field | Value | +|-------|--------| +| Tool name | `rename-episode-file` (`RENAME_EPISODE_FILE`) | +| Input | `{ mediaFolder: string, from: string, to: string }` — absolute paths (POSIX or Windows); same semantics as HTTP | +| Output | `{ renamed: boolean, succeeded: Array<{from,to}>, failed: Array<{path,error}>, error?: string }` (shape may align with existing `toolOk` / `createSuccessResponse` conventions) | + +Execution (MCP host, e.g. Bun cli): + +1. Validate non-empty strings. +2. **Ask for confirmation** via Socket `askForConfirmation` (or host-equivalent acknowledge), with a message that names the primary episode rename and notes that same-stem associates will also be renamed. Title e.g. `Rename episode file`. +3. If cancelled → return cancelled result (no disk writes). +4. If confirmed → call `Core.renameEpisodeFile` (preferred) or `POST /api/rename-episode-file` on the same process. +5. Map Core throw → tool error string; map `{ succeeded, failed }` → tool success payload (include `failed` even when `succeeded.length > 0`). + +**Host notes:** + +- Hosts that cannot rename files (e.g. HarmonyOS sandbox) must pass `disabledTools: [RENAME_EPISODE_FILE]` so `tools/list` never advertises the tool (same pattern as `RENAME_FOLDER`). +- Description must state: **TV episode file only**; not for folders; not for movies; not for arbitrary paths; for multi-file batch plans use `begin/add/end-rename-files-task`. + +#### AI tool (in-app assistant) + +Same tool name / description / input schema as MCP (`packages/core/types/ai-tools/renameEpisodeFile`). + +| Concern | Choice | +|---------|--------| +| Registration | `apps/ui` assistant-ui tool (like `RenameFolder.tsx`) | +| Confirmation | UI confirmation dialog / `requestConfirmation` bridge **before** HTTP | +| Execute | `POST /api/rename-episode-file` (not a second Core embed in the browser) | +| Result copy | Shared helpers under `@core/ai-tool/` (`renameEpisodeFileConfirm`, `renameEpisodeFileResult`) mirroring rename-folder | +| Model guidance | Prefer this tool when the user asks to rename **one** linked episode file; prefer plan tools when renaming **many** files under a naming rule | + +Do **not** teach the model to call generic `/api/renameFiles` for this UX — that bypasses episode identity checks. + ## 3. HTTP API All endpoints: **HTTP 200**. Clients use `error` for business failure and `data` for success. @@ -306,29 +379,55 @@ sequenceDiagram * **Then** response is `{ error: "Error Reason: …" }` * **And** no files are renamed +### 4.6 CLI rename episode file + +* **Given** a managed TV folder with a linked episode file +* **When** the operator runs `smm rename-episode-file --from … --to …` +* **Then** Core renames the episode (+ associates) +* **And** stdout lists each succeeded pair +* **And** exit code is `0` on full success + +### 4.7 MCP / AI tool with confirmation + +* **Given** MCP or in-app AI invokes `rename-episode-file` +* **When** the user **cancels** confirmation +* **Then** no files are renamed and the tool reports cancelled +* **When** the user **confirms** +* **Then** Core (or HTTP → Core) runs the same path as the UI v3 context menu + +### 4.8 Boundary vs batch rename plan tools + +* **Given** the assistant needs to rename many files under a plex/emby-style plan +* **When** choosing a tool +* **Then** it uses `begin/add/end-rename-files-task`, **not** `rename-episode-file` + ## 5. Out of scope -- Media **folder** rename (`Core.renameFolder` / `/api/rename-folder`) +- Media **folder** rename (`Core.renameFolder` / `/api/rename-folder`) — separate tool `rename-folder` - **Movie** panel / movie-folder file rename (keep legacy) - Renaming **arbitrary** files or videos not linked as episodes in `mediaFiles` - Toolbar **plex/emby** rule-based batch rename / `try-to-rename` plans -- AI / MCP rename **plan** tools (`begin/add/end-rename-files-task`) +- Replacing AI / MCP **batch plan** tools (`begin/add/end-rename-files-task`) — they remain; this doc adds a **different** single-episode tool beside them - Changing dialog UX (suggestions, i18n) beyond swapping the confirm API - Replacing `/api/renameFiles` for plan apply / movie in this milestone - Job-based progress UI for a single rename +- Interactive CLI confirmation (shell is trusted; MCP/AI confirm instead) ## 6. Testing strategy -- **Core unit:** episode assert accepts linked `mediaFiles` entry and rejects unlinked paths / movie folders; stem associate expansion matches `computeAssociatedFileRenames` fixtures (`S01E01.en.srt` → new stem); rejects unmanaged / escape paths; metadata updated via `updateMediaMetadataAfterRename`. +- **Core unit:** episode assert accepts linked `mediaFiles` entry and rejects unlinked paths / movie folders; stem associate expansion matches `computeAssociatedFileRenames` fixtures (`S01E01.en.srt` → new stem); rejects unmanaged / escape paths; preflight refuses dest-exists / missing source before any rename; metadata updated via `updateMediaMetadataAfterRename`; partial failure still writes metadata for succeeded pairs. - **HTTP route tests:** success returns `data.succeeded`; validation / not-episode / not-TV errors. - **UI unit:** TV context-menu flow with v3 on posts `{ mediaFolder, from, to }` to `/api/rename-episode-file` only; v3 off keeps legacy; movie panel unchanged. +- **CLI e2e / unit:** resolve relative `--from`/`--to`; success prints pairs and exit 0; reject unmanaged / not-linked episode with exit 1. +- **MCP / AI tool unit:** shared schema; cancel confirmation → no Core call; confirm → Core/HTTP invoked with primary paths only (no client-side associate list). - **E2E:** `TVShow-RenameEpisodeFile.e2e.ts` must pass with v3 on against Core path (Web / Electron / OHOS / Docker as already tagged). ## 7. Compatibility notes -- Relative path editing stays in the dialog (Layer 1); Core receives absolute `from` / `to`. +- Relative path editing stays in the dialog (Layer 1); Core receives absolute `from` / `to`. CLI may resolve relatives at the edge. - Do not conflate with `buildTvShowRenameListForPlan` (moves associates into season folders). - `metadata.files` is legacy; prefer disk `listFiles` inside Core for associate discovery. -- Until movie / plan / AI flows migrate, `/api/rename-episode-file` and `/api/renameFiles` coexist. -- Feature flag: same `isSmmV3Enabled()` used by folder rename and scrape UI. -- Legacy hook name `useRenameVideoFileFlow` may remain until refactored; the **Core/HTTP contract** must still be `renameEpisodeFile`. +- Until movie / plan / AI **batch** flows migrate, `/api/rename-episode-file` and `/api/renameFiles` coexist; plan tools stay on `/api/renameFiles` apply. +- Feature flag: same `isSmmV3Enabled()` used by folder rename and scrape UI (UI context menu only; CLI/MCP always hit Core path once implemented). +- Legacy hook name `useRenameVideoFileFlow` may remain until refactored; the **Core/HTTP/CLI/MCP/AI contract** must still be `renameEpisodeFile` / `rename-episode-file`. +- Shared Zod contracts live under `packages/core/types/ai-tools/renameEpisodeFile` so MCP and in-app AI cannot drift. diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index f2146d8c..69f663ee 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -9,6 +9,7 @@ import { GET_EPISODES } from "@smm/core/types/ai-tools/getEpisodes"; import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { LIST_FILES_IN_MEDIA_FOLDER } from "@smm/core/types/ai-tools/listFilesInMediaFolder"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; +import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -133,6 +134,7 @@ export async function doChat( [GET_MEDIA_FOLDERS]: tools[GET_MEDIA_FOLDERS], [LIST_FILES_IN_MEDIA_FOLDER]: tools[LIST_FILES_IN_MEDIA_FOLDER], [RENAME_FOLDER]: tools[RENAME_FOLDER], + [RENAME_EPISODE_FILE]: tools[RENAME_EPISODE_FILE], [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], diff --git a/packages/core-routes/src/mcp/createServer.ts b/packages/core-routes/src/mcp/createServer.ts index c121543d..0a4c37ac 100644 --- a/packages/core-routes/src/mcp/createServer.ts +++ b/packages/core-routes/src/mcp/createServer.ts @@ -3,6 +3,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { defaultChatFs } from "../chatFs.ts"; import { defaultRenameFilesTaskDeps } from "../tools/renameFilesTaskDefaults.ts"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; +import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { registerAddRecognizedFileTool } from "./toolHandlers/addRecognizedFile.ts"; import { registerAddRenameFileTool } from "./toolHandlers/addRenameFile.ts"; import { registerBeginRecognizeTaskTool } from "./toolHandlers/beginRecognizeTask.ts"; @@ -17,6 +18,7 @@ import { registerGetMediaMetadataTool } from "./toolHandlers/getMediaMetadata.ts import { registerIsFolderExistTool } from "./toolHandlers/isFolderExist.ts"; import { registerListFilesTool } from "./toolHandlers/listFiles.ts"; import { registerRenameFolderTool } from "./toolHandlers/renameFolder.ts"; +import { registerRenameEpisodeFileTool } from "./toolHandlers/renameEpisodeFile.ts"; import { registerStaticTextTools } from "./toolHandlers/staticText.ts"; import type { McpConfig } from "./types.ts"; @@ -97,6 +99,10 @@ export async function createMcpStreamableHttpHandler( registerRenameFolderTool(server, config); } + if (!config.disabledTools?.includes(RENAME_EPISODE_FILE)) { + registerRenameEpisodeFileTool(server, config); + } + // Episode-level rename task (begin / add / end). registerBeginRenameTaskTool(server, config, renameFilesTaskDeps); registerAddRenameFileTool(server, config, renameFilesTaskDeps); diff --git a/packages/core-routes/src/mcp/index.ts b/packages/core-routes/src/mcp/index.ts index 24a5a6be..e827f387 100644 --- a/packages/core-routes/src/mcp/index.ts +++ b/packages/core-routes/src/mcp/index.ts @@ -3,6 +3,7 @@ export { type McpRequestHandler, } from "./createServer.ts"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; +import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; export { createErrorResponse, createSuccessResponse, @@ -29,6 +30,7 @@ export { * without resolving `@smm/core` directly. */ export { RENAME_FOLDER }; +export { RENAME_EPISODE_FILE }; /** * Constants exposed to hosts that load `core-routes.js` as a @@ -36,4 +38,7 @@ export { RENAME_FOLDER }; * `@smm/core` directly at build time. Keep in sync with the * tool-name constants under `@smm/core/types/ai-tools/*`. */ -export const MCP_TOOL_NAMES = { RENAME_FOLDER } as const; +export const MCP_TOOL_NAMES = { + RENAME_FOLDER, + RENAME_EPISODE_FILE, +} as const; diff --git a/packages/core-routes/src/mcp/mcp.test.ts b/packages/core-routes/src/mcp/mcp.test.ts index c4d16226..239f0747 100644 --- a/packages/core-routes/src/mcp/mcp.test.ts +++ b/packages/core-routes/src/mcp/mcp.test.ts @@ -241,6 +241,49 @@ describe("createMcpStreamableHttpHandler", () => { } }) + it("registers rename-episode-file when not disabled", async () => { + const { appDataDir, userDataDir, userConfig, cleanup } = await makeTempConfig() + try { + const handler = await createMcpStreamableHttpHandler({ + getUserConfig: async () => userConfig, + appDataDir, + userDataDir, + fs: defaultChatFs(), + logger: { + debug() {}, + info() {}, + warn() {}, + error() {}, + }, + renameEpisodeFile: async () => ({ succeeded: [], failed: [] }), + }) + + await callMcp(handler, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.1" }, + }, + }) + + const listResponse = await callMcp(handler, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }) + const tools = (listResponse.result as { tools: Array<{ name: string }> }) + .tools + const names = tools.map((t) => t.name) + expect(names).toContain("rename-episode-file") + } finally { + await cleanup() + } + }) + it("registers rename-folder when disabledTools is empty", async () => { const { appDataDir, userDataDir, userConfig, cleanup } = await makeTempConfig() try { diff --git a/packages/core-routes/src/mcp/toolHandlers/renameEpisodeFile.ts b/packages/core-routes/src/mcp/toolHandlers/renameEpisodeFile.ts new file mode 100644 index 00000000..dbc41e3e --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/renameEpisodeFile.ts @@ -0,0 +1,118 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Path } from "@smm/core/path"; +import { + RENAME_EPISODE_FILE, + RENAME_EPISODE_FILE_DESCRIPTION, + renameEpisodeFileInputSchema, + renameEpisodeFileOutputSchema, +} from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { buildRenameEpisodeFileConfirmationMessage } from "@smm/core/ai-tool/renameEpisodeFileConfirm"; +import { renameEpisodeFileCancelled } from "@smm/core/ai-tool/renameEpisodeFileResult"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; +import { executeRenameEpisodeFile } from "../../tools/renameEpisodeFile.ts"; + +/** + * Register the `rename-episode-file` MCP tool. + * When `acknowledge` is set, asks the UI for confirmation first. + * Execution uses `McpConfig.renameEpisodeFile` (typically Core). + */ +export function registerRenameEpisodeFileTool( + server: McpServer, + config: McpConfig, +): void { + const description = + config.toolDescriptions?.[RENAME_EPISODE_FILE] ?? RENAME_EPISODE_FILE_DESCRIPTION; + + server.registerTool( + RENAME_EPISODE_FILE, + { + description, + inputSchema: renameEpisodeFileInputSchema, + outputSchema: renameEpisodeFileOutputSchema, + }, + async (args: unknown): Promise => { + const params = (args ?? {}) as { + mediaFolder?: string; + from?: string; + to?: string; + }; + if (typeof params.mediaFolder !== "string" || params.mediaFolder.trim() === "") { + return createErrorResponse( + "Invalid path: 'mediaFolder' must be a non-empty string", + ); + } + if (typeof params.from !== "string" || params.from.trim() === "") { + return createErrorResponse( + "Invalid path: 'from' must be a non-empty string", + ); + } + if (typeof params.to !== "string" || params.to.trim() === "") { + return createErrorResponse( + "Invalid path: 'to' must be a non-empty string", + ); + } + + try { + if (config.acknowledge) { + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage( + params.from, + params.to, + ); + const responseData = (await config.acknowledge( + { + event: "askForConfirmation", + data: { message: confirmationMessage }, + clientId: "mcp", + }, + 30_000, + )) as { confirmed?: boolean; response?: string } | undefined; + + const confirmed = + responseData?.confirmed ?? responseData?.response === "yes"; + if (!confirmed) { + return createSuccessResponse( + renameEpisodeFileCancelled( + params.mediaFolder, + params.from, + params.to, + ) as { [x: string]: unknown }, + ); + } + } + + const result = await executeRenameEpisodeFile( + { + mediaFolder: params.mediaFolder, + from: params.from, + to: params.to, + }, + config.renameEpisodeFile, + ); + + if (result.renamed) { + config.broadcast?.({ + event: "mediaMetadataUpdated", + data: { + folderPath: Path.posix(params.mediaFolder), + }, + }); + } + + if (result.error && !result.renamed) { + return createSuccessResponse(result as { [x: string]: unknown }); + } + + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse( + error instanceof Error ? error.message : String(error), + ); + } + }, + ); +} diff --git a/packages/core-routes/src/mcp/types.ts b/packages/core-routes/src/mcp/types.ts index c67570dd..e88957ca 100644 --- a/packages/core-routes/src/mcp/types.ts +++ b/packages/core-routes/src/mcp/types.ts @@ -80,6 +80,20 @@ export interface McpConfig { */ disabledTools?: readonly string[]; + /** + * Optional runner for `rename-episode-file`. Hosts that expose Core + * (e.g. Bun cli) inject `Core.renameEpisodeFile`. When omitted, the + * tool reports that it is unavailable on this host. + */ + renameEpisodeFile?: (input: { + mediaFolderPath: string; + from: string; + to: string; + }) => Promise<{ + succeeded: Array<{ from: string; to: string }>; + failed: Array<{ path: string; error: string }>; + }>; + /** * Optional localized tool descriptions, keyed by tool name * (e.g. `"get-media-folders"`, `"is-folder-exist"`). When diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index ed467282..08e9e586 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -7,6 +7,7 @@ import { GET_EPISODES } from "@smm/core/types/ai-tools/getEpisodes"; import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { LIST_FILES_IN_MEDIA_FOLDER } from "@smm/core/types/ai-tools/listFilesInMediaFolder"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; +import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -29,6 +30,10 @@ import { buildGetEpisodesTool } from "./getEpisodes.ts"; import { buildGetMediaFoldersTool } from "./getMediaFolders.ts"; import { buildListFilesInMediaFolderTool } from "./listFilesInMediaFolder.ts"; import { buildRenameFolderTool } from "./renameFolder.ts"; +import { + buildRenameEpisodeFileTool, + type RenameEpisodeFileRunner, +} from "./renameEpisodeFile.ts"; import { buildAddRenameFileToTaskTool, buildBeginRenameFilesTaskTool, @@ -43,7 +48,7 @@ import { } from "./recognizeMediaFilesTask.ts"; /** - * The 13 chat tools registered in `streamText({ tools })`, keyed by + * The 14 chat tools registered in `streamText({ tools })`, keyed by * their AI tool name constant. The object is constructed per-request * so each chat call gets tools bound to its own `clientId`, * `abortSignal`, and `UserConfig` snapshot. @@ -56,6 +61,7 @@ export interface ChatTools { [GET_MEDIA_FOLDERS]: ReturnType; [LIST_FILES_IN_MEDIA_FOLDER]: ReturnType; [RENAME_FOLDER]: ReturnType; + [RENAME_EPISODE_FILE]: ReturnType; [BEGIN_RENAME_FILES_TASK]: ReturnType; [ADD_RENAME_FILE_TO_TASK]: ReturnType; [END_RENAME_FILES_TASK]: ReturnType; @@ -72,6 +78,8 @@ export interface ChatTools { */ export interface ChatToolsExtraDeps { renameFilesTask?: RenameFilesTaskDeps; + /** Host Core runner for single-episode rename (Bun cli / Electron). */ + renameEpisodeFile?: RenameEpisodeFileRunner; } export interface CreateChatToolsArgs { @@ -150,6 +158,12 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { abortSignal, acknowledge, ), + [RENAME_EPISODE_FILE]: buildRenameEpisodeFileTool( + clientId, + extra?.renameEpisodeFile, + abortSignal, + acknowledge, + ), [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool( clientId, config.appDataDir, diff --git a/packages/core-routes/src/tools/renameEpisodeFile.test.ts b/packages/core-routes/src/tools/renameEpisodeFile.test.ts new file mode 100644 index 00000000..187aa328 --- /dev/null +++ b/packages/core-routes/src/tools/renameEpisodeFile.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeRenameEpisodeFile } from "./renameEpisodeFile.ts"; + +describe("executeRenameEpisodeFile", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeRenameEpisodeFile( + { + mediaFolder: "/m/Show", + from: "/m/Show/a.mp4", + to: "/m/Show/b.mp4", + }, + undefined, + ); + expect(result.renamed).toBe(false); + expect(result.error).toMatch(/not available/i); + }); + + it("maps runner success to renamed output", async () => { + const runner = vi.fn(async () => ({ + succeeded: [{ from: "/m/Show/a.mp4", to: "/m/Show/b.mp4" }], + failed: [], + })); + const result = await executeRenameEpisodeFile( + { + mediaFolder: "/m/Show", + from: "/m/Show/a.mp4", + to: "/m/Show/b.mp4", + }, + runner, + ); + expect(result.renamed).toBe(true); + expect(result.succeeded).toHaveLength(1); + expect(runner).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core-routes/src/tools/renameEpisodeFile.ts b/packages/core-routes/src/tools/renameEpisodeFile.ts new file mode 100644 index 00000000..e9c78ba9 --- /dev/null +++ b/packages/core-routes/src/tools/renameEpisodeFile.ts @@ -0,0 +1,196 @@ +import { Path } from "@smm/core/path"; +import { buildRenameEpisodeFileConfirmationMessage } from "@smm/core/ai-tool/renameEpisodeFileConfirm"; +import { + renameEpisodeFileCancelled, + renameEpisodeFileFailed, + renameEpisodeFileSucceeded, +} from "@smm/core/ai-tool/renameEpisodeFileResult"; +import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; +import { + RENAME_EPISODE_FILE, + RENAME_EPISODE_FILE_DESCRIPTION, + renameEpisodeFileInputSchema, + renameEpisodeFileOutputSchema, + type RenameEpisodeFileOutput, +} from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { defaultAcknowledge } from "./acknowledge.ts"; + +export interface RenameEpisodeFileParams { + mediaFolder: string; + from: string; + to: string; +} + +export type RenameEpisodeFileRunner = (input: { + mediaFolderPath: string; + from: string; + to: string; +}) => Promise<{ + succeeded: Array<{ from: string; to: string }>; + failed: Array<{ path: string; error: string }>; +}>; + +/** + * Core rename-episode-file execution (no confirmation). Hosts inject + * a runner that typically calls `Core.renameEpisodeFile`. + */ +export async function executeRenameEpisodeFile( + params: RenameEpisodeFileParams, + runner: RenameEpisodeFileRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + const folderCheck = requireNonEmptyString(params.mediaFolder, "mediaFolder"); + if (typeof folderCheck !== "string") { + return renameEpisodeFileFailed("", "", "", folderCheck.error); + } + const fromCheck = requireNonEmptyString(params.from, "from"); + if (typeof fromCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, "", "", fromCheck.error); + } + const toCheck = requireNonEmptyString(params.to, "to"); + if (typeof toCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, fromCheck, "", toCheck.error); + } + + if (!runner) { + return renameEpisodeFileFailed( + folderCheck, + fromCheck, + toCheck, + "rename-episode-file is not available on this host", + ); + } + + try { + const result = await runner({ + mediaFolderPath: folderCheck, + from: fromCheck, + to: toCheck, + }); + return renameEpisodeFileSucceeded( + folderCheck, + fromCheck, + toCheck, + result.succeeded, + result.failed, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return renameEpisodeFileFailed( + folderCheck, + fromCheck, + toCheck, + `Error renaming episode file: ${message}`, + ); + } +} + +async function confirmRenameEpisodeFileViaSocket( + clientId: string, + from: string, + to: string, + acknowledge: ( + message: unknown, + timeoutMs?: number, + ) => Promise, +): Promise { + const confirmationMessage = buildRenameEpisodeFileConfirmationMessage(from, to); + + try { + const responseData = (await acknowledge( + { + event: "askForConfirmation", + data: { message: confirmationMessage }, + clientId, + }, + 30_000, + )) as { confirmed?: boolean; response?: string } | undefined; + + const confirmed = + responseData?.confirmed ?? responseData?.response === "yes"; + + if (!confirmed) { + return renameEpisodeFileCancelled("", from, to); + } + return null; + } catch (error) { + return renameEpisodeFileFailed( + "", + from, + to, + `Failed to get user confirmation: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } +} + +/** + * Build the AI SDK tool for `RENAME_EPISODE_FILE` (server-side chat). + * Confirms via Socket, then runs the injected Core runner. + */ +export function buildRenameEpisodeFileTool( + clientId: string, + runner: RenameEpisodeFileRunner | undefined, + abortSignal?: AbortSignal, + acknowledge?: ( + message: unknown, + timeoutMs?: number, + ) => Promise, +) { + const ack = acknowledge ?? defaultAcknowledge; + return { + description: RENAME_EPISODE_FILE_DESCRIPTION, + inputSchema: renameEpisodeFileInputSchema, + outputSchema: renameEpisodeFileOutputSchema, + execute: async (args: unknown) => { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + const params = (args ?? {}) as RenameEpisodeFileParams; + + const folderCheck = requireNonEmptyString(params.mediaFolder, "mediaFolder"); + if (typeof folderCheck !== "string") { + return renameEpisodeFileFailed("", "", "", folderCheck.error); + } + const fromCheck = requireNonEmptyString(params.from, "from"); + if (typeof fromCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, "", "", fromCheck.error); + } + const toCheck = requireNonEmptyString(params.to, "to"); + if (typeof toCheck !== "string") { + return renameEpisodeFileFailed(folderCheck, fromCheck, "", toCheck.error); + } + + const cancelOrError = await confirmRenameEpisodeFileViaSocket( + clientId, + fromCheck, + toCheck, + ack, + ); + if (cancelOrError) { + return { + ...cancelOrError, + mediaFolder: Path.toPlatformPath(folderCheck), + }; + } + + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + return executeRenameEpisodeFile( + { mediaFolder: folderCheck, from: fromCheck, to: toCheck }, + runner, + abortSignal, + ); + }, + }; +} + +/** Re-exported tool name constant for the tools registry. */ +export const RENAME_EPISODE_FILE_TOOL_NAME = RENAME_EPISODE_FILE; diff --git a/packages/core/ai-tool/registry.ts b/packages/core/ai-tool/registry.ts index 2e181bf0..25b7a5b2 100644 --- a/packages/core/ai-tool/registry.ts +++ b/packages/core/ai-tool/registry.ts @@ -45,6 +45,7 @@ import { GET_EPISODES } from '../types/ai-tools/getEpisodes' import { GET_MEDIA_FOLDERS } from '../types/ai-tools/getMediaFolders' import { LIST_FILES_IN_MEDIA_FOLDER } from '../types/ai-tools/listFilesInMediaFolder' import { RENAME_FOLDER } from '../types/ai-tools/renameFolder' +import { RENAME_EPISODE_FILE } from '../types/ai-tools/renameEpisodeFile' import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -96,6 +97,7 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ // Mutating tools (require user confirmation via Socket.IO / bridge) { name: RENAME_FOLDER, backend: true, frontend: true }, + { name: RENAME_EPISODE_FILE, backend: true, frontend: true }, // Rename files task { name: BEGIN_RENAME_FILES_TASK, backend: true, frontend: true }, diff --git a/packages/core/ai-tool/renameEpisodeFileConfirm.test.ts b/packages/core/ai-tool/renameEpisodeFileConfirm.test.ts new file mode 100644 index 00000000..008d43e9 --- /dev/null +++ b/packages/core/ai-tool/renameEpisodeFileConfirm.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + buildRenameEpisodeFileConfirmationMessage, + getEpisodeBasename, +} from './renameEpisodeFileConfirm' + +describe('renameEpisodeFileConfirm', () => { + it('getEpisodeBasename returns last path segment', () => { + expect(getEpisodeBasename('/m/Show/S01E01.mp4')).toBe('S01E01.mp4') + }) + + it('buildRenameEpisodeFileConfirmationMessage mentions associates', () => { + const msg = buildRenameEpisodeFileConfirmationMessage( + '/m/Show/S01E01.mp4', + '/m/Show/S01E01_renamed.mp4', + ) + expect(msg).toContain('"S01E01.mp4"') + expect(msg).toContain('"S01E01_renamed.mp4"') + expect(msg).toContain('same-stem associated files') + }) +}) diff --git a/packages/core/ai-tool/renameEpisodeFileConfirm.ts b/packages/core/ai-tool/renameEpisodeFileConfirm.ts new file mode 100644 index 00000000..92d589f4 --- /dev/null +++ b/packages/core/ai-tool/renameEpisodeFileConfirm.ts @@ -0,0 +1,19 @@ +import { Path } from '../path' + +export function getEpisodeBasename(filePath: string): string { + const parts = Path.posix(filePath).split('/').filter(Boolean) + return parts[parts.length - 1] ?? Path.posix(filePath) +} + +export function buildRenameEpisodeFileConfirmationMessage( + from: string, + to: string, +): string { + return ( + `Rename episode file "${getEpisodeBasename(from)}" to "${getEpisodeBasename(to)}"?\n\n` + + 'This will:\n' + + ' • Rename the episode video on disk\n' + + ' • Rename same-stem associated files (e.g. subtitles) in the same directory\n' + + ' • Update media metadata' + ) +} diff --git a/packages/core/ai-tool/renameEpisodeFileResult.ts b/packages/core/ai-tool/renameEpisodeFileResult.ts new file mode 100644 index 00000000..ee9ca37e --- /dev/null +++ b/packages/core/ai-tool/renameEpisodeFileResult.ts @@ -0,0 +1,59 @@ +import { Path } from '../path' +import type { RenameEpisodeFileOutput } from '../types/ai-tools/renameEpisodeFile' +import { RENAME_EPISODE_FILE_CANCELLED } from '../types/ai-tools/renameEpisodeFile' + +export function renameEpisodeFileCancelled( + mediaFolder: string, + from: string, + to: string, +): RenameEpisodeFileOutput { + return { + renamed: false, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: [], + failed: [], + error: RENAME_EPISODE_FILE_CANCELLED, + } +} + +export function renameEpisodeFileFailed( + mediaFolder: string, + from: string, + to: string, + error: string, +): RenameEpisodeFileOutput { + return { + renamed: false, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: [], + failed: [], + error, + } +} + +export function renameEpisodeFileSucceeded( + mediaFolder: string, + from: string, + to: string, + succeeded: Array<{ from: string; to: string }>, + failed: Array<{ path: string; error: string }> = [], +): RenameEpisodeFileOutput { + return { + renamed: succeeded.length > 0, + mediaFolder: Path.toPlatformPath(mediaFolder), + from: Path.toPlatformPath(from), + to: Path.toPlatformPath(to), + succeeded: succeeded.map((p) => ({ + from: Path.toPlatformPath(p.from), + to: Path.toPlatformPath(p.to), + })), + failed, + ...(failed.length > 0 + ? { error: failed.map((f) => f.error).join('; ') } + : {}), + } +} diff --git a/packages/core/types/ai-tools/renameEpisodeFile.ts b/packages/core/types/ai-tools/renameEpisodeFile.ts new file mode 100644 index 00000000..c56d5b69 --- /dev/null +++ b/packages/core/types/ai-tools/renameEpisodeFile.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' + +export const RENAME_EPISODE_FILE = 'rename-episode-file' as const + +export const RENAME_EPISODE_FILE_DESCRIPTION = + 'Rename a linked TV episode video file (and same-stem associates such as subtitles) in a managed TV show folder. ' + + 'Use ONLY for a single episode file that already has seasonNumber and episodeNumber in media metadata. ' + + 'Do NOT use for folders (use rename-folder), movies, orphan files, or bulk renames ' + + '(use begin/add/end-rename-files-task for multi-file plans).\n\n' + + 'Example: Rename episode file in folder "/path/to/show" from ".../S01E01.mp4" to ".../S01E01_renamed.mp4".' + +export const renameEpisodeFileInputSchema = z.object({ + mediaFolder: z + .string() + .describe( + 'Absolute path of the managed TV show media folder (POSIX or Windows format)', + ), + from: z + .string() + .describe( + 'Absolute current path of the linked episode video file (POSIX or Windows format)', + ), + to: z + .string() + .describe( + 'Absolute target path for the episode video file under the same media folder (POSIX or Windows format)', + ), +}) + +export const renameEpisodeFileOutputSchema = z.object({ + renamed: z + .boolean() + .describe('True when at least one file was renamed successfully'), + mediaFolder: z.string().describe('The media folder path after normalization'), + from: z.string().describe('The primary source episode path after normalization'), + to: z.string().describe('The primary destination episode path after normalization'), + succeeded: z + .array( + z.object({ + from: z.string(), + to: z.string(), + }), + ) + .describe('Successful rename pairs (episode + associates)'), + failed: z + .array( + z.object({ + path: z.string(), + error: z.string(), + }), + ) + .describe('Per-path failures'), + error: z.string().optional().describe('Error or cancellation message when rename did not fully succeed'), +}) + +export type RenameEpisodeFileInput = z.infer +export type RenameEpisodeFileOutput = z.infer + +export const RENAME_EPISODE_FILE_CANCELLED = 'User cancelled the operation' + +export const RENAME_EPISODE_FILE_CONFIRMATION_TITLE = 'Rename episode file' From 2b4b74d0cdf72b61d023f5f05077bbe669a6abb0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 20 Aug 2026 23:36:54 +0800 Subject: [PATCH 094/214] feat(cli): support smm --help and unified rename dispatch Route -h/--help into runCli without starting the HTTP server, and auto-dispatch smm rename between media folders and episode files. Co-authored-by: Cursor --- apps/cli/index.ts | 6 +- apps/cli/src/cli/renameDispatch.test.ts | 66 ++++++++++++++++++ apps/cli/src/cli/renameDispatch.ts | 89 +++++++++++++++++++++++++ apps/cli/src/cli/runCli.ts | 55 ++++++++++++--- apps/cli/test/cli.e2e.ts | 12 ++++ apps/cli/test/helpers/smm.ts | 15 ++++- apps/cli/test/rename.e2e.ts | 70 +++++++++++++++++-- docs/dev/rename-episode-file.md | 63 +++++++++++------ 8 files changed, 335 insertions(+), 41 deletions(-) create mode 100644 apps/cli/src/cli/renameDispatch.test.ts create mode 100644 apps/cli/src/cli/renameDispatch.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 37547627..974d7a84 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,8 +18,10 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job']) -if (process.argv[2] !== undefined && cliCommands.has(process.argv[2])) { +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file']) +const firstArg = process.argv[2] +const isCliHelp = firstArg === '--help' || firstArg === '-h' +if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { const { runCli } = await import('./src/cli/runCli'); const code = await runCli(process.argv); process.exit(code); diff --git a/apps/cli/src/cli/renameDispatch.test.ts b/apps/cli/src/cli/renameDispatch.test.ts new file mode 100644 index 00000000..090de5bf --- /dev/null +++ b/apps/cli/src/cli/renameDispatch.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + classifyRenameTarget, + findLongestManagedFolder, + isExactManagedFolder, +} from './renameDispatch' + +describe('renameDispatch', () => { + it('isExactManagedFolder matches POSIX and platform paths', () => { + const folders = ['/media/TV/Show'] + expect(isExactManagedFolder('/media/TV/Show', folders)).toBe(true) + expect(isExactManagedFolder('/media/TV/Show/', folders)).toBe(true) + expect(isExactManagedFolder('/media/TV/Show/S01E01.mkv', folders)).toBe(false) + }) + + it('findLongestManagedFolder prefers the deepest managed folder', () => { + const folders = ['/media/TV', '/media/TV/Show'] + expect(findLongestManagedFolder('/media/TV/Show/S01E01.mkv', folders)).toBe( + '/media/TV/Show', + ) + expect(findLongestManagedFolder('/media/TV/Other/x.mkv', folders)).toBe('/media/TV') + expect(findLongestManagedFolder('/elsewhere/x.mkv', folders)).toBeNull() + }) + + it('classifyRenameTarget returns folder for managed root', async () => { + const classified = await classifyRenameTarget('/media/TV/Show', ['/media/TV/Show']) + expect(classified).toEqual({ kind: 'folder' }) + }) + + it('classifyRenameTarget returns episode for a file under a managed folder', async () => { + const root = mkdtempSync(join(tmpdir(), 'smm-rename-dispatch-')) + try { + const media = join(root, 'Show') + mkdirSync(media) + const episode = join(media, 'S01E01.mkv') + writeFileSync(episode, 'x') + const classified = await classifyRenameTarget(episode, [media]) + expect(classified).toEqual({ kind: 'episode', mediaFolderPath: media }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('classifyRenameTarget rejects a non-root directory under a managed folder', async () => { + const root = mkdtempSync(join(tmpdir(), 'smm-rename-dispatch-')) + try { + const media = join(root, 'Show') + const season = join(media, 'Season 01') + mkdirSync(season, { recursive: true }) + await expect(classifyRenameTarget(season, [media])).rejects.toThrow( + /directory but not a managed media folder/, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('classifyRenameTarget rejects unmanaged paths', async () => { + await expect( + classifyRenameTarget('/not/managed/file.mkv', ['/media/TV/Show']), + ).rejects.toThrow(/not under a managed media folder/) + }) +}) diff --git a/apps/cli/src/cli/renameDispatch.ts b/apps/cli/src/cli/renameDispatch.ts new file mode 100644 index 00000000..51801764 --- /dev/null +++ b/apps/cli/src/cli/renameDispatch.ts @@ -0,0 +1,89 @@ +import { Path } from '@core/path' +import { stat } from 'node:fs/promises' + +export interface EpisodeRenamePrintResult { + succeeded: Array<{ from: string; to: string }> + failed: Array<{ path: string; error: string }> +} + +export function isExactManagedFolder(path: string, folders: string[]): boolean { + const targetPlatform = Path.toPlatformPath(path) + const targetPosix = Path.posix(path) + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ) +} + +/** Longest POSIX prefix among managed folders that contains `filePath`. */ +export function findLongestManagedFolder( + filePath: string, + folders: string[], +): string | null { + const filePosix = Path.posix(filePath) + let best: string | null = null + let bestLen = -1 + for (const folder of folders) { + const folderPosix = Path.posix(folder) + const prefix = folderPosix.endsWith('/') ? folderPosix : `${folderPosix}/` + if (filePosix === folderPosix || filePosix.startsWith(prefix)) { + if (folderPosix.length > bestLen) { + best = folder + bestLen = folderPosix.length + } + } + } + return best +} + +export type ClassifiedRenameTarget = + | { kind: 'folder' } + | { kind: 'episode'; mediaFolderPath: string } + +/** + * Decide whether `from` is a managed media-folder root or a path under one + * (episode file). Rejects unmanaged paths and non-root directories. + */ +export async function classifyRenameTarget( + from: string, + folders: string[], +): Promise { + if (isExactManagedFolder(from, folders)) { + return { kind: 'folder' } + } + + const mediaFolderPath = findLongestManagedFolder(from, folders) + if (mediaFolderPath === null) { + throw new Error(`${Path.posix(from)} is not under a managed media folder`) + } + + try { + const st = await stat(Path.toPlatformPath(from)) + if (st.isDirectory()) { + throw new Error( + `${Path.posix(from)} is a directory but not a managed media folder; ` + + 'only media folder roots or linked episode files can be renamed', + ) + } + } catch (error) { + const code = + error !== null && typeof error === 'object' && 'code' in error + ? String((error as { code: unknown }).code) + : undefined + if (code !== 'ENOENT') { + throw error + } + } + + return { kind: 'episode', mediaFolderPath } +} + +export function printEpisodeRenameResult(result: EpisodeRenamePrintResult): boolean { + for (const pair of result.succeeded) { + console.log(`${pair.from} → ${pair.to}`) + } + for (const fail of result.failed) { + console.error(`FAILED ${fail.path}: ${fail.error}`) + } + return result.failed.length > 0 +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index ffcb1033..01e492be 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -1,4 +1,4 @@ -import { Command, Option } from 'commander' +import { Command, CommanderError, Option } from 'commander' import { readFile } from 'node:fs/promises' import type { FolderType, RenameRuleName } from 'core-app' import type { MediaMetadata } from '@smm/core' @@ -15,6 +15,11 @@ import { resolveShowFolder, } from './folderDisplay' import { resolvePathUnderMediaFolder } from './resolvePathUnderMediaFolder' +import { + classifyRenameTarget, + printEpisodeRenameResult, +} from './renameDispatch' +import { Path } from '@core/path' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] const TYPE_CHOICES = [...FOLDER_TYPES, 'anime'] as const @@ -301,10 +306,41 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('rename') + .description( + 'Rename a managed media folder, or a linked TV episode file (+ associates)', + ) + .argument('', 'Absolute path of media folder or episode file') + .argument('', 'Absolute target path') + .action(async (from: string, to: string) => { + try { + const core = getCore() + const folders = await core.getFolders() + const classified = await classifyRenameTarget(from, folders) + if (classified.kind === 'folder') { + await core.renameFolder({ from, to }) + console.log(`${Path.posix(from)} → ${Path.posix(to)}`) + return + } + const result = await core.renameEpisodeFile({ + mediaFolderPath: classified.mediaFolderPath, + from, + to, + }) + if (printEpisodeRenameResult(result)) { + exitCode = 1 + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + program .command('rename-episode-file') .description( - 'Rename a linked TV episode file (+ same-stem associates) via Core', + 'Alias: rename a linked TV episode file (+ associates) under a media folder', ) .argument('', 'Imported TV show media folder path') .requiredOption('--from ', 'Current episode file path (absolute or relative to folder)') @@ -318,13 +354,7 @@ export async function runCli(argv: string[] = process.argv): Promise { from, to, }) - for (const pair of result.succeeded) { - console.log(`${pair.from} → ${pair.to}`) - } - for (const fail of result.failed) { - console.error(`FAILED ${fail.path}: ${fail.error}`) - } - if (result.failed.length > 0) { + if (printEpisodeRenameResult(result)) { exitCode = 1 } } catch (error) { @@ -417,6 +447,13 @@ export async function runCli(argv: string[] = process.argv): Promise { try { await program.parseAsync(argv, { from: 'node' }) } catch (error) { + // exitOverride(): help/version throw after writing to stdout with exitCode 0. + if (error instanceof CommanderError) { + if (error.exitCode !== 0) { + console.error(error.message) + } + return error.exitCode + } const message = error instanceof Error ? error.message : String(error) console.error(message) exitCode = 1 diff --git a/apps/cli/test/cli.e2e.ts b/apps/cli/test/cli.e2e.ts index a6fde991..2b2db2a2 100644 --- a/apps/cli/test/cli.e2e.ts +++ b/apps/cli/test/cli.e2e.ts @@ -11,6 +11,18 @@ import { type TestFolder, } from './helpers/testFolders' +describe('smm --help', () => { + it.each(['--help', '-h'] as const)('prints usage and commands for %s', async (flag) => { + const result = await smm([flag]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toMatch(/Usage:\s+smm/) + expect(result.stdout).toContain('list') + expect(result.stdout).toContain('add') + expect(result.stdout).toContain('scrape') + }) +}) + describe('smm CLI e2e', () => { let userDataDir: string let mediaDir: string diff --git a/apps/cli/test/helpers/smm.ts b/apps/cli/test/helpers/smm.ts index f6394bd7..19321a89 100644 --- a/apps/cli/test/helpers/smm.ts +++ b/apps/cli/test/helpers/smm.ts @@ -8,20 +8,31 @@ export interface SmmResult { stderr: string } -/** Run one `smm` command in-process and capture console output. */ +/** Run one `smm` command in-process and capture console / stdout output. */ export async function smm(args: string[]): Promise { const logs: string[] = [] const errors: string[] = [] + let stdoutChunks = '' const logSpy = vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]) => { logs.push(parts.map(String).join(' ')) }) const errorSpy = vi.spyOn(console, 'error').mockImplementation((...parts: unknown[]) => { errors.push(parts.map(String).join(' ')) }) + // Commander help writes via process.stdout.write, not console.log. + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((( + chunk: string | Uint8Array, + ) => { + stdoutChunks += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString() + return true + }) as typeof process.stdout.write) try { const code = await runCli(['node', 'smm', ...args]) - return { code, stdout: logs.join('\n'), stderr: errors.join('\n') } + const consoleOut = logs.join('\n') + const stdout = stdoutChunks ? `${stdoutChunks}${consoleOut}` : consoleOut + return { code, stdout, stderr: errors.join('\n') } } finally { + writeSpy.mockRestore() logSpy.mockRestore() errorSpy.mockRestore() } diff --git a/apps/cli/test/rename.e2e.ts b/apps/cli/test/rename.e2e.ts index eee40e30..e7664b3e 100644 --- a/apps/cli/test/rename.e2e.ts +++ b/apps/cli/test/rename.e2e.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Path } from '@core/path' @@ -13,7 +13,7 @@ import { tvShowFolder, } from './helpers/testFolders' -describe('smm renameFolder CLI e2e (pre-initialized import)', () => { +describe('smm rename CLI e2e (pre-initialized import)', () => { let userDataDir: string let mediaDir: string let prevUserDataDir: string | undefined @@ -34,12 +34,14 @@ describe('smm renameFolder CLI e2e (pre-initialized import)', () => { rmSync(mediaDir, { recursive: true, force: true }) }) - it('renames a pre-initialized tvshow folder and rewrites metadata paths', async () => { + it('renames a pre-initialized tvshow folder via smm rename', async () => { const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) const from = folder.path! const to = renamedFolderPath(from, folder.folderName) - await getCore().renameFolder({ from, to }) + const renamed = await smm(['rename', from, to]) + expect(renamed.code, renamed.stderr || renamed.stdout).toBe(0) + expect(renamed.stdout).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) expect(existsSync(from)).toBe(false) expect(existsSync(to)).toBe(true) @@ -78,7 +80,7 @@ describe('smm renameFolder CLI e2e (pre-initialized import)', () => { ]) }) - it('renames a pre-initialized movie folder and rewrites metadata paths', async () => { + it('renames a pre-initialized movie folder via smm rename', async () => { const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { updateMediaMetadata: (mediaMetadata) => { const folderPath = Path.toPlatformPath(mediaMetadata.mediaFolderPath!) @@ -107,7 +109,9 @@ describe('smm renameFolder CLI e2e (pre-initialized import)', () => { Path.posix(join(from, 'The Dark Knight [1080P].mkv')), ) - await getCore().renameFolder({ from, to }) + const renamed = await smm(['rename', from, to]) + expect(renamed.code, renamed.stderr || renamed.stdout).toBe(0) + expect(renamed.stdout).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) expect(existsSync(from)).toBe(false) expect(existsSync(to)).toBe(true) @@ -131,4 +135,58 @@ describe('smm renameFolder CLI e2e (pre-initialized import)', () => { }, ]) }) + + it('renames a linked episode file (+ associates) via smm rename', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) + const media = folder.path! + const from = join(media, 'S01E01.mkv') + const to = join(media, 'S01E01_renamed.mkv') + + const renamed = await smm(['rename', from, to]) + expect(renamed.code, renamed.stderr || renamed.stdout).toBe(0) + expect(renamed.stdout).toContain( + `${Path.posix(from)} → ${Path.posix(to)}`, + ) + expect(renamed.stdout).toContain( + `${Path.posix(join(media, 'S01E01.jpg'))} → ${Path.posix(join(media, 'S01E01_renamed.jpg'))}`, + ) + expect(renamed.stdout).toContain( + `${Path.posix(join(media, 'S01E01.sc.ass'))} → ${Path.posix(join(media, 'S01E01_renamed.sc.ass'))}`, + ) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(media, 'S01E01_renamed.jpg'))).toBe(true) + expect(existsSync(join(media, 'S01E01.jpg'))).toBe(false) + + const mm = await getCore().getMediaMetadata(media) + expect(mm).not.toBeNull() + expect(mm!.mediaFiles).toEqual( + expect.arrayContaining([ + { + absolutePath: Path.posix(to), + seasonNumber: 1, + episodeNumber: 1, + }, + ]), + ) + }) + + it('rejects renaming a subdirectory that is not a managed media folder', async () => { + const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) + const media = folder.path! + const season = join(media, 'Season 01') + mkdirSync(season) + + const result = await smm(['rename', season, join(media, 'Season 01 - Renamed')]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/directory but not a managed media folder/) + }) + + it('rejects unmanaged paths', async () => { + const orphan = join(mediaDir, 'orphan.mkv') + const result = await smm(['rename', orphan, join(mediaDir, 'orphan2.mkv')]) + expect(result.code).toBe(1) + expect(result.stderr).toMatch(/not under a managed media folder/) + }) }) diff --git a/docs/dev/rename-episode-file.md b/docs/dev/rename-episode-file.md index 0c4592e0..213c7726 100644 --- a/docs/dev/rename-episode-file.md +++ b/docs/dev/rename-episode-file.md @@ -67,7 +67,7 @@ Problem: associate discovery and orchestration live in **Layer 1**. Electron / O 1. **Core** — `Core.renameEpisodeFile` with episode check + associate expansion + disk + metadata + shared rename preflight 2. **HTTP** — `POST /api/rename-episode-file` → Core; Socket/metadata broadcast parity with today’s `/api/renameFiles` 3. **UI v3 (TV only)** — episode context-menu confirm calls the new API; no client-side `computeAssociatedFileRenames` when v3 is on -4. **CLI** — `smm rename-episode-file` → `Core.renameEpisodeFile` (operators / scripting / debugging) +4. **CLI** — `smm rename ` auto-dispatches `renameFolder` vs `renameEpisodeFile` (alias: `rename-episode-file`) 5. **MCP + AI tool** — shared `rename-episode-file` tool schemas; MCP handler + in-app assistant tool; **user confirmation** before disk write (mirror `rename-folder`) 6. **Cleanup** — optional: dual paths for TV; keep generic `/api/renameFiles` for movie / plan apply until those migrate @@ -107,7 +107,7 @@ Per [refactoring.md](../../refactoring.md): UI only collects intent (dialog rela | `POST /api/rename-episode-file` | Body → Core → `{ data }` / `{ error }`, HTTP 200 | | Broadcast | After success: `mediaMetadataUpdated` for the folder (parity with `/api/renameFiles`) | | UI TV context menu | v3 ON → new API with `{ mediaFolder, from, to }` only; v3 OFF → legacy client expand + `/api/renameFiles` | -| CLI | `smm rename-episode-file --from … --to …` → Core (no Socket confirmation) | +| CLI | `smm rename ` auto-dispatches folder vs episode; alias `rename-episode-file` | | MCP tool `rename-episode-file` | Same args as HTTP; **confirm** then Core (or HTTP); omit on hosts that cannot rename files | | In-app AI tool `rename-episode-file` | Same schemas as MCP; UI confirmation bridge then `POST /api/rename-episode-file` | @@ -198,32 +198,48 @@ Note: Core already has `findAssociatedFiles` for **rule-based** apply (extension #### CLI +Unified command (preferred): + ``` -smm rename-episode-file --from --to +smm rename ``` -| Arg / option | Meaning | -|--------------|---------| -| `` | Absolute media folder path (managed TV show folder) | -| `--from` | Current episode file path (absolute, or relative to ``) | -| `--to` | Target episode file path (absolute, or relative to ``) | +| Arg | Meaning | +|-----|---------| +| `` | Absolute path of a **managed media folder**, or of a **linked episode file** under one | +| `` | Absolute target path | + +**Auto-dispatch** (no `--folder` / `--episode` required for the happy paths): + +1. Load managed folders via `Core.getFolders()`. +2. If `` **exactly matches** a managed media folder → `Core.renameFolder({ from, to })`. +3. Else find the managed folder that **contains** `` (longest POSIX prefix wins if folders nest). +4. If none → error: not under a managed media folder. +5. If `` is a **directory** but not a media-folder root (e.g. `…/Show/Season 01`) → error: only media-folder roots or linked episode files. +6. Otherwise treat as episode file → `Core.renameEpisodeFile({ mediaFolderPath, from, to })` (Core expands associates + preflight). + +Examples: -Behavior: +```bash +# Episode file (+ associates) +smm rename "/media/TV/Show/Season 01/ep1.mkv" "/media/TV/Show/Season 01/S01E01.mkv" -1. Resolve relative `--from` / `--to` against `` to absolute paths; normalize via the same helpers Core uses. -2. Call `Core.renameEpisodeFile({ mediaFolderPath, from, to })` directly (no HTTP, no confirmation prompt — operator owns the shell). -3. On success: print one line per `succeeded` pair (`from → to`), then any `failed` lines (`FAILED path: error`). -4. Exit `0` if Core returns and `failed.length === 0`; exit `1` if Core throws or any pair failed. -5. Do **not** invent associate paths in the CLI — Core expands them. +# Media folder +smm rename "/media/TV/Show" "/media/TV/Show2" +``` + +Output: + +- Folder rename: one line `from → to`, exit `0` / `1`. +- Episode rename: one line per `succeeded` pair; `FAILED path: error` for failures; exit `1` if Core throws or any pair failed. -Example (happy path, one associate): +**Compatibility alias** (still supported): ``` -/m/Show/S01E01.mp4 → /m/Show/S01E01_renamed.mp4 -/m/Show/S01E01.srt → /m/Show/S01E01_renamed.srt +smm rename-episode-file --from --to ``` -Errors (unmanaged / not TV / not linked episode / preflight): print the Core error message to stderr and exit `1`. +Resolves relative `--from` / `--to` against ``, then calls the same episode path as `smm rename` (does not auto-dispatch to folder rename). #### MCP tool @@ -379,13 +395,16 @@ sequenceDiagram * **Then** response is `{ error: "Error Reason: …" }` * **And** no files are renamed -### 4.6 CLI rename episode file +### 4.6 CLI rename (unified) * **Given** a managed TV folder with a linked episode file -* **When** the operator runs `smm rename-episode-file --from … --to …` +* **When** the operator runs `smm rename ` * **Then** Core renames the episode (+ associates) * **And** stdout lists each succeeded pair -* **And** exit code is `0` on full success + +* **Given** a managed media folder +* **When** the operator runs `smm rename ` +* **Then** Core renames the folder and rewrites metadata / user config ### 4.7 MCP / AI tool with confirmation @@ -418,7 +437,7 @@ sequenceDiagram - **Core unit:** episode assert accepts linked `mediaFiles` entry and rejects unlinked paths / movie folders; stem associate expansion matches `computeAssociatedFileRenames` fixtures (`S01E01.en.srt` → new stem); rejects unmanaged / escape paths; preflight refuses dest-exists / missing source before any rename; metadata updated via `updateMediaMetadataAfterRename`; partial failure still writes metadata for succeeded pairs. - **HTTP route tests:** success returns `data.succeeded`; validation / not-episode / not-TV errors. - **UI unit:** TV context-menu flow with v3 on posts `{ mediaFolder, from, to }` to `/api/rename-episode-file` only; v3 off keeps legacy; movie panel unchanged. -- **CLI e2e / unit:** resolve relative `--from`/`--to`; success prints pairs and exit 0; reject unmanaged / not-linked episode with exit 1. +- **CLI e2e / unit:** `smm rename` dispatches folder vs episode; reject subdirectory / unmanaged; episode success prints pairs; folder success updates `list` + metadata cache. - **MCP / AI tool unit:** shared schema; cancel confirmation → no Core call; confirm → Core/HTTP invoked with primary paths only (no client-side associate list). - **E2E:** `TVShow-RenameEpisodeFile.e2e.ts` must pass with v3 on against Core path (Web / Electron / OHOS / Docker as already tagged). From ff2e3a0c24f709147f32019463221e4fc6519408 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 21 Aug 2026 16:04:06 +0800 Subject: [PATCH 095/214] feat(scrape): wire AI and MCP scrape/get-job tools with e2e Expose fire-and-forget scrape plus get-job polling for in-app AI and MCP, add Web/MCP/AI e2e coverage, and keep e2e defaults from blocking StatusBar clicks. Co-authored-by: Cursor --- apps/cli/public/locales/en/tools.json | 6 + apps/cli/public/locales/zh-CN/tools.json | 6 + apps/cli/server.ts | 4 + apps/cli/src/mcp/mcp.ts | 6 + apps/cli/src/route/chatRoute.ts | 2 + apps/cli/src/route/debug/debugGetJobTool.ts | 71 ++++++++++ apps/cli/src/route/debug/debugScrapeTool.ts | 76 +++++++++++ .../e2e/common/mcp/McpOther-ScrapeTool.e2e.ts | 121 ++++++++++++++++++ apps/e2e/test/lib/McpClient.ts | 20 +++ apps/e2e/test/lib/browser-fs.ts | 2 + apps/e2e/test/lib/debugGetJobTool.ts | 48 +++++++ apps/e2e/test/lib/debugScrapeTool.ts | 43 +++++++ apps/e2e/test/lib/e2e-smm-v3.test.ts | 38 ++++++ apps/e2e/test/lib/e2e-smm-v3.ts | 20 +++ apps/e2e/test/lib/mcpToolTypes.ts | 59 +++++++++ apps/e2e/test/lib/testbed.ts | 14 +- .../test/specs/ai/AiTool-ScrapeTool.e2e.ts | 108 ++++++++++++++++ apps/ui/src/ai/Assistant.tsx | 4 + apps/ui/src/ai/tools/GetJob.tsx | 40 ++++++ apps/ui/src/ai/tools/Scrape.tsx | 42 ++++++ apps/ui/src/ai/tools/index.ts | 2 + ci/run-e2e-test-lib.ts | 11 ++ ci/run-e2e-test.test.ts | 25 ++++ docs/dev/scrape.md | 26 +++- packages/core-routes/src/chat.ts | 4 + packages/core-routes/src/index.ts | 11 ++ packages/core-routes/src/mcp/createServer.ts | 12 ++ packages/core-routes/src/mcp/index.ts | 6 + packages/core-routes/src/mcp/mcp.test.ts | 45 +++++++ .../src/mcp/toolHandlers/getJob.ts | 57 +++++++++ .../src/mcp/toolHandlers/scrape.ts | 63 +++++++++ packages/core-routes/src/mcp/types.ts | 15 +++ packages/core-routes/src/tools/getJob.test.ts | 35 +++++ packages/core-routes/src/tools/getJob.ts | 68 ++++++++++ packages/core-routes/src/tools/index.ts | 20 ++- packages/core-routes/src/tools/scrape.test.ts | 33 +++++ packages/core-routes/src/tools/scrape.ts | 86 +++++++++++++ packages/core/ai-tool/getJobResult.ts | 9 ++ packages/core/ai-tool/registry.ts | 6 + packages/core/ai-tool/scrapeResult.test.ts | 21 +++ packages/core/ai-tool/scrapeResult.ts | 22 ++++ packages/core/ai-tool/systemPrompt.test.ts | 4 + packages/core/ai-tool/systemPrompt.ts | 12 ++ packages/core/types/ai-tools/getJob.ts | 82 ++++++++++++ packages/core/types/ai-tools/scrape.ts | 42 ++++++ packages/test/src/index.ts | 2 + 46 files changed, 1439 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/route/debug/debugGetJobTool.ts create mode 100644 apps/cli/src/route/debug/debugScrapeTool.ts create mode 100644 apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts create mode 100644 apps/e2e/test/lib/debugGetJobTool.ts create mode 100644 apps/e2e/test/lib/debugScrapeTool.ts create mode 100644 apps/e2e/test/lib/e2e-smm-v3.test.ts create mode 100644 apps/e2e/test/lib/e2e-smm-v3.ts create mode 100644 apps/e2e/test/specs/ai/AiTool-ScrapeTool.e2e.ts create mode 100644 apps/ui/src/ai/tools/GetJob.tsx create mode 100644 apps/ui/src/ai/tools/Scrape.tsx create mode 100644 packages/core-routes/src/mcp/toolHandlers/getJob.ts create mode 100644 packages/core-routes/src/mcp/toolHandlers/scrape.ts create mode 100644 packages/core-routes/src/tools/getJob.test.ts create mode 100644 packages/core-routes/src/tools/getJob.ts create mode 100644 packages/core-routes/src/tools/scrape.test.ts create mode 100644 packages/core-routes/src/tools/scrape.ts create mode 100644 packages/core/ai-tool/getJobResult.ts create mode 100644 packages/core/ai-tool/scrapeResult.test.ts create mode 100644 packages/core/ai-tool/scrapeResult.ts create mode 100644 packages/core/types/ai-tools/getJob.ts create mode 100644 packages/core/types/ai-tools/scrape.ts diff --git a/apps/cli/public/locales/en/tools.json b/apps/cli/public/locales/en/tools.json index 0085a04f..04963f5b 100644 --- a/apps/cli/public/locales/en/tools.json +++ b/apps/cli/public/locales/en/tools.json @@ -20,6 +20,12 @@ "rename-episode-file": { "description": "Rename a linked TV episode video file and same-stem associates (e.g. subtitles). TV episode files only — not folders, movies, or bulk rename plans." }, + "scrape": { + "description": "Start a scrape job for a managed TV show or movie folder (poster, fanart, thumbnails, nfo). Returns a job id immediately; use get-job to check status." + }, + "get-job": { + "description": "Get the status of a background job by id (scrape or import). Poll until status is succeeded, failed, or aborted." + }, "begin-rename-task": { "description": "Begin a rename files task for a media folder. Call this first, then addRenameFileToTask for each file, then endRenameFilesTask. The user will be asked to confirm before files are renamed. Returns a task ID to use with addRenameFileToTask and endRenameFilesTask." }, diff --git a/apps/cli/public/locales/zh-CN/tools.json b/apps/cli/public/locales/zh-CN/tools.json index 1b4a90a1..2cd60e7f 100644 --- a/apps/cli/public/locales/zh-CN/tools.json +++ b/apps/cli/public/locales/zh-CN/tools.json @@ -20,6 +20,12 @@ "rename-episode-file": { "description": "重命名已关联的电视剧集视频文件及其同名关联文件(如字幕)。仅用于剧集文件,不可用于文件夹、电影或批量重命名计划。" }, + "scrape": { + "description": "为已管理的电视剧或电影文件夹启动刮削任务(海报、背景、缩略图、NFO)。立即返回任务 id;请用 get-job 查询状态。" + }, + "get-job": { + "description": "按 id 查询后台任务状态(刮削或导入)。轮询直到 status 为 succeeded、failed 或 aborted。" + }, "begin-rename-task": { "description": "为媒体文件夹开始重命名文件任务。首先调用此工具,然后为每个文件调用 addRenameFileToTask,最后调用 endRenameFilesTask。用户将被要求确认后才会重命名文件。返回一个任务 ID,用于 addRenameFileToTask 和 endRenameFilesTask。" }, diff --git a/apps/cli/server.ts b/apps/cli/server.ts index ff77ab82..5316fe9c 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -39,6 +39,8 @@ import { handleDebugRenameFilesTaskRoutes } from './src/route/debug/debugRenameF import { handleDebugGetApplicationContextRoute } from './src/route/debug/debugGetApplicationContext'; import { handleDebugGetMediaMetadataRoute } from './src/route/debug/debugGetMediaMetadata'; import { handleDebugRenameFolderToolRoute } from './src/route/debug/debugRenameFolderTool'; +import { handleDebugScrapeToolRoute } from './src/route/debug/debugScrapeTool'; +import { handleDebugGetJobToolRoute } from './src/route/debug/debugGetJobTool'; import { handleDebugListFilesToolRoute } from './src/route/debug/debugListFilesTool'; import { handleDebugGetMediaFoldersRoute } from './src/route/debug/debugGetMediaFolders'; import { handleDebugGetEpisodesToolRoute } from './src/route/debug/debugGetEpisodesTool'; @@ -281,6 +283,8 @@ export class Server { handleDebugGetApplicationContextRoute(this.app); handleDebugGetMediaMetadataRoute(this.app); handleDebugRenameFolderToolRoute(this.app); + handleDebugScrapeToolRoute(this.app); + handleDebugGetJobToolRoute(this.app); handleDebugListFilesToolRoute(this.app); handleDebugGetMediaFoldersRoute(this.app); handleDebugGetEpisodesToolRoute(this.app); diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index e28bb7e1..69d31ffa 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -9,6 +9,8 @@ import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { GET_MEDIA_METADATA } from "@smm/core/types/ai-tools/getMediaMetadata"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; +import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -46,6 +48,8 @@ const TOOL_NAME_KEYS = [ LIST_FILES_KEY, RENAME_FOLDER, RENAME_EPISODE_FILE, + SCRAPE, + GET_JOB, BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, END_RENAME_FILES_TASK, @@ -136,6 +140,8 @@ async function buildMcpConfig(): Promise { broadcast(message as Parameters[0]), toolDescriptions: await loadLocalizedToolDescriptions(), renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), + scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), + getJob: (id) => getCore().getJob(id), logger: { debug: (obj, msg) => logger.debug(obj, msg), info: (obj, msg) => logger.info(obj, msg), diff --git a/apps/cli/src/route/chatRoute.ts b/apps/cli/src/route/chatRoute.ts index 06798df8..5fe98836 100644 --- a/apps/cli/src/route/chatRoute.ts +++ b/apps/cli/src/route/chatRoute.ts @@ -17,6 +17,8 @@ export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { try { const response = await doChat(chatConfig, c.req.raw, { renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), + scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), + getJob: (id) => getCore().getJob(id), }) return response } catch (error) { diff --git a/apps/cli/src/route/debug/debugGetJobTool.ts b/apps/cli/src/route/debug/debugGetJobTool.ts new file mode 100644 index 00000000..d9a137c5 --- /dev/null +++ b/apps/cli/src/route/debug/debugGetJobTool.ts @@ -0,0 +1,71 @@ +import { z } from 'zod/v3' +import type { Hono } from 'hono' +import { executeGetJob } from '@smm/core-routes' +import type { GetJobOutput } from '@core/types/ai-tools/getJob' +import { getCore } from '../../core/getCore' +import { logger } from '../../../lib/logger' + +interface DebugGetJobToolResponseBody { + success: boolean + data?: GetJobOutput + error?: string +} + +const getJobToolSchema = z.object({ + id: z.string().min(1, 'id is required'), +}) + +export async function processGetJobTool( + body: unknown, +): Promise { + try { + const validationResult = getJobToolSchema.safeParse(body ?? {}) + if (!validationResult.success) { + return { + success: false, + error: `Validation failed: ${validationResult.error.issues.map((i) => i.message).join(', ')}`, + } + } + + const result = await executeGetJob(validationResult.data.id, (id) => + getCore().getJob(id), + ) + + if (result.error) { + return { + success: false, + data: result, + error: result.error, + } + } + + return { + success: true, + data: result, + } + } catch (error) { + return { + success: false, + error: `Failed to execute get-job tool: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } +} + +export function handleDebugGetJobToolRoute(app: Hono) { + app.post('/debug/getJobTool', async (c) => { + try { + const rawBody = await c.req.json() + const result = await processGetJobTool(rawBody) + return c.json(result, 200) + } catch (error) { + logger.error({ error }, 'Debug API getJobTool route error:') + return c.json( + { + success: false, + error: `Failed to process getJobTool request: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + 500, + ) + } + }) +} diff --git a/apps/cli/src/route/debug/debugScrapeTool.ts b/apps/cli/src/route/debug/debugScrapeTool.ts new file mode 100644 index 00000000..ea92c5c5 --- /dev/null +++ b/apps/cli/src/route/debug/debugScrapeTool.ts @@ -0,0 +1,76 @@ +import { z } from 'zod/v3' +import type { Hono } from 'hono' +import { executeScrape } from '@smm/core-routes' +import type { ScrapeOutput } from '@core/types/ai-tools/scrape' +import { getCore } from '../../core/getCore' +import { logger } from '../../../lib/logger' + +interface DebugScrapeToolResponseBody { + success: boolean + data?: ScrapeOutput + error?: string +} + +const scrapeToolSchema = z.object({ + path: z.string().min(1, 'path is required'), + language: z.string().optional(), +}) + +export async function processScrapeTool( + body: unknown, +): Promise { + try { + const validationResult = scrapeToolSchema.safeParse(body ?? {}) + if (!validationResult.success) { + return { + success: false, + error: `Validation failed: ${validationResult.error.issues.map((i) => i.message).join(', ')}`, + } + } + + const result = await executeScrape( + { + path: validationResult.data.path, + language: validationResult.data.language, + }, + (path, options) => getCore().scrapeFolder(path, options), + ) + + if (result.error) { + return { + success: false, + data: result, + error: result.error, + } + } + + return { + success: true, + data: result, + } + } catch (error) { + return { + success: false, + error: `Failed to execute scrape tool: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } +} + +export function handleDebugScrapeToolRoute(app: Hono) { + app.post('/debug/scrapeTool', async (c) => { + try { + const rawBody = await c.req.json() + const result = await processScrapeTool(rawBody) + return c.json(result, 200) + } catch (error) { + logger.error({ error }, 'Debug API scrapeTool route error:') + return c.json( + { + success: false, + error: `Failed to process scrapeTool request: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + 500, + ) + } + }) +} diff --git a/apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts b/apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts new file mode 100644 index 00000000..fdb4b2b4 --- /dev/null +++ b/apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts @@ -0,0 +1,121 @@ +import { expect, browser } from '@wdio/globals' +import { SCRAPE_JOB_CREATED_MESSAGE } from '@smm/core/types/ai-tools/scrape' +import mcpClient from 'test/lib/McpClient' +import { folder2 } from 'test/actions/import-folders' +import { cleanup, setup } from 'test/lib/testbed' +import { testbedOs } from 'test/lib/e2e-platform' +import { + clearFolderViaBrowser, + resolveSmmTestFolderViaBrowser, +} from 'test/lib/browser-fs' +import { + cleanupMcpTest, + createMcpSpecContext, + seedRecognizedMovieFolder, + setupMcpTest, + skipIfOhos, +} from 'test/lib/mcpSpecShared' + +/** + * Movie fixture: Core skips thumbnails so this contract test does not + * hang on episode-still downloads. + * + * @supports local, Electron, Docker + * @unsupported HarmonyOS + */ +describe('MCP Other - ScrapeTool', () => { + const ctx = createMcpSpecContext() + let testFolder = '' + + before(function () { + skipIfOhos(this) + }) + + beforeEach(async () => { + await setup({ + removeDirInSidebar: true, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + openBrowserPage: true, + os: testbedOs, + }) + await setupMcpTest() + + testFolder = await resolveSmmTestFolderViaBrowser() + await clearFolderViaBrowser(testFolder) + }) + + afterEach(async () => { + await cleanupMcpTest() + await cleanup({ + removeDirInSidebar: true, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: false, + os: testbedOs, + }) + if (testFolder) { + await clearFolderViaBrowser(testFolder) + } + }) + + it('scrape starts a job; get-job polls until terminal', async function () { + this.timeout(120 * 1000) + + const folderPath = await seedRecognizedMovieFolder( + { + ...folder2, + folderName: '哪吒之魔童降世 (2019) {tmdbid=552524}', + files: ['movie.mkv'], + }, + testFolder, + { + database: 'TMDB', + id: '552524', + name: '哪吒之魔童降世', + }, + ) + + const scrapeResult = await mcpClient.scrape(ctx.clientCwd, ctx.mcpAddress, { + path: folderPath, + language: 'zh-CN', + }) + + expect(scrapeResult.id).toBeTruthy() + expect(scrapeResult.message).toBe(SCRAPE_JOB_CREATED_MESSAGE) + expect(scrapeResult.error).toBeUndefined() + + const jobId = scrapeResult.id + + await browser.waitUntil( + async () => { + const jobResult = await mcpClient.getJob(ctx.clientCwd, ctx.mcpAddress, { + id: jobId, + }) + const status = jobResult.job?.status + return status === 'succeeded' || status === 'failed' + }, + { + timeout: 90_000, + interval: 1000, + timeoutMsg: `Scrape job ${jobId} did not reach a terminal status`, + }, + ) + + const finalJob = await mcpClient.getJob(ctx.clientCwd, ctx.mcpAddress, { + id: jobId, + }) + expect(finalJob.job?.kind).toBe('scrape') + expect(finalJob.job?.id).toBe(jobId) + expect(finalJob.job?.status).toBe('succeeded') + if (finalJob.job?.kind === 'scrape') { + expect(finalJob.job.tasks.poster.status).toMatch(/completed|skipped/) + expect(finalJob.job.tasks.fanart.status).toMatch(/completed|skipped/) + expect(finalJob.job.tasks.thumbnails.status).toBe('skipped') + expect(finalJob.job.tasks.nfo.status).toMatch(/completed|skipped/) + } + }) +}) diff --git a/apps/e2e/test/lib/McpClient.ts b/apps/e2e/test/lib/McpClient.ts index eb99a7b1..488c5555 100644 --- a/apps/e2e/test/lib/McpClient.ts +++ b/apps/e2e/test/lib/McpClient.ts @@ -31,6 +31,10 @@ import { type MarkdownTextResponse, type RenameFolderRequest, type RenameFolderResponse, + type ScrapeRequest, + type ScrapeResponse, + type GetJobRequest, + type GetJobResponse, type TmdbGetMovieRequest, type TmdbGetTvShowRequest, type TmdbMovieDetailsResponse, @@ -211,6 +215,22 @@ class McpClient { return this.execTyped(clientCwd, mcpAddress, McpToolName.renameFolder, toolArgs(req)) } + async scrape( + clientCwd: string, + mcpAddress: string, + req: ScrapeRequest, + ): Promise { + return this.execTyped(clientCwd, mcpAddress, McpToolName.scrape, toolArgs(req)) + } + + async getJob( + clientCwd: string, + mcpAddress: string, + req: GetJobRequest, + ): Promise { + return this.execTyped(clientCwd, mcpAddress, McpToolName.getJob, toolArgs(req)) + } + async beginRenameFilesTask( clientCwd: string, mcpAddress: string, diff --git a/apps/e2e/test/lib/browser-fs.ts b/apps/e2e/test/lib/browser-fs.ts index 1791df04..32d3f485 100644 --- a/apps/e2e/test/lib/browser-fs.ts +++ b/apps/e2e/test/lib/browser-fs.ts @@ -566,6 +566,8 @@ export function buildDefaultUserConfig(initConfig?: Partial): UserCo enableMcpServer: false, mcpHost: '127.0.0.1', mcpPort: 30001, + // Avoid first-run AnonymousTelemetryConsentDialog blocking StatusBar clicks in e2e. + anonymousTelemetryConsent: false, } return initConfig ? { ...userConfig, ...initConfig } : userConfig diff --git a/apps/e2e/test/lib/debugGetJobTool.ts b/apps/e2e/test/lib/debugGetJobTool.ts new file mode 100644 index 00000000..e63773d5 --- /dev/null +++ b/apps/e2e/test/lib/debugGetJobTool.ts @@ -0,0 +1,48 @@ +const API_BASE_URL = 'http://localhost:30000' + +export interface GetJobToolOptions { + id: string +} + +export interface GetJobToolData { + job?: { + kind: string + id: string + status: string + folderPath?: string + tasks?: Record + error?: string + } + error?: string +} + +export interface DebugApiResponse { + success: boolean + data?: T + error?: string +} + +export async function getJobTool( + options: GetJobToolOptions, +): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/debug/getJobTool`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(options), + }) + + const data = (await response.json()) as DebugApiResponse + console.log('getJobTool response:' + JSON.stringify(data)) + return data + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + console.error('getJobTool error:', message) + return { + success: false, + error: message, + } + } +} diff --git a/apps/e2e/test/lib/debugScrapeTool.ts b/apps/e2e/test/lib/debugScrapeTool.ts new file mode 100644 index 00000000..afeebd4a --- /dev/null +++ b/apps/e2e/test/lib/debugScrapeTool.ts @@ -0,0 +1,43 @@ +const API_BASE_URL = 'http://localhost:30000' + +export interface ScrapeToolOptions { + path: string + language?: string +} + +export interface ScrapeToolData { + id: string + message: string + error?: string +} + +export interface DebugApiResponse { + success: boolean + data?: T + error?: string +} + +export async function scrapeTool( + options: ScrapeToolOptions, +): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/debug/scrapeTool`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(options), + }) + + const data = (await response.json()) as DebugApiResponse + console.log('scrapeTool response:' + JSON.stringify(data)) + return data + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + console.error('scrapeTool error:', message) + return { + success: false, + error: message, + } + } +} diff --git a/apps/e2e/test/lib/e2e-smm-v3.test.ts b/apps/e2e/test/lib/e2e-smm-v3.test.ts new file mode 100644 index 00000000..911e3d89 --- /dev/null +++ b/apps/e2e/test/lib/e2e-smm-v3.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { isE2eSmmV3Enabled, localStorageEntriesAfterClear } from './e2e-smm-v3.ts' + +describe('isE2eSmmV3Enabled', () => { + const prev = process.env.E2E_SMM_V3 + + afterEach(() => { + if (prev === undefined) delete process.env.E2E_SMM_V3 + else process.env.E2E_SMM_V3 = prev + }) + + test('is false when E2E_SMM_V3 is unset', () => { + delete process.env.E2E_SMM_V3 + expect(isE2eSmmV3Enabled()).toBe(false) + }) + + test('is false when E2E_SMM_V3 is not "true"', () => { + process.env.E2E_SMM_V3 = '1' + expect(isE2eSmmV3Enabled()).toBe(false) + }) + + test('is true when E2E_SMM_V3 is "true"', () => { + process.env.E2E_SMM_V3 = 'true' + expect(isE2eSmmV3Enabled()).toBe(true) + }) +}) + +describe('localStorageEntriesAfterClear', () => { + test('is empty when v3 is off', () => { + expect(localStorageEntriesAfterClear({} as NodeJS.ProcessEnv)).toEqual({}) + }) + + test('sets smm.v3.enabled when E2E_SMM_V3=true', () => { + expect(localStorageEntriesAfterClear({ E2E_SMM_V3: 'true' } as NodeJS.ProcessEnv)).toEqual({ + 'smm.v3.enabled': 'true', + }) + }) +}) diff --git a/apps/e2e/test/lib/e2e-smm-v3.ts b/apps/e2e/test/lib/e2e-smm-v3.ts new file mode 100644 index 00000000..161d8a93 --- /dev/null +++ b/apps/e2e/test/lib/e2e-smm-v3.ts @@ -0,0 +1,20 @@ +/** Browser localStorage key read by `isSmmV3Enabled()` in apps/ui. */ +export const SMM_V3_LOCAL_STORAGE_KEY = 'smm.v3.enabled' + +/** + * Opt-in for e2e: `E2E_SMM_V3=true` turns on UI v3 without changing production defaults. + * Any other value (unset, `"1"`, `"false"`) leaves v3 off. + */ +export function isE2eSmmV3Enabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.E2E_SMM_V3 === 'true' +} + +/** + * Keys to restore after `localStorage.clear()` so the v3 flag survives testbed cleanup. + */ +export function localStorageEntriesAfterClear( + env: NodeJS.ProcessEnv = process.env, +): Record { + if (!isE2eSmmV3Enabled(env)) return {} + return { [SMM_V3_LOCAL_STORAGE_KEY]: 'true' } +} diff --git a/apps/e2e/test/lib/mcpToolTypes.ts b/apps/e2e/test/lib/mcpToolTypes.ts index 54d45288..4f950548 100644 --- a/apps/e2e/test/lib/mcpToolTypes.ts +++ b/apps/e2e/test/lib/mcpToolTypes.ts @@ -16,6 +16,8 @@ export const McpToolName = { howToRenameEpisodeVideoFiles: 'how-to-rename-episode-video-files', howToRecognizeEpisodeVideoFiles: 'how-to-recognize-episode-video-files', renameFolder: 'rename-folder', + scrape: 'scrape', + getJob: 'get-job', beginRenameFilesTask: 'begin-rename-files-task', addRenameFileToTask: 'add-rename-file-to-task', endRenameFilesTask: 'end-rename-files-task', @@ -136,6 +138,63 @@ export interface RenameFolderResponse { error?: string } +// --- scrape --- +export interface ScrapeRequest { + path: string + language?: string +} + +export interface ScrapeResponse { + id: string + message: string + error?: string +} + +// --- get-job --- +export interface GetJobRequest { + id: string +} + +export interface GetJobScrapeTask { + status: string + error?: string +} + +export interface GetJobScrapeJob { + kind: 'scrape' + id: string + folderPath: string + status: string + tasks: { + poster: GetJobScrapeTask + fanart: GetJobScrapeTask + thumbnails: GetJobScrapeTask + nfo: GetJobScrapeTask + } + error?: string + createdAt: number + updatedAt: number +} + +export interface GetJobImportJob { + kind: 'import' + id: string + folderPath: string + type: string + status: string + stage: string | null + progress: number + recognizedTitle?: string + error?: string + createdAt: number + updatedAt: number +} + +export interface GetJobResponse { + job?: GetJobScrapeJob | GetJobImportJob + error?: string +} + // --- rename task (begin/append/end) --- export interface BeginRenameFilesTaskRequest { mediaFolderPath: string diff --git a/apps/e2e/test/lib/testbed.ts b/apps/e2e/test/lib/testbed.ts index d0c88278..466329b8 100644 --- a/apps/e2e/test/lib/testbed.ts +++ b/apps/e2e/test/lib/testbed.ts @@ -29,6 +29,7 @@ import { import type { TestbedOs } from './ui-page-url' import { isOhosE2e, testbedOs as defaultTestbedOs } from './e2e-platform' import { browser } from '@wdio/globals' +import { localStorageEntriesAfterClear } from './e2e-smm-v3' export type { TestbedOs } from './ui-page-url' // Re-export for convenience (switched wrappers are `export async function` below) @@ -117,6 +118,7 @@ export async function setup(options: { * Clear `localStorage` during cleanup and again after opening the page. * Defaults to `true` so Ohos/Electron attach sessions do not leak debug * overrides (e.g. wronghost TMDB asset host) across specs. + * When `E2E_SMM_V3=true`, `smm.v3.enabled` is written back after clear. */ clearLocalStorage?: boolean, /** @@ -265,6 +267,7 @@ export async function cleanup(options?: { * Clear `localStorage` (debug overrides, agreement flags, etc.). * Defaults to `true` so leftover keys do not pollute the next spec * when the session is reused (Ohos/Electron attach). + * When `E2E_SMM_V3=true`, `smm.v3.enabled` is written back after clear. */ clearLocalStorage?: boolean, /** @@ -370,9 +373,14 @@ export async function removeDirInSidebar(): Promise { async function clearBrowserLocalStorage(): Promise { // In some cleanup paths browser context may not be ready. try { - await browser.execute(() => { - (globalThis as { localStorage?: { clear: () => void } }).localStorage?.clear() - }) + const restore = localStorageEntriesAfterClear() + await browser.execute((entries: Record) => { + const storage = (globalThis as { localStorage?: Storage }).localStorage + storage?.clear() + for (const [key, value] of Object.entries(entries)) { + storage?.setItem(key, value) + } + }, restore) } catch (error) { console.warn('Skip clearing localStorage because browser is not ready:', error) } diff --git a/apps/e2e/test/specs/ai/AiTool-ScrapeTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-ScrapeTool.e2e.ts new file mode 100644 index 00000000..e00b1aa1 --- /dev/null +++ b/apps/e2e/test/specs/ai/AiTool-ScrapeTool.e2e.ts @@ -0,0 +1,108 @@ +import { expect, browser } from '@wdio/globals' +import { Path } from '@smm/core' +import { SCRAPE_JOB_CREATED_MESSAGE } from '@smm/core/types/ai-tools/scrape' +import { createBeforeHook, importFolderWithMediaMetadata } from '../../lib/testbed' +import env from 'test/lib/env' +import { createFolderInTestFolder, folder2 } from 'test/actions/import-folders' +import Sidebar from 'test/componentobjects/Sidebar' +import page from 'test/pageobjects/page' +import { scrapeTool } from 'test/lib/debugScrapeTool' +import { getJobTool } from 'test/lib/debugGetJobTool' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +const tmpMediaRoot = path.join(os.tmpdir(), 'smm-test-media') + +/** + * Uses a movie folder so Core skips thumbnails (legacy parity). That keeps + * this AI-tool contract test off the flaky episode-still download path. + * + * @supports local, Electron, Docker + */ +describe('AI Assistant - Scrape Tool', async () => { + before(async () => { + await createBeforeHook({ setupMediaFolders: false, setupMediaMetadata: false })() + }) + + after(async () => { + if (fs.existsSync(tmpMediaRoot)) { + fs.rmSync(tmpMediaRoot, { recursive: true, force: true }) + console.log('Removed tmp media folder:', tmpMediaRoot) + } + }) + + it('starts scrape via scrape tool and polls status via get-job tool', async function () { + this.timeout(env.slowdown ? 5 * 60 * 1000 : 120 * 1000) + + const folder = createFolderInTestFolder({ + ...folder2, + path: undefined, + folderName: '哪吒之魔童降世 (2019) {tmdbid=552524}', + files: ['movie.mkv'], + }) + + await importFolderWithMediaMetadata( + folder, + '天使降临到我身边.metadata.json', + (mediaMetadata) => { + mediaMetadata.type = 'movie-folder' + mediaMetadata.tvShow = undefined + mediaMetadata.mediaFiles = [ + { + absolutePath: Path.posix(path.join(folder.path!, 'movie.mkv')), + }, + ] + mediaMetadata.movie = { + database: 'TMDB', + id: '552524', + name: '哪吒之魔童降世', + } + return mediaMetadata + }, + ) + + await page.refresh() + await Sidebar.waitForFolderName(folder.folderName!, 60_000) + await browser.pause(1000) + + const scrapeResponse = await scrapeTool({ + path: folder.path!, + language: 'zh-CN', + }) + + expect(scrapeResponse.success).toBe(true) + expect(scrapeResponse.error).toBeUndefined() + expect(scrapeResponse.data?.id).toBeTruthy() + expect(scrapeResponse.data?.message).toBe(SCRAPE_JOB_CREATED_MESSAGE) + + const jobId = scrapeResponse.data!.id + + await browser.waitUntil( + async () => { + const jobResponse = await getJobTool({ id: jobId }) + const status = jobResponse.data?.job?.status + return status === 'succeeded' || status === 'failed' + }, + { + timeout: 90_000, + interval: 1000, + timeoutMsg: `Scrape job ${jobId} did not reach a terminal status`, + }, + ) + + const finalJob = await getJobTool({ id: jobId }) + expect(finalJob.success).toBe(true) + expect(finalJob.data?.job?.kind).toBe('scrape') + expect(finalJob.data?.job?.id).toBe(jobId) + expect(finalJob.data?.job?.status).toBe('succeeded') + expect(finalJob.data?.job?.tasks?.poster?.status).toMatch(/completed|skipped/) + expect(finalJob.data?.job?.tasks?.fanart?.status).toMatch(/completed|skipped/) + expect(finalJob.data?.job?.tasks?.thumbnails?.status).toBe('skipped') + expect(finalJob.data?.job?.tasks?.nfo?.status).toMatch(/completed|skipped/) + + if (env.slowdown) { + await browser.pause(5 * 1000) + } + }) +}) diff --git a/apps/ui/src/ai/Assistant.tsx b/apps/ui/src/ai/Assistant.tsx index c70dda3f..cc1779d6 100644 --- a/apps/ui/src/ai/Assistant.tsx +++ b/apps/ui/src/ai/Assistant.tsx @@ -20,6 +20,8 @@ import { GetMediaMetadataTool, RenameFolderTool, RenameEpisodeFileTool, + ScrapeTool, + GetJobTool, BeginRecognizeTaskTool, AddRecognizedMediaFileTool, EndRecognizeTaskTool, @@ -316,6 +318,8 @@ function AssistantImpl() { + + {/* Rename/recognize task tools run HTTP plan APIs in the renderer. On desktop `AssistantChatTransport`, the same tools are executed server-side in `doChat` — mounting them here would duplicate diff --git a/apps/ui/src/ai/tools/GetJob.tsx b/apps/ui/src/ai/tools/GetJob.tsx new file mode 100644 index 00000000..9cb190cb --- /dev/null +++ b/apps/ui/src/ai/tools/GetJob.tsx @@ -0,0 +1,40 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + GET_JOB, + GET_JOB_DESCRIPTION, + getJobInputSchema, + type GetJobOutput, + type JobToolPayload, +} from '@core/types/ai-tools/getJob' +import { getJobFailed, getJobSucceeded } from '@core/ai-tool/getJobResult' +import { formatToolError, requireNonEmptyString, toolOk } from '@core/ai-tool/toolResult' +import { getJob } from '@/api/getJob' + +const getJobTool = tool({ + description: GET_JOB_DESCRIPTION, + parameters: getJobInputSchema, + execute: async ({ id }): Promise => { + const idCheck = requireNonEmptyString(id, 'id') + if (typeof idCheck !== 'string') { + return getJobFailed(idCheck.error) + } + + try { + const body = await getJob(idCheck) + if (body.error) { + return getJobFailed(body.error) + } + if (!body.data) { + return getJobFailed('Error Reason: Job not found') + } + return toolOk(getJobSucceeded(body.data as JobToolPayload)) + } catch (error) { + return getJobFailed(formatToolError(error).error) + } + }, +}) + +export const GetJobTool = makeAssistantTool({ + ...getJobTool, + toolName: GET_JOB, +}) diff --git a/apps/ui/src/ai/tools/Scrape.tsx b/apps/ui/src/ai/tools/Scrape.tsx new file mode 100644 index 00000000..02d2a744 --- /dev/null +++ b/apps/ui/src/ai/tools/Scrape.tsx @@ -0,0 +1,42 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + SCRAPE, + SCRAPE_DESCRIPTION, + scrapeInputSchema, + type ScrapeOutput, +} from '@core/types/ai-tools/scrape' +import { scrapeFailed, scrapeSucceeded } from '@core/ai-tool/scrapeResult' +import { formatToolError, requireNonEmptyString, toolOk } from '@core/ai-tool/toolResult' +import { scrapeFolderV3 } from '@/api/scrapeV3' + +const scrapeTool = tool({ + description: SCRAPE_DESCRIPTION, + parameters: scrapeInputSchema, + execute: async ({ path, language }): Promise => { + const pathCheck = requireNonEmptyString(path, 'path') + if (typeof pathCheck !== 'string') { + return scrapeFailed('', pathCheck.error) + } + + try { + const result = await scrapeFolderV3({ + path: pathCheck, + language, + }) + if (result.error) { + return scrapeFailed(pathCheck, result.error) + } + if (!result.data?.id) { + return scrapeFailed(pathCheck, 'Error Reason: scrape job id missing') + } + return toolOk(scrapeSucceeded(result.data.id)) + } catch (error) { + return scrapeFailed(pathCheck, formatToolError(error).error) + } + }, +}) + +export const ScrapeTool = makeAssistantTool({ + ...scrapeTool, + toolName: SCRAPE, +}) diff --git a/apps/ui/src/ai/tools/index.ts b/apps/ui/src/ai/tools/index.ts index c34bd03e..2898550c 100644 --- a/apps/ui/src/ai/tools/index.ts +++ b/apps/ui/src/ai/tools/index.ts @@ -6,6 +6,8 @@ export { IsFolderExistTool } from './IsFolderExist'; export { GetEpisodesTool } from './GetEpisodes'; export { RenameFolderTool } from './RenameFolder'; export { RenameEpisodeFileTool } from './RenameEpisodeFile'; +export { ScrapeTool } from './Scrape'; +export { GetJobTool } from './GetJob'; export { BeginRecognizeTaskTool } from './BeginRecognizeTask'; export { AddRecognizedMediaFileTool } from './AddRecognizedMediaFile'; export { EndRecognizeTaskTool, cleanupRecognizePlan } from './EndRecognizeTask'; diff --git a/ci/run-e2e-test-lib.ts b/ci/run-e2e-test-lib.ts index 44cd30c1..fab6a80b 100644 --- a/ci/run-e2e-test-lib.ts +++ b/ci/run-e2e-test-lib.ts @@ -36,6 +36,13 @@ export const USAGE = const PLATFORMS = new Set(['desktop', 'ohos', 'electron', 'docker']); +/** Opt-in UI v3 for e2e (`localStorage smm.v3.enabled`). Production default stays off. */ +function assignE2eSmmV3Env(env: Record): void { + if (process.env.E2E_SMM_V3 === 'true') { + env.E2E_SMM_V3 = 'true'; + } +} + function parsePlatform(value: string): Platform { if (!PLATFORMS.has(value as Platform)) { throw new Error( @@ -186,6 +193,7 @@ export function buildDesktopConfig(specs: string[]): CicdConfig { if (process.env.EXTERNAL_CONFIG_FILE_URL) { env.EXTERNAL_CONFIG_FILE_URL = process.env.EXTERNAL_CONFIG_FILE_URL; } + assignE2eSmmV3Env(env); return { name: 'smm-e2e', @@ -233,6 +241,7 @@ export function buildOhosConfig(specs: string[]): CicdConfig { if (process.env.HDC_PORT_FORWARD_ENABLED) { env.HDC_PORT_FORWARD_ENABLED = process.env.HDC_PORT_FORWARD_ENABLED; } + assignE2eSmmV3Env(env); return { name: 'smm-e2e-ohos', @@ -272,6 +281,7 @@ export function buildElectronConfig(specs: string[]): CicdConfig { if (process.env.EXTERNAL_CONFIG_FILE_URL) { env.EXTERNAL_CONFIG_FILE_URL = process.env.EXTERNAL_CONFIG_FILE_URL; } + assignE2eSmmV3Env(env); return { name: 'smm-e2e-electron', @@ -347,6 +357,7 @@ export function buildDockerConfig(specs: string[]): CicdConfig { if (tvdbHttpProxy) { env.TVDB_HTTP_PROXY = tvdbHttpProxy; } + assignE2eSmmV3Env(env); return { name: 'smm-e2e-docker', diff --git a/ci/run-e2e-test.test.ts b/ci/run-e2e-test.test.ts index a2a87fef..db4a8444 100644 --- a/ci/run-e2e-test.test.ts +++ b/ci/run-e2e-test.test.ts @@ -111,4 +111,29 @@ describe('run-e2e-test docker platform', () => { else process.env.TMDB_HTTP_PROXY = prevProxy; } }); + + test('buildConfig forwards E2E_SMM_V3=true into cicd env', () => { + const prev = process.env.E2E_SMM_V3; + process.env.E2E_SMM_V3 = 'true'; + try { + expect(buildConfig('desktop', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); + expect(buildConfig('docker', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); + expect(buildConfig('electron', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); + expect(buildConfig('ohos', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBe('true'); + } finally { + if (prev === undefined) delete process.env.E2E_SMM_V3; + else process.env.E2E_SMM_V3 = prev; + } + }); + + test('buildConfig omits E2E_SMM_V3 when unset', () => { + const prev = process.env.E2E_SMM_V3; + delete process.env.E2E_SMM_V3; + try { + expect(buildConfig('desktop', ['common/tv/Scrape.e2e.ts']).env.E2E_SMM_V3).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.E2E_SMM_V3; + else process.env.E2E_SMM_V3 = prev; + } + }); }); diff --git a/docs/dev/scrape.md b/docs/dev/scrape.md index fc68ebd9..abf01224 100644 --- a/docs/dev/scrape.md +++ b/docs/dev/scrape.md @@ -37,6 +37,7 @@ Source of truth for resolvers/builders (port into Core, do not call UI): 1. **Core parity** — done (TV + movie × TMDB + TVDB; movie skips `thumbnails`) 2. **HTTP** — done (`POST /api/scrape` + scrape shape on `POST /api/get-job`) 3. **UI v3** — done (`isSmmV3Enabled()` → job start + poll; legacy mutations when v3 off) +4. **AI + MCP** — done (`scrape` + `get-job` tools; no confirmation; fire-and-forget scrape) ## 2. Architecture @@ -74,6 +75,8 @@ Per [refactoring.md](../../refactoring.md): HTTP is the Internal API channel int | `POST /api/scrape` | Validate body → Core → `{ data: { id } }` or `{ error }` | | `POST /api/get-job` | Existing route; `data` is discriminated job union | | UI `useScrapeDialog` | v3 ON → job poll path; v3 OFF → existing four mutations | +| MCP / AI `scrape` | Start job via Core (MCP) or `POST /api/scrape` (in-app AI); returns `{ id, message }`; **no confirmation** | +| MCP / AI `get-job` | Poll any job (`scrape` \| `import`) via Core / `POST /api/get-job` | ### 2.3 Key Design @@ -379,6 +382,15 @@ sequenceDiagram * **And** dialog task rows mirror job `tasks` * **And** when v3 is false, the legacy four mutations run unchanged +### 4.6 AI / MCP scrape + get-job + +* **Given** a managed TV or movie folder +* **When** the assistant or MCP client calls `scrape` with `{ path }` +* **Then** the tool returns `{ id, message: "scrape job created, use get-job tool to check job status by id." }` without waiting +* **And** no user confirmation dialog is shown +* **When** the client calls `get-job` with that `id` +* **Then** the tool returns the job payload (`kind: "scrape"` or `"import"`) or an error if not found + ## 5. Out of scope - Movie thumbnail scraping (legacy UI also TODO) @@ -388,13 +400,15 @@ sequenceDiagram - New REST path `/api/job/{id}` - Removing legacy UI mutations while v3 is off -## 6. Testing strategy +## 6. Testing -- **Core unit:** prepare accepts TV/movie × TMDB/TVDB; rejects music / unknown DB; movie marks thumbnails skipped; poster/fanart/nfo dispatch smoke with mocks. -- **Core integration:** existing TV+TMDB e2e remains; add/adapt cases for TV+TVDB and movie when fixtures exist. -- **HTTP route tests:** scrape success returns id; start errors; get-job scrape shape. -- **CLI e2e:** `--wait` icon lines; reject unmanaged; update “not a TV show” / TVDB reject tests once Core accepts those folders. -- **UI (phase 3):** ScrapeDialog v3 branch polls job; v3 off keeps mutations. +| Layer | Scenario | Test file | +|-------|----------|-----------| +| Web e2e | ScrapeDialog: TMDB/TVDB × TV show / movie; failover; HTTP proxy | `apps/e2e/common/tv/Scrape.e2e.ts`, `ScrapeFailover.e2e.ts`, `apps/e2e/common/httpproxy/ScrapeTvShowByTmdbBehindHttpProxy.e2e.ts`, `ScrapeTvShowByTvdbBehindHttpProxy.e2e.ts` | +| CLI e2e | `smm scrape --wait` creates poster / fanart / thumbnail / nfo | `apps/cli/test/scrape.e2e.ts` | +| HTTP | `POST /api/scrape` + `POST /api/get-job` | `apps/cli/src/route/Scrape.test.ts`, `GetJob.test.ts` | +| MCP tool | MCP `scrape` + `get-job`; movie fixture; thumbnails skipped | `apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts` | +| AI tool | Debug `scrape` + `get-job`; movie fixture; thumbnails skipped | `apps/e2e/test/specs/ai/AiTool-ScrapeTool.e2e.ts` | ## 7. Compatibility notes diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index 69f663ee..d8aff7ad 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -10,6 +10,8 @@ import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { LIST_FILES_IN_MEDIA_FOLDER } from "@smm/core/types/ai-tools/listFilesInMediaFolder"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; +import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -135,6 +137,8 @@ export async function doChat( [LIST_FILES_IN_MEDIA_FOLDER]: tools[LIST_FILES_IN_MEDIA_FOLDER], [RENAME_FOLDER]: tools[RENAME_FOLDER], [RENAME_EPISODE_FILE]: tools[RENAME_EPISODE_FILE], + [SCRAPE]: tools[SCRAPE], + [GET_JOB]: tools[GET_JOB], [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], diff --git a/packages/core-routes/src/index.ts b/packages/core-routes/src/index.ts index 1e3a4565..4abc53ab 100644 --- a/packages/core-routes/src/index.ts +++ b/packages/core-routes/src/index.ts @@ -28,6 +28,17 @@ export { migrateAIConfig } from "@smm/core/configMigration"; export type { AIProviderFactory, ChatConfig, ChatFs, ChatRequestBody } from "./chatTypes.ts"; export { defaultChatFs } from "./chatFs.ts"; export { createChatTools, type ChatTools, type ChatToolsExtraDeps } from "./tools/index.ts"; +export { + executeScrape, + buildScrapeTool, + type ScrapeFolderRunner, + type ScrapeParams, +} from "./tools/scrape.ts"; +export { + executeGetJob, + buildGetJobTool, + type GetJobRunner, +} from "./tools/getJob.ts"; export { buildUpstreamUrl, DEFAULT_ALLOWED_UPSTREAM_HOSTS, diff --git a/packages/core-routes/src/mcp/createServer.ts b/packages/core-routes/src/mcp/createServer.ts index 0a4c37ac..d1dc67a0 100644 --- a/packages/core-routes/src/mcp/createServer.ts +++ b/packages/core-routes/src/mcp/createServer.ts @@ -19,8 +19,12 @@ import { registerIsFolderExistTool } from "./toolHandlers/isFolderExist.ts"; import { registerListFilesTool } from "./toolHandlers/listFiles.ts"; import { registerRenameFolderTool } from "./toolHandlers/renameFolder.ts"; import { registerRenameEpisodeFileTool } from "./toolHandlers/renameEpisodeFile.ts"; +import { registerScrapeTool } from "./toolHandlers/scrape.ts"; +import { registerGetJobTool } from "./toolHandlers/getJob.ts"; import { registerStaticTextTools } from "./toolHandlers/staticText.ts"; import type { McpConfig } from "./types.ts"; +import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; +import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; /** * HTTP request handler for the MCP server. Created by @@ -103,6 +107,14 @@ export async function createMcpStreamableHttpHandler( registerRenameEpisodeFileTool(server, config); } + if (!config.disabledTools?.includes(SCRAPE)) { + registerScrapeTool(server, config); + } + + if (!config.disabledTools?.includes(GET_JOB)) { + registerGetJobTool(server, config); + } + // Episode-level rename task (begin / add / end). registerBeginRenameTaskTool(server, config, renameFilesTaskDeps); registerAddRenameFileTool(server, config, renameFilesTaskDeps); diff --git a/packages/core-routes/src/mcp/index.ts b/packages/core-routes/src/mcp/index.ts index e827f387..950f78d2 100644 --- a/packages/core-routes/src/mcp/index.ts +++ b/packages/core-routes/src/mcp/index.ts @@ -4,6 +4,8 @@ export { } from "./createServer.ts"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; +import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; export { createErrorResponse, createSuccessResponse, @@ -31,6 +33,8 @@ export { */ export { RENAME_FOLDER }; export { RENAME_EPISODE_FILE }; +export { SCRAPE }; +export { GET_JOB }; /** * Constants exposed to hosts that load `core-routes.js` as a @@ -41,4 +45,6 @@ export { RENAME_EPISODE_FILE }; export const MCP_TOOL_NAMES = { RENAME_FOLDER, RENAME_EPISODE_FILE, + SCRAPE, + GET_JOB, } as const; diff --git a/packages/core-routes/src/mcp/mcp.test.ts b/packages/core-routes/src/mcp/mcp.test.ts index 239f0747..78803970 100644 --- a/packages/core-routes/src/mcp/mcp.test.ts +++ b/packages/core-routes/src/mcp/mcp.test.ts @@ -284,6 +284,51 @@ describe("createMcpStreamableHttpHandler", () => { } }) + it("registers scrape and get-job when runners are provided", async () => { + const { appDataDir, userDataDir, userConfig, cleanup } = await makeTempConfig() + try { + const handler = await createMcpStreamableHttpHandler({ + getUserConfig: async () => userConfig, + appDataDir, + userDataDir, + fs: defaultChatFs(), + logger: { + debug() {}, + info() {}, + warn() {}, + error() {}, + }, + scrapeFolder: async () => ({ id: "job-1" }), + getJob: () => undefined, + }) + + await callMcp(handler, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.1" }, + }, + }) + + const listResponse = await callMcp(handler, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }) + const tools = (listResponse.result as { tools: Array<{ name: string }> }) + .tools + const names = tools.map((t) => t.name) + expect(names).toContain("scrape") + expect(names).toContain("get-job") + } finally { + await cleanup() + } + }) + it("registers rename-folder when disabledTools is empty", async () => { const { appDataDir, userDataDir, userConfig, cleanup } = await makeTempConfig() try { diff --git a/packages/core-routes/src/mcp/toolHandlers/getJob.ts b/packages/core-routes/src/mcp/toolHandlers/getJob.ts new file mode 100644 index 00000000..0b1ec1ac --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/getJob.ts @@ -0,0 +1,57 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + GET_JOB, + GET_JOB_DESCRIPTION, + getJobInputSchema, + getJobOutputSchema, +} from "@smm/core/types/ai-tools/getJob"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; +import { executeGetJob } from "../../tools/getJob.ts"; + +/** + * Register the `get-job` MCP tool. + * Returns scrape or import job status by id via Core. + */ +export function registerGetJobTool( + server: McpServer, + config: McpConfig, +): void { + const description = + config.toolDescriptions?.[GET_JOB] ?? GET_JOB_DESCRIPTION; + + server.registerTool( + GET_JOB, + { + description, + inputSchema: getJobInputSchema, + outputSchema: getJobOutputSchema, + }, + async (args: unknown): Promise => { + const params = (args ?? {}) as { id?: string }; + if (typeof params.id !== "string" || params.id.trim() === "") { + return createErrorResponse( + "Invalid id: 'id' must be a non-empty string", + ); + } + + try { + const result = await executeGetJob(params.id, config.getJob); + + if (result.error) { + return createErrorResponse(result.error); + } + + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse( + error instanceof Error ? error.message : String(error), + ); + } + }, + ); +} diff --git a/packages/core-routes/src/mcp/toolHandlers/scrape.ts b/packages/core-routes/src/mcp/toolHandlers/scrape.ts new file mode 100644 index 00000000..5f965656 --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/scrape.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + SCRAPE, + SCRAPE_DESCRIPTION, + scrapeInputSchema, + scrapeOutputSchema, +} from "@smm/core/types/ai-tools/scrape"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; +import { executeScrape } from "../../tools/scrape.ts"; + +/** + * Register the `scrape` MCP tool. + * Starts a Core scrape job (no confirmation) and returns `{ id, message }`. + */ +export function registerScrapeTool( + server: McpServer, + config: McpConfig, +): void { + const description = + config.toolDescriptions?.[SCRAPE] ?? SCRAPE_DESCRIPTION; + + server.registerTool( + SCRAPE, + { + description, + inputSchema: scrapeInputSchema, + outputSchema: scrapeOutputSchema, + }, + async (args: unknown): Promise => { + const params = (args ?? {}) as { path?: string; language?: string }; + if (typeof params.path !== "string" || params.path.trim() === "") { + return createErrorResponse( + "Invalid path: 'path' must be a non-empty string", + ); + } + + try { + const result = await executeScrape( + { + path: params.path, + language: params.language, + }, + config.scrapeFolder, + ); + + if (result.error) { + return createErrorResponse(result.error); + } + + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse( + error instanceof Error ? error.message : String(error), + ); + } + }, + ); +} diff --git a/packages/core-routes/src/mcp/types.ts b/packages/core-routes/src/mcp/types.ts index e88957ca..68c500a2 100644 --- a/packages/core-routes/src/mcp/types.ts +++ b/packages/core-routes/src/mcp/types.ts @@ -94,6 +94,21 @@ export interface McpConfig { failed: Array<{ path: string; error: string }>; }>; + /** + * Optional runner for `scrape`. Hosts that expose Core inject + * `Core.scrapeFolder`. When omitted, the tool reports unavailable. + */ + scrapeFolder?: ( + path: string, + options?: { language?: string }, + ) => Promise<{ id: string }>; + + /** + * Optional runner for `get-job`. Hosts that expose Core inject + * `Core.getJob`. When omitted, the tool reports unavailable. + */ + getJob?: (id: string) => unknown; + /** * Optional localized tool descriptions, keyed by tool name * (e.g. `"get-media-folders"`, `"is-folder-exist"`). When diff --git a/packages/core-routes/src/tools/getJob.test.ts b/packages/core-routes/src/tools/getJob.test.ts new file mode 100644 index 00000000..01cdcfcb --- /dev/null +++ b/packages/core-routes/src/tools/getJob.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeGetJob } from "./getJob.ts"; + +describe("executeGetJob", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeGetJob("job-1", undefined); + expect(result.error).toMatch(/not available/i); + }); + + it("returns job when found", async () => { + const job = { + kind: "scrape" as const, + id: "job-1", + folderPath: "/m/Show", + status: "running" as const, + tasks: { + poster: { status: "completed" as const }, + fanart: { status: "running" as const }, + thumbnails: { status: "pending" as const }, + nfo: { status: "pending" as const }, + }, + createdAt: 1, + updatedAt: 2, + }; + const runner = vi.fn(() => job); + const result = await executeGetJob("job-1", runner); + expect(result.job).toEqual(job); + expect(result.error).toBeUndefined(); + }); + + it("returns not found when runner returns undefined", async () => { + const result = await executeGetJob("missing", () => undefined); + expect(result.error).toMatch(/Job not found/); + }); +}); diff --git a/packages/core-routes/src/tools/getJob.ts b/packages/core-routes/src/tools/getJob.ts new file mode 100644 index 00000000..69009067 --- /dev/null +++ b/packages/core-routes/src/tools/getJob.ts @@ -0,0 +1,68 @@ +import { getJobFailed, getJobSucceeded } from "@smm/core/ai-tool/getJobResult"; +import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; +import { + GET_JOB, + GET_JOB_DESCRIPTION, + getJobInputSchema, + getJobOutputSchema, + type GetJobOutput, + type JobToolPayload, +} from "@smm/core/types/ai-tools/getJob"; + +export type GetJobRunner = (id: string) => unknown; + +/** + * Load a job by id via host-injected Core runner. + */ +export async function executeGetJob( + id: string, + runner: GetJobRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + const idCheck = requireNonEmptyString(id, "id"); + if (typeof idCheck !== "string") { + return getJobFailed(idCheck.error); + } + + if (!runner) { + return getJobFailed("get-job is not available on this host"); + } + + try { + const job = runner(idCheck); + if (job == null) { + return getJobFailed("Error Reason: Job not found"); + } + return getJobSucceeded(job as JobToolPayload); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const withPrefix = message.startsWith("Error Reason:") + ? message + : `Error Reason: ${message}`; + return getJobFailed(withPrefix); + } +} + +/** + * Build the AI SDK tool for backend chat (`doChat`). + */ +export function buildGetJobTool( + runner: GetJobRunner | undefined, + abortSignal?: AbortSignal, +) { + return { + description: GET_JOB_DESCRIPTION, + inputSchema: getJobInputSchema, + outputSchema: getJobOutputSchema, + execute: async (args: unknown): Promise => { + const params = (args ?? {}) as { id?: string }; + return executeGetJob(params.id ?? "", runner, abortSignal); + }, + }; +} + +export { GET_JOB }; diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index 08e9e586..3c6ccefd 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -8,6 +8,8 @@ import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { LIST_FILES_IN_MEDIA_FOLDER } from "@smm/core/types/ai-tools/listFilesInMediaFolder"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; +import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -34,6 +36,14 @@ import { buildRenameEpisodeFileTool, type RenameEpisodeFileRunner, } from "./renameEpisodeFile.ts"; +import { + buildScrapeTool, + type ScrapeFolderRunner, +} from "./scrape.ts"; +import { + buildGetJobTool, + type GetJobRunner, +} from "./getJob.ts"; import { buildAddRenameFileToTaskTool, buildBeginRenameFilesTaskTool, @@ -48,7 +58,7 @@ import { } from "./recognizeMediaFilesTask.ts"; /** - * The 14 chat tools registered in `streamText({ tools })`, keyed by + * The chat tools registered in `streamText({ tools })`, keyed by * their AI tool name constant. The object is constructed per-request * so each chat call gets tools bound to its own `clientId`, * `abortSignal`, and `UserConfig` snapshot. @@ -62,6 +72,8 @@ export interface ChatTools { [LIST_FILES_IN_MEDIA_FOLDER]: ReturnType; [RENAME_FOLDER]: ReturnType; [RENAME_EPISODE_FILE]: ReturnType; + [SCRAPE]: ReturnType; + [GET_JOB]: ReturnType; [BEGIN_RENAME_FILES_TASK]: ReturnType; [ADD_RENAME_FILE_TO_TASK]: ReturnType; [END_RENAME_FILES_TASK]: ReturnType; @@ -80,6 +92,10 @@ export interface ChatToolsExtraDeps { renameFilesTask?: RenameFilesTaskDeps; /** Host Core runner for single-episode rename (Bun cli / Electron). */ renameEpisodeFile?: RenameEpisodeFileRunner; + /** Host Core runner for scrape job start. */ + scrapeFolder?: ScrapeFolderRunner; + /** Host Core runner for job status lookup. */ + getJob?: GetJobRunner; } export interface CreateChatToolsArgs { @@ -164,6 +180,8 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { abortSignal, acknowledge, ), + [SCRAPE]: buildScrapeTool(extra?.scrapeFolder, abortSignal), + [GET_JOB]: buildGetJobTool(extra?.getJob, abortSignal), [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool( clientId, config.appDataDir, diff --git a/packages/core-routes/src/tools/scrape.test.ts b/packages/core-routes/src/tools/scrape.test.ts new file mode 100644 index 00000000..c547a38c --- /dev/null +++ b/packages/core-routes/src/tools/scrape.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { SCRAPE_JOB_CREATED_MESSAGE } from "@smm/core/types/ai-tools/scrape"; +import { executeScrape } from "./scrape.ts"; + +describe("executeScrape", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeScrape({ path: "/m/Show" }, undefined); + expect(result.id).toBe(""); + expect(result.error).toMatch(/not available/i); + }); + + it("returns id and fixed message on success", async () => { + const runner = vi.fn(async () => ({ id: "job-abc" })); + const result = await executeScrape( + { path: "/m/Show", language: "zh-CN" }, + runner, + ); + expect(result).toEqual({ + id: "job-abc", + message: SCRAPE_JOB_CREATED_MESSAGE, + }); + expect(runner).toHaveBeenCalledWith("/m/Show", { language: "zh-CN" }); + }); + + it("maps runner throw to scrapeFailed", async () => { + const runner = vi.fn(async () => { + throw new Error("folder is not managed by SMM"); + }); + const result = await executeScrape({ path: "/m/Show" }, runner); + expect(result.id).toBe(""); + expect(result.error).toMatch(/not managed/i); + }); +}); diff --git a/packages/core-routes/src/tools/scrape.ts b/packages/core-routes/src/tools/scrape.ts new file mode 100644 index 00000000..1e06e223 --- /dev/null +++ b/packages/core-routes/src/tools/scrape.ts @@ -0,0 +1,86 @@ +import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; +import { scrapeFailed, scrapeSucceeded } from "@smm/core/ai-tool/scrapeResult"; +import { + SCRAPE, + SCRAPE_DESCRIPTION, + scrapeInputSchema, + scrapeOutputSchema, + type ScrapeOutput, +} from "@smm/core/types/ai-tools/scrape"; + +export interface ScrapeParams { + path: string; + language?: string; +} + +export type ScrapeFolderRunner = ( + path: string, + options?: { language?: string }, +) => Promise<{ id: string }>; + +/** + * Start a scrape job via host-injected Core runner. No confirmation. + */ +export async function executeScrape( + params: ScrapeParams, + runner: ScrapeFolderRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + const pathCheck = requireNonEmptyString(params.path, "path"); + if (typeof pathCheck !== "string") { + return scrapeFailed("", pathCheck.error); + } + + if (!runner) { + return scrapeFailed(pathCheck, "scrape is not available on this host"); + } + + try { + const language = + typeof params.language === "string" && params.language.trim() !== "" + ? params.language + : undefined; + const { id } = await runner( + pathCheck, + language !== undefined ? { language } : undefined, + ); + return scrapeSucceeded(id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const withPrefix = message.startsWith("Error Reason:") + ? message + : `Error Reason: ${message}`; + return scrapeFailed(pathCheck, withPrefix); + } +} + +/** + * Build the AI SDK tool for backend chat (`doChat`). + */ +export function buildScrapeTool( + runner: ScrapeFolderRunner | undefined, + abortSignal?: AbortSignal, +) { + return { + description: SCRAPE_DESCRIPTION, + inputSchema: scrapeInputSchema, + outputSchema: scrapeOutputSchema, + execute: async (args: unknown): Promise => { + const params = (args ?? {}) as ScrapeParams; + return executeScrape( + { + path: params.path, + language: params.language, + }, + runner, + abortSignal, + ); + }, + }; +} + +export { SCRAPE }; diff --git a/packages/core/ai-tool/getJobResult.ts b/packages/core/ai-tool/getJobResult.ts new file mode 100644 index 00000000..6fac0403 --- /dev/null +++ b/packages/core/ai-tool/getJobResult.ts @@ -0,0 +1,9 @@ +import type { GetJobOutput, JobToolPayload } from '../types/ai-tools/getJob' + +export function getJobSucceeded(job: JobToolPayload): GetJobOutput { + return { job } +} + +export function getJobFailed(error: string): GetJobOutput { + return { error } +} diff --git a/packages/core/ai-tool/registry.ts b/packages/core/ai-tool/registry.ts index 25b7a5b2..845e9e07 100644 --- a/packages/core/ai-tool/registry.ts +++ b/packages/core/ai-tool/registry.ts @@ -46,6 +46,8 @@ import { GET_MEDIA_FOLDERS } from '../types/ai-tools/getMediaFolders' import { LIST_FILES_IN_MEDIA_FOLDER } from '../types/ai-tools/listFilesInMediaFolder' import { RENAME_FOLDER } from '../types/ai-tools/renameFolder' import { RENAME_EPISODE_FILE } from '../types/ai-tools/renameEpisodeFile' +import { SCRAPE } from '../types/ai-tools/scrape' +import { GET_JOB } from '../types/ai-tools/getJob' import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -99,6 +101,10 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ { name: RENAME_FOLDER, backend: true, frontend: true }, { name: RENAME_EPISODE_FILE, backend: true, frontend: true }, + // Scrape job (no confirmation; poll with get-job) + { name: SCRAPE, backend: true, frontend: true }, + { name: GET_JOB, backend: true, frontend: true }, + // Rename files task { name: BEGIN_RENAME_FILES_TASK, backend: true, frontend: true }, { name: ADD_RENAME_FILE_TO_TASK, backend: true, frontend: true }, diff --git a/packages/core/ai-tool/scrapeResult.test.ts b/packages/core/ai-tool/scrapeResult.test.ts new file mode 100644 index 00000000..41cc18e9 --- /dev/null +++ b/packages/core/ai-tool/scrapeResult.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { SCRAPE_JOB_CREATED_MESSAGE } from '../types/ai-tools/scrape' +import { scrapeFailed, scrapeSucceeded } from './scrapeResult' + +describe('scrapeResult', () => { + it('scrapeSucceeded returns id and fixed message', () => { + const result = scrapeSucceeded('job-1') + expect(result).toEqual({ + id: 'job-1', + message: SCRAPE_JOB_CREATED_MESSAGE, + }) + }) + + it('scrapeFailed includes path in error', () => { + const result = scrapeFailed('/m/Show', 'Error Reason: not managed') + expect(result.id).toBe('') + expect(result.message).toBe('') + expect(result.error).toMatch(/not managed/) + expect(result.error).toMatch(/Show/) + }) +}) diff --git a/packages/core/ai-tool/scrapeResult.ts b/packages/core/ai-tool/scrapeResult.ts new file mode 100644 index 00000000..3979a3be --- /dev/null +++ b/packages/core/ai-tool/scrapeResult.ts @@ -0,0 +1,22 @@ +import { Path } from '../path' +import { + SCRAPE_JOB_CREATED_MESSAGE, + type ScrapeOutput, +} from '../types/ai-tools/scrape' + +export function scrapeSucceeded(id: string): ScrapeOutput { + return { + id, + message: SCRAPE_JOB_CREATED_MESSAGE, + } +} + +export function scrapeFailed(path: string, error: string): ScrapeOutput { + return { + id: '', + message: '', + error: path.trim() + ? `${error} (path: ${Path.toPlatformPath(path)})` + : error, + } +} diff --git a/packages/core/ai-tool/systemPrompt.test.ts b/packages/core/ai-tool/systemPrompt.test.ts index 374da3bf..49589cc8 100644 --- a/packages/core/ai-tool/systemPrompt.test.ts +++ b/packages/core/ai-tool/systemPrompt.test.ts @@ -14,6 +14,8 @@ import { ADD_RECOGNIZED_MEDIA_FILE, END_RECOGNIZE_TASK, } from '../types/ai-tools/recognizeMediaFileTask' +import { SCRAPE } from '../types/ai-tools/scrape' +import { GET_JOB } from '../types/ai-tools/getJob' /** * The system prompt is interpolated with kebab-case tool name @@ -40,6 +42,8 @@ describe('systemPrompt', () => { BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, END_RECOGNIZE_TASK, + SCRAPE, + GET_JOB, ] for (const name of required) { expect( diff --git a/packages/core/ai-tool/systemPrompt.ts b/packages/core/ai-tool/systemPrompt.ts index 8784a605..463b3cb9 100644 --- a/packages/core/ai-tool/systemPrompt.ts +++ b/packages/core/ai-tool/systemPrompt.ts @@ -12,6 +12,8 @@ import { GET_APPLICATION_CONTEXT } from '../types/ai-tools/getApplicationContext import { GET_MEDIA_METADATA } from '../types/ai-tools/getMediaMetadata' import { GET_EPISODES } from '../types/ai-tools/getEpisodes' import { LIST_FILES_IN_MEDIA_FOLDER } from '../types/ai-tools/listFilesInMediaFolder' +import { SCRAPE } from '../types/ai-tools/scrape' +import { GET_JOB } from '../types/ai-tools/getJob' /** * The system prompt for the AI Assistant, shared between the @@ -68,6 +70,16 @@ Steps [ ] Call "${ADD_RENAME_FILE_TO_TASK}" to add a file to rename task, call multiple times to add multiple files [ ] Call "${END_RENAME_FILES_TASK}" to notify AI Agent to end the rename files task +### Scrape Media Artwork and NFO + +When user asks to scrape, download poster/fanart/thumbnails, or write NFO files for a media folder, +use the scrape job tools: + +1. Resolve which media folder (ask user or call "${GET_APPLICATION_CONTEXT}"). +2. Call "${SCRAPE}" with the folder path (optional language). It returns a job id immediately. +3. Call "${GET_JOB}" with that id to check progress. Poll until status is succeeded, failed, or aborted. +4. Report per-task results (poster, fanart, thumbnails, nfo) from the scrape job. + ## User Preferences Language: zh-CN (you should answer in user's language) diff --git a/packages/core/types/ai-tools/getJob.ts b/packages/core/types/ai-tools/getJob.ts new file mode 100644 index 00000000..9e4e7bf0 --- /dev/null +++ b/packages/core/types/ai-tools/getJob.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' + +export const GET_JOB = 'get-job' as const + +export const GET_JOB_DESCRIPTION = + 'Get the status of a background job by id. ' + + 'Supports scrape jobs (kind: "scrape" with poster/fanart/thumbnails/nfo tasks) ' + + 'and import jobs (kind: "import"). ' + + 'Poll until status is succeeded, failed, or aborted.\n\n' + + 'Example: Check job status for id "550e8400-e29b-41d4-a716-446655440000".' + +const jobStatusSchema = z.enum([ + 'pending', + 'running', + 'succeeded', + 'failed', + 'aborted', +]) + +const scrapeTaskRuntimeStatusSchema = z.enum([ + 'pending', + 'running', + 'skipped', + 'completed', + 'failed', +]) + +const scrapeJobTaskSchema = z.object({ + status: scrapeTaskRuntimeStatusSchema, + error: z.string().optional(), +}) + +export const scrapeJobSchema = z.object({ + kind: z.literal('scrape'), + id: z.string(), + folderPath: z.string(), + status: jobStatusSchema, + tasks: z.object({ + poster: scrapeJobTaskSchema, + fanart: scrapeJobTaskSchema, + thumbnails: scrapeJobTaskSchema, + nfo: scrapeJobTaskSchema, + }), + error: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}) + +export const importJobSchema = z.object({ + kind: z.literal('import'), + id: z.string(), + folderPath: z.string(), + type: z.string(), + status: jobStatusSchema, + stage: z.string().nullable(), + progress: z.number(), + recognizedTitle: z.string().optional(), + error: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}) + +export const jobSchema = z.discriminatedUnion('kind', [ + scrapeJobSchema, + importJobSchema, +]) + +export const getJobInputSchema = z.object({ + id: z.string().describe('Job id returned by scrape or import-folder'), +}) + +export const getJobOutputSchema = z.object({ + job: jobSchema.optional().describe('Job payload when found'), + error: z + .string() + .optional() + .describe('Error message when the job could not be loaded'), +}) + +export type GetJobInput = z.infer +export type GetJobOutput = z.infer +export type JobToolPayload = z.infer diff --git a/packages/core/types/ai-tools/scrape.ts b/packages/core/types/ai-tools/scrape.ts new file mode 100644 index 00000000..d2addec9 --- /dev/null +++ b/packages/core/types/ai-tools/scrape.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' + +export const SCRAPE = 'scrape' as const + +export const SCRAPE_DESCRIPTION = + 'Start a scrape job for a managed TV show or movie folder (poster, fanart, thumbnails, nfo). ' + + 'Returns a job id immediately; the scrape runs in the background. ' + + 'Call get-job with the returned id to check progress and per-task status. ' + + 'Supports TMDB and TVDB. Movie folders skip thumbnails.\n\n' + + 'Example: Scrape media folder "/path/to/Show".' + +/** Fixed success message returned with a new scrape job id. */ +export const SCRAPE_JOB_CREATED_MESSAGE = + 'scrape job created, use get-job tool to check job status by id.' + +export const scrapeInputSchema = z.object({ + path: z + .string() + .describe( + 'Absolute path of the managed media folder to scrape (POSIX or Windows format)', + ), + language: z + .string() + .optional() + .describe( + 'Optional language code for metadata/assets (defaults to user preferMediaLanguage)', + ), +}) + +export const scrapeOutputSchema = z.object({ + id: z.string().describe('Scrape job id; pass to get-job to poll status'), + message: z + .string() + .describe('Guidance for checking job status with get-job'), + error: z + .string() + .optional() + .describe('Error message when the scrape job could not be started'), +}) + +export type ScrapeInput = z.infer +export type ScrapeOutput = z.infer diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index ac9ef90c..138ec8aa 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -195,6 +195,8 @@ export async function resetUserConfig(userConfigPath?: string, initConfig?: Part enableMcpServer: false, mcpHost: '127.0.0.1', mcpPort: 30001, + // Avoid first-run AnonymousTelemetryConsentDialog blocking StatusBar clicks in e2e. + anonymousTelemetryConsent: false, } if(initConfig) { From 8ff35993d066d3e4f05fc02a45152acede7e5c7e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 22 Aug 2026 10:41:13 +0800 Subject: [PATCH 096/214] feat(core): add platform NetworkPort adapters with proxy and abort Introduce Nodejs, Ohos, and Browser NetworkPort implementations with http/https/socks5 proxy support, POST /api/core/fetch for the browser relay, and AbortSignal propagation through to outbound requests. Co-authored-by: Cursor --- apps/cli/package.json | 4 + apps/cli/server.ts | 2 + apps/cli/src/core/NodejsNetworkPort.test.ts | 280 ++++++++++++++++++ apps/cli/src/core/NodejsNetworkPort.ts | 237 +++++++++++++++ apps/cli/src/core/__fixtures__/proxy-cert.pem | 19 ++ apps/cli/src/core/__fixtures__/proxy-key.pem | 28 ++ apps/cli/src/core/getCore.ts | 4 +- apps/cli/src/route/CoreFetch.test.ts | 133 +++++++++ apps/cli/src/route/CoreFetch.ts | 111 +++++++ apps/core/src/ports/NetworkPort.ts | 2 + apps/ohos/src/core-routes-loader.ts | 9 +- apps/ohos/src/core/OhosNetworkPort.ts | 105 +++++++ apps/ui/src/core/BrowserNetworkPort.test.ts | 89 ++++++ apps/ui/src/core/BrowserNetworkPort.ts | 115 +++++++ docs/dev/network-core.md | 23 ++ pnpm-lock.yaml | 12 + 16 files changed, 1170 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/core/NodejsNetworkPort.test.ts create mode 100644 apps/cli/src/core/NodejsNetworkPort.ts create mode 100644 apps/cli/src/core/__fixtures__/proxy-cert.pem create mode 100644 apps/cli/src/core/__fixtures__/proxy-key.pem create mode 100644 apps/cli/src/route/CoreFetch.test.ts create mode 100644 apps/cli/src/route/CoreFetch.ts create mode 100644 apps/ohos/src/core/OhosNetworkPort.ts create mode 100644 apps/ui/src/core/BrowserNetworkPort.test.ts create mode 100644 apps/ui/src/core/BrowserNetworkPort.ts create mode 100644 docs/dev/network-core.md diff --git a/apps/cli/package.json b/apps/cli/package.json index 96caf0a4..34117539 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -8,6 +8,7 @@ "@ai-sdk/mcp": "latest", "@types/bun": "latest", "@vitest/coverage-v8": "^4.0.18", + "proxy-chain": "^3.0.0", "socket.io-client": "^4.8.3", "typescript": "^5", "vitest": "^4.0.18" @@ -28,6 +29,8 @@ "dotenv": "^17.2.3", "es-toolkit": "^1.43.0", "hono": "^4.10.8", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "i18next": "^25.8.4", "i18next-fs-backend": "^2.6.1", "node-pty": "^1.1.0", @@ -37,6 +40,7 @@ "sanitize-filename": "^1.6.3", "shelljs": "^0.10.0", "socket.io": "^4.8.3", + "socks-proxy-agent": "^8.0.5", "strip-ansi": "^7.2.0", "xmlbuilder2": "^4.0.3", "zod": "^4.1.8" diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 5316fe9c..35203888 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -51,6 +51,7 @@ import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleImportFolder } from './src/route/ImportFolder'; import { handleGetJob } from './src/route/GetJob'; import { handleScrape } from './src/route/Scrape'; +import { handleCoreFetch } from './src/route/CoreFetch'; import { handleShowFolder } from './src/route/ShowFolder'; import { handleFolderMetadata } from './src/route/FolderMetadata'; import { handleTencentAsrTranscribe } from './src/route/tencentAsr/Transcribe'; @@ -295,6 +296,7 @@ export class Server { handleImportFolder(this.app); handleGetJob(this.app); handleScrape(this.app); + handleCoreFetch(this.app); handleShowFolder(this.app); handleFolderMetadata(this.app); handleTencentAsrTranscribe(this.app); diff --git a/apps/cli/src/core/NodejsNetworkPort.test.ts b/apps/cli/src/core/NodejsNetworkPort.test.ts new file mode 100644 index 00000000..c1a9bb61 --- /dev/null +++ b/apps/cli/src/core/NodejsNetworkPort.test.ts @@ -0,0 +1,280 @@ +import { + createServer as createHttpServer, + request as httpRequest, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http' +import { createServer as createHttpsServer, type Server as HttpsServer } from 'node:https' +import { + createServer as createNetServer, + connect as netConnect, + type Server as NetServer, + type Socket, +} from 'node:net' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Server as ProxyChainServer } from 'proxy-chain' +import { NodejsNetworkPort } from './NodejsNetworkPort' + +const FIXTURES = resolve(import.meta.dirname, '__fixtures__') +const PROXY_KEY = readFileSync(resolve(FIXTURES, 'proxy-key.pem')) +const PROXY_CERT = readFileSync(resolve(FIXTURES, 'proxy-cert.pem')) + +function listen(server: HttpServer | HttpsServer | NetServer): Promise { + return new Promise((resolveListen, reject) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr && typeof addr === 'object') resolveListen(addr.port) + else reject(new Error('failed to bind')) + }) + }) +} + +function closeServer(server: { close: (cb?: (err?: Error) => void) => void }): Promise { + return new Promise((resolveClose, reject) => { + server.close((err) => (err ? reject(err) : resolveClose())) + }) +} + +/** Plain HTTP upstream that returns fixed JSON. */ +async function startUpstream(): Promise<{ url: string; close: () => Promise }> { + const server = createHttpServer((req: IncomingMessage, res: ServerResponse) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true, path: req.url ?? '/' })) + }) + const port = await listen(server) + return { + url: `http://127.0.0.1:${port}/hello`, + close: () => closeServer(server), + } +} + +/** HTTP proxy via proxy-chain (same family as e2e embedded proxy). */ +async function startHttpProxy(): Promise<{ url: string; close: () => Promise }> { + const server = new ProxyChainServer({ port: 0, host: '127.0.0.1', verbose: false }) + await server.listen() + return { + url: `http://127.0.0.1:${server.port}`, + close: async () => { + await server.close(true) + }, + } +} + +/** + * Minimal TLS-terminated HTTP proxy (`https://` proxy URL). + * Handles absolute-form requests for `http://` upstreams. + */ +async function startHttpsProxy(): Promise<{ url: string; close: () => Promise }> { + const server = createHttpsServer({ key: PROXY_KEY, cert: PROXY_CERT }, (req, res) => { + const target = req.url + if (!target || !/^https?:\/\//i.test(target)) { + res.writeHead(400) + res.end('absolute-form URL required') + return + } + const upstream = new URL(target) + const uReq = httpRequest( + { + hostname: upstream.hostname, + port: Number(upstream.port) || (upstream.protocol === 'https:' ? 443 : 80), + path: upstream.pathname + upstream.search, + method: req.method, + headers: { ...req.headers, host: upstream.host }, + }, + (uRes) => { + res.writeHead(uRes.statusCode ?? 502, uRes.headers) + uRes.pipe(res) + }, + ) + uReq.on('error', (err) => { + res.writeHead(502) + res.end(String(err)) + }) + req.pipe(uReq) + }) + + server.on('connect', (req, clientSocket, head) => { + const [host, portStr] = (req.url ?? '').split(':') + const port = Number(portStr) || 443 + const upstream = netConnect(port, host!, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + if (head.length) upstream.write(head) + upstream.pipe(clientSocket) + clientSocket.pipe(upstream) + }) + upstream.on('error', () => clientSocket.destroy()) + clientSocket.on('error', () => upstream.destroy()) + }) + + const port = await listen(server) + return { + url: `https://127.0.0.1:${port}`, + close: () => closeServer(server), + } +} + +/** Minimal no-auth SOCKS5 CONNECT proxy. */ +async function startSocks5Proxy(): Promise<{ url: string; close: () => Promise }> { + const server = createNetServer((client: Socket) => { + const fail = () => { + try { + client.destroy() + } catch { + /* ignore */ + } + } + + client.once('data', (greeting: Buffer) => { + if (greeting[0] !== 0x05) { + fail() + return + } + client.write(Buffer.from([0x05, 0x00])) + + client.once('data', (req: Buffer) => { + if (req[0] !== 0x05 || req[1] !== 0x01) { + client.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + fail() + return + } + const atyp = req[3] + let offset = 4 + let host: string + if (atyp === 0x01) { + host = `${req[4]}.${req[5]}.${req[6]}.${req[7]}` + offset = 8 + } else if (atyp === 0x03) { + const len = req[4]! + host = req.subarray(5, 5 + len).toString('utf8') + offset = 5 + len + } else { + client.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + fail() + return + } + const port = (req[offset]! << 8) | req[offset + 1]! + + const upstream = netConnect(port, host, () => { + client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + upstream.pipe(client) + client.pipe(upstream) + }) + upstream.on('error', fail) + client.on('error', () => upstream.destroy()) + }) + }) + }) + + const port = await listen(server) + return { + url: `socks5://127.0.0.1:${port}`, + close: () => closeServer(server), + } +} + +describe('NodejsNetworkPort', () => { + const network = new NodejsNetworkPort() + let upstream: { url: string; close: () => Promise } + + beforeAll(async () => { + upstream = await startUpstream() + }) + + afterAll(async () => { + await upstream.close() + }) + + it('fetches without proxy (direct)', async () => { + const res = await network.fetch(upstream.url) + expect(res.ok).toBe(true) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ ok: true, path: '/hello' }) + }) + + it('fetches via http:// proxy', async () => { + const proxy = await startHttpProxy() + try { + const res = await network.fetch(upstream.url, { proxy: proxy.url }) + expect(res.ok).toBe(true) + await expect(res.json()).resolves.toEqual({ ok: true, path: '/hello' }) + } finally { + await proxy.close() + } + }) + + it('fetches via https:// proxy', async () => { + const proxy = await startHttpsProxy() + const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + try { + const res = await network.fetch(upstream.url, { proxy: proxy.url }) + expect(res.ok).toBe(true) + await expect(res.json()).resolves.toEqual({ ok: true, path: '/hello' }) + } finally { + if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED + else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev + await proxy.close() + } + }) + + it('fetches via socks5:// proxy', async () => { + const proxy = await startSocks5Proxy() + try { + const res = await network.fetch(upstream.url, { proxy: proxy.url }) + expect(res.ok).toBe(true) + await expect(res.json()).resolves.toEqual({ ok: true, path: '/hello' }) + } finally { + await proxy.close() + } + }) + + it('rejects unsupported proxy scheme', async () => { + await expect(network.fetch(upstream.url, { proxy: 'ftp://127.0.0.1:1' })).rejects.toThrow( + /unsupported proxy scheme/i, + ) + }) + + it('rejects when proxy is unreachable', async () => { + await expect(network.fetch(upstream.url, { proxy: 'http://127.0.0.1:1' })).rejects.toThrow() + }) + + it('aborts an in-flight direct fetch when AbortSignal fires', async () => { + let upstreamHit = 0 + const slow = createHttpServer((_req, res) => { + upstreamHit += 1 + // Hang until client aborts / connection closes + _req.on('close', () => { + try { + res.destroy() + } catch { + /* ignore */ + } + }) + }) + const port = await listen(slow) + const url = `http://127.0.0.1:${port}/slow` + const controller = new AbortController() + const pending = network.fetch(url, { signal: controller.signal }) + await new Promise((r) => setTimeout(r, 30)) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + await closeServer(slow) + expect(upstreamHit).toBeGreaterThanOrEqual(0) + }) + + it('rejects http-proxy fetch when AbortSignal is already aborted', async () => { + const proxy = await startHttpProxy() + try { + const controller = new AbortController() + controller.abort() + await expect( + network.fetch(upstream.url, { proxy: proxy.url, signal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }) + } finally { + await proxy.close() + } + }) +}) diff --git a/apps/cli/src/core/NodejsNetworkPort.ts b/apps/cli/src/core/NodejsNetworkPort.ts new file mode 100644 index 00000000..b7534130 --- /dev/null +++ b/apps/cli/src/core/NodejsNetworkPort.ts @@ -0,0 +1,237 @@ +import http from 'node:http' +import https from 'node:https' +import type { Agent } from 'node:http' +import { HttpProxyAgent } from 'http-proxy-agent' +import { HttpsProxyAgent } from 'https-proxy-agent' +import { SocksProxyAgent } from 'socks-proxy-agent' +import type { FetchInit, HttpResponse, NetworkPort } from 'core-app' + +const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks5:', 'socks5h:']) + +function isBunRuntime(): boolean { + return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined' +} + +function assertSupportedProxy(proxyUrl: string): URL { + let parsed: URL + try { + parsed = new URL(proxyUrl) + } catch { + throw new Error(`Unsupported proxy scheme: invalid URL "${proxyUrl}"`) + } + if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `Unsupported proxy scheme: "${parsed.protocol}". Use http://, https://, or socks5://.`, + ) + } + return parsed +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { + out[key] = value + }) + return out +} + +function toHttpResponse(response: Response): HttpResponse { + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: headersToRecord(response.headers), + text: () => response.text(), + json: () => response.json() as Promise, + arrayBuffer: () => response.arrayBuffer(), + } +} + +function stripProxyFromInit(init?: FetchInit): RequestInit { + if (!init) return {} + const { proxy: _proxy, ...rest } = init + return rest +} + +function buildAgentRequestHeaders(init: FetchInit | undefined, target: URL): Record { + const headers: Record = { ...(init?.headers ?? {}) } + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'accept-encoding') { + delete headers[key] + } + } + headers.Host = target.host + return headers +} + +function abortError(signal?: AbortSignal): Error { + if (signal?.reason instanceof Error) return signal.reason + const err = new Error('This operation was aborted') + err.name = 'AbortError' + return err +} + +function requestViaAgent( + input: string, + init: FetchInit | undefined, + agent: Agent, + timeoutMessage: string, +): Promise { + const url = new URL(input) + const method = (init?.method ?? 'GET').toUpperCase() + const isBodyAllowed = method !== 'GET' && method !== 'HEAD' + const isHttps = url.protocol === 'https:' + const headers = buildAgentRequestHeaders(init, url) + const signal = init?.signal + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError(signal)) + return + } + + let settled = false + const settleReject = (err: Error) => { + if (settled) return + settled = true + signal?.removeEventListener('abort', onAbort) + reject(err) + } + const settleResolve = (res: Response) => { + if (settled) return + settled = true + signal?.removeEventListener('abort', onAbort) + resolve(res) + } + + const requestOptions: http.RequestOptions = { + hostname: url.hostname, + port: Number(url.port) || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method, + headers, + agent, + timeout: 30_000, + } + + const req = (isHttps ? https : http).request(requestOptions, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + const body = Buffer.concat(chunks) + const headerInit: Record = {} + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined) continue + headerInit[key] = Array.isArray(value) ? value.join(', ') : value + } + settleResolve( + new Response(body, { + status: res.statusCode ?? 502, + statusText: res.statusMessage ?? '', + headers: headerInit, + }), + ) + }) + res.on('error', (err) => { + if (signal?.aborted) settleReject(abortError(signal)) + else settleReject(err instanceof Error ? err : new Error(String(err))) + }) + }) + + const onAbort = () => { + req.destroy() + settleReject(abortError(signal)) + } + signal?.addEventListener('abort', onAbort, { once: true }) + // Cover abort between the initial aborted check and listener registration. + if (signal?.aborted) { + onAbort() + return + } + + req.on('error', (err) => { + if (signal?.aborted) { + settleReject(abortError(signal)) + return + } + settleReject(err instanceof Error ? err : new Error(String(err))) + }) + req.on('timeout', () => { + req.destroy() + settleReject(new Error(timeoutMessage)) + }) + + if (isBodyAllowed && init?.body !== undefined) { + req.write(init.body) + } + req.end() + }) +} + +function agentForProxy(proxyUrl: string, targetUrl: string): Agent { + const target = new URL(targetUrl) + const proxy = new URL(proxyUrl) + + // Cast: agent packages type `http` vs `node:http` Agent incompatibly under strict TS. + if (proxy.protocol === 'socks5:' || proxy.protocol === 'socks5h:') { + return new SocksProxyAgent(proxyUrl) as unknown as Agent + } + + // https:// target → CONNECT tunnel; http:// target → absolute-URL forward. + // HttpsProxyAgent / HttpProxyAgent also speak to https:// proxy endpoints. + if (target.protocol === 'https:') { + return new HttpsProxyAgent(proxyUrl) as unknown as Agent + } + return new HttpProxyAgent(proxyUrl) as unknown as Agent +} + +async function fetchViaNodeProxy( + input: string, + init: FetchInit | undefined, + proxyUrl: string, +): Promise { + const agent = agentForProxy(proxyUrl, input) + const proxy = new URL(proxyUrl) + const timeoutMessage = + proxy.protocol === 'socks5:' || proxy.protocol === 'socks5h:' + ? 'SOCKS5 proxy request timeout' + : new URL(input).protocol === 'https:' + ? 'HTTPS proxy request timeout' + : 'HTTP proxy request timeout' + const response = await requestViaAgent(input, init, agent, timeoutMessage) + return toHttpResponse(response) +} + +async function fetchViaBun( + input: string, + init: FetchInit | undefined, + proxyUrl: string, +): Promise { + const response = await fetch(input, { + ...stripProxyFromInit(init), + proxy: proxyUrl, + } as RequestInit & { proxy: string }) + return toHttpResponse(response) +} + +/** + * Node/Bun NetworkPort with optional outbound proxy (`http://`, `https://`, `socks5://`). + * Independent of `@smm/core-routes` `createProxiedFetch`. + */ +export class NodejsNetworkPort implements NetworkPort { + async fetch(input: string, init?: FetchInit): Promise { + const proxy = init?.proxy?.trim() + if (!proxy) { + const response = await fetch(input, stripProxyFromInit(init)) + return toHttpResponse(response) + } + + assertSupportedProxy(proxy) + + if (isBunRuntime()) { + return fetchViaBun(input, init, proxy) + } + + return fetchViaNodeProxy(input, init, proxy) + } +} diff --git a/apps/cli/src/core/__fixtures__/proxy-cert.pem b/apps/cli/src/core/__fixtures__/proxy-cert.pem new file mode 100644 index 00000000..cb8ab155 --- /dev/null +++ b/apps/cli/src/core/__fixtures__/proxy-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUdu1bm4ZEvOG60whIS1ceQFTPl1QwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgyMTA4NTcwN1oXDTM2MDgx +ODA4NTcwN1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAqIem2nAa+Tl/Sncr0mEgcztsWUkR7poUOehCe4KIj/xf +m5N+Aoiy6v932ueAv5DyKkV/1HGwlQNcqGhzq6g83br760y5YLdMHi5B3LIgIorV +t7Ewov9UPa2cayYHvvJ9jjNuFJK9urLQf+SBybeDSic5x8vjzfpMOB8QKQF/2ET+ +aN8SczBRwA7LdXGmqt05MjdF6mNff7KucKbd5FfwJcfBu36rVdZbdMxI2es60xOF +XQAs8aYMWq1UsTs0IeExNX2FSEaAkbdFaqerSlsP9lRmXD+GRYBL8EWbuBbSCIFo +G5nwjEAYPO60w3jqvcIlOlLDQp2LpkilbDXIHgToDwIDAQABo1MwUTAdBgNVHQ4E +FgQUyXX9Sx8LuOfKclEWeSl//n3mLkQwHwYDVR0jBBgwFoAUyXX9Sx8LuOfKclEW +eSl//n3mLkQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAjIHv +7KEL7QrCpGbLxwWAsFkMIFMV8ZUIovJ+ZCDnKoerAfsEshFYll58cTGafPjMoUD2 +5aMBQIvJjx2oYIQoqTcgIBkipee0PTAhV5pHt89ri901QarsFc5X7aDsvjZ0GlMB +FCAr95c1DEt0e+g5EU04At6DjHJr1qjJaR/pXj64CCeO+RFoqmsTlFwu2y3jf4my +QQEKqdaEsNUAthAcyQIuBRy5ShMt9i0fUc5lvIUA63XtBGK/J6i+64RJM3Yh2LGe +MTH2QdsrtPtbk73/BVKKzU1Zi4ew7Rj1YjDUsLPs5iUDsFEtPjgqt4KoT3+VzUgG +3Rgwq628RUNghjZZ0w== +-----END CERTIFICATE----- diff --git a/apps/cli/src/core/__fixtures__/proxy-key.pem b/apps/cli/src/core/__fixtures__/proxy-key.pem new file mode 100644 index 00000000..dd0d4b6c --- /dev/null +++ b/apps/cli/src/core/__fixtures__/proxy-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCoh6bacBr5OX9K +dyvSYSBzO2xZSRHumhQ56EJ7goiP/F+bk34CiLLq/3fa54C/kPIqRX/UcbCVA1yo +aHOrqDzduvvrTLlgt0weLkHcsiAiitW3sTCi/1Q9rZxrJge+8n2OM24Ukr26stB/ +5IHJt4NKJznHy+PN+kw4HxApAX/YRP5o3xJzMFHADst1caaq3TkyN0XqY19/sq5w +pt3kV/Alx8G7fqtV1lt0zEjZ6zrTE4VdACzxpgxarVSxOzQh4TE1fYVIRoCRt0Vq +p6tKWw/2VGZcP4ZFgEvwRZu4FtIIgWgbmfCMQBg87rTDeOq9wiU6UsNCnYumSKVs +NcgeBOgPAgMBAAECggEABBm/iL03OemDvDUNGqKBXOcUL6dNN+Vo2rCDi2E8pqA6 +yNyRsYHB32FcsidZFqycr9KCTsrUQ7PuS0knyK8SNIFjCs4ERc2qnPfTRvtexnab +/6BglL64r9grg6c99R4qV81dG7XRqoz91L3qrBTeEPwbrvhWqSUWHR+vel1HXToB +epGwuIWOPIKRjPgAPTyCn9u/Kmedu12XNIMXCLw4TDe8tlqrQxg5NlPssfb/NzWW +sK1OJqabX7hq6gnbfDy0QLuZo/oodgwObpH0gJo4INsf5POcGp2I2R6cpVRHIhtL +FHuZCSVkGjMyWSA+q7beFo+9+FU6BTIwx9vyzRz/3QKBgQDm44ftC0UowSk7xHAO +Kwdq+yUCvk0tBKXVH/4ZUPc0nEvqGrpMEcgT5lDxi0SnEjIliRfeomuawf7jaFTt +4Y/shT3D3XrvvKEz45DFpUv49m0gpP1HtwmYzBaY1BUk0WqJlKbepDZudEQUK1Io +s/q0x11AcaG5c3CBm26lKh/9QwKBgQC62+g6dxS8A4DiNA/FKbWqjTRLDND5KWGc +qKaOtzDwjWAv4AMtq4I9H0I2LF8EtI8fTSAV8uuq9HtTCe7Qn0YMxuQbbGpC3/AP +MoeFxl8JqaS91SnMLgBFbDXc7qZxJ+T0qgjmh7me3IRCYGJeeU3aOuLf0d7pYeS6 +bi/azC73RQKBgQCK3uuQfajZPGUKl7o2K1dI10mJfuB4ZUVBDP8ORw6IzEE0y2Km +AkPvBCZHQdbHDkCvJuf+dn52FJy0NrpuYa7pObLOrdYVf8UNqqAW4LbzRXSZn+Lf +UY4qIwasYVNeU77ahVhgrTA3+yI/Z1cBOE9gkFDcQ/xbyL0wl0ZTfSTEdQKBgElF +JaCXRRgcP5NzWqOzITsJN3IeSmsJIO9u6jCYAKayqP7c1iI4WE9jTHE1oOSVcHGT +qnswz9wvFZSQFobTjYJBEvz13AzxBo7O/3kjOezuCl0tksAvYYfXUJ+bLMS+IfcW +HXtXyR9T1MC6TphA9JgX8xJUlxXofunjfvxKiDgJAoGAClGB0NEFFtMbxCGCKncV +KAuSG9EJ2X1vnDp0qPkzlvVZVnqcekaT+2jpknx0WTp6zbsRNHRH9GJ4wLKTM1xi +Nz2Kht/Ioh8g2TBDQHIk0Mgr6TPHDxomzllFb465lNREcP0NjIPXXmELlWqpKcHt +DlQnZrs50VOMLBiET/Tvfnk= +-----END PRIVATE KEY----- diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index b1a49827..94050ee0 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -1,12 +1,12 @@ import { Core, - FetchNetworkAdapter, NodejsFsAdapter, NoopLoggerAdapter, StaticDiscoverAdapter, type LoggerPort, } from 'core-app' import { getUserDataDir } from '@/utils/config' +import { NodejsNetworkPort } from './NodejsNetworkPort' let instance: Core | undefined @@ -20,7 +20,7 @@ export function getCore(options?: GetCoreOptions): Core { const userDataDir = getUserDataDir() instance = new Core({ fs: new NodejsFsAdapter(), - network: new FetchNetworkAdapter(), + network: new NodejsNetworkPort(), logger: options?.logger ?? new NoopLoggerAdapter(), appDataDir: userDataDir, userDataDir, diff --git a/apps/cli/src/route/CoreFetch.test.ts b/apps/cli/src/route/CoreFetch.test.ts new file mode 100644 index 00000000..5c0ac48a --- /dev/null +++ b/apps/cli/src/route/CoreFetch.test.ts @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createServer, type Server } from 'node:http' +import { Hono } from 'hono' +import { handleCoreFetch } from './CoreFetch' + +describe('POST /api/core/fetch', () => { + let app: Hono + let upstream: Server + let upstreamUrl: string + + beforeAll(async () => { + app = new Hono() + handleCoreFetch(app) + + upstream = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true, via: 'upstream' })) + }) + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', () => resolve()) + }) + const addr = upstream.address() + if (!addr || typeof addr === 'string') throw new Error('no port') + upstreamUrl = `http://127.0.0.1:${addr.port}/hello` + }) + + afterAll(async () => { + await new Promise((resolve, reject) => { + upstream.close((err) => (err ? reject(err) : resolve())) + }) + }) + + async function postCoreFetch(body: unknown) { + return app.request('/api/core/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('returns Error Reason when url is missing', async () => { + const res = await postCoreFetch({}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: url is required/) + }) + + it('fetches upstream via NodejsNetworkPort and returns bodyBase64', async () => { + const res = await postCoreFetch({ url: upstreamUrl, method: 'GET' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { + data?: { + ok: boolean + status: number + headers: Record + bodyBase64: string + } + error?: string + } + expect(json.error).toBeUndefined() + expect(json.data?.ok).toBe(true) + expect(json.data?.status).toBe(200) + const body = Buffer.from(json.data!.bodyBase64, 'base64').toString('utf8') + expect(JSON.parse(body)).toEqual({ ok: true, via: 'upstream' }) + }) + + it('returns Error Reason for unsupported proxy scheme', async () => { + const res = await postCoreFetch({ url: upstreamUrl, proxy: 'ftp://127.0.0.1:1' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string; data?: unknown } + expect(json.data).toBeUndefined() + expect(json.error).toMatch(/Unsupported proxy scheme/i) + }) + + it('forwards client AbortSignal to NodejsNetworkPort', async () => { + let seenSignal: AbortSignal | undefined + const network = { + async fetch(_url: string, init?: { signal?: AbortSignal }) { + seenSignal = init?.signal + await new Promise((_resolve, reject) => { + const signal = init?.signal + if (!signal) { + reject(new Error('expected signal')) + return + } + if (signal.aborted) { + const err = new Error('aborted') + err.name = 'AbortError' + reject(err) + return + } + signal.addEventListener( + 'abort', + () => { + const err = new Error('aborted') + err.name = 'AbortError' + reject(err) + }, + { once: true }, + ) + }) + throw new Error('unreachable') + }, + } + + const abortApp = new Hono() + handleCoreFetch(abortApp, { network }) + + const controller = new AbortController() + const pending = abortApp.request('/api/core/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://127.0.0.1:9/hang' }), + signal: controller.signal, + }) + + await new Promise((r) => setTimeout(r, 20)) + expect(seenSignal).toBeDefined() + controller.abort() + + // Hono may surface abort as a rejected fetch or an error Response. + const result = await pending.then( + (res) => ({ type: 'response' as const, res }), + (err: unknown) => ({ type: 'reject' as const, err }), + ) + expect(seenSignal?.aborted).toBe(true) + if (result.type === 'reject') { + expect(result.err).toBeTruthy() + } else { + expect(result.res.ok).toBe(false) + } + }) +}) diff --git a/apps/cli/src/route/CoreFetch.ts b/apps/cli/src/route/CoreFetch.ts new file mode 100644 index 00000000..0dd34777 --- /dev/null +++ b/apps/cli/src/route/CoreFetch.ts @@ -0,0 +1,111 @@ +import type { Hono } from 'hono' +import { NodejsNetworkPort } from '../core/NodejsNetworkPort' +import { logger } from '../../lib/logger' + +export interface CoreFetchRequestBody { + url?: unknown + method?: unknown + headers?: unknown + body?: unknown + proxy?: unknown +} + +export interface CoreFetchResponseData { + ok: boolean + status: number + statusText: string + headers: Record + bodyBase64: string +} + +export interface CoreFetchResponseBody { + data?: CoreFetchResponseData + error?: string +} + +function isPlainStringRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + return Object.values(value).every((v) => typeof v === 'string') +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' +} + +/** + * `POST /api/core/fetch` — browser NetworkPort relay. + * Performs the outbound request with {@link NodejsNetworkPort} (direct or via proxy). + * + * Client disconnect / `AbortSignal` on the incoming request is forwarded to + * the network port via `c.req.raw.signal` (same pattern as executeCmd). + */ +export function handleCoreFetch( + app: Hono, + options?: { network?: import('core-app').NetworkPort }, +): void { + const network = options?.network ?? new NodejsNetworkPort() + + app.post('/api/core/fetch', async (c) => { + try { + let body: CoreFetchRequestBody = {} + try { + body = (await c.req.json()) as CoreFetchRequestBody + } catch { + /* empty / invalid JSON — may also abort while reading body */ + if (c.req.raw.signal.aborted) throw abortFromSignal(c.req.raw.signal) + } + + if (typeof body.url !== 'string' || body.url.trim() === '') { + const err: CoreFetchResponseBody = { error: 'Error Reason: url is required' } + return c.json(err, 200) + } + + const method = typeof body.method === 'string' ? body.method : undefined + const headers = isPlainStringRecord(body.headers) ? body.headers : undefined + const requestBody = typeof body.body === 'string' ? body.body : undefined + const proxy = typeof body.proxy === 'string' ? body.proxy : undefined + + // Penetrate browser AbortSignal → NodejsNetworkPort (and upstream). + const signal = c.req.raw.signal + + const upstream = await network.fetch(body.url, { + method, + headers, + body: requestBody, + proxy, + signal, + }) + + const bytes = new Uint8Array(await upstream.arrayBuffer()) + const data: CoreFetchResponseData = { + ok: upstream.ok, + status: upstream.status, + statusText: upstream.statusText, + headers: upstream.headers, + bodyBase64: Buffer.from(bytes).toString('base64'), + } + const ok: CoreFetchResponseBody = { data } + return c.json(ok, 200) + } catch (error) { + // Do not convert abort into a business `{ error }` JSON — let the + // connection drop / fetch reject like a cancelled request. + if (isAbortError(error) || c.req.raw.signal.aborted) { + throw error + } + logger.error({ error }, '[POST /api/core/fetch] route error') + const err: CoreFetchResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} + +function abortFromSignal(signal: AbortSignal): Error { + if (signal.reason instanceof Error) return signal.reason + const err = new Error('This operation was aborted') + err.name = 'AbortError' + return err +} diff --git a/apps/core/src/ports/NetworkPort.ts b/apps/core/src/ports/NetworkPort.ts index a0b9cd17..bdb4071c 100644 --- a/apps/core/src/ports/NetworkPort.ts +++ b/apps/core/src/ports/NetworkPort.ts @@ -3,6 +3,8 @@ export interface FetchInit { headers?: Record; body?: string; signal?: AbortSignal; + /** Outbound proxy URL: `http://`, `https://`, or `socks5://`. */ + proxy?: string; } /** Minimal HTTP response shape; runtime-agnostic (Node/browser). */ diff --git a/apps/ohos/src/core-routes-loader.ts b/apps/ohos/src/core-routes-loader.ts index e3fc8b6e..e2948dbf 100644 --- a/apps/ohos/src/core-routes-loader.ts +++ b/apps/ohos/src/core-routes-loader.ts @@ -36,7 +36,14 @@ export interface CoreRoutesModule { createReverseProxyRequestHandler: ( config: ReverseProxyConfig, ) => (req: IncomingMessage, res: ServerResponse) => void - createProxiedFetch?: unknown + /** + * Outbound proxied fetch factory (HTTP(S) + SOCKS5). Present in + * core-routes bundles that include proxiedFetch. + */ + createProxiedFetch?: ( + proxyUrl: string, + logger?: CoreRoutesLogger, + ) => ((input: RequestInfo | URL, init?: RequestInit) => Promise) | undefined createSocketIOManager: ( httpServer: HttpServer, config?: SocketIOConfig, diff --git a/apps/ohos/src/core/OhosNetworkPort.ts b/apps/ohos/src/core/OhosNetworkPort.ts new file mode 100644 index 00000000..a1b4ae74 --- /dev/null +++ b/apps/ohos/src/core/OhosNetworkPort.ts @@ -0,0 +1,105 @@ +import type { FetchInit, HttpResponse, NetworkPort } from "../../../core/src/ports/NetworkPort" +import { loadCoreRoutes } from "../core-routes-loader" + +type FetchLike = (input: string, init?: RequestInit) => Promise + +export interface OhosNetworkPortOptions { + /** Direct (no-proxy) fetch. Defaults to `createNodeHttpFetch()` from core-routes.js. */ + createNodeHttpFetch?: () => FetchLike + /** + * Factory for proxied fetch. Defaults to `createProxiedFetch` from core-routes.js + * (HTTP(S) + SOCKS5 agents bundled there for OHOS). + */ + createProxiedFetch?: (proxyUrl: string) => FetchLike | undefined +} + +const SUPPORTED_PROXY_PROTOCOLS = new Set(["http:", "https:", "socks5:", "socks5h:"]) + +function assertSupportedProxy(proxyUrl: string): void { + let parsed: URL + try { + parsed = new URL(proxyUrl) + } catch { + throw new Error(`Unsupported proxy scheme: invalid URL "${proxyUrl}"`) + } + if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `Unsupported proxy scheme: "${parsed.protocol}". Use http://, https://, or socks5://.`, + ) + } +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {} + headers.forEach((value, key) => { + out[key] = value + }) + return out +} + +function toHttpResponse(response: Response): HttpResponse { + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: headersToRecord(response.headers), + text: () => response.text(), + json: () => response.json() as Promise, + arrayBuffer: () => response.arrayBuffer(), + } +} + +function stripProxyFromInit(init?: FetchInit): RequestInit { + if (!init) return {} + const { proxy: _proxy, ...rest } = init + return rest +} + +function defaultCreateNodeHttpFetch(): FetchLike { + return loadCoreRoutes().createNodeHttpFetch() as FetchLike +} + +function defaultCreateProxiedFetch(proxyUrl: string): FetchLike | undefined { + const createProxiedFetch = loadCoreRoutes().createProxiedFetch + if (!createProxiedFetch) { + throw new Error( + "createProxiedFetch is not available in core-routes.js; rebuild the OHOS core-routes bundle", + ) + } + return createProxiedFetch(proxyUrl) as FetchLike | undefined +} + +/** + * HarmonyOS Electron NetworkPort. + * + * Direct traffic uses `createNodeHttpFetch` (undici/WASM is broken on OHOS). + * Proxied traffic uses `createProxiedFetch` from the external `core-routes.js` + * bundle (same agent stack as reverse proxy). + */ +export class OhosNetworkPort implements NetworkPort { + private readonly createNodeHttpFetch: () => FetchLike + private readonly createProxiedFetch: (proxyUrl: string) => FetchLike | undefined + + constructor(options?: OhosNetworkPortOptions) { + this.createNodeHttpFetch = options?.createNodeHttpFetch ?? defaultCreateNodeHttpFetch + this.createProxiedFetch = options?.createProxiedFetch ?? defaultCreateProxiedFetch + } + + async fetch(input: string, init?: FetchInit): Promise { + const proxy = init?.proxy?.trim() + const requestInit = stripProxyFromInit(init) + + if (!proxy) { + const response = await this.createNodeHttpFetch()(input, requestInit) + return toHttpResponse(response) + } + + assertSupportedProxy(proxy) + const proxiedFetch = this.createProxiedFetch(proxy) + if (!proxiedFetch) { + throw new Error(`Failed to create proxied fetch for "${proxy}"`) + } + const response = await proxiedFetch(input, requestInit) + return toHttpResponse(response) + } +} diff --git a/apps/ui/src/core/BrowserNetworkPort.test.ts b/apps/ui/src/core/BrowserNetworkPort.test.ts new file mode 100644 index 00000000..bcc8e1b0 --- /dev/null +++ b/apps/ui/src/core/BrowserNetworkPort.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BrowserNetworkPort } from './BrowserNetworkPort' + +describe('BrowserNetworkPort', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('POSTs to /api/core/fetch and maps data to HttpResponse', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + ok: true, + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + bodyBase64: btoa(unescape(encodeURIComponent(JSON.stringify({ hello: 'world' })))), + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const port = new BrowserNetworkPort() + const res = await port.fetch('https://example.com/api', { + method: 'GET', + headers: { Accept: 'application/json' }, + proxy: 'socks5://127.0.0.1:1080', + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('/api/core/fetch') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ + url: 'https://example.com/api', + method: 'GET', + headers: { Accept: 'application/json' }, + proxy: 'socks5://127.0.0.1:1080', + }) + + expect(res.ok).toBe(true) + expect(res.status).toBe(200) + expect(res.headers['content-type']).toBe('application/json') + await expect(res.json()).resolves.toEqual({ hello: 'world' }) + }) + + it('throws when API returns error field', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'Error Reason: Unsupported proxy scheme: "ftp:"' }), { + status: 200, + }), + ), + ) + + const port = new BrowserNetworkPort() + await expect(port.fetch('https://example.com', { proxy: 'ftp://x' })).rejects.toThrow( + /Unsupported proxy scheme/i, + ) + }) + + it('forwards AbortSignal to the internal API fetch', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + ok: true, + status: 200, + statusText: 'OK', + headers: {}, + bodyBase64: btoa('ok'), + }, + }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const controller = new AbortController() + const port = new BrowserNetworkPort() + await port.fetch('https://example.com', { signal: controller.signal }) + + expect(fetchMock.mock.calls[0]![1].signal).toBe(controller.signal) + }) +}) diff --git a/apps/ui/src/core/BrowserNetworkPort.ts b/apps/ui/src/core/BrowserNetworkPort.ts new file mode 100644 index 00000000..9c98f68c --- /dev/null +++ b/apps/ui/src/core/BrowserNetworkPort.ts @@ -0,0 +1,115 @@ +import type { FetchInit, HttpResponse, NetworkPort } from '../../../core/src/ports/NetworkPort' +import { apiFetch } from '@/lib/apiFetch' + +/** Request body for `POST /api/core/fetch`. */ +export interface CoreFetchRequestBody { + url: string + method?: string + headers?: Record + body?: string + proxy?: string +} + +/** Upstream response payload inside API `data`. */ +export interface CoreFetchResponseData { + ok: boolean + status: number + statusText: string + headers: Record + /** Raw response body, base64-encoded (binary-safe). */ + bodyBase64: string +} + +export interface CoreFetchResponseBody { + data?: CoreFetchResponseData + error?: string +} + +function decodeBodyBase64(bodyBase64: string): Uint8Array { + const binary = atob(bodyBase64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes +} + +function dataToHttpResponse(data: CoreFetchResponseData): HttpResponse { + const bytes = decodeBodyBase64(data.bodyBase64) + const textDecoder = new TextDecoder() + let textCache: string | undefined + let jsonCache: unknown + + return { + ok: data.ok, + status: data.status, + statusText: data.statusText, + headers: data.headers, + async text() { + if (textCache === undefined) { + textCache = textDecoder.decode(bytes) + } + return textCache + }, + async json() { + if (jsonCache === undefined) { + jsonCache = JSON.parse(await this.text()) + } + return jsonCache as T + }, + async arrayBuffer() { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + }, + } +} + +export interface BrowserNetworkPortOptions { + /** Override internal API fetch (defaults to {@link apiFetch}). */ + fetchImpl?: typeof fetch + /** Path of the core fetch RPC (default `/api/core/fetch`). */ + endpoint?: string +} + +/** + * Browser NetworkPort: relays outbound HTTP through `POST /api/core/fetch`, + * which runs {@link NodejsNetworkPort} on the CLI (proxy-capable). + */ +export class BrowserNetworkPort implements NetworkPort { + private readonly fetchImpl: typeof fetch + private readonly endpoint: string + + constructor(options?: BrowserNetworkPortOptions) { + this.fetchImpl = options?.fetchImpl ?? apiFetch + this.endpoint = options?.endpoint ?? '/api/core/fetch' + } + + async fetch(input: string, init?: FetchInit): Promise { + const requestBody: CoreFetchRequestBody = { + url: input, + } + if (init?.method !== undefined) requestBody.method = init.method + if (init?.headers !== undefined) requestBody.headers = init.headers + if (init?.body !== undefined) requestBody.body = init.body + if (init?.proxy !== undefined) requestBody.proxy = init.proxy + + const response = await this.fetchImpl(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: init?.signal, + }) + + if (!response.ok) { + throw new Error(`HTTP Layer Error: ${response.status} ${response.statusText}`) + } + + const json = (await response.json()) as CoreFetchResponseBody + if (json.error) { + throw new Error(json.error) + } + if (!json.data) { + throw new Error('Error Reason: /api/core/fetch returned no data') + } + return dataToHttpResponse(json.data) + } +} diff --git a/docs/dev/network-core.md b/docs/dev/network-core.md new file mode 100644 index 00000000..ec41b640 --- /dev/null +++ b/docs/dev/network-core.md @@ -0,0 +1,23 @@ +# The network ability in core module + +Support proxy procotol: http, https and socks5. + +| Platform | Implementation | +|--|--| +| cli | apps/cli/src/core/NodejsNetworkPort.ts | +| ohos | apps/oho/src/core/OhosNetworkPort.ts | +| browser | apps/ui/src/core/BrowserNetworkPort.ts | + +## BrowserNetworkPort + +在 `apps/ui/src/core/BrowserNetworkPort` 实现 `NetworkPort`。 + +浏览器无法直连任意上游 / 出站代理,因此通过 CLI RPC: + +1. `BrowserNetworkPort.fetch` → `POST /api/core/fetch`(`apiFetch`,带 auth) +2. CLI `handleCoreFetch` 用 `NodejsNetworkPort` 发起真实请求(含 `proxy`) +3. 响应以 `{ data: { ok, status, statusText, headers, bodyBase64 }, error? }` 返回;Port 再还原为 `HttpResponse` + +`AbortSignal` 穿透:Browser `signal` → `POST /api/core/fetch`(断开连接)→ `c.req.raw.signal` → `NodejsNetworkPort.fetch({ signal })` → 上游 `fetch` / agent `req.destroy()`。 + +请求体:`{ url, method?, headers?, body?, proxy? }`。 \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d199e68..572a6fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,12 @@ importers: hono: specifier: ^4.10.8 version: 4.12.2 + http-proxy-agent: + specifier: ^7.0.2 + version: 7.0.2 + https-proxy-agent: + specifier: ^7.0.6 + version: 7.0.6 i18next: specifier: ^25.8.4 version: 25.8.13(typescript@5.9.3) @@ -111,6 +117,9 @@ importers: socket.io: specifier: ^4.8.3 version: 4.8.3 + socks-proxy-agent: + specifier: ^8.0.5 + version: 8.0.5 strip-ansi: specifier: ^7.2.0 version: 7.2.0 @@ -130,6 +139,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) + proxy-chain: + specifier: ^3.0.0 + version: 3.0.0 socket.io-client: specifier: ^4.8.3 version: 4.8.3 From bdd2417f2ebb45535ae4384d26d138f980274112 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 22 Aug 2026 13:35:36 +0800 Subject: [PATCH 097/214] feat(cli): add smm tmdb search with offline lang validation Implement Core.searchInTmdb and the tmdb search CLI via NodejsNetworkPort, with static primary_translations validation for --lang, socks5h proxy handling, and e2e tests against live TMDB using layered .env.local. Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/src/cli/runCli.ts | 54 +++++- apps/cli/src/cli/tmdb.test.ts | 181 ++++++++++++++++++ apps/cli/src/cli/tmdbSearchFormat.test.ts | 76 ++++++++ apps/cli/src/cli/tmdbSearchFormat.ts | 28 +++ apps/cli/src/core/NodejsNetworkPort.ts | 6 +- apps/cli/test/helpers/loadEnvLocal.test.ts | 58 ++++++ apps/cli/test/helpers/loadEnvLocal.ts | 45 +++++ apps/cli/test/tmdb.e2e.ts | 140 ++++++++++++++ apps/core/src/Core.test.ts | 130 +++++++++++++ apps/core/src/Core.ts | 56 +++++- .../clients/mediaDatabaseTransport.test.ts | 20 ++ .../src/clients/mediaDatabaseTransport.ts | 32 +++- apps/core/src/index.ts | 1 + docs/dev/tmdb.md | 146 ++++++++++++++ packages/core/locale.test.ts | 29 +++ packages/core/locale.ts | 99 ++++++++++ packages/core/tmdbPrimaryTranslations.ts | 92 +++++++++ 18 files changed, 1182 insertions(+), 13 deletions(-) create mode 100644 apps/cli/src/cli/tmdb.test.ts create mode 100644 apps/cli/src/cli/tmdbSearchFormat.test.ts create mode 100644 apps/cli/src/cli/tmdbSearchFormat.ts create mode 100644 apps/cli/test/helpers/loadEnvLocal.test.ts create mode 100644 apps/cli/test/helpers/loadEnvLocal.ts create mode 100644 apps/cli/test/tmdb.e2e.ts create mode 100644 docs/dev/tmdb.md create mode 100644 packages/core/tmdbPrimaryTranslations.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 974d7a84..10978f97 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file', 'tmdb']) const firstArg = process.argv[2] const isCliHelp = firstArg === '--help' || firstArg === '-h' if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 01e492be..6e11d3e7 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -19,6 +19,7 @@ import { classifyRenameTarget, printEpisodeRenameResult, } from './renameDispatch' +import { formatTmdbSearchResults } from './tmdbSearchFormat' import { Path } from '@core/path' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] @@ -58,7 +59,7 @@ const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 const SCRAPE_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `rename-episode-file`, `job`, `config`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `rename-episode-file`, `job`, `config`, `tmdb`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -388,6 +389,57 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + const tmdbCmd = program.command('tmdb').description('TMDB helpers') + + tmdbCmd + .command('search') + .description('Search TMDB for TV shows or movies') + .argument('', 'Search keyword') + .addOption( + new Option('--type ', 'Media type') + .choices(['tv', 'movie']) + .makeOptionMandatory(), + ) + .option('--host ', 'TMDB API base URL (overrides userConfig.tmdb.host)') + .option('--password ', 'TMDB API key (overrides userConfig.tmdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tmdb.httpProxy)') + .option( + '--lang ', + 'TMDB primary translation IETF tag (static list from /configuration/primary_translations, e.g. zh-CN, en-US, fr-FR); defaults from userConfig then OS locale', + ) + .action( + async ( + keyword: string, + opts: { + type: 'tv' | 'movie' + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const body = await getCore().searchInTmdb(keyword, { + type: opts.type, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + language: opts.lang, + }) + if (body.error) { + console.error(body.error) + exitCode = 1 + return + } + const text = formatTmdbSearchResults(body, opts.type) + if (text) console.log(text) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) + const configCmd = program.command('config').description('Read or write user config (smm.json)') configCmd diff --git a/apps/cli/src/cli/tmdb.test.ts b/apps/cli/src/cli/tmdb.test.ts new file mode 100644 index 00000000..1824f6a6 --- /dev/null +++ b/apps/cli/src/cli/tmdb.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm tmdb search', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-tmdb-cli-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + vi.doUnmock('../core/getCore') + vi.resetModules() + }) + + it('prints formatted results and exits 0', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTmdb: async () => ({ + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + id: 42, + name: 'My Show', + original_name: 'My Show', + overview: 'A show', + poster_path: null, + backdrop_path: null, + first_air_date: '2020-01-01', + vote_average: 0, + vote_count: 0, + popularity: 0, + genre_ids: [], + origin_country: [], + }, + ], + }), + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tmdb', 'search', 'keyword', '--type', 'tv']) + + expect(code).toBe(0) + expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) + }) + + it('passes host, password, proxy and lang to Core.searchInTmdb', async () => { + const calls: unknown[] = [] + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTmdb: async (keyword: string, options: unknown) => { + calls.push({ keyword, options }) + return { page: 1, total_pages: 1, total_results: 0, results: [] } + }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', + 'smm', + 'tmdb', + 'search', + 'hello', + '--type', + 'movie', + '--host', + 'https://tmdb.example.com/v3', + '--password', + 'secret', + '--proxy', + 'socks5://127.0.0.1:1080', + '--lang', + 'ja-JP', + ]) + + expect(code).toBe(0) + expect(calls).toEqual([ + { + keyword: 'hello', + options: { + type: 'movie', + host: 'https://tmdb.example.com/v3', + password: 'secret', + proxy: 'socks5://127.0.0.1:1080', + language: 'ja-JP', + }, + }, + ]) + }) + + it('omits language when --lang is not passed', async () => { + const calls: unknown[] = [] + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTmdb: async (_keyword: string, options: unknown) => { + calls.push(options) + return { page: 1, total_pages: 1, total_results: 0, results: [] } + }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tmdb', 'search', 'x', '--type', 'tv']) + + expect(code).toBe(0) + expect(calls[0]).toEqual({ + type: 'tv', + host: undefined, + password: undefined, + proxy: undefined, + language: undefined, + }) + }) + + it('exits non-zero when --lang is invalid', async () => { + vi.resetModules() + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', + 'smm', + 'tmdb', + 'search', + 'keyword', + '--type', + 'tv', + '--lang', + 'cn', + ]) + + expect(code).not.toBe(0) + const errText = String(errorSpy.mock.calls.flat().join(' ')) + expect(errText).toMatch(/Unsupported language "cn"/) + expect(errText).toMatch(/--lang zh-CN/) + expect(errText).not.toMatch(/cn-CN/) + }) + + it('writes to stderr and exits 1 when search throws', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTmdb: async () => { + throw new Error('network down') + }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tmdb', 'search', 'x', '--type', 'tv']) + + expect(code).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0] ?? '')).toContain('network down') + }) +}) diff --git a/apps/cli/src/cli/tmdbSearchFormat.test.ts b/apps/cli/src/cli/tmdbSearchFormat.test.ts new file mode 100644 index 00000000..0d7f5e62 --- /dev/null +++ b/apps/cli/src/cli/tmdbSearchFormat.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { formatTmdbSearchResults } from './tmdbSearchFormat' +import type { TmdbSearchResponseBody } from '@smm/core' + +describe('formatTmdbSearchResults', () => { + it('formats tv results as index id name (date) then overview', () => { + const body: TmdbSearchResponseBody = { + page: 1, + total_pages: 1, + total_results: 2, + results: [ + { + id: 42, + name: 'My Show', + original_name: 'My Show', + overview: 'A show about things', + poster_path: null, + backdrop_path: null, + first_air_date: '2020-01-01', + vote_average: 0, + vote_count: 0, + popularity: 0, + genre_ids: [], + origin_country: [], + }, + { + id: 7, + name: 'Other', + original_name: 'Other', + overview: '', + poster_path: null, + backdrop_path: null, + first_air_date: '2019-05-05', + vote_average: 0, + vote_count: 0, + popularity: 0, + genre_ids: [], + origin_country: [], + }, + ], + } + + expect(formatTmdbSearchResults(body, 'tv')).toBe( + ['#1 42 My Show (2020-01-01)', 'A show about things', '#2 7 Other (2019-05-05)', ''].join( + '\n', + ), + ) + }) + + it('formats movie results with title and release_date', () => { + const body: TmdbSearchResponseBody = { + page: 1, + total_pages: 1, + total_results: 1, + results: [ + { + id: 99, + title: 'My Film', + original_title: 'My Film', + overview: 'A film', + poster_path: null, + backdrop_path: null, + release_date: '2021-02-03', + vote_average: 0, + vote_count: 0, + popularity: 0, + genre_ids: [], + adult: false, + video: false, + }, + ], + } + + expect(formatTmdbSearchResults(body, 'movie')).toBe('#1 99 My Film (2021-02-03)\nA film') + }) +}) diff --git a/apps/cli/src/cli/tmdbSearchFormat.ts b/apps/cli/src/cli/tmdbSearchFormat.ts new file mode 100644 index 00000000..711eac8b --- /dev/null +++ b/apps/cli/src/cli/tmdbSearchFormat.ts @@ -0,0 +1,28 @@ +import type { TMDBMovie, TMDBTVShow, TmdbSearchResponseBody } from '@smm/core' + +export type TmdbSearchMediaType = 'tv' | 'movie' + +/** + * Format TMDB search results for CLI stdout: + * `#{index} {tmdbid} {title} ({release date})` then overview on the next line. + */ +export function formatTmdbSearchResults( + body: TmdbSearchResponseBody, + type: TmdbSearchMediaType, +): string { + const lines: string[] = [] + for (let i = 0; i < body.results.length; i++) { + const item = body.results[i]! + const index = i + 1 + if (type === 'tv') { + const show = item as TMDBTVShow + lines.push(`#${index} ${show.id} ${show.name} (${show.first_air_date ?? ''})`) + lines.push(show.overview ?? '') + } else { + const movie = item as TMDBMovie + lines.push(`#${index} ${movie.id} ${movie.title} (${movie.release_date ?? ''})`) + lines.push(movie.overview ?? '') + } + } + return lines.join('\n') +} diff --git a/apps/cli/src/core/NodejsNetworkPort.ts b/apps/cli/src/core/NodejsNetworkPort.ts index b7534130..49a6ee85 100644 --- a/apps/cli/src/core/NodejsNetworkPort.ts +++ b/apps/cli/src/core/NodejsNetworkPort.ts @@ -174,7 +174,11 @@ function agentForProxy(proxyUrl: string, targetUrl: string): Agent { // Cast: agent packages type `http` vs `node:http` Agent incompatibly under strict TS. if (proxy.protocol === 'socks5:' || proxy.protocol === 'socks5h:') { - return new SocksProxyAgent(proxyUrl) as unknown as Agent + // Clash/V2Ray and similar SOCKS ports reset TLS when Node resolves DNS locally + // (`socks5://`). `socks5h://` lets the proxy resolve the hostname (same as curl --socks5-hostname). + const socks = new URL(proxyUrl) + if (socks.protocol === 'socks5:') socks.protocol = 'socks5h:' + return new SocksProxyAgent(socks) as unknown as Agent } // https:// target → CONNECT tunnel; http:// target → absolute-URL forward. diff --git a/apps/cli/test/helpers/loadEnvLocal.test.ts b/apps/cli/test/helpers/loadEnvLocal.test.ts new file mode 100644 index 00000000..fbb725d7 --- /dev/null +++ b/apps/cli/test/helpers/loadEnvLocal.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { findEnvLocalFiles, loadEnvLocal } from './loadEnvLocal' + +describe('loadEnvLocal', () => { + it('walks up directories and lets nearer .env.local override farther ones', () => { + const root = mkdtempSync(join(tmpdir(), 'smm-env-local-')) + const mid = join(root, 'apps') + const leaf = join(mid, 'cli') + mkdirSync(leaf, { recursive: true }) + writeFileSync(join(root, '.env.local'), 'SHARED=from-root\nNEAR=from-root\n') + writeFileSync(join(leaf, '.env.local'), 'NEAR=from-leaf\nLEAF_ONLY=cli\n') + + try { + expect(findEnvLocalFiles(leaf)).toEqual([join(leaf, '.env.local'), join(root, '.env.local')]) + + const prevShared = process.env.SHARED + const prevNear = process.env.NEAR + const prevLeaf = process.env.LEAF_ONLY + delete process.env.SHARED + delete process.env.NEAR + delete process.env.LEAF_ONLY + + const merged = loadEnvLocal(leaf) + expect(merged.SHARED).toBe('from-root') + expect(merged.NEAR).toBe('from-leaf') + expect(merged.LEAF_ONLY).toBe('cli') + expect(process.env.SHARED).toBe('from-root') + expect(process.env.NEAR).toBe('from-leaf') + + if (prevShared === undefined) delete process.env.SHARED + else process.env.SHARED = prevShared + if (prevNear === undefined) delete process.env.NEAR + else process.env.NEAR = prevNear + if (prevLeaf === undefined) delete process.env.LEAF_ONLY + else process.env.LEAF_ONLY = prevLeaf + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('does not override keys already present in process.env', () => { + const root = mkdtempSync(join(tmpdir(), 'smm-env-local-preset-')) + writeFileSync(join(root, '.env.local'), 'PRESET_KEY=from-file\n') + const prev = process.env.PRESET_KEY + process.env.PRESET_KEY = 'already-set' + try { + loadEnvLocal(root) + expect(process.env.PRESET_KEY).toBe('already-set') + } finally { + if (prev === undefined) delete process.env.PRESET_KEY + else process.env.PRESET_KEY = prev + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/cli/test/helpers/loadEnvLocal.ts b/apps/cli/test/helpers/loadEnvLocal.ts new file mode 100644 index 00000000..9d2bb92a --- /dev/null +++ b/apps/cli/test/helpers/loadEnvLocal.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import dotenv from 'dotenv' + +/** + * `.env.local` paths from `startDir` up to the filesystem root (nearest first). + */ +export function findEnvLocalFiles(startDir: string): string[] { + const files: string[] = [] + let dir = resolve(startDir) + while (true) { + const candidate = join(dir, '.env.local') + if (existsSync(candidate)) files.push(candidate) + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return files +} + +/** + * Load every `.env.local` from `startDir` to the repo/filesystem root. + * Nearer files override farther ones. Existing `process.env` keys are not overwritten. + */ +export function loadEnvLocal(startDir: string = process.cwd()): Record { + const files = findEnvLocalFiles(startDir) + const merged: Record = {} + for (const file of [...files].reverse()) { + Object.assign(merged, dotenv.parse(readFileSync(file))) + } + for (const [key, value] of Object.entries(merged)) { + if (process.env[key] === undefined) { + process.env[key] = value + } + } + return merged +} + +export function requiredEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + throw new Error(`${name} is not set (searched .env.local from cwd up to filesystem root)`) + } + return value +} diff --git a/apps/cli/test/tmdb.e2e.ts b/apps/cli/test/tmdb.e2e.ts new file mode 100644 index 00000000..37763750 --- /dev/null +++ b/apps/cli/test/tmdb.e2e.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './helpers/smm' +import { loadEnvLocal, requiredEnv } from './helpers/loadEnvLocal' + +loadEnvLocal(import.meta.dirname) + +const TMDB_SEARCH_TIMEOUT_MS = 60_000 +const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m +const TV_TITLE = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i + +function officialTmdb(): { host: string; password: string; proxy: string } { + return { + host: requiredEnv('TMDB_HOST'), + password: requiredEnv('TMDB_API_KEY'), + proxy: requiredEnv('TMDB_HTTP_PROXY'), + } +} + +describe('smm tmdb search CLI e2e (docs/dev/tmdb.md scenarios)', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tmdb-e2e-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it( + '1) Search through SMM-provided TMDB host', + { timeout: TMDB_SEARCH_TIMEOUT_MS }, + async () => { + const result = await smm(['tmdb', 'search', '天使降临到我身边', '--type', 'tv']) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(TV_TITLE) + }, + ) + + it( + '2) Search through SMM-provided TMDB host and HTTP/SOCKS proxy', + { timeout: TMDB_SEARCH_TIMEOUT_MS }, + async () => { + const { proxy } = officialTmdb() + const result = await smm([ + 'tmdb', + 'search', + '天使降临到我身边', + '--type', + 'tv', + '--proxy', + proxy, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(TV_TITLE) + }, + ) + + it( + '3) Search through custom TMDB host', + { timeout: TMDB_SEARCH_TIMEOUT_MS }, + async () => { + // Official TMDB rejects unauthenticated search; --password is required for this host. + const { host, password } = officialTmdb() + const result = await smm([ + 'tmdb', + 'search', + '天使降临到我身边', + '--type', + 'tv', + '--host', + host, + '--password', + password, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(TV_TITLE) + }, + ) + + it( + '4) Search through custom TMDB host and password', + { timeout: TMDB_SEARCH_TIMEOUT_MS }, + async () => { + const { host, password } = officialTmdb() + const result = await smm([ + 'tmdb', + 'search', + '天使降临到我身边', + '--type', + 'tv', + '--host', + host, + '--password', + password, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(TV_TITLE) + }, + ) + + it( + '5) Search through custom TMDB host, password and HTTP proxy', + { timeout: TMDB_SEARCH_TIMEOUT_MS }, + async () => { + const { host, password, proxy } = officialTmdb() + const result = await smm([ + 'tmdb', + 'search', + '天使降临到我身边', + '--type', + 'tv', + '--host', + host, + '--password', + password, + '--proxy', + proxy, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(TV_TITLE) + }, + ) +}) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 92a81243..ddc42751 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -1028,3 +1028,133 @@ describe("scrapeFolder", () => { await expect(core.scrapeFolder("/m/Show")).rejects.toThrow(/Unsupported media database/); }); }); + +describe("Core.searchInTmdb", () => { + it("searches via NetworkPort with host, password and proxy", async () => { + const calls: Array<{ url: string; proxy?: string; auth?: string }> = []; + const network: NetworkPort = { + fetch: async (url, init) => { + calls.push({ + url, + proxy: init?.proxy, + auth: init?.headers?.Authorization, + }); + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: async () => + JSON.stringify({ + results: [{ id: 42, name: "My Show", first_air_date: "2020-01-01", overview: "A show" }], + page: 1, + total_pages: 1, + total_results: 1, + }), + json: async () => + ({ + results: [{ id: 42, name: "My Show", first_air_date: "2020-01-01", overview: "A show" }], + page: 1, + total_pages: 1, + total_results: 1, + }) as T, + arrayBuffer: async () => new ArrayBuffer(0), + }; + }, + }; + const core = new Core({ + fs: inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: [], + preferMediaLanguage: "zh-CN", + tmdb: {}, + tvdb: {}, + }), + }), + network, + appDataDir: "/data/smm", + }); + + const body = await core.searchInTmdb("keyword", { + type: "tv", + host: "https://tmdb.example.com/v3", + password: "secret", + proxy: "socks5://127.0.0.1:1080", + }); + + expect(body.results[0]?.id).toBe(42); + expect(calls[0]?.url).toContain("https://tmdb.example.com/v3/search/tv"); + expect(calls[0]?.url).toContain("query=keyword"); + expect(calls[0]?.url).toContain("language=zh-CN"); + expect(calls[0]?.proxy).toBe("socks5://127.0.0.1:1080"); + expect(calls[0]?.auth).toBe("Bearer secret"); + }); + + it("validates options.language offline against static primary_translations", async () => { + const calls: string[] = []; + const network: NetworkPort = { + fetch: async (url) => { + calls.push(url); + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: async () => JSON.stringify({ results: [], page: 1, total_pages: 0, total_results: 0 }), + json: async () => + ({ results: [], page: 1, total_pages: 0, total_results: 0 }) as T, + arrayBuffer: async () => new ArrayBuffer(0), + }; + }, + }; + const core = new Core({ + fs: inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ + folders: [], + preferMediaLanguage: "zh-CN", + tmdb: {}, + tvdb: {}, + }), + }), + network, + appDataDir: "/data/smm", + }); + + await core.searchInTmdb("keyword", { + type: "movie", + language: "ja-JP", + host: "https://tmdb.example.com/v3", + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toContain("language=ja-JP"); + expect(calls[0]).not.toContain("primary_translations"); + }); + + it('rejects language not in static TMDB primary_translations such as "cn"', async () => { + const core = new Core({ + fs: inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }), + }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.searchInTmdb("keyword", { type: "tv", language: "cn" })).rejects.toThrow( + /Unsupported language "cn"/, + ); + await expect(core.searchInTmdb("keyword", { type: "tv", language: "cn" })).rejects.toThrow( + /--lang zh-CN/, + ); + }); + + it("rejects empty keyword", async () => { + const core = new Core({ + fs: inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }), + }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.searchInTmdb(" ", { type: "movie" })).rejects.toThrow(/keyword/i); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 2e79ffbb..14634c80 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -1,5 +1,12 @@ import { Path } from "@core/path"; -import type { AppConfig, FolderType, MediaMetadata, UserConfig as UserConfigData } from "@smm/core"; +import type { + AppConfig, + FolderType, + MediaMetadata, + TmdbSearchResponseBody, + UserConfig as UserConfigData, +} from "@smm/core"; +import { detectOsLocale, parseTmdbSearchLanguage, resolveMediaLanguage } from "@core/locale"; import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "./ports/FsPort"; @@ -7,6 +14,7 @@ import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; import type { DiscoverPort } from "./ports/DiscoverPort"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +import { TmdbClient } from "./clients/TmdbClient"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; @@ -32,6 +40,18 @@ import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import { initialScrapeTasks, type ImportJob, type Job } from "./jobs/types"; +export interface SearchInTmdbOptions { + type: "tv" | "movie"; + /** TMDB language (CLI `--lang`). Validated offline against static primary_translations. */ + language?: string; + /** Override userConfig.tmdb.host */ + host?: string; + /** Override userConfig.tmdb.apiKey (CLI `--password`) */ + password?: string; + /** Override userConfig.tmdb.httpProxy (CLI `--proxy`) */ + proxy?: string; +} + export type { RenameFolderArgs, RenameEpisodeFileInput, @@ -250,6 +270,40 @@ export class Core { return { id: job.id }; } + /** + * Search TMDB via {@link NetworkPort} (CLI uses NodejsNetworkPort). + * Uses direct host+proxy (no reverse proxy) so outbound `proxy` applies on NetworkPort. + * Explicit `language` is validated offline against the static TMDB primary_translations snapshot. + */ + async searchInTmdb(keyword: string, options: SearchInTmdbOptions): Promise { + const trimmed = keyword.trim(); + if (!trimmed) { + throw new Error("keyword is required"); + } + + const config = await this.userConfig.read(); + const language = options.language?.trim() + ? parseTmdbSearchLanguage(options.language) + : resolveMediaLanguage({ + preferMediaLanguage: config.preferMediaLanguage, + configured: config.applicationLanguage, + osLocale: detectOsLocale(), + }); + const host = options.host?.trim() || config.tmdb?.host; + const apiKey = options.password?.trim() || config.tmdb?.apiKey; + const httpProxy = options.proxy?.trim() || config.tmdb?.httpProxy; + + const client = new TmdbClient(this.network, { + host, + apiKey, + httpProxy, + reverseProxyUrl: null, + discover: this.discover, + }); + + return client.search(trimmed, options.type, language); + } + private normalizePosix(path: string): string { try { return Path.posix(path); diff --git a/apps/core/src/clients/mediaDatabaseTransport.test.ts b/apps/core/src/clients/mediaDatabaseTransport.test.ts index 0cca8fe3..17ba49c0 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.test.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.test.ts @@ -137,4 +137,24 @@ describe("fetchMediaDatabase", () => { expect(resp.ok).toBe(true); expect(urls).toHaveLength(2); }); + + it("passes httpProxy to NetworkPort on direct custom-host fetch", async () => { + const proxies: Array = []; + const network: NetworkPort = { + fetch: async (_url, init) => { + proxies.push(init?.proxy); + return jsonOk({ results: [] }); + }, + }; + + await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + configuredHost: "https://tmdb.example.com/v3", + apiKey: "key", + httpProxy: "socks5://127.0.0.1:1080", + }); + + expect(proxies[0]).toBe("socks5://127.0.0.1:1080"); + }); }); diff --git a/apps/core/src/clients/mediaDatabaseTransport.ts b/apps/core/src/clients/mediaDatabaseTransport.ts index d281f37e..73a03f29 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.ts @@ -99,9 +99,12 @@ async function tryFetch( network: NetworkPort, url: string, headers: Record, + proxy?: string, ): Promise { try { const init: FetchInit = { method: "GET", headers }; + const trimmedProxy = proxy?.trim(); + if (trimmedProxy) init.proxy = trimmedProxy; const resp = await network.fetch(url, init); if (resp.ok) return resp; return undefined; @@ -138,13 +141,19 @@ export async function fetchMediaDatabase( if (resp !== undefined) return resp; throw new MediaDatabaseFailoverExhaustedError(attempted); } - // Unit-test / no-proxy fallback: hit custom host directly + // Unit-test / CLI / no-proxy fallback: hit custom host directly via NetworkPort + // (httpProxy is passed as NetworkPort `proxy`, not X-Http-Proxy). const url = joinUrl(upstream, path); attempted.push(url); - const resp = await tryFetch(network, url, { - Accept: "application/json", - ...authHeaders(options.apiKey), - }); + const resp = await tryFetch( + network, + url, + { + Accept: "application/json", + ...authHeaders(options.apiKey), + }, + options.httpProxy, + ); if (resp !== undefined) return resp; throw new MediaDatabaseFailoverExhaustedError(attempted); } @@ -175,10 +184,15 @@ export async function fetchMediaDatabase( for (const host of hosts) { const directUrl = joinUrl(host, path); attempted.push(directUrl); - const direct = await tryFetch(network, directUrl, { - Accept: "application/json", - ...authHeaders(options.apiKey), - }); + const direct = await tryFetch( + network, + directUrl, + { + Accept: "application/json", + ...authHeaders(options.apiKey), + }, + options.httpProxy, + ); if (direct !== undefined) return direct; for (const proxy of proxies) { diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 5e3bcd70..ddcef372 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -7,6 +7,7 @@ export { type ScrapeFolderHandle, type ScrapeFolderOptions, type ScrapeFolderResult, + type SearchInTmdbOptions, } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; diff --git a/docs/dev/tmdb.md b/docs/dev/tmdb.md new file mode 100644 index 00000000..a7a04ccb --- /dev/null +++ b/docs/dev/tmdb.md @@ -0,0 +1,146 @@ +# TMDB via SMM cli + + +**输出格式** +```bash +$ smm tmdb search "keyword" +#{index} {tmdbid} {title} ({release data}) +{description} +#{index2} {tmdbid2} {title2} ({release data 2}) +{description2} +``` + +**使用场景** +``` +# Search through SMM-provided TMDB host +smm tmdb search "keyword" --type tv + +# Search with explicit language +smm tmdb search "keyword" --type tv --lang zh-CN + +# Search through SMM-provided TMDB host and HTTP proxy +smm tmdb search "keyword" --type tv --proxy "socks5://proxy.example.com:7079" + +# Search through custom TMDB host +smm tmdb search "keyword" --type tv --host "https://tmdb.example.com/v3" + +# Search through custom TMDB host and password +smm tmdb search "keyword" --type tv --host "https://tmdb.example.com/v3" --password "password-here" + +# Search through custom TMDB host, password and HTTP proxy +smm tmdb search "keyword" --type tv --host "https://tmdb.example.com/v3" --password "password-here" --proxy "socks5://proxy.example.com:7079" +``` + +**Arguments** +``` +--type [tv|movie] +--lang # TMDB primary translation IETF tag (static snapshot of /3/configuration/primary_translations); omit → userConfig then OS; invalid values error offline +``` + +## Web UI Implementation + +Web UI 的 TMDB 搜索入口是 `MediaDatabaseSearchbox`(电视剧 / 电影面板标题栏)。组件同时支持 TMDB 与 TVDB;本节只描述 **TMDB** 路径。 + +### 入口与触发 + +| 位置 | `mediaType` | 选中结果后续 | +|------|-------------|--------------| +| `TvShowPanelHeader` | `tv` | `useSelectTvShowForFolderMutation` → 按 id 拉详情并写 metadata | +| `MovieHeaderV2` | `movie` | 对应 movie 选择 mutation(同样再拉详情) | + +用户在 `ImmersiveSearchbox` 输入关键词后按 Enter / 点搜索按钮 → `handleSearch`。 + +搜索语言优先级:`localStorage.lastSelectedTmdbLanguage` → `userConfig.preferMediaLanguage` → 默认。默认数据库来自 `userConfig.primaryDatabase`(可在搜索框内切换)。 + +### 流程图(TMDB 搜索) + +```mermaid +flowchart TD + A[用户在 MediaDatabaseSearchbox 输入关键词并搜索] --> B{searchDatabase?} + B -->|TVDB| Z[TVDB 客户端路径 略] + B -->|TMDB| C[拼 query + language
GET /search/tv 或 /search/movie] + C --> D[fetchTmdbOrUndefined] + D --> E[fetchTmdb] + + E --> F{userConfig.tmdb.host
非空且可解析?} + F -->|是 自定义上游| G[fetchByInternalReverseProxy] + G --> G1[hello 拿到 CLI reverseProxyUrl] + G1 --> G2[浏览器 GET reverseProxyUrl + /search/...] + G2 --> G3[Header:
X-SMM-Proxy-Upstream-BaseURL = host
可选 Authorization Bearer apiKey
可选 X-Http-Proxy] + G3 --> G4[CLI reverseProxy
packages/core-routes] + G4 --> G5[转发到自定义 TMDB host
必要时经用户 SOCKS/HTTP 代理] + G5 --> H + + F -->|否 默认 / Discover| I[fetchDiscoverConfig] + I --> J[mediaDatabases 中 type=tmdb 的 URL 列表
若空则用 SMM 默认
mediadb.vercel.app/api/tmdb] + J --> K[fetchWithFailover] + K --> K1[先: Discover reverseProxies × upstream 成对尝试] + K1 --> K2[再: 直连各 TMDB host + /search/...] + K2 --> K3{全部失败?} + K3 -->|是| L[HttpFailoverExhaustedError
→ fetchTmdbOrUndefined 返回 undefined] + K3 -->|否| H + + H{Response ok?} + L --> M[buildTmdbErrorFromResponse
classifyTmdbError → 展示错误] + H -->|否| M + H -->|是| N[解析 TmdbSearchResponseBody] + N --> O{response.error?} + O -->|是| P[searchFailed] + O -->|否| Q[按 mediaType 过滤 results
tv: name / movie: title] + Q --> R{results 为空?} + R -->|是| S[searchNoResults] + R -->|否| T[映射为 ImmersiveSearchResultItem
海报 getTMDBImageUrl w200] + T --> U[用户点击某条结果] + U --> V[onSearchResultSelected
database=TMDB + result + searchLanguage] + V --> W[useSelect*ForFolderMutation
再 fetchTmdb 拉 /tv/:id 或 /movie/:id
写入 MediaMetadata] +``` + +### 关键代码 + +| 层 | 文件 | 职责 | +|----|------|------| +| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` | 搜索状态、语言、调 `fetchTmdbOrUndefined`、结果映射 | +| UI | `apps/ui/src/components/ImmersiveSearchbox.tsx` | 输入框 / 结果列表 / 库与语言切换 | +| API | `apps/ui/src/api/tmdb.ts` | `fetchTmdb` / `fetchTmdbOrUndefined`;自定义 host vs Discover 分流 | +| API | `apps/ui/src/api/fetchByInternalReverseProxy.ts` | 经本地 CLI reverse proxy 访问自定义上游 | +| HTTP | `apps/ui/src/lib/http.ts` | `fetchWithFailover`:代理对 + 直连链,失败域名写入 `disabledDomains` | +| CLI | `packages/core-routes` reverse proxy | 读 `X-SMM-Proxy-Upstream-BaseURL` / `X-Http-Proxy` 转发上游 | +| 选中后 | `apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts` 等 | 用搜索结果 id 再拉详情并更新文件夹 metadata | + +### 两条出站路径对照 + +1. **自定义 TMDB host**(设置里配置了 `userConfig.tmdb.host`) + 浏览器 → CLI 内置 reverse proxy →(可选用户 `httpProxy`)→ 自定义 host。 + API key 以 `Authorization: Bearer` 经代理头传到上游。与 CLI `smm tmdb search --host … --password … --proxy …` 语义对齐。 + +2. **未配置 host(默认)** + 浏览器 → Discover 下发的 TMDB 镜像 / 默认 `https://mediadb.vercel.app/api/tmdb`(可先经 Discover reverse proxy,再直连)。 + 搜索失败时 `fetchTmdbOrUndefined` 把 failover 耗尽转成 `undefined`,再走 `classifyTmdbError` 做用户可读错误;scrape / 拉详情等路径仍用 `fetchTmdb`,让 `HttpFailoverExhaustedError` 直接抛出。 + +### 与 CLI 的关系 + +- Web 搜索**不**调用 `smm tmdb search` 子命令。 +- 自定义 host 时共用同一套 CLI reverse proxy 转发能力。 +- 默认路径走浏览器侧 Discover + failover,与 CLI 直连 host/proxy 参数是平行实现。 + + +## apps/core design + +Core exposes TMDB search for CLI (and other in-process callers): + +### `searchInTmdb(keyword, options)` + +```ts +core.searchInTmdb(keyword, { + type: "tv" | "movie", + language?: string, // CLI `--lang`; offline check vs packages/core/tmdbPrimaryTranslations.ts; default: preferMediaLanguage → OS → en-US + host?: string, // default: userConfig.tmdb.host + password?: string, // default: userConfig.tmdb.apiKey (CLI `--password`) + proxy?: string, // default: userConfig.tmdb.httpProxy (CLI `--proxy`) +}) +``` + +- Builds `TmdbClient` with Core’s `NetworkPort` (CLI: `NodejsNetworkPort`). +- When `--lang` / `language` is set, validates offline against `TMDB_PRIMARY_TRANSLATIONS` (static snapshot of TMDB primary translations). +- Sets `reverseProxyUrl: null` so requests go **direct** through NetworkPort; `proxy` is passed as `FetchInit.proxy` (not `X-Http-Proxy` / reverse proxy). +- Returns `TmdbSearchResponseBody`. diff --git a/packages/core/locale.test.ts b/packages/core/locale.test.ts index a54db8b2..f4490dcd 100644 --- a/packages/core/locale.test.ts +++ b/packages/core/locale.test.ts @@ -5,7 +5,9 @@ import { resolveMediaLanguage, appLanguageToMediaLanguage, detectOsLocale, + parseTmdbSearchLanguage, } from './locale' +import { TMDB_PRIMARY_TRANSLATIONS } from './tmdbPrimaryTranslations' describe('normalizeToAppLanguage', () => { it('maps Chinese variants', () => { @@ -99,6 +101,33 @@ describe('resolveMediaLanguage', () => { }) }) +describe('parseTmdbSearchLanguage', () => { + it('accepts tags from the static TMDB primary_translations snapshot', () => { + expect(parseTmdbSearchLanguage('zh-CN')).toBe('zh-CN') + expect(parseTmdbSearchLanguage('fr-FR')).toBe('fr-FR') + expect(parseTmdbSearchLanguage('cn-CN')).toBe('cn-CN') + expect(parseTmdbSearchLanguage('en-us')).toBe('en-US') + expect(TMDB_PRIMARY_TRANSLATIONS).toContain('ko-KR') + }) + + it('rejects "cn" with a friendly hint toward zh-CN (not Cantonese cn-CN)', () => { + try { + parseTmdbSearchLanguage('cn') + expect.unreachable('expected throw') + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + expect(msg).toMatch(/Unsupported language "cn"/) + expect(msg).toMatch(/--lang zh-CN/) + expect(msg).not.toMatch(/cn-CN/) + } + }) + + it('rejects unknown tags with usage examples', () => { + expect(() => parseTmdbSearchLanguage('not-a-lang')).toThrow(/Unsupported language "not-a-lang"/) + expect(() => parseTmdbSearchLanguage('not-a-lang')).toThrow(/zh-CN, en-US, or ja-JP/) + }) +}) + describe('appLanguageToMediaLanguage', () => { it('maps zh variants to zh-CN', () => { expect(appLanguageToMediaLanguage('zh-CN')).toBe('zh-CN') diff --git a/packages/core/locale.ts b/packages/core/locale.ts index 75d785b6..1a5f7338 100644 --- a/packages/core/locale.ts +++ b/packages/core/locale.ts @@ -1,8 +1,107 @@ import type { LanguageCode, PreferMediaLanguage } from './types' +import { TMDB_PRIMARY_TRANSLATIONS } from './tmdbPrimaryTranslations' const APP_LANGUAGE_FALLBACK: LanguageCode = 'en' const MEDIA_LANGUAGE_FALLBACK: PreferMediaLanguage = 'en-US' +/** App-config media languages (`userConfig.preferMediaLanguage`). */ +export const PREFER_MEDIA_LANGUAGES = ['zh-CN', 'en-US', 'ja-JP'] as const satisfies readonly PreferMediaLanguage[] + +export function isPreferMediaLanguage(value: string): value is PreferMediaLanguage { + return (PREFER_MEDIA_LANGUAGES as readonly string[]).includes(value) +} + +/** + * Validate an explicit TMDB search language against the static snapshot of + * {@link https://developer.themoviedb.org/reference/configuration-primary-translations GET /3/configuration/primary_translations} + * ({@link TMDB_PRIMARY_TRANSLATIONS}). Offline — no network. + * Returns the canonical tag from the list (preserves TMDB casing). + */ +export function parseTmdbSearchLanguage( + raw: string, + primaryTranslations: readonly string[] = TMDB_PRIMARY_TRANSLATIONS, +): string { + const trimmed = raw.trim() + if (!trimmed) { + throw new Error( + 'Missing --lang value. Use a TMDB language tag such as zh-CN, en-US, or ja-JP (example: --lang zh-CN).', + ) + } + + const lower = trimmed.toLowerCase() + const found = primaryTranslations.find((tag) => tag.toLowerCase() === lower) + if (found) { + return found + } + + const suggested = suggestTmdbSearchLanguage(lower, primaryTranslations) + if (suggested) { + throw new Error( + `Unsupported language "${trimmed}". Use a TMDB language tag such as zh-CN, en-US, or ja-JP.\nDid you mean: --lang ${suggested}`, + ) + } + + throw new Error( + `Unsupported language "${trimmed}". Use a TMDB language tag such as zh-CN, en-US, or ja-JP (example: --lang zh-CN).`, + ) +} + +/** + * Common short / mistaken inputs → preferred TMDB primary translation. + * Note: TMDB also has `cn-CN` (Cantonese); bare `cn` almost always means Simplified Chinese. + */ +const TMDB_LANG_ALIASES: Record = { + cn: 'zh-CN', + zh: 'zh-CN', + 'zh-hans': 'zh-CN', + 'zh-hant': 'zh-TW', + en: 'en-US', + eng: 'en-US', + ja: 'ja-JP', + jp: 'ja-JP', + jpn: 'ja-JP', + ko: 'ko-KR', + kr: 'ko-KR', + kor: 'ko-KR', + fr: 'fr-FR', + de: 'de-DE', + es: 'es-ES', + pt: 'pt-BR', + ru: 'ru-RU', +} + +function suggestTmdbSearchLanguage( + lower: string, + primaryTranslations: readonly string[], +): string | undefined { + const alias = TMDB_LANG_ALIASES[lower] + if (alias && primaryTranslations.some((tag) => tag.toLowerCase() === alias.toLowerCase())) { + return alias + } + + // Only suggest when the input looks like a language subtag of an existing tag + // (e.g. "fr" → "fr-FR"), not accidental prefixes of unrelated codes. + const regionMatches = primaryTranslations.filter((tag) => { + const [lang] = tag.toLowerCase().split('-') + return lang === lower + }) + if (regionMatches.length === 1) { + return regionMatches[0] + } + if (regionMatches.length > 1) { + // Prefer *-US / *-CN style commons when multiple regions exist + const preferred = + regionMatches.find((t) => t.endsWith('-US') || t.endsWith('-CN') || t.endsWith('-JP')) ?? + regionMatches[0] + return preferred + } + + return undefined +} + +/** @deprecated Use {@link parseTmdbSearchLanguage} */ +export const matchTmdbPrimaryTranslation = parseTmdbSearchLanguage + /** * Maps an arbitrary locale tag to a supported app language code. * Returns null when the tag cannot be mapped. diff --git a/packages/core/tmdbPrimaryTranslations.ts b/packages/core/tmdbPrimaryTranslations.ts new file mode 100644 index 00000000..1f172ec0 --- /dev/null +++ b/packages/core/tmdbPrimaryTranslations.ts @@ -0,0 +1,92 @@ +/** + * Snapshot of TMDB officially supported primary translations (IETF tags). + * Source: GET /3/configuration/primary_translations + * @see https://developer.themoviedb.org/reference/configuration-primary-translations + * + * Update this list when TMDB adds/removes primary translations. + */ +export const TMDB_PRIMARY_TRANSLATIONS = [ + 'af-ZA', + 'ar-AE', + 'ar-SA', + 'be-BY', + 'bg-BG', + 'bn-BD', + 'ca-ES', + 'ch-GU', + /** Cantonese on TMDB (not Simplified Chinese; that is `zh-CN`). */ + 'cn-CN', + 'cs-CZ', + 'cy-GB', + 'da-DK', + 'de-AT', + 'de-CH', + 'de-DE', + 'el-GR', + 'en-AU', + 'en-CA', + 'en-GB', + 'en-IE', + 'en-NZ', + 'en-US', + 'eo-EO', + 'es-ES', + 'es-MX', + 'et-EE', + 'eu-ES', + 'fa-IR', + 'fi-FI', + 'fr-CA', + 'fr-FR', + 'ga-IE', + 'gd-GB', + 'gl-ES', + 'he-IL', + 'hi-IN', + 'hr-HR', + 'hu-HU', + 'id-ID', + 'it-IT', + 'ja-JP', + 'ka-GE', + 'kk-KZ', + 'kn-IN', + 'ko-KR', + 'ky-KG', + 'lt-LT', + 'lv-LV', + 'ml-IN', + 'mr-IN', + 'ms-MY', + 'ms-SG', + 'nb-NO', + 'nl-BE', + 'nl-NL', + 'no-NO', + 'pa-IN', + 'pl-PL', + 'pt-BR', + 'pt-PT', + 'ro-RO', + 'ru-RU', + 'si-LK', + 'sk-SK', + 'sl-SI', + 'sq-AL', + 'sr-RS', + 'sv-SE', + 'ta-IN', + 'te-IN', + 'th-TH', + 'tl-PH', + 'tr-TR', + 'uk-UA', + 'vi-VN', + 'zh-CN', + 'zh-HK', + 'zh-SG', + 'zh-TW', + 'zu-ZA', +] as const + +export type TmdbPrimaryTranslation = (typeof TMDB_PRIMARY_TRANSLATIONS)[number] From 808c3f198ef21c3a4423cbb83fe720703d332f8f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 22 Aug 2026 22:27:49 +0800 Subject: [PATCH 098/214] feat(tmdb): add AI and MCP tools via Core v3 with e2e tests Expose tmdb-search, tmdb-get-movie, and tmdb-get-tv-show through chat and MCP, backed by Core search and detail APIs. Add MCP e2e coverage and reorganize docs/dev/tmdb.md for the v3 architecture. Co-authored-by: Cursor --- apps/cli/src/mcp/mcp.ts | 9 + apps/cli/src/route/chatRoute.ts | 5 + apps/cli/src/test/ai-tool-registry.test.ts | 17 +- apps/core/src/Core.ts | 36 ++- apps/core/src/index.ts | 1 + apps/e2e/common-e2e-tests-verification.md | 5 +- apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts | 114 +++++++ apps/e2e/test/lib/McpClient.ts | 34 ++- apps/e2e/test/lib/mcpToolTypes.ts | 15 +- docs/dev/tmdb.md | 279 +++++++++++------- packages/core-routes/src/chat.ts | 6 + packages/core-routes/src/mcp/createServer.ts | 2 + packages/core-routes/src/mcp/index.ts | 7 + .../src/mcp/toolHandlers/tmdbTools.ts | 117 ++++++++ packages/core-routes/src/mcp/types.ts | 34 +++ packages/core-routes/src/tools/index.ts | 19 ++ packages/core-routes/src/tools/tmdb.test.ts | 74 +++++ packages/core-routes/src/tools/tmdb.ts | 197 +++++++++++++ packages/core/ai-tool/registry.ts | 8 + packages/core/ai-tool/systemPrompt.test.ts | 6 + packages/core/ai-tool/systemPrompt.ts | 10 + packages/core/types/ai-tools/tmdbCommon.ts | 36 +++ packages/core/types/ai-tools/tmdbGetMovie.ts | 24 ++ packages/core/types/ai-tools/tmdbGetTvShow.ts | 24 ++ packages/core/types/ai-tools/tmdbSearch.ts | 27 ++ 25 files changed, 974 insertions(+), 132 deletions(-) create mode 100644 apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts create mode 100644 packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts create mode 100644 packages/core-routes/src/tools/tmdb.test.ts create mode 100644 packages/core-routes/src/tools/tmdb.ts create mode 100644 packages/core/types/ai-tools/tmdbCommon.ts create mode 100644 packages/core/types/ai-tools/tmdbGetMovie.ts create mode 100644 packages/core/types/ai-tools/tmdbGetTvShow.ts create mode 100644 packages/core/types/ai-tools/tmdbSearch.ts diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 69d31ffa..69d14fbc 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -11,6 +11,9 @@ import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; +import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; +import { TMDB_GET_MOVIE } from "@smm/core/types/ai-tools/tmdbGetMovie"; +import { TMDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tmdbGetTvShow"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -61,6 +64,9 @@ const TOOL_NAME_KEYS = [ HOW_TO_RENAME_KEY, HOW_TO_RECOGNIZE_KEY, README_KEY, + TMDB_SEARCH, + TMDB_GET_MOVIE, + TMDB_GET_TV_SHOW, ] as const; let handlerPromise: Promise | null = null; @@ -142,6 +148,9 @@ async function buildMcpConfig(): Promise { renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), getJob: (id) => getCore().getJob(id), + searchInTmdb: (keyword, options) => getCore().searchInTmdb(keyword, options), + getMovieInTmdb: (id, options) => getCore().getMovieInTmdb(id, options), + getTvShowInTmdb: (id, options) => getCore().getTvShowInTmdb(id, options), logger: { debug: (obj, msg) => logger.debug(obj, msg), info: (obj, msg) => logger.info(obj, msg), diff --git a/apps/cli/src/route/chatRoute.ts b/apps/cli/src/route/chatRoute.ts index 5fe98836..4148680b 100644 --- a/apps/cli/src/route/chatRoute.ts +++ b/apps/cli/src/route/chatRoute.ts @@ -19,6 +19,11 @@ export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { renameEpisodeFile: (input) => getCore().renameEpisodeFile(input), scrapeFolder: (path, options) => getCore().scrapeFolder(path, options), getJob: (id) => getCore().getJob(id), + tmdb: { + searchInTmdb: (keyword, options) => getCore().searchInTmdb(keyword, options), + getMovieInTmdb: (id, options) => getCore().getMovieInTmdb(id, options), + getTvShowInTmdb: (id, options) => getCore().getTvShowInTmdb(id, options), + }, }) return response } catch (error) { diff --git a/apps/cli/src/test/ai-tool-registry.test.ts b/apps/cli/src/test/ai-tool-registry.test.ts index 49195a8b..d96f0cb2 100644 --- a/apps/cli/src/test/ai-tool-registry.test.ts +++ b/apps/cli/src/test/ai-tool-registry.test.ts @@ -58,6 +58,11 @@ const CONSTANT_NAME_TO_TOOL_NAME: Record = { LIST_FILES_IN_MEDIA_FOLDER: 'list-files-in-media-folder', RENAME_FOLDER: 'rename-folder', RENAME_EPISODE_FILE: 'rename-episode-file', + SCRAPE: 'scrape', + GET_JOB: 'get-job', + TMDB_SEARCH: 'tmdb-search', + TMDB_GET_MOVIE: 'tmdb-get-movie', + TMDB_GET_TV_SHOW: 'tmdb-get-tv-show', BEGIN_RENAME_FILES_TASK: 'begin-rename-files-task', ADD_RENAME_FILE_TO_TASK: 'add-rename-file-to-task', END_RENAME_FILES_TASK: 'end-rename-files-task', @@ -113,16 +118,18 @@ describe('AI tool registry alignment — chat.ts (core-routes)', () => { } }) - it('does not register any tool that the registry marks as backend-only', () => { + it('registers backend-only tools on the chat backend path', () => { const backendOnly = new Set( AI_TOOL_REGISTRY.filter((t) => t.backend && !t.frontend).map( (t) => t.name, ), ) - // (none today; the check is forward-compatible — if a tool - // is added that is backend-only, the registry will reflect - // it, and this test will continue to pass as a no-op.) - expect(backendOnly.size).toBe(0) + for (const name of backendOnly) { + expect( + registeredKeys.has(name), + `Backend-only tool "${name}" is in the registry but missing from chat.ts`, + ).toBe(true) + } }) it('contains no unknown tool keys (typo guard)', () => { diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 14634c80..253ef4f4 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -3,7 +3,9 @@ import type { AppConfig, FolderType, MediaMetadata, + TmdbMovieDetails, TmdbSearchResponseBody, + TmdbSeriesDetails, UserConfig as UserConfigData, } from "@smm/core"; import { detectOsLocale, parseTmdbSearchLanguage, resolveMediaLanguage } from "@core/locale"; @@ -40,8 +42,7 @@ import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; import { JobStore } from "./jobs/jobStore"; import { initialScrapeTasks, type ImportJob, type Job } from "./jobs/types"; -export interface SearchInTmdbOptions { - type: "tv" | "movie"; +export interface TmdbRequestOptions { /** TMDB language (CLI `--lang`). Validated offline against static primary_translations. */ language?: string; /** Override userConfig.tmdb.host */ @@ -52,6 +53,10 @@ export interface SearchInTmdbOptions { proxy?: string; } +export interface SearchInTmdbOptions extends TmdbRequestOptions { + type: "tv" | "movie"; +} + export type { RenameFolderArgs, RenameEpisodeFileInput, @@ -281,6 +286,31 @@ export class Core { throw new Error("keyword is required"); } + const { client, language } = await this.createTmdbClient(options); + return client.search(trimmed, options.type, language); + } + + /** Fetch TMDB movie details by id via {@link NetworkPort}. */ + async getMovieInTmdb(id: number, options: TmdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTmdbClient(options); + return client.getMovieById(id, language); + } + + /** Fetch TMDB TV series details by id via {@link NetworkPort}. */ + async getTvShowInTmdb(id: number, options: TmdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTmdbClient(options); + return client.getTvShowById(id, language); + } + + private async createTmdbClient( + options: TmdbRequestOptions, + ): Promise<{ client: TmdbClient; language: string }> { const config = await this.userConfig.read(); const language = options.language?.trim() ? parseTmdbSearchLanguage(options.language) @@ -301,7 +331,7 @@ export class Core { discover: this.discover, }); - return client.search(trimmed, options.type, language); + return { client, language }; } private normalizePosix(path: string): string { diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index ddcef372..a1881f5c 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -8,6 +8,7 @@ export { type ScrapeFolderOptions, type ScrapeFolderResult, type SearchInTmdbOptions, + type TmdbRequestOptions, } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; diff --git a/apps/e2e/common-e2e-tests-verification.md b/apps/e2e/common-e2e-tests-verification.md index bf397653..be011558 100644 --- a/apps/e2e/common-e2e-tests-verification.md +++ b/apps/e2e/common-e2e-tests-verification.md @@ -152,10 +152,12 @@ Columns: **L** local · **E** electron · **O** ohos · **D** docker | RenameFolder | ✓ | ✓ | — | ✓ | local, Electron, Docker / unsupported HarmonyOS | | Subtitle | ✓ | ✓ | — | ✓ | local, Electron, Docker / unsupported HarmonyOS | -### mcp (14) — `@supports local, Electron, Docker` · `@unsupported HarmonyOS` +### mcp (15) — `@supports local, Electron, Docker` · `@unsupported HarmonyOS` Non-init MCP specs seed recognized folders via `seedRecognizedTvShowFolder` / `seedRecognizedMovieFolder` in `test/lib/mcpSpecShared.ts` (no TMDB init). Docker: MCP CLI uses `resolveMcpAddressForE2eRunner()` → `host.docker.internal:30001`; container publishes `-p 30001:30001`. +`McpOther-TmdbTools` hits live TMDB via Core (`tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show`); configure `TMDB_HOST` / `TMDB_API_KEY` / `TMDB_HTTP_PROXY` in `apps/e2e/.env.local` when the default host is blocked. + | Spec | L | E | O | D | | --- | --- | --- | --- | --- | | McpAppData-GetApplicationContextTool | ✓ | ✓ | — | ✓ | @@ -168,6 +170,7 @@ Non-init MCP specs seed recognized folders via `seedRecognizedTvShowFolder` / `s | McpOther-RecognizeTaskFlow | ✓ | ✓ | — | ✓ | | McpOther-RenameFolderTool | ✓ | ✓ | — | ✓ | | McpOther-RenameTaskFlow | ✓ | ✓ | — | ✓ | +| McpOther-TmdbTools | ✓ | ✓ | — | ✓ | | McpPrompt-CancelPreparingPlan | ✓ | ✓ | — | ✓ | | McpPrompts-HowToRecognizeEpisodeVideoFilesTool | ✓ | ✓ | — | ✓ | | McpPrompts-HowToRenameEpisodeVideoFilesTool | ✓ | ✓ | — | ✓ | diff --git a/apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts b/apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts new file mode 100644 index 00000000..aafc9fbe --- /dev/null +++ b/apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts @@ -0,0 +1,114 @@ +import { expect } from '@wdio/globals' +import type { UserConfig } from '@smm/core/types' +import mcpClient from 'test/lib/McpClient' +import { cleanup, getConfiguredHttpProxyAddress, setup } from 'test/lib/testbed' +import { testbedOs } from 'test/lib/e2e-platform' +import { + cleanupMcpTest, + createMcpSpecContext, + setupMcpTest, + skipIfOhos, +} from 'test/lib/mcpSpecShared' + +const TV_SEARCH_KEYWORD = '天使降临到我身边' +const TV_TMDB_ID = 84666 +const MOVIE_TMDB_ID = 552524 +const TV_TITLE_PATTERN = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i + +function applyTmdbUserConfig(config: UserConfig): UserConfig { + const host = (process.env.TMDB_HOST || '').trim() + const apiKey = (process.env.TMDB_API_KEY || '').trim() + const httpProxy = getConfiguredHttpProxyAddress('tmdb') + if (host || apiKey || httpProxy) { + config.tmdb = { + ...(host ? { host } : {}), + ...(apiKey ? { apiKey } : {}), + ...(httpProxy ? { httpProxy } : {}), + } + } + return config +} + +/** + * MCP TMDB tools (`tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show`) via Core v3. + * + * Requires TMDB network access (see `apps/e2e/.env.local` for `TMDB_HOST` / + * `TMDB_API_KEY` / `TMDB_HTTP_PROXY` when the default host is blocked). + * + * @supports local, Electron, Docker + * @unsupported HarmonyOS + */ +describe('MCP Other - TmdbTools', () => { + const ctx = createMcpSpecContext() + + before(function () { + skipIfOhos(this) + }) + + beforeEach(async () => { + await setup({ + removeDirInSidebar: true, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: applyTmdbUserConfig, + openBrowserPage: true, + os: testbedOs, + }) + await setupMcpTest() + }) + + afterEach(async () => { + await cleanupMcpTest() + await cleanup({ + removeDirInSidebar: true, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: false, + os: testbedOs, + }) + }) + + it('tmdb-search returns TV results', async function () { + this.timeout(120_000) + + const result = await mcpClient.tmdbSearch(ctx.clientCwd, ctx.mcpAddress, { + keyword: TV_SEARCH_KEYWORD, + type: 'tv', + language: 'zh-CN', + }) + + expect(result.results?.length).toBeGreaterThan(0) + expect(result.page).toBeGreaterThanOrEqual(1) + + const first = result.results[0]! + expect(first.id).toBeGreaterThan(0) + expect(first.name).toMatch(TV_TITLE_PATTERN) + }) + + it('tmdb-get-tv-show returns series details', async function () { + this.timeout(120_000) + + const result = await mcpClient.tmdbGetTvShow(ctx.clientCwd, ctx.mcpAddress, { + id: TV_TMDB_ID, + language: 'zh-CN', + }) + + expect(result.id).toBe(TV_TMDB_ID) + expect(result.name).toMatch(TV_TITLE_PATTERN) + }) + + it('tmdb-get-movie returns movie details', async function () { + this.timeout(120_000) + + const result = await mcpClient.tmdbGetMovie(ctx.clientCwd, ctx.mcpAddress, { + id: MOVIE_TMDB_ID, + language: 'zh-CN', + }) + + expect(result.id).toBe(MOVIE_TMDB_ID) + expect(typeof result.title).toBe('string') + expect(result.title!.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/e2e/test/lib/McpClient.ts b/apps/e2e/test/lib/McpClient.ts index 488c5555..2aabd259 100644 --- a/apps/e2e/test/lib/McpClient.ts +++ b/apps/e2e/test/lib/McpClient.ts @@ -44,15 +44,21 @@ import { } from './mcpToolTypes' const EXEC_TIMEOUT_MS = 30_000 +/** TMDB upstream calls can exceed the default MCP client timeout. */ +const TMDB_EXEC_TIMEOUT_MS = 90_000 const MAX_ATTEMPTS = 3 const RETRY_DELAY_MS = 1000 +export type McpRunToolOptions = { + timeoutMs?: number +} + const execFileAsync = promisify(execFile) const BUN_EXECUTABLE = 'bun' function execShellAsync( command: string, - opts: { cwd?: string; env?: NodeJS.ProcessEnv }, + opts: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { shell.exec( @@ -60,7 +66,7 @@ function execShellAsync( { silent: true, async: true, - timeout: EXEC_TIMEOUT_MS, + timeout: opts.timeoutMs ?? EXEC_TIMEOUT_MS, windowsHide: true, cwd: opts.cwd, env: opts.env, @@ -87,6 +93,7 @@ async function execMcpTestClientOnce( env: NodeJS.ProcessEnv, toolName: string, args?: Record, + timeoutMs: number = EXEC_TIMEOUT_MS, ): Promise<{ stdout: string; stderr: string }> { if (process.platform === 'win32') { const bunArgs = ['index.ts', '--tool', toolName] @@ -96,14 +103,14 @@ async function execMcpTestClientOnce( return await execFileAsync(BUN_EXECUTABLE, bunArgs, { cwd: clientCwd, env, - timeout: EXEC_TIMEOUT_MS, + timeout: timeoutMs, windowsHide: true, }) } const argsFlag = args ? ` --args '${JSON.stringify(args)}'` : '' const bashLcCommand = `cd "${clientCwd}" && bun index.ts --tool ${toolName}${argsFlag}` - return execShellAsync(`/bin/bash -lc ${JSON.stringify(bashLcCommand)}`, { env }) + return execShellAsync(`/bin/bash -lc ${JSON.stringify(bashLcCommand)}`, { env, timeoutMs }) } function parseJsonObjectFromStdout(stdout: string): T { @@ -129,7 +136,9 @@ class McpClient { mcpAddress: string, toolName: string, args?: Record, + options?: McpRunToolOptions, ): Promise<{ stdout: string; stderr: string }> { + const timeoutMs = options?.timeoutMs ?? EXEC_TIMEOUT_MS const env: NodeJS.ProcessEnv = { ...process.env, SMM_MCP_URL: mcpAddress, @@ -137,7 +146,7 @@ class McpClient { let lastErr: unknown for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { try { - return await execMcpTestClientOnce(clientCwd, env, toolName, args) + return await execMcpTestClientOnce(clientCwd, env, toolName, args, timeoutMs) } catch (err) { lastErr = err await delay(RETRY_DELAY_MS) @@ -151,8 +160,9 @@ class McpClient { mcpAddress: string, toolName: string, args?: Record, + options?: McpRunToolOptions, ): Promise { - const { stdout } = await this.runTool(clientCwd, mcpAddress, toolName, args) + const { stdout } = await this.runTool(clientCwd, mcpAddress, toolName, args, options) console.log(`MCP tool ${toolName} response: ${stdout}`) return parseJsonObjectFromStdout(stdout) } @@ -315,7 +325,9 @@ class McpClient { mcpAddress: string, req: TmdbSearchRequest, ): Promise { - return this.execTyped(clientCwd, mcpAddress, McpToolName.tmdbSearch, toolArgs(req)) + return this.execTyped(clientCwd, mcpAddress, McpToolName.tmdbSearch, toolArgs(req), { + timeoutMs: TMDB_EXEC_TIMEOUT_MS, + }) } async tmdbGetMovie( @@ -323,7 +335,9 @@ class McpClient { mcpAddress: string, req: TmdbGetMovieRequest, ): Promise { - return this.execTyped(clientCwd, mcpAddress, McpToolName.tmdbGetMovie, toolArgs(req)) + return this.execTyped(clientCwd, mcpAddress, McpToolName.tmdbGetMovie, toolArgs(req), { + timeoutMs: TMDB_EXEC_TIMEOUT_MS, + }) } async tmdbGetTvShow( @@ -331,7 +345,9 @@ class McpClient { mcpAddress: string, req: TmdbGetTvShowRequest, ): Promise { - return this.execTyped(clientCwd, mcpAddress, McpToolName.tmdbGetTvShow, toolArgs(req)) + return this.execTyped(clientCwd, mcpAddress, McpToolName.tmdbGetTvShow, toolArgs(req), { + timeoutMs: TMDB_EXEC_TIMEOUT_MS, + }) } } diff --git a/apps/e2e/test/lib/mcpToolTypes.ts b/apps/e2e/test/lib/mcpToolTypes.ts index 4f950548..d4c2a1fa 100644 --- a/apps/e2e/test/lib/mcpToolTypes.ts +++ b/apps/e2e/test/lib/mcpToolTypes.ts @@ -1,9 +1,9 @@ /** * MCP tool request/response shapes for e2e `McpClient`. - * Keep in sync with apps/cli/src/tools and apps/cli/src/mcp/tools (see mcp.ts registrations). + * Keep in sync with packages/core-routes MCP handlers and apps/cli/src/mcp/mcp.ts registrations. */ -import type { TmdbMovieDetails, TmdbTvShowResponseBody } from '@smm/core/types' +import type { TmdbMovieDetails, TmdbSeriesDetails } from '@smm/core/types' /** Kebab-case names passed to `mcp-test-client --tool`. */ export const McpToolName = { @@ -299,8 +299,13 @@ export interface TmdbSearchRequest { baseURL?: string } +export interface TmdbSearchTvResult { + id: number + name?: string +} + export interface TmdbSearchResponse { - results: unknown[] + results: TmdbSearchTvResult[] page: number total_pages: number total_results: number @@ -325,6 +330,6 @@ export interface TmdbGetTvShowRequest { export type TmdbMovieDetailsResponse = TmdbMovieDetails /** - * Same for `tmdb-get-tv-show` and {@link TmdbTvShowResponseBody}. + * MCP `tmdb-get-tv-show` returns {@link TmdbSeriesDetails} from Core (flat TMDB TV details). */ -export type TmdbTvShowDetailsResponse = TmdbTvShowResponseBody +export type TmdbTvShowDetailsResponse = TmdbSeriesDetails diff --git a/docs/dev/tmdb.md b/docs/dev/tmdb.md index a7a04ccb..08e80500 100644 --- a/docs/dev/tmdb.md +++ b/docs/dev/tmdb.md @@ -1,146 +1,207 @@ -# TMDB via SMM cli +# TMDB 集成(v3) +SMM 通过 `apps/core` 统一访问 TMDB:**全部出站流量**经 `Core` + `NetworkPort`,不直连上游(浏览器侧见 [network-core.md](./network-core.md))。 + +| 入口 | 状态 | 说明 | +|------|------|------| +| CLI | ✅ | `smm tmdb search` → 进程内 `Core` | +| AI Tool | ✅ | Chat 工具 → `core-routes` → `Core` | +| MCP Tool | ✅ | MCP 工具 → `core-routes` → `Core` | +| Web UI | ⏳ | 目标:`BrowserNetworkPort` → `POST /api/core/fetch` → `Core`(迁移中) | + +```mermaid +flowchart TB + CLI["apps/cli"] + WEB["apps/ui ⏳"] + AI["AI Tool ✅"] + MCP["MCP Tool ✅"] + + CR["packages/core-routes
/api/core/fetch · chat · MCP"] + CORE["apps/core
searchInTmdb · getMovieInTmdb · getTvShowInTmdb"] + NET["NetworkPort
NodejsNetworkPort / BrowserNetworkPort"] + TMDB["TMDB 上游"] + + CLI -->|进程内| CORE + WEB --> CR + AI --> CR + MCP --> CR + CR --> CORE + CORE --> NET --> TMDB +``` + +**配置**:host / apiKey / httpProxy 来自 `userConfig.tmdb`;各入口可临时覆盖(CLI flags、工具参数 `baseURL` 等)。 + +--- + +## apps/core API + +三个公开方法,共用 `createTmdbClient` 与 `NetworkPort` 出站。 + +| 方法 | 用途 | +|------|------| +| `searchInTmdb(keyword, options)` | 按关键词搜索 TV / movie | +| `getMovieInTmdb(id, options)` | 按 TMDB id 拉电影详情 | +| `getTvShowInTmdb(id, options)` | 按 TMDB id 拉剧集详情 | + +```ts +core.searchInTmdb(keyword, { + type: "tv" | "movie", + language?: string, // 离线校验 packages/core/tmdbPrimaryTranslations.ts + host?: string, // 默认 userConfig.tmdb.host + password?: string, // 默认 userConfig.tmdb.apiKey + proxy?: string, // 默认 userConfig.tmdb.httpProxy +}) + +core.getMovieInTmdb(id, { language?, host?, password?, proxy? }) +core.getTvShowInTmdb(id, { language?, host?, password?, proxy? }) +``` + +- `TmdbClient` 经 `NetworkPort` 发请求;`reverseProxyUrl: null`,`proxy` 作为 `FetchInit.proxy`(非浏览器 reverse proxy)。 +- 显式 `language` 须在静态 primary translations 列表中;省略时按 `preferMediaLanguage` → OS → `en-US`。 + +实现:`apps/core/src/Core.ts`。 + +--- + +## CLI + +### 命令 + +目前仅暴露搜索子命令: -**输出格式** ```bash -$ smm tmdb search "keyword" -#{index} {tmdbid} {title} ({release data}) -{description} -#{index2} {tmdbid2} {title2} ({release data 2}) -{description2} +smm tmdb search "" --type tv|movie [options] ``` -**使用场景** +### 输出格式 + +```bash +$ smm tmdb search "keyword" --type tv +#1 {tmdbid} {title} ({release date}) +{overview} +#2 {tmdbid2} {title2} ({release date}) +{overview} ``` -# Search through SMM-provided TMDB host + +### 常用场景 + +```bash +# 默认 host(userConfig / 内置) smm tmdb search "keyword" --type tv -# Search with explicit language +# 指定语言 smm tmdb search "keyword" --type tv --lang zh-CN -# Search through SMM-provided TMDB host and HTTP proxy +# 经 HTTP/SOCKS 代理 smm tmdb search "keyword" --type tv --proxy "socks5://proxy.example.com:7079" -# Search through custom TMDB host -smm tmdb search "keyword" --type tv --host "https://tmdb.example.com/v3" +# 自定义 host + API key +smm tmdb search "keyword" --type tv \ + --host "https://api.themoviedb.org/3" \ + --password "your-api-key" \ + --proxy "socks5://proxy.example.com:7079" +``` -# Search through custom TMDB host and password -smm tmdb search "keyword" --type tv --host "https://tmdb.example.com/v3" --password "password-here" +### 参数 -# Search through custom TMDB host, password and HTTP proxy -smm tmdb search "keyword" --type tv --host "https://tmdb.example.com/v3" --password "password-here" --proxy "socks5://proxy.example.com:7079" -``` +| 参数 | 说明 | +|------|------| +| `--type` | `tv` \| `movie`(必填) | +| `--lang` | TMDB primary translation(如 `zh-CN`);无效值离线报错 | +| `--host` | 覆盖 `userConfig.tmdb.host` | +| `--password` | 覆盖 `userConfig.tmdb.apiKey` | +| `--proxy` | 覆盖 `userConfig.tmdb.httpProxy` | -**Arguments** -``` ---type [tv|movie] ---lang # TMDB primary translation IETF tag (static snapshot of /3/configuration/primary_translations); omit → userConfig then OS; invalid values error offline -``` +实现:`apps/cli/src/cli/runCli.ts`(格式化:`tmdbSearchFormat.ts`)。 -## Web UI Implementation +--- -Web UI 的 TMDB 搜索入口是 `MediaDatabaseSearchbox`(电视剧 / 电影面板标题栏)。组件同时支持 TMDB 与 TVDB;本节只描述 **TMDB** 路径。 +## AI Tool -### 入口与触发 +Chat 后端工具,**仅服务端**(`packages/core/ai-tool/registry.ts` 中 `frontend: false`)。 -| 位置 | `mediaType` | 选中结果后续 | -|------|-------------|--------------| -| `TvShowPanelHeader` | `tv` | `useSelectTvShowForFolderMutation` → 按 id 拉详情并写 metadata | -| `MovieHeaderV2` | `movie` | 对应 movie 选择 mutation(同样再拉详情) | +| 工具名 | Core 方法 | 说明 | +|--------|-----------|------| +| `tmdb-search` | `searchInTmdb` | 关键词搜索 | +| `tmdb-get-movie` | `getMovieInTmdb` | 电影详情 | +| `tmdb-get-tv-show` | `getTvShowInTmdb` | 剧集详情 | -用户在 `ImmersiveSearchbox` 输入关键词后按 Enter / 点搜索按钮 → `handleSearch`。 +### 参数(与 CLI 对照) -搜索语言优先级:`localStorage.lastSelectedTmdbLanguage` → `userConfig.preferMediaLanguage` → 默认。默认数据库来自 `userConfig.primaryDatabase`(可在搜索框内切换)。 +| 工具字段 | 对应 Core | +|----------|-----------| +| `keyword`, `type` | `searchInTmdb` | +| `id` | `getMovieInTmdb` / `getTvShowInTmdb` | +| `language` | `language`(同 CLI `--lang`) | +| `baseURL` | `host`(同 CLI `--host`) | -### 流程图(TMDB 搜索) +host / apiKey / proxy 未在工具参数中指定时,仍走 `userConfig.tmdb`。 -```mermaid -flowchart TD - A[用户在 MediaDatabaseSearchbox 输入关键词并搜索] --> B{searchDatabase?} - B -->|TVDB| Z[TVDB 客户端路径 略] - B -->|TMDB| C[拼 query + language
GET /search/tv 或 /search/movie] - C --> D[fetchTmdbOrUndefined] - D --> E[fetchTmdb] - - E --> F{userConfig.tmdb.host
非空且可解析?} - F -->|是 自定义上游| G[fetchByInternalReverseProxy] - G --> G1[hello 拿到 CLI reverseProxyUrl] - G1 --> G2[浏览器 GET reverseProxyUrl + /search/...] - G2 --> G3[Header:
X-SMM-Proxy-Upstream-BaseURL = host
可选 Authorization Bearer apiKey
可选 X-Http-Proxy] - G3 --> G4[CLI reverseProxy
packages/core-routes] - G4 --> G5[转发到自定义 TMDB host
必要时经用户 SOCKS/HTTP 代理] - G5 --> H - - F -->|否 默认 / Discover| I[fetchDiscoverConfig] - I --> J[mediaDatabases 中 type=tmdb 的 URL 列表
若空则用 SMM 默认
mediadb.vercel.app/api/tmdb] - J --> K[fetchWithFailover] - K --> K1[先: Discover reverseProxies × upstream 成对尝试] - K1 --> K2[再: 直连各 TMDB host + /search/...] - K2 --> K3{全部失败?} - K3 -->|是| L[HttpFailoverExhaustedError
→ fetchTmdbOrUndefined 返回 undefined] - K3 -->|否| H - - H{Response ok?} - L --> M[buildTmdbErrorFromResponse
classifyTmdbError → 展示错误] - H -->|否| M - H -->|是| N[解析 TmdbSearchResponseBody] - N --> O{response.error?} - O -->|是| P[searchFailed] - O -->|否| Q[按 mediaType 过滤 results
tv: name / movie: title] - Q --> R{results 为空?} - R -->|是| S[searchNoResults] - R -->|否| T[映射为 ImmersiveSearchResultItem
海报 getTMDBImageUrl w200] - T --> U[用户点击某条结果] - U --> V[onSearchResultSelected
database=TMDB + result + searchLanguage] - V --> W[useSelect*ForFolderMutation
再 fetchTmdb 拉 /tv/:id 或 /movie/:id
写入 MediaMetadata] -``` +类型定义:`packages/core/types/ai-tools/tmdb*.ts` +执行与构建:`packages/core-routes/src/tools/tmdb.ts` +注册:`packages/core-routes/src/tools/index.ts`、`chat.ts`;CLI 注入 runner:`apps/cli/src/route/chatRoute.ts`。 -### 关键代码 +System prompt 指引见 `packages/core/ai-tool/systemPrompt.ts`(先 `tmdb-search`,再按 id 拉详情)。 -| 层 | 文件 | 职责 | -|----|------|------| -| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` | 搜索状态、语言、调 `fetchTmdbOrUndefined`、结果映射 | -| UI | `apps/ui/src/components/ImmersiveSearchbox.tsx` | 输入框 / 结果列表 / 库与语言切换 | -| API | `apps/ui/src/api/tmdb.ts` | `fetchTmdb` / `fetchTmdbOrUndefined`;自定义 host vs Discover 分流 | -| API | `apps/ui/src/api/fetchByInternalReverseProxy.ts` | 经本地 CLI reverse proxy 访问自定义上游 | -| HTTP | `apps/ui/src/lib/http.ts` | `fetchWithFailover`:代理对 + 直连链,失败域名写入 `disabledDomains` | -| CLI | `packages/core-routes` reverse proxy | 读 `X-SMM-Proxy-Upstream-BaseURL` / `X-Http-Proxy` 转发上游 | -| 选中后 | `apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts` 等 | 用搜索结果 id 再拉详情并更新文件夹 metadata | +--- -### 两条出站路径对照 +## MCP Tool -1. **自定义 TMDB host**(设置里配置了 `userConfig.tmdb.host`) - 浏览器 → CLI 内置 reverse proxy →(可选用户 `httpProxy`)→ 自定义 host。 - API key 以 `Authorization: Bearer` 经代理头传到上游。与 CLI `smm tmdb search --host … --password … --proxy …` 语义对齐。 +与 AI Tool **同名、同 schema、同 Core 路径**,经 MCP HTTP 暴露。 -2. **未配置 host(默认)** - 浏览器 → Discover 下发的 TMDB 镜像 / 默认 `https://mediadb.vercel.app/api/tmdb`(可先经 Discover reverse proxy,再直连)。 - 搜索失败时 `fetchTmdbOrUndefined` 把 failover 耗尽转成 `undefined`,再走 `classifyTmdbError` 做用户可读错误;scrape / 拉详情等路径仍用 `fetchTmdb`,让 `HttpFailoverExhaustedError` 直接抛出。 +| MCP 工具名 | Core 方法 | +|------------|-----------| +| `tmdb-search` | `searchInTmdb` | +| `tmdb-get-movie` | `getMovieInTmdb` | +| `tmdb-get-tv-show` | `getTvShowInTmdb` | -### 与 CLI 的关系 +注册:`packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts` +CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTmdb` / `getMovieInTmdb` / `getTvShowInTmdb`)。 -- Web 搜索**不**调用 `smm tmdb search` 子命令。 -- 自定义 host 时共用同一套 CLI reverse proxy 转发能力。 -- 默认路径走浏览器侧 Discover + failover,与 CLI 直连 host/proxy 参数是平行实现。 +本地调试 MCP 客户端:`test/mcp-test-client/index.ts`(`SMM_MCP_URL` + `--tool tmdb-search`)。 +--- -## apps/core design +## Web UI(⏳ v3 迁移) -Core exposes TMDB search for CLI (and other in-process callers): +搜索入口:`MediaDatabaseSearchbox`(`TvShowPanelHeader` / `MovieHeaderV2`)。 -### `searchInTmdb(keyword, options)` +**目标路径**(与 Core v3 一致): -```ts -core.searchInTmdb(keyword, { - type: "tv" | "movie", - language?: string, // CLI `--lang`; offline check vs packages/core/tmdbPrimaryTranslations.ts; default: preferMediaLanguage → OS → en-US - host?: string, // default: userConfig.tmdb.host - password?: string, // default: userConfig.tmdb.apiKey (CLI `--password`) - proxy?: string, // default: userConfig.tmdb.httpProxy (CLI `--proxy`) -}) +```mermaid +flowchart LR + A[MediaDatabaseSearchbox] --> B[Core.searchInTmdb] + B --> C[BrowserNetworkPort] + C --> D[POST /api/core/fetch] + D --> E[NodejsNetworkPort] + E --> F[TMDB] ``` -- Builds `TmdbClient` with Core’s `NetworkPort` (CLI: `NodejsNetworkPort`). -- When `--lang` / `language` is set, validates offline against `TMDB_PRIMARY_TRANSLATIONS` (static snapshot of TMDB primary translations). -- Sets `reverseProxyUrl: null` so requests go **direct** through NetworkPort; `proxy` is passed as `FetchInit.proxy` (not `X-Http-Proxy` / reverse proxy). -- Returns `TmdbSearchResponseBody`. +| 层 | 文件 | +|----|------| +| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` | +| 浏览器 Port | `apps/ui/src/core/BrowserNetworkPort.ts` | +| RPC | `apps/cli/src/route/CoreFetch.ts` | +| 出站 | `apps/cli/src/core/NodejsNetworkPort.ts` | + +Web 不调用 `smm tmdb search`,但与 CLI 共用 `Core` 与 `userConfig.tmdb` 语义。 + +--- + +## 测试 + +| 范围 | 文件 | +|------|------| +| CLI e2e | `apps/cli/test/tmdb.e2e.ts` — 文档「常用场景」四类搜索 | +| core-routes 单测 | `packages/core-routes/src/tools/tmdb.test.ts` | +| MCP e2e | `apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts` — 三工具 + live TMDB | +| MCP 客户端 | `apps/e2e/test/lib/McpClient.ts` | + +MCP / 需直连 TMDB 的 e2e 在 `apps/e2e/.env.local` 配置 `TMDB_HOST`、`TMDB_API_KEY`、`TMDB_HTTP_PROXY`(网络受限环境)。 + +运行 MCP spec: + +```bash +cd apps/e2e +pnpm run wdio --spec ./common/mcp/McpOther-TmdbTools.e2e.ts +``` diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index d8aff7ad..02ef3adf 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -12,6 +12,9 @@ import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; +import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; +import { TMDB_GET_MOVIE } from "@smm/core/types/ai-tools/tmdbGetMovie"; +import { TMDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tmdbGetTvShow"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -139,6 +142,9 @@ export async function doChat( [RENAME_EPISODE_FILE]: tools[RENAME_EPISODE_FILE], [SCRAPE]: tools[SCRAPE], [GET_JOB]: tools[GET_JOB], + [TMDB_SEARCH]: tools[TMDB_SEARCH], + [TMDB_GET_MOVIE]: tools[TMDB_GET_MOVIE], + [TMDB_GET_TV_SHOW]: tools[TMDB_GET_TV_SHOW], [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], diff --git a/packages/core-routes/src/mcp/createServer.ts b/packages/core-routes/src/mcp/createServer.ts index d1dc67a0..1da8e0cd 100644 --- a/packages/core-routes/src/mcp/createServer.ts +++ b/packages/core-routes/src/mcp/createServer.ts @@ -21,6 +21,7 @@ import { registerRenameFolderTool } from "./toolHandlers/renameFolder.ts"; import { registerRenameEpisodeFileTool } from "./toolHandlers/renameEpisodeFile.ts"; import { registerScrapeTool } from "./toolHandlers/scrape.ts"; import { registerGetJobTool } from "./toolHandlers/getJob.ts"; +import { registerTmdbTools } from "./toolHandlers/tmdbTools.ts"; import { registerStaticTextTools } from "./toolHandlers/staticText.ts"; import type { McpConfig } from "./types.ts"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; @@ -92,6 +93,7 @@ export async function createMcpStreamableHttpHandler( registerIsFolderExistTool(server, config); registerListFilesTool(server, config); registerGetMediaMetadataTool(server, config); + registerTmdbTools(server, config); // Read-only documentation tools. registerStaticTextTools(server, config); diff --git a/packages/core-routes/src/mcp/index.ts b/packages/core-routes/src/mcp/index.ts index 950f78d2..237b5225 100644 --- a/packages/core-routes/src/mcp/index.ts +++ b/packages/core-routes/src/mcp/index.ts @@ -6,6 +6,9 @@ import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; +import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; +import { TMDB_GET_MOVIE } from "@smm/core/types/ai-tools/tmdbGetMovie"; +import { TMDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tmdbGetTvShow"; export { createErrorResponse, createSuccessResponse, @@ -35,6 +38,7 @@ export { RENAME_FOLDER }; export { RENAME_EPISODE_FILE }; export { SCRAPE }; export { GET_JOB }; +export { TMDB_SEARCH, TMDB_GET_MOVIE, TMDB_GET_TV_SHOW }; /** * Constants exposed to hosts that load `core-routes.js` as a @@ -47,4 +51,7 @@ export const MCP_TOOL_NAMES = { RENAME_EPISODE_FILE, SCRAPE, GET_JOB, + TMDB_SEARCH, + TMDB_GET_MOVIE, + TMDB_GET_TV_SHOW, } as const; diff --git a/packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts b/packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts new file mode 100644 index 00000000..17b9db6b --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts @@ -0,0 +1,117 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + TMDB_SEARCH, + TMDB_SEARCH_DESCRIPTION, + tmdbSearchInputSchema, + tmdbSearchOutputSchema, +} from "@smm/core/types/ai-tools/tmdbSearch"; +import { + TMDB_GET_MOVIE, + TMDB_GET_MOVIE_DESCRIPTION, + tmdbGetMovieInputSchema, + tmdbGetMovieOutputSchema, +} from "@smm/core/types/ai-tools/tmdbGetMovie"; +import { + TMDB_GET_TV_SHOW, + TMDB_GET_TV_SHOW_DESCRIPTION, + tmdbGetTvShowInputSchema, + tmdbGetTvShowOutputSchema, +} from "@smm/core/types/ai-tools/tmdbGetTvShow"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; +import { + executeTmdbGetMovie, + executeTmdbGetTvShow, + executeTmdbSearch, +} from "../../tools/tmdb.ts"; + +/** + * Register TMDB query MCP tools (`tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show`). + */ +export function registerTmdbTools(server: McpServer, config: McpConfig): void { + const searchDescription = + config.toolDescriptions?.[TMDB_SEARCH] ?? TMDB_SEARCH_DESCRIPTION; + const movieDescription = + config.toolDescriptions?.[TMDB_GET_MOVIE] ?? TMDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = + config.toolDescriptions?.[TMDB_GET_TV_SHOW] ?? TMDB_GET_TV_SHOW_DESCRIPTION; + + server.registerTool( + TMDB_SEARCH, + { + description: searchDescription, + inputSchema: tmdbSearchInputSchema, + outputSchema: tmdbSearchOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTmdbSearch( + (args ?? {}) as Parameters[0], + config.searchInTmdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse( + error instanceof Error ? error.message : String(error), + ); + } + }, + ); + + server.registerTool( + TMDB_GET_MOVIE, + { + description: movieDescription, + inputSchema: tmdbGetMovieInputSchema, + outputSchema: tmdbGetMovieOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTmdbGetMovie( + (args ?? {}) as Parameters[0], + config.getMovieInTmdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse( + error instanceof Error ? error.message : String(error), + ); + } + }, + ); + + server.registerTool( + TMDB_GET_TV_SHOW, + { + description: tvShowDescription, + inputSchema: tmdbGetTvShowInputSchema, + outputSchema: tmdbGetTvShowOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTmdbGetTvShow( + (args ?? {}) as Parameters[0], + config.getTvShowInTmdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse( + error instanceof Error ? error.message : String(error), + ); + } + }, + ); +} diff --git a/packages/core-routes/src/mcp/types.ts b/packages/core-routes/src/mcp/types.ts index 68c500a2..bce62ab7 100644 --- a/packages/core-routes/src/mcp/types.ts +++ b/packages/core-routes/src/mcp/types.ts @@ -109,6 +109,40 @@ export interface McpConfig { */ getJob?: (id: string) => unknown; + /** Optional runner for `tmdb-search` → `Core.searchInTmdb`. */ + searchInTmdb?: ( + keyword: string, + options: { + type: "tv" | "movie"; + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tmdb-get-movie` → `Core.getMovieInTmdb`. */ + getMovieInTmdb?: ( + id: number, + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tmdb-get-tv-show` → `Core.getTvShowInTmdb`. */ + getTvShowInTmdb?: ( + id: number, + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + /** * Optional localized tool descriptions, keyed by tool name * (e.g. `"get-media-folders"`, `"is-folder-exist"`). When diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index 3c6ccefd..e54812a0 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -10,6 +10,15 @@ import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; +import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; +import { TMDB_GET_MOVIE } from "@smm/core/types/ai-tools/tmdbGetMovie"; +import { TMDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tmdbGetTvShow"; +import { + buildTmdbGetMovieTool, + buildTmdbGetTvShowTool, + buildTmdbSearchTool, + type TmdbToolRunners, +} from "./tmdb.ts"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -74,6 +83,9 @@ export interface ChatTools { [RENAME_EPISODE_FILE]: ReturnType; [SCRAPE]: ReturnType; [GET_JOB]: ReturnType; + [TMDB_SEARCH]: ReturnType; + [TMDB_GET_MOVIE]: ReturnType; + [TMDB_GET_TV_SHOW]: ReturnType; [BEGIN_RENAME_FILES_TASK]: ReturnType; [ADD_RENAME_FILE_TO_TASK]: ReturnType; [END_RENAME_FILES_TASK]: ReturnType; @@ -96,6 +108,8 @@ export interface ChatToolsExtraDeps { scrapeFolder?: ScrapeFolderRunner; /** Host Core runner for job status lookup. */ getJob?: GetJobRunner; + /** Host Core runners for TMDB query tools. */ + tmdb?: TmdbToolRunners; } export interface CreateChatToolsArgs { @@ -145,6 +159,8 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { const renameFilesTaskDeps = extra?.renameFilesTask ?? defaultRenameFilesTaskDeps(config.appDataDir); + const tmdbRunners: TmdbToolRunners | undefined = extra?.tmdb; + return { [GET_APPLICATION_CONTEXT]: buildGetApplicationContextTool( clientId, @@ -182,6 +198,9 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { ), [SCRAPE]: buildScrapeTool(extra?.scrapeFolder, abortSignal), [GET_JOB]: buildGetJobTool(extra?.getJob, abortSignal), + [TMDB_SEARCH]: buildTmdbSearchTool(tmdbRunners, abortSignal), + [TMDB_GET_MOVIE]: buildTmdbGetMovieTool(tmdbRunners, abortSignal), + [TMDB_GET_TV_SHOW]: buildTmdbGetTvShowTool(tmdbRunners, abortSignal), [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool( clientId, config.appDataDir, diff --git a/packages/core-routes/src/tools/tmdb.test.ts b/packages/core-routes/src/tools/tmdb.test.ts new file mode 100644 index 00000000..970f12bd --- /dev/null +++ b/packages/core-routes/src/tools/tmdb.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { + executeTmdbGetMovie, + executeTmdbGetTvShow, + executeTmdbSearch, +} from "./tmdb.ts"; + +describe("executeTmdbSearch", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeTmdbSearch( + { keyword: "naruto", type: "tv" }, + undefined, + ); + expect(result.error).toMatch(/not available/i); + }); + + it("maps keyword and type to Core runner", async () => { + const runner = vi.fn(async () => ({ + results: [{ id: 1, name: "Show" }], + page: 1, + total_pages: 1, + total_results: 1, + })); + const result = await executeTmdbSearch( + { keyword: "naruto", type: "tv", baseURL: "https://tmdb.example/v3" }, + runner, + ); + expect(result.results).toHaveLength(1); + expect(runner).toHaveBeenCalledWith("naruto", { + type: "tv", + host: "https://tmdb.example/v3", + language: undefined, + }); + }); + + it("returns body.error from runner", async () => { + const runner = vi.fn(async () => ({ + results: [], + page: 1, + total_pages: 0, + total_results: 0, + error: "upstream failed", + })); + const result = await executeTmdbSearch({ keyword: "x", type: "movie" }, runner); + expect(result.error).toBe("upstream failed"); + }); +}); + +describe("executeTmdbGetMovie", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeTmdbGetMovie({ id: 550 }, undefined); + expect(result.error).toMatch(/not available/i); + }); + + it("returns movie details on success", async () => { + const runner = vi.fn(async () => ({ id: 550, title: "Fight Club" })); + const result = await executeTmdbGetMovie({ id: 550, language: "en-US" }, runner); + expect(result.title).toBe("Fight Club"); + expect(runner).toHaveBeenCalledWith(550, { language: "en-US", host: undefined }); + }); +}); + +describe("executeTmdbGetTvShow", () => { + it("validates id", async () => { + const result = await executeTmdbGetTvShow({ id: 0 }, vi.fn()); + expect(result.error).toMatch(/positive integer/i); + }); + + it("returns series details on success", async () => { + const runner = vi.fn(async () => ({ id: 31917, name: "Show" })); + const result = await executeTmdbGetTvShow({ id: 31917 }, runner); + expect(result.name).toBe("Show"); + }); +}); diff --git a/packages/core-routes/src/tools/tmdb.ts b/packages/core-routes/src/tools/tmdb.ts new file mode 100644 index 00000000..7f412a42 --- /dev/null +++ b/packages/core-routes/src/tools/tmdb.ts @@ -0,0 +1,197 @@ +import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; +import type { TmdbMovieDetails, TmdbSearchResponseBody, TmdbSeriesDetails } from "@smm/core/types"; +import { + formatTmdbToolError, + toTmdbCoreOptions, + type TmdbToolHostOptions, +} from "@smm/core/types/ai-tools/tmdbCommon"; +import { + TMDB_SEARCH, + TMDB_SEARCH_DESCRIPTION, + tmdbSearchInputSchema, + tmdbSearchOutputSchema, + type TmdbSearchInput, + type TmdbSearchOutput, +} from "@smm/core/types/ai-tools/tmdbSearch"; +import { + TMDB_GET_MOVIE, + TMDB_GET_MOVIE_DESCRIPTION, + tmdbGetMovieInputSchema, + tmdbGetMovieOutputSchema, + type TmdbGetMovieInput, + type TmdbGetMovieOutput, +} from "@smm/core/types/ai-tools/tmdbGetMovie"; +import { + TMDB_GET_TV_SHOW, + TMDB_GET_TV_SHOW_DESCRIPTION, + tmdbGetTvShowInputSchema, + tmdbGetTvShowOutputSchema, + type TmdbGetTvShowInput, + type TmdbGetTvShowOutput, +} from "@smm/core/types/ai-tools/tmdbGetTvShow"; + +export type SearchInTmdbRunner = ( + keyword: string, + options: { + type: "tv" | "movie"; + language?: string; + host?: string; + password?: string; + proxy?: string; + }, +) => Promise; + +export type GetMovieInTmdbRunner = ( + id: number, + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise; + +export type GetTvShowInTmdbRunner = ( + id: number, + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise; + +export interface TmdbToolRunners { + searchInTmdb?: SearchInTmdbRunner; + getMovieInTmdb?: GetMovieInTmdbRunner; + getTvShowInTmdb?: GetTvShowInTmdbRunner; +} + +function unavailable(message: string): { error: string } { + return { error: message }; +} + +function assertNotAborted(abortSignal?: AbortSignal): void { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} + +export async function executeTmdbSearch( + params: TmdbSearchInput, + runner: SearchInTmdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + + if (!runner) { + return unavailable("tmdb-search is not available on this host"); + } + + try { + const body = await runner(keywordCheck, { + type: params.type, + ...toTmdbCoreOptions(params), + }); + if (body.error) { + return { error: body.error }; + } + return { + results: body.results, + page: body.page, + total_pages: body.total_pages, + total_results: body.total_results, + }; + } catch (error) { + return { error: formatTmdbToolError(error) }; + } +} + +export async function executeTmdbGetMovie( + params: TmdbGetMovieInput, + runner: GetMovieInTmdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + + if (!runner) { + return unavailable("tmdb-get-movie is not available on this host"); + } + + try { + const details = await runner(params.id, toTmdbCoreOptions(params)); + return details as TmdbGetMovieOutput; + } catch (error) { + return { error: formatTmdbToolError(error) }; + } +} + +export async function executeTmdbGetTvShow( + params: TmdbGetTvShowInput, + runner: GetTvShowInTmdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + + if (!runner) { + return unavailable("tmdb-get-tv-show is not available on this host"); + } + + try { + const details = await runner(params.id, toTmdbCoreOptions(params)); + return details as TmdbGetTvShowOutput; + } catch (error) { + return { error: formatTmdbToolError(error) }; + } +} + +export function buildTmdbSearchTool( + runners: TmdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TMDB_SEARCH_DESCRIPTION, + inputSchema: tmdbSearchInputSchema, + outputSchema: tmdbSearchOutputSchema, + execute: async (args: unknown): Promise => { + return executeTmdbSearch((args ?? {}) as TmdbSearchInput, runners?.searchInTmdb, abortSignal); + }, + }; +} + +export function buildTmdbGetMovieTool( + runners: TmdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TMDB_GET_MOVIE_DESCRIPTION, + inputSchema: tmdbGetMovieInputSchema, + outputSchema: tmdbGetMovieOutputSchema, + execute: async (args: unknown): Promise => { + return executeTmdbGetMovie((args ?? {}) as TmdbGetMovieInput, runners?.getMovieInTmdb, abortSignal); + }, + }; +} + +export function buildTmdbGetTvShowTool( + runners: TmdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TMDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tmdbGetTvShowInputSchema, + outputSchema: tmdbGetTvShowOutputSchema, + execute: async (args: unknown): Promise => { + return executeTmdbGetTvShow( + (args ?? {}) as TmdbGetTvShowInput, + runners?.getTvShowInTmdb, + abortSignal, + ); + }, + }; +} + +export { TMDB_SEARCH, TMDB_GET_MOVIE, TMDB_GET_TV_SHOW }; diff --git a/packages/core/ai-tool/registry.ts b/packages/core/ai-tool/registry.ts index 845e9e07..8b2eadbb 100644 --- a/packages/core/ai-tool/registry.ts +++ b/packages/core/ai-tool/registry.ts @@ -48,6 +48,9 @@ import { RENAME_FOLDER } from '../types/ai-tools/renameFolder' import { RENAME_EPISODE_FILE } from '../types/ai-tools/renameEpisodeFile' import { SCRAPE } from '../types/ai-tools/scrape' import { GET_JOB } from '../types/ai-tools/getJob' +import { TMDB_SEARCH } from '../types/ai-tools/tmdbSearch' +import { TMDB_GET_MOVIE } from '../types/ai-tools/tmdbGetMovie' +import { TMDB_GET_TV_SHOW } from '../types/ai-tools/tmdbGetTvShow' import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -105,6 +108,11 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ { name: SCRAPE, backend: true, frontend: true }, { name: GET_JOB, backend: true, frontend: true }, + // TMDB query (Core-backed; backend chat / MCP on hosts with Core) + { name: TMDB_SEARCH, backend: true, frontend: false }, + { name: TMDB_GET_MOVIE, backend: true, frontend: false }, + { name: TMDB_GET_TV_SHOW, backend: true, frontend: false }, + // Rename files task { name: BEGIN_RENAME_FILES_TASK, backend: true, frontend: true }, { name: ADD_RENAME_FILE_TO_TASK, backend: true, frontend: true }, diff --git a/packages/core/ai-tool/systemPrompt.test.ts b/packages/core/ai-tool/systemPrompt.test.ts index 49589cc8..db7143ff 100644 --- a/packages/core/ai-tool/systemPrompt.test.ts +++ b/packages/core/ai-tool/systemPrompt.test.ts @@ -16,6 +16,9 @@ import { } from '../types/ai-tools/recognizeMediaFileTask' import { SCRAPE } from '../types/ai-tools/scrape' import { GET_JOB } from '../types/ai-tools/getJob' +import { TMDB_SEARCH } from '../types/ai-tools/tmdbSearch' +import { TMDB_GET_MOVIE } from '../types/ai-tools/tmdbGetMovie' +import { TMDB_GET_TV_SHOW } from '../types/ai-tools/tmdbGetTvShow' /** * The system prompt is interpolated with kebab-case tool name @@ -44,6 +47,9 @@ describe('systemPrompt', () => { END_RECOGNIZE_TASK, SCRAPE, GET_JOB, + TMDB_SEARCH, + TMDB_GET_MOVIE, + TMDB_GET_TV_SHOW, ] for (const name of required) { expect( diff --git a/packages/core/ai-tool/systemPrompt.ts b/packages/core/ai-tool/systemPrompt.ts index 463b3cb9..a4013d5c 100644 --- a/packages/core/ai-tool/systemPrompt.ts +++ b/packages/core/ai-tool/systemPrompt.ts @@ -14,6 +14,9 @@ import { GET_EPISODES } from '../types/ai-tools/getEpisodes' import { LIST_FILES_IN_MEDIA_FOLDER } from '../types/ai-tools/listFilesInMediaFolder' import { SCRAPE } from '../types/ai-tools/scrape' import { GET_JOB } from '../types/ai-tools/getJob' +import { TMDB_SEARCH } from '../types/ai-tools/tmdbSearch' +import { TMDB_GET_MOVIE } from '../types/ai-tools/tmdbGetMovie' +import { TMDB_GET_TV_SHOW } from '../types/ai-tools/tmdbGetTvShow' /** * The system prompt for the AI Assistant, shared between the @@ -80,6 +83,13 @@ use the scrape job tools: 3. Call "${GET_JOB}" with that id to check progress. Poll until status is succeeded, failed, or aborted. 4. Report per-task results (poster, fanart, thumbnails, nfo) from the scrape job. +### TMDB Search and Details + +When user asks to search TMDB, find a TV show or movie on TMDB, or look up TMDB metadata by id: + +1. Call "${TMDB_SEARCH}" with keyword and type (\`tv\` or \`movie\`) to find candidates. +2. Call "${TMDB_GET_TV_SHOW}" or "${TMDB_GET_MOVIE}" with the chosen TMDB id for full details (seasons/episodes for TV). + ## User Preferences Language: zh-CN (you should answer in user's language) diff --git a/packages/core/types/ai-tools/tmdbCommon.ts b/packages/core/types/ai-tools/tmdbCommon.ts new file mode 100644 index 00000000..0f911825 --- /dev/null +++ b/packages/core/types/ai-tools/tmdbCommon.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +/** Optional TMDB primary translation tag (e.g. zh-CN, en-US). */ +export const tmdbLanguageSchema = z + .string() + .optional() + .describe( + 'TMDB primary translation IETF tag (e.g. zh-CN, en-US). Defaults from userConfig.preferMediaLanguage.', + ) + +/** Override TMDB API base URL (MCP/AI tool name: baseURL). */ +export const tmdbBaseUrlSchema = z + .string() + .optional() + .describe('Optional TMDB API base URL override (defaults from userConfig.tmdb.host)') + +export interface TmdbToolHostOptions { + language?: string + baseURL?: string +} + +export function toTmdbCoreOptions(params: TmdbToolHostOptions): { + language?: string + host?: string +} { + const host = params.baseURL?.trim() + return { + language: params.language, + host: host || undefined, + } +} + +export function formatTmdbToolError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.startsWith('Error Reason:') ? message : `Error Reason: ${message}` +} diff --git a/packages/core/types/ai-tools/tmdbGetMovie.ts b/packages/core/types/ai-tools/tmdbGetMovie.ts new file mode 100644 index 00000000..3da36a3a --- /dev/null +++ b/packages/core/types/ai-tools/tmdbGetMovie.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { tmdbBaseUrlSchema, tmdbLanguageSchema } from './tmdbCommon' + +export const TMDB_GET_MOVIE = 'tmdb-get-movie' as const + +export const TMDB_GET_MOVIE_DESCRIPTION = + 'Retrieve detailed movie information from TMDB by TMDB ID. ' + + 'Includes title, overview, release date, runtime, genres, poster images, and more.\n\n' + + 'Example: Get movie details for TMDB id 550.' + +export const tmdbGetMovieInputSchema = z.object({ + id: z.number().int().positive().describe('TMDB movie id'), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema, +}) + +export const tmdbGetMovieOutputSchema = z + .object({ + error: z.string().optional(), + }) + .passthrough() + +export type TmdbGetMovieInput = z.infer +export type TmdbGetMovieOutput = z.infer diff --git a/packages/core/types/ai-tools/tmdbGetTvShow.ts b/packages/core/types/ai-tools/tmdbGetTvShow.ts new file mode 100644 index 00000000..bf098d85 --- /dev/null +++ b/packages/core/types/ai-tools/tmdbGetTvShow.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { tmdbBaseUrlSchema, tmdbLanguageSchema } from './tmdbCommon' + +export const TMDB_GET_TV_SHOW = 'tmdb-get-tv-show' as const + +export const TMDB_GET_TV_SHOW_DESCRIPTION = + 'Retrieve detailed TV show information from TMDB by TMDB ID, including seasons and episodes ' + + 'with titles, overviews, and air dates.\n\n' + + 'Example: Get TV show details for TMDB id 31917.' + +export const tmdbGetTvShowInputSchema = z.object({ + id: z.number().int().positive().describe('TMDB TV series id'), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema, +}) + +export const tmdbGetTvShowOutputSchema = z + .object({ + error: z.string().optional(), + }) + .passthrough() + +export type TmdbGetTvShowInput = z.infer +export type TmdbGetTvShowOutput = z.infer diff --git a/packages/core/types/ai-tools/tmdbSearch.ts b/packages/core/types/ai-tools/tmdbSearch.ts new file mode 100644 index 00000000..1ed08f8e --- /dev/null +++ b/packages/core/types/ai-tools/tmdbSearch.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import { tmdbBaseUrlSchema, tmdbLanguageSchema } from './tmdbCommon' + +export const TMDB_SEARCH = 'tmdb-search' as const + +export const TMDB_SEARCH_DESCRIPTION = + 'Search TMDB (The Movie Database) for movies or TV shows by keyword. ' + + 'Returns matching results with title, release date, overview, and TMDB ID.\n\n' + + 'Example: Search TV shows matching "naruto".' + +export const tmdbSearchInputSchema = z.object({ + keyword: z.string().describe('Search keyword'), + type: z.enum(['tv', 'movie']).describe('Media type to search'), + language: tmdbLanguageSchema, + baseURL: tmdbBaseUrlSchema, +}) + +export const tmdbSearchOutputSchema = z.object({ + results: z.array(z.record(z.string(), z.unknown())).optional(), + page: z.number().optional(), + total_pages: z.number().optional(), + total_results: z.number().optional(), + error: z.string().optional(), +}) + +export type TmdbSearchInput = z.infer +export type TmdbSearchOutput = z.infer From d1bef6b9131fce24c705cc6eee767916476b7dc0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 00:45:40 +0800 Subject: [PATCH 099/214] feat(mcp): migrate MCP tool tests to MCP Inspector against smm mcp start - add `smm mcp start` CLI subcommand to run the MCP server standalone (host/port options, prints URL, graceful SIGINT/SIGTERM shutdown) - fix stale `mcp` package.json script pointing to a nonexistent file - add test/mcp/ suite (bun test) that starts an isolated MCP server and invokes tools via @modelcontextprotocol/inspector CLI - migrate 13 MCP tool e2e tests from apps/e2e/common/mcp to test/mcp; keep the 3 UI-confirmation tests (RenameTaskFlow, RecognizeTaskFlow, CancelPreparingPlan) in apps/e2e - add @modelcontextprotocol/inspector as a root devDependency --- apps/cli/index.ts | 2 +- apps/cli/package.json | 2 +- apps/cli/src/cli/runCli.ts | 42 +- ...cpAppData-GetApplicationContextTool.e2e.ts | 53 - .../mcp/McpAppData-GetMediaFoldersTool.e2e.ts | 69 -- .../common/mcp/McpOther-GetEpisodeTool.e2e.ts | 74 -- .../mcp/McpOther-GetEpisodesTool.e2e.ts | 72 -- .../mcp/McpOther-GetMediaMetadataTool.e2e.ts | 75 -- .../mcp/McpOther-IsFolderExistTool.e2e.ts | 83 -- .../common/mcp/McpOther-ListFilesTool.e2e.ts | 79 -- .../mcp/McpOther-RenameFolderTool.e2e.ts | 137 -- .../e2e/common/mcp/McpOther-ScrapeTool.e2e.ts | 121 -- apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts | 114 -- ...HowToRecognizeEpisodeVideoFilesTool.e2e.ts | 53 - ...ts-HowToRenameEpisodeVideoFilesTool.e2e.ts | 53 - .../common/mcp/McpPrompts-ReadmeTool.e2e.ts | 53 - package.json | 1 + pnpm-lock.yaml | 1100 ++++++++++++++++- test/mcp/get-app-context.test.ts | 15 + test/mcp/get-episode.test.ts | 48 + test/mcp/get-episodes.test.ts | 46 + test/mcp/get-media-folders.test.ts | 26 + test/mcp/get-media-metadata.test.ts | 48 + ...w-to-recognize-episode-video-files.test.ts | 14 + .../how-to-rename-episode-video-files.test.ts | 14 + test/mcp/is-folder-exist.test.ts | 29 + test/mcp/lib/mcpInspectorClient.ts | 108 ++ test/mcp/lib/mcpServer.ts | 124 ++ test/mcp/lib/testSetup.ts | 171 +++ test/mcp/lib/useMcpServer.ts | 51 + test/mcp/list-files.test.ts | 30 + test/mcp/readme.test.ts | 23 + test/mcp/rename-folder.test.ts | 122 ++ test/mcp/scrape.test.ts | 84 ++ test/mcp/tmdb-tools.test.ts | 99 ++ test/mcp/tsconfig.json | 28 + 36 files changed, 2159 insertions(+), 1104 deletions(-) delete mode 100644 apps/e2e/common/mcp/McpAppData-GetApplicationContextTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpAppData-GetMediaFoldersTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-GetEpisodeTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-GetEpisodesTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-GetMediaMetadataTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-IsFolderExistTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-ListFilesTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-RenameFolderTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpPrompts-HowToRecognizeEpisodeVideoFilesTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpPrompts-HowToRenameEpisodeVideoFilesTool.e2e.ts delete mode 100644 apps/e2e/common/mcp/McpPrompts-ReadmeTool.e2e.ts create mode 100644 test/mcp/get-app-context.test.ts create mode 100644 test/mcp/get-episode.test.ts create mode 100644 test/mcp/get-episodes.test.ts create mode 100644 test/mcp/get-media-folders.test.ts create mode 100644 test/mcp/get-media-metadata.test.ts create mode 100644 test/mcp/how-to-recognize-episode-video-files.test.ts create mode 100644 test/mcp/how-to-rename-episode-video-files.test.ts create mode 100644 test/mcp/is-folder-exist.test.ts create mode 100644 test/mcp/lib/mcpInspectorClient.ts create mode 100644 test/mcp/lib/mcpServer.ts create mode 100644 test/mcp/lib/testSetup.ts create mode 100644 test/mcp/lib/useMcpServer.ts create mode 100644 test/mcp/list-files.test.ts create mode 100644 test/mcp/readme.test.ts create mode 100644 test/mcp/rename-folder.test.ts create mode 100644 test/mcp/scrape.test.ts create mode 100644 test/mcp/tmdb-tools.test.ts create mode 100644 test/mcp/tsconfig.json diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 10978f97..bff0bb2b 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file', 'tmdb']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file', 'tmdb', 'mcp']) const firstArg = process.argv[2] const isCliHelp = firstArg === '--help' || firstArg === '-h' if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { diff --git a/apps/cli/package.json b/apps/cli/package.json index 34117539..1572591d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -49,7 +49,7 @@ "dev": "bun run index.ts | pino-pretty", "dev:prd-ui": "bun run index.ts --staticDir \"C:\\Users\\lawrence\\AppData\\Local\\Programs\\SMM\\resources\\public\" --port 30000", "start": "bun run index.ts", - "mcp": "bun run src/mcp/server.ts", + "mcp": "bun run index.ts mcp start", "build": "bun run scripts/build.ts", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 6e11d3e7..a8a19705 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -1,5 +1,5 @@ import { Command, CommanderError, Option } from 'commander' -import { readFile } from 'node:fs/promises' +import { mkdir, readFile } from 'node:fs/promises' import type { FolderType, RenameRuleName } from 'core-app' import type { MediaMetadata } from '@smm/core' import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' @@ -496,6 +496,46 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + const mcpCmd = program.command('mcp').description('MCP server management') + + mcpCmd + .command('start') + .description('Start the MCP server and keep running until interrupted') + .option('--host ', 'MCP server bind host (default: user config mcpHost or 127.0.0.1)') + .option('--port ', 'MCP server port (default: user config mcpPort or 30001)') + .action(async (opts: { host?: string; port?: string }) => { + // Lazy imports: pulling in the MCP lifecycle manager (and thus + // `@smm/core-routes`) at module load breaks vitest's CLI unit tests + // which don't alias `@smm/core/path`. + const { getAppDataDir, getLogDir, getUserDataDir } = await import('@/utils/config') + const { getBunMcpLifecycleManager } = await import('@/mcp/mcpServerManager') + + await mkdir(getUserDataDir(), { recursive: true }) + await mkdir(getAppDataDir(), { recursive: true }) + await mkdir(getLogDir(), { recursive: true }) + + const manager = getBunMcpLifecycleManager() + await manager.start({ + hostname: opts.host, + port: opts.port ? Number(opts.port) : undefined, + }) + const state = manager.getState() + if (state.status !== 'running' || !state.url) { + throw new Error(state.error ?? 'MCP server failed to start') + } + console.log(`MCP server running at ${state.url}`) + + // Keep the process alive until interrupted, then stop the server gracefully. + await new Promise((resolve) => { + const stop = async () => { + await manager.stop() + resolve() + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + }) + }) + try { await program.parseAsync(argv, { from: 'node' }) } catch (error) { diff --git a/apps/e2e/common/mcp/McpAppData-GetApplicationContextTool.e2e.ts b/apps/e2e/common/mcp/McpAppData-GetApplicationContextTool.e2e.ts deleted file mode 100644 index 439b408e..00000000 --- a/apps/e2e/common/mcp/McpAppData-GetApplicationContextTool.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP AppData - GetApplicationContextTool', () => { - const ctx = createMcpSpecContext() - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - }) - - it('GetApplicationContextTool should return context fields', async () => { - const r = await mcpClient.getAppContext(ctx.clientCwd, ctx.mcpAddress) - expect(r).toHaveProperty('selectedMediaFolder') - expect(r).toHaveProperty('language') - }) -}) diff --git a/apps/e2e/common/mcp/McpAppData-GetMediaFoldersTool.e2e.ts b/apps/e2e/common/mcp/McpAppData-GetMediaFoldersTool.e2e.ts deleted file mode 100644 index bac491f7..00000000 --- a/apps/e2e/common/mcp/McpAppData-GetMediaFoldersTool.e2e.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { folder1 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - createAndImportFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP AppData - GetMediaFoldersTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('GetMediaFoldersTool should return folders field', async () => { - let r = await mcpClient.getMediaFolders(ctx.clientCwd, ctx.mcpAddress) - expect(r.folders.length).toEqual(0) - - await createAndImportFolderViaBrowser(folder1, 'e2eTest:GetMediaFoldersTool', testFolder) - r = await mcpClient.getMediaFolders(ctx.clientCwd, ctx.mcpAddress) - expect(r.folders.length).toEqual(1) - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-GetEpisodeTool.e2e.ts b/apps/e2e/common/mcp/McpOther-GetEpisodeTool.e2e.ts deleted file mode 100644 index 7a2fa37e..00000000 --- a/apps/e2e/common/mcp/McpOther-GetEpisodeTool.e2e.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { folder1 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - seedRecognizedTvShowFolder, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - GetEpisodeTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('GetEpisodeTool should return mapped video file path', async () => { - const folderPath = await seedRecognizedTvShowFolder({ ...folder1 }, testFolder) - - const r = await mcpClient.getEpisode(ctx.clientCwd, ctx.mcpAddress, { - mediaFolderPath: folderPath, - season: 1, - episode: 1, - }) - expect(r.message).toBe('succeeded') - expect(r.season).toBe(1) - expect(r.episode).toBe(1) - expect(r.videoFilePath).toContain('S01E01.mkv') - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-GetEpisodesTool.e2e.ts b/apps/e2e/common/mcp/McpOther-GetEpisodesTool.e2e.ts deleted file mode 100644 index d270e0f4..00000000 --- a/apps/e2e/common/mcp/McpOther-GetEpisodesTool.e2e.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { folder1 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - seedRecognizedTvShowFolder, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - GetEpisodesTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('GetEpisodesTool should return episodes list with mapped video path', async () => { - const folderPath = await seedRecognizedTvShowFolder({ ...folder1 }, testFolder) - - const r = await mcpClient.getEpisodes(ctx.clientCwd, ctx.mcpAddress, { - mediaFolderPath: folderPath, - }) - - expect(r.totalCount).toEqual(13) - expect(r.showName).toEqual(folder1.translations?.title?.['en-US'] ?? 'N/A') - expect(r.numberOfSeasons).toEqual(2) - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-GetMediaMetadataTool.e2e.ts b/apps/e2e/common/mcp/McpOther-GetMediaMetadataTool.e2e.ts deleted file mode 100644 index d615913f..00000000 --- a/apps/e2e/common/mcp/McpOther-GetMediaMetadataTool.e2e.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { folder1 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - seedRecognizedTvShowFolder, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - GetMediaMetadataTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('GetMediaMetadataTool should return cached metadata for folder', async () => { - const folderPath = await seedRecognizedTvShowFolder({ ...folder1 }, testFolder, (mm) => { - if (mm.tvShow) { - mm.tvShow.name = folder1.mediaName! - } - return mm - }) - - const r = await mcpClient.getMediaMetadata(ctx.clientCwd, ctx.mcpAddress, { - mediaFolderPath: folderPath, - }) - const json = JSON.stringify(r) - expect(json).toContain(folder1.mediaName!) - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-IsFolderExistTool.e2e.ts b/apps/e2e/common/mcp/McpOther-IsFolderExistTool.e2e.ts deleted file mode 100644 index 2b90cefe..00000000 --- a/apps/e2e/common/mcp/McpOther-IsFolderExistTool.e2e.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { folder1 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - createAndImportFolderViaBrowser, - fetchHelloPathsViaBrowser, - joinPlatformPath, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - IsFolderExistTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('IsFolderExistTool should return exists=true for existing folder', async () => { - const { tmpDir } = await fetchHelloPathsViaBrowser() - if (!tmpDir) { - throw new Error('hello tmpDir is required for IsFolderExistTool e2e') - } - const inexistentPath = joinPlatformPath(tmpDir, `smm-mcp-inexistent-${Date.now()}`) - let r = await mcpClient.isFolderExist(ctx.clientCwd, ctx.mcpAddress, { path: inexistentPath }) - expect(r.exists).toBe(false) - expect(r.path).toBe(inexistentPath) - - const folderPath = await createAndImportFolderViaBrowser( - folder1, - 'e2eTest:IsFolderExistTool', - testFolder, - ) - - r = await mcpClient.isFolderExist(ctx.clientCwd, ctx.mcpAddress, { path: folderPath }) - expect(r.exists).toBe(true) - expect(r.path).toBe(folderPath) - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-ListFilesTool.e2e.ts b/apps/e2e/common/mcp/McpOther-ListFilesTool.e2e.ts deleted file mode 100644 index e87d4fbf..00000000 --- a/apps/e2e/common/mcp/McpOther-ListFilesTool.e2e.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { expect } from '@wdio/globals' -import { expect as expectChai } from 'chai' -import mcpClient from 'test/lib/McpClient' -import { folder1 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - createAndImportFolderViaBrowser, - joinPlatformPath, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - ListFilesTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('ListFilesTool should list files from target folder', async () => { - const folderPath = await createAndImportFolderViaBrowser( - folder1, - 'e2eTest:ListFilesTool', - testFolder, - ) - const r = await mcpClient.listFiles(ctx.clientCwd, ctx.mcpAddress, { - folderPath, - recursive: false, - filter: undefined, - videoFileOnly: false, - }) - - const expectedFilePaths = folder1.files.map((file) => joinPlatformPath(folderPath, file)) - expectChai(r.files).to.have.deep.members(expectedFilePaths) - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-RenameFolderTool.e2e.ts b/apps/e2e/common/mcp/McpOther-RenameFolderTool.e2e.ts deleted file mode 100644 index 4e4b1ba2..00000000 --- a/apps/e2e/common/mcp/McpOther-RenameFolderTool.e2e.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import type { MediaMetadata } from '@smm/core/types' -import { Path } from '@smm/core' -import mcpClient from 'test/lib/McpClient' -import { testbedOs } from 'test/lib/e2e-platform' -import { - expectMediaMetadataViaBrowser, - cleanup, - setup, -} from 'test/lib/testbed' -import { folder1, folder2 } from 'test/actions/import-folders' -import Sidebar from 'test/componentobjects/Sidebar' -import { - clearFolderViaBrowser, - joinPlatformPath, - listFilesViaBrowser, - basenamePlatformPath, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - seedRecognizedMovieFolder, - seedRecognizedTvShowFolder, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - RenameFolderTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('TV Show', async () => { - const folderPath = await seedRecognizedTvShowFolder({ ...folder1 }, testFolder) - - const newFolderName = `new-${folder1.folderName}` - const newFolderPath = joinPlatformPath(testFolder, newFolderName) - const r = await mcpClient.renameFolder(ctx.clientCwd, ctx.mcpAddress, { - from: folderPath, - to: newFolderPath, - }) - expect(r.renamed).toBe(true) - expect(r.from).toBe(folderPath) - expect(r.to).toBe(newFolderPath) - - await browser.pause(2000) - - await expectMediaMetadataViaBrowser(newFolderPath, (obj) => { - const mm = obj as MediaMetadata - expect(mm.tvShow).toBeDefined() - return mm.mediaFolderPath === Path.posix(newFolderPath) - }) - - expect(await listFilesViaBrowser(testFolder).then((items) => - items.some( - (item) => item.isDirectory && basenamePlatformPath(item.path) === newFolderName, - ), - )).toBe(true) - expect(await Sidebar.getDisplayedFolderNames()).toContain(newFolderName) - }) - - it('Movie', async () => { - const folderPath = await seedRecognizedMovieFolder( - { ...folder2 }, - testFolder, - { - database: 'TMDB', - id: '1311031', - name: folder2.translations?.title?.['en-US'] ?? folder2.mediaName!, - }, - ) - - const newFolderName = `new-${folder2.folderName}` - const newFolderPath = joinPlatformPath(testFolder, newFolderName) - const r = await mcpClient.renameFolder(ctx.clientCwd, ctx.mcpAddress, { - from: folderPath, - to: newFolderPath, - }) - expect(r.renamed).toBe(true) - expect(r.from).toBe(folderPath) - expect(r.to).toBe(newFolderPath) - - await browser.pause(2000) - - await expectMediaMetadataViaBrowser(newFolderPath, (obj) => { - const mm = obj as MediaMetadata - expect(mm.movie).toBeDefined() - return mm.mediaFolderPath === Path.posix(newFolderPath) - }) - - expect(await listFilesViaBrowser(testFolder).then((items) => - items.some( - (item) => item.isDirectory && basenamePlatformPath(item.path) === newFolderName, - ), - )).toBe(true) - expect(await Sidebar.getDisplayedFolderNames()).toContain(newFolderName) - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts b/apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts deleted file mode 100644 index fdb4b2b4..00000000 --- a/apps/e2e/common/mcp/McpOther-ScrapeTool.e2e.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { SCRAPE_JOB_CREATED_MESSAGE } from '@smm/core/types/ai-tools/scrape' -import mcpClient from 'test/lib/McpClient' -import { folder2 } from 'test/actions/import-folders' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { - cleanupMcpTest, - createMcpSpecContext, - seedRecognizedMovieFolder, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * Movie fixture: Core skips thumbnails so this contract test does not - * hang on episode-still downloads. - * - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - ScrapeTool', () => { - const ctx = createMcpSpecContext() - let testFolder = '' - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('scrape starts a job; get-job polls until terminal', async function () { - this.timeout(120 * 1000) - - const folderPath = await seedRecognizedMovieFolder( - { - ...folder2, - folderName: '哪吒之魔童降世 (2019) {tmdbid=552524}', - files: ['movie.mkv'], - }, - testFolder, - { - database: 'TMDB', - id: '552524', - name: '哪吒之魔童降世', - }, - ) - - const scrapeResult = await mcpClient.scrape(ctx.clientCwd, ctx.mcpAddress, { - path: folderPath, - language: 'zh-CN', - }) - - expect(scrapeResult.id).toBeTruthy() - expect(scrapeResult.message).toBe(SCRAPE_JOB_CREATED_MESSAGE) - expect(scrapeResult.error).toBeUndefined() - - const jobId = scrapeResult.id - - await browser.waitUntil( - async () => { - const jobResult = await mcpClient.getJob(ctx.clientCwd, ctx.mcpAddress, { - id: jobId, - }) - const status = jobResult.job?.status - return status === 'succeeded' || status === 'failed' - }, - { - timeout: 90_000, - interval: 1000, - timeoutMsg: `Scrape job ${jobId} did not reach a terminal status`, - }, - ) - - const finalJob = await mcpClient.getJob(ctx.clientCwd, ctx.mcpAddress, { - id: jobId, - }) - expect(finalJob.job?.kind).toBe('scrape') - expect(finalJob.job?.id).toBe(jobId) - expect(finalJob.job?.status).toBe('succeeded') - if (finalJob.job?.kind === 'scrape') { - expect(finalJob.job.tasks.poster.status).toMatch(/completed|skipped/) - expect(finalJob.job.tasks.fanart.status).toMatch(/completed|skipped/) - expect(finalJob.job.tasks.thumbnails.status).toBe('skipped') - expect(finalJob.job.tasks.nfo.status).toMatch(/completed|skipped/) - } - }) -}) diff --git a/apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts b/apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts deleted file mode 100644 index aafc9fbe..00000000 --- a/apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { expect } from '@wdio/globals' -import type { UserConfig } from '@smm/core/types' -import mcpClient from 'test/lib/McpClient' -import { cleanup, getConfiguredHttpProxyAddress, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -const TV_SEARCH_KEYWORD = '天使降临到我身边' -const TV_TMDB_ID = 84666 -const MOVIE_TMDB_ID = 552524 -const TV_TITLE_PATTERN = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i - -function applyTmdbUserConfig(config: UserConfig): UserConfig { - const host = (process.env.TMDB_HOST || '').trim() - const apiKey = (process.env.TMDB_API_KEY || '').trim() - const httpProxy = getConfiguredHttpProxyAddress('tmdb') - if (host || apiKey || httpProxy) { - config.tmdb = { - ...(host ? { host } : {}), - ...(apiKey ? { apiKey } : {}), - ...(httpProxy ? { httpProxy } : {}), - } - } - return config -} - -/** - * MCP TMDB tools (`tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show`) via Core v3. - * - * Requires TMDB network access (see `apps/e2e/.env.local` for `TMDB_HOST` / - * `TMDB_API_KEY` / `TMDB_HTTP_PROXY` when the default host is blocked). - * - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Other - TmdbTools', () => { - const ctx = createMcpSpecContext() - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: applyTmdbUserConfig, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - }) - - it('tmdb-search returns TV results', async function () { - this.timeout(120_000) - - const result = await mcpClient.tmdbSearch(ctx.clientCwd, ctx.mcpAddress, { - keyword: TV_SEARCH_KEYWORD, - type: 'tv', - language: 'zh-CN', - }) - - expect(result.results?.length).toBeGreaterThan(0) - expect(result.page).toBeGreaterThanOrEqual(1) - - const first = result.results[0]! - expect(first.id).toBeGreaterThan(0) - expect(first.name).toMatch(TV_TITLE_PATTERN) - }) - - it('tmdb-get-tv-show returns series details', async function () { - this.timeout(120_000) - - const result = await mcpClient.tmdbGetTvShow(ctx.clientCwd, ctx.mcpAddress, { - id: TV_TMDB_ID, - language: 'zh-CN', - }) - - expect(result.id).toBe(TV_TMDB_ID) - expect(result.name).toMatch(TV_TITLE_PATTERN) - }) - - it('tmdb-get-movie returns movie details', async function () { - this.timeout(120_000) - - const result = await mcpClient.tmdbGetMovie(ctx.clientCwd, ctx.mcpAddress, { - id: MOVIE_TMDB_ID, - language: 'zh-CN', - }) - - expect(result.id).toBe(MOVIE_TMDB_ID) - expect(typeof result.title).toBe('string') - expect(result.title!.length).toBeGreaterThan(0) - }) -}) diff --git a/apps/e2e/common/mcp/McpPrompts-HowToRecognizeEpisodeVideoFilesTool.e2e.ts b/apps/e2e/common/mcp/McpPrompts-HowToRecognizeEpisodeVideoFilesTool.e2e.ts deleted file mode 100644 index 327cab10..00000000 --- a/apps/e2e/common/mcp/McpPrompts-HowToRecognizeEpisodeVideoFilesTool.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Prompts - HowToRecognizeEpisodeVideoFilesTool', () => { - const ctx = createMcpSpecContext() - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - }) - - it('HowToRecognizeEpisodeVideoFilesTool should return guideline markdown', async () => { - const r = await mcpClient.howToRecognizeEpisodeVideoFiles(ctx.clientCwd, ctx.mcpAddress) - expect(r.text).toContain('如何使用 SMM MCP tool 识别季集视频文件') - expect(r.text).toContain('begin-recognize-task') - }) -}) diff --git a/apps/e2e/common/mcp/McpPrompts-HowToRenameEpisodeVideoFilesTool.e2e.ts b/apps/e2e/common/mcp/McpPrompts-HowToRenameEpisodeVideoFilesTool.e2e.ts deleted file mode 100644 index 7854c8e2..00000000 --- a/apps/e2e/common/mcp/McpPrompts-HowToRenameEpisodeVideoFilesTool.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Prompts - HowToRenameEpisodeVideoFilesTool', () => { - const ctx = createMcpSpecContext() - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - }) - - it('HowToRenameEpisodeVideoFilesTool should return guideline markdown', async () => { - const r = await mcpClient.howToRenameEpisodeVideoFiles(ctx.clientCwd, ctx.mcpAddress) - expect(r.text).toContain('如何使用 SMM MCP tool 重命名媒体文件') - expect(r.text).toContain('begin-rename-episode-video-file-task') - }) -}) diff --git a/apps/e2e/common/mcp/McpPrompts-ReadmeTool.e2e.ts b/apps/e2e/common/mcp/McpPrompts-ReadmeTool.e2e.ts deleted file mode 100644 index 5ccb1ac2..00000000 --- a/apps/e2e/common/mcp/McpPrompts-ReadmeTool.e2e.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from '@wdio/globals' -import mcpClient from 'test/lib/McpClient' -import { cleanup, setup } from 'test/lib/testbed' -import { testbedOs } from 'test/lib/e2e-platform' -import { - cleanupMcpTest, - createMcpSpecContext, - setupMcpTest, - skipIfOhos, -} from 'test/lib/mcpSpecShared' - -/** - * @supports local, Electron, Docker - * @unsupported HarmonyOS - */ -describe('MCP Prompts - ReadmeTool', () => { - const ctx = createMcpSpecContext() - - before(function () { - skipIfOhos(this) - }) - - beforeEach(async () => { - await setup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - await setupMcpTest() - }) - - afterEach(async () => { - await cleanupMcpTest() - await cleanup({ - removeDirInSidebar: true, - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - resetUserConfig: false, - os: testbedOs, - }) - }) - - it('ReadmeTool should return README markdown', async () => { - const r = await mcpClient.readme(ctx.clientCwd, ctx.mcpAddress) - expect(r.text).toContain('Simple Media Manager (SMM)') - expect(r.text).toContain('## 核心概念') - }) -}) diff --git a/package.json b/package.json index 3c05c2b4..431e7f68 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ }, "devDependencies": { "@changesets/cli": "^2.29.8", + "@modelcontextprotocol/inspector": "^2.3.0", "concurrently": "^9.2.1", "cross-env": "^7.0.3", "npm-run-all2": "^8.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 572a6fc0..6690777d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@26.2.0) + '@modelcontextprotocol/inspector': + specifier: ^2.3.0 + version: 2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.4.3))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.6.1)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0) concurrently: specifier: ^9.2.1 version: 9.2.1 @@ -150,7 +153,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/convex: dependencies: @@ -172,7 +175,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/docker: devDependencies: @@ -294,7 +297,7 @@ importers: version: 26.8.1(electron-builder-squirrel-windows@26.8.1) electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) eslint: specifier: ^9.39.1 version: 9.39.3(jiti@2.6.1) @@ -306,10 +309,10 @@ importers: version: 5.9.3 vite: specifier: ^7.2.6 - version: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/ohos: dependencies: @@ -328,7 +331,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) apps/tools: devDependencies: @@ -439,7 +442,7 @@ importers: version: link:../../packages/tvdb4 '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.2.4) @@ -575,10 +578,10 @@ importers: version: 10.3.6(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-docs': specifier: ^10.3.6 - version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/react-vite': specifier: ^10.3.6 - version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 @@ -599,7 +602,7 @@ importers: version: 15.5.13 '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/coverage-istanbul': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -644,10 +647,10 @@ importers: version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.2.4 - version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/core: dependencies: @@ -672,7 +675,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/core-routes: dependencies: @@ -718,7 +721,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/e2e-test-base-image: {} @@ -735,7 +738,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/test: dependencies: @@ -769,7 +772,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) packages/utils: devDependencies: @@ -828,6 +831,10 @@ packages: peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -1873,6 +1880,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2110,6 +2123,33 @@ packages: '@types/react': '>=16' react: '>=16' + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/ext-apps@1.7.5': + resolution: {integrity: sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.29.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@modelcontextprotocol/inspector@2.3.0': + resolution: {integrity: sha512-eXqGdLuSIGLLDmmpYzhTZMfs2HEn2ykV9CQ1cjcyf0snAoeV5pcDBs9epIxz9nRWw6HfsvcqBlr8m55olCLGDg==} + engines: {node: '>=22.19.0'} + hasBin: true + '@modelcontextprotocol/sdk@1.27.0': resolution: {integrity: sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==} engines: {node: '>=18'} @@ -2120,6 +2160,101 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server-legacy@2.0.0': + resolution: {integrity: sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==} + engines: {node: '>=20'} + deprecated: This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features. + peerDependencies: + express: ^4.18.0 || ^5.0.0 + peerDependenciesMeta: + express: + optional: true + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2160,6 +2295,9 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2973,9 +3111,108 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/binding-android-arm-eabi@1.2.5': + resolution: {integrity: sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.5': + resolution: {integrity: sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.5': + resolution: {integrity: sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.5': + resolution: {integrity: sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.5': + resolution: {integrity: sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + resolution: {integrity: sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + resolution: {integrity: sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.5': + resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.5': + resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.5': + resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.5': + resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + resolution: {integrity: sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.5': + resolution: {integrity: sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -3629,6 +3866,22 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitejs/plugin-react@6.1.0': + resolution: {integrity: sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/coverage-istanbul@4.0.18': resolution: {integrity: sha512-0OhjP30owEDihYTZGWuq20rNtV1RjjJs1Mv4MaZIKcFBmiLUXX7HJLX4fU7wE+Mrc3lQxI2HKq6WrSXi5FGuCQ==} peerDependencies: @@ -3851,6 +4104,10 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3968,10 +4225,17 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + author-regex@1.0.0: resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==} engines: {node: '>=0.8'} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -4270,10 +4534,18 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -4282,6 +4554,10 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -4314,6 +4590,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4331,6 +4611,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -4385,6 +4669,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + convex@1.39.1: resolution: {integrity: sha512-W+gVXA7BpRF1xLlS1kGTtKVaqd5yonqbGESKiPtIUXjV744GdDz8IG7RVsSY5KzHbgxuJBHKaJYk+92OIHTskQ==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} @@ -4822,6 +5110,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -5505,6 +5797,10 @@ packages: resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==} engines: {node: '>=16.9.0'} + hono@4.13.3: + resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + engines: {node: '>=16.9.0'} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -5638,6 +5934,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + index-to-position@1.2.0: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} @@ -5649,6 +5949,19 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ink@6.8.0: + resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5746,6 +6059,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -5753,6 +6070,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -6047,30 +6369,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.31.1: resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.31.1: resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.31.1: resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.31.1: resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.31.1: resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} @@ -6078,6 +6430,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} @@ -6085,6 +6444,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} @@ -6092,6 +6458,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} @@ -6099,22 +6472,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.31.1: resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.31.1: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@2.0.4: resolution: {integrity: sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6574,6 +6970,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.6: resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} engines: {node: ^18 || >=20} @@ -6879,6 +7280,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify-esm@1.0.6: resolution: {integrity: sha512-9nUwYvvu/yq1PYrUyYCihNWmpzacaRYF6gGbjLWErrZ4MRDWyfPN7RpE8E7tsw8eqBU/rr7mcoTXbS+Vih8uUA==} @@ -6963,6 +7368,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -7012,6 +7421,10 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -7199,6 +7612,12 @@ packages: '@types/react': '>=18' react: '>=18' + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -7435,6 +7854,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -7459,6 +7882,11 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + rolldown@1.2.5: + resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -7665,6 +8093,10 @@ packages: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -7823,6 +8255,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string_decoder@0.10.31: resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} @@ -7882,6 +8318,12 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -7923,6 +8365,10 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -7957,6 +8403,10 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} @@ -8008,6 +8458,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinyrainbow@1.2.0: resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} engines: {node: '>=14.0.0'} @@ -8129,6 +8583,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -8174,6 +8632,10 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -8385,6 +8847,49 @@ packages: yaml: optional: true + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8501,6 +9006,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -8533,6 +9041,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8700,6 +9212,9 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -8724,6 +9239,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -8798,6 +9316,11 @@ snapshots: transitivePeerDependencies: - zod + '@alcalzone/ansi-tokenize@0.2.5': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -9741,6 +10264,10 @@ snapshots: dependencies: hono: 4.12.2 + '@hono/node-server@2.1.1(hono@4.13.3)': + dependencies: + hono: 4.13.3 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -9930,11 +10457,11 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: typescript: 5.9.3 @@ -9992,6 +10519,75 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + jose: 6.1.3 + pkce-challenge: 5.0.1 + zod: 4.4.3 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/ext-apps@1.7.5(@modelcontextprotocol/sdk@1.27.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.27.0(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@modelcontextprotocol/inspector@2.3.0(@modelcontextprotocol/sdk@1.27.0(zod@4.4.3))(@types/node@26.2.0)(@types/react@19.2.14)(babel-plugin-react-compiler@1.0.0)(esbuild@0.28.1)(express@5.2.1)(jiti@2.6.1)(react-dom@19.2.4(react@19.2.4))(tsx@4.21.0)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.3) + '@modelcontextprotocol/client': 2.0.0 + '@modelcontextprotocol/core': 2.0.0 + '@modelcontextprotocol/ext-apps': 1.7.5(@modelcontextprotocol/sdk@1.27.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + '@modelcontextprotocol/server': 2.0.0 + '@modelcontextprotocol/server-legacy': 2.0.0(express@5.2.1) + '@napi-rs/keyring': 1.3.0 + '@vitejs/plugin-react': 6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + ajv: 8.18.0 + atomically: 2.1.1 + chokidar: 4.0.3 + commander: 13.1.0 + hono: 4.13.3 + ink: 6.8.0(@types/react@19.2.14)(react@19.2.4) + open: 10.2.0 + pino: 9.14.0 + react: 19.2.4 + undici: 8.10.0 + vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) + yaml: 2.9.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@rolldown/plugin-babel' + - '@types/node' + - '@types/react' + - '@vitejs/devtools' + - babel-plugin-react-compiler + - bufferutil + - esbuild + - express + - jiti + - less + - oxc-transform-react + - react-devtools-core + - react-dom + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - utf-8-validate + '@modelcontextprotocol/sdk@1.27.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.2) @@ -10014,6 +10610,96 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.27.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.2) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.2 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@modelcontextprotocol/server-legacy@2.0.0(express@5.2.1)': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + content-type: 1.0.5 + cors: 2.8.6 + express-rate-limit: 8.2.1(express@5.2.1) + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + optionalDependencies: + express: 5.2.1 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10059,6 +10745,8 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@oxc-project/types@0.146.0': {} + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -10921,8 +11609,55 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rolldown/binding-android-arm-eabi@1.2.5': + optional: true + + '@rolldown/binding-android-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.5': + optional: true + + '@rolldown/binding-darwin-x64@1.2.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.5': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.5': + optional: true + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rolldown/pluginutils@1.0.1': {} + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': dependencies: '@types/estree': 1.0.8 @@ -11064,10 +11799,10 @@ snapshots: axe-core: 4.11.4 storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/icons': 2.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@storybook/react-dom-shim': 10.3.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 @@ -11081,25 +11816,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/builder-vite@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/csf-plugin@10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 rollup: 4.59.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -11114,11 +11849,11 @@ snapshots: react-dom: 19.2.4(react@19.2.4) storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-vite@10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@storybook/react-vite@10.3.6(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@storybook/builder-vite': 10.3.6(esbuild@0.28.1)(rollup@4.59.0)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@storybook/react': 10.3.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -11128,7 +11863,7 @@ snapshots: resolve: 1.22.12 storybook: 10.3.6(@testing-library/dom@8.20.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tsconfig-paths: 4.2.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -11215,12 +11950,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@tanstack/query-core@5.96.2': {} @@ -11560,7 +12295,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -11568,10 +12303,17 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@6.1.0(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) + optionalDependencies: + babel-plugin-react-compiler: 1.0.0 + '@vitest/coverage-istanbul@4.0.18(vitest@4.0.18)': dependencies: '@istanbuljs/schema': 0.1.3 @@ -11584,7 +12326,7 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -11600,7 +12342,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -11619,29 +12361,29 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -11687,7 +12429,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -11976,6 +12718,10 @@ snapshots: ansi-colors@4.1.3: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -12127,8 +12873,15 @@ snapshots: atomic-sleep@1.0.0: {} + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + author-regex@1.0.0: {} + auto-bind@5.0.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -12472,10 +13225,16 @@ snapshots: classnames@2.5.1: {} + cli-boxes@3.0.0: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-spinners@2.9.2: {} cli-truncate@2.1.0: @@ -12484,6 +13243,11 @@ snapshots: string-width: 4.2.3 optional: true + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.2 + cli-width@4.1.0: {} cliui@7.0.4: @@ -12525,6 +13289,10 @@ snapshots: - '@types/react' - '@types/react-dom' + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -12539,6 +13307,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@13.1.0: {} + commander@14.0.3: {} commander@15.0.0: {} @@ -12580,6 +13350,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + convex@1.39.1(react@19.2.4): dependencies: esbuild: 0.27.0 @@ -12983,7 +13755,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)): + electron-vite@5.0.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -12991,7 +13763,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -13027,8 +13799,7 @@ snapshots: embla-carousel@8.6.0: {} - emoji-regex@10.6.0: - optional: true + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -13100,6 +13871,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + err-code@2.0.3: {} error-ex@1.3.4: @@ -13568,6 +14341,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fflate@0.8.2: {} figures@6.1.0: @@ -13750,8 +14527,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.6.0: - optional: true + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: dependencies: @@ -14061,6 +14837,8 @@ snapshots: hono@4.12.2: {} + hono@4.13.3: {} + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -14192,6 +14970,8 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + index-to-position@1.2.0: {} inflight@1.0.6: @@ -14201,6 +14981,40 @@ snapshots: inherits@2.0.4: {} + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.44.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.4 + react-reconciler: 0.33.0(react@19.2.4) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.2 + terminal-size: 4.0.1 + type-fest: 5.8.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.21.1 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.7: {} inline-style-prefixer@7.0.1: @@ -14290,12 +15104,18 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-hexadecimal@2.0.1: {} + is-in-ci@2.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -14587,36 +15407,69 @@ snapshots: lightningcss-android-arm64@1.31.1: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.31.1: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.31.1: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.31.1: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.31.1: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.31.1: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.31.1: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.31.1: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.31.1: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.31.1: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 @@ -14633,6 +15486,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@2.0.4: {} load-json-file@2.0.0: @@ -15300,6 +16169,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.18: {} + nanoid@5.1.6: {} natural-compare@1.4.0: {} @@ -15635,6 +16506,8 @@ snapshots: parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-browserify-esm@1.0.6: {} path-browserify@1.0.1: {} @@ -15689,6 +16562,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.5: {} + pidtree@0.6.0: {} pify@2.3.0: {} @@ -15763,6 +16638,12 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -16036,6 +16917,11 @@ snapshots: transitivePeerDependencies: - supports-color + react-reconciler@0.33.0(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -16364,6 +17250,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + ret@0.5.0: {} retry@0.12.0: {} @@ -16386,6 +17277,27 @@ snapshots: sprintf-js: 1.1.3 optional: true + rolldown@1.2.5: + dependencies: + '@oxc-project/types': 0.146.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.5 + '@rolldown/binding-android-arm64': 1.2.5 + '@rolldown/binding-darwin-arm64': 1.2.5 + '@rolldown/binding-darwin-x64': 1.2.5 + '@rolldown/binding-freebsd-x64': 1.2.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.5 + '@rolldown/binding-linux-arm64-gnu': 1.2.5 + '@rolldown/binding-linux-arm64-musl': 1.2.5 + '@rolldown/binding-linux-ppc64-gnu': 1.2.5 + '@rolldown/binding-linux-s390x-gnu': 1.2.5 + '@rolldown/binding-linux-x64-gnu': 1.2.5 + '@rolldown/binding-linux-x64-musl': 1.2.5 + '@rolldown/binding-openharmony-arm64': 1.2.5 + '@rolldown/binding-win32-arm64-msvc': 1.2.5 + '@rolldown/binding-win32-x64-msvc': 1.2.5 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -16643,6 +17555,11 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} smol-toml@1.7.0: {} @@ -16845,7 +17762,11 @@ snapshots: emoji-regex: 10.6.0 get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - optional: true + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 string_decoder@0.10.31: {} @@ -16896,6 +17817,12 @@ snapshots: strnum@2.1.2: {} + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -16940,6 +17867,8 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} tailwindcss@4.2.1: {} @@ -16995,6 +17924,8 @@ snapshots: term-size@2.2.1: {} + terminal-size@4.0.1: {} + text-decoder@1.2.7: dependencies: b4a: 1.8.0 @@ -17046,6 +17977,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + tinyrainbow@1.2.0: {} tinyrainbow@2.0.0: {} @@ -17138,6 +18074,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -17180,6 +18120,8 @@ snapshots: undici@7.22.0: {} + undici@8.10.0: {} + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -17347,7 +18289,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -17359,11 +18301,11 @@ snapshots: '@types/node': 22.19.11 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.33.0 tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -17375,11 +18317,11 @@ snapshots: '@types/node': 24.10.13 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.33.0 tsx: 4.21.0 yaml: 2.9.0 - vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -17391,14 +18333,29 @@ snapshots: '@types/node': 26.2.0 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.31.1 + lightningcss: 1.33.0 + tsx: 4.21.0 + yaml: 2.9.0 + + vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.6.1 tsx: 4.21.0 yaml: 2.9.0 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -17415,7 +18372,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17435,10 +18392,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -17455,7 +18412,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17475,10 +18432,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@26.2.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -17495,7 +18452,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@26.2.0)(jiti@2.6.1)(lightningcss@1.33.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -17668,6 +18625,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + when-exit@2.1.5: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -17710,6 +18669,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@6.0.0: + dependencies: + string-width: 8.2.2 + word-wrap@1.2.5: {} workerpool@6.5.1: {} @@ -17737,7 +18700,6 @@ snapshots: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 - optional: true wrappy@1.0.2: {} @@ -17837,6 +18799,8 @@ snapshots: yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 @@ -17847,6 +18811,10 @@ snapshots: dependencies: zod: 4.3.6 + zod-to-json-schema@3.25.1(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 @@ -17858,6 +18826,8 @@ snapshots: zod@4.3.6: {} + zod@4.4.3: {} + zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 diff --git a/test/mcp/get-app-context.test.ts b/test/mcp/get-app-context.test.ts new file mode 100644 index 00000000..cd0e44d3 --- /dev/null +++ b/test/mcp/get-app-context.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' + +describe('MCP AppData - GetApplicationContextTool', () => { + const ctx = useMcpServer() + + it('should return context fields', async () => { + const r = await callTool(ctx.url, 'get-app-context') + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc).toHaveProperty('selectedMediaFolder') + expect(sc).toHaveProperty('language') + }) +}) diff --git a/test/mcp/get-episode.test.ts b/test/mcp/get-episode.test.ts new file mode 100644 index 00000000..8b7b5199 --- /dev/null +++ b/test/mcp/get-episode.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { + folder1, + seedMediaMetadata, + setupTestMediaFolders, + writeUserConfig, + type TestFolder, +} from './lib/testSetup' +import type { MediaMetadata } from '@smm/core/types' + +const TV_SHOW_METADATA_TEMPLATE = '天使降临到我身边.metadata.json' + +async function seedRecognizedTvShowFolder( + ctx: { userDataDir: string; appDataDir: string }, + folder: TestFolder, + updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata, +): Promise<{ path: string; cleanup: () => Promise }> { + const media = await setupTestMediaFolders() + const created = media.materialize(folder) + await writeUserConfig(ctx.userDataDir, { folders: [created.path!] }) + await seedMediaMetadata(ctx.appDataDir, created, TV_SHOW_METADATA_TEMPLATE, updateMediaMetadata) + return { path: created.path!, cleanup: () => media.cleanup() } +} + +describe('MCP Other - GetEpisodeTool', () => { + const ctx = useMcpServer() + + it('should return mapped video file path', async () => { + const seeded = await seedRecognizedTvShowFolder(ctx, { ...folder1 }) + try { + const r = await callTool(ctx.url, 'get-episode', { + mediaFolderPath: seeded.path, + season: 1, + episode: 1, + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc.message).toBe('succeeded') + expect(sc.season).toBe(1) + expect(sc.episode).toBe(1) + expect(sc.videoFilePath).toContain('S01E01.mkv') + } finally { + await seeded.cleanup() + } + }) +}) diff --git a/test/mcp/get-episodes.test.ts b/test/mcp/get-episodes.test.ts new file mode 100644 index 00000000..7b4cba87 --- /dev/null +++ b/test/mcp/get-episodes.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { + folder1, + seedMediaMetadata, + setupTestMediaFolders, + writeUserConfig, + type TestFolder, +} from './lib/testSetup' +import type { MediaMetadata } from '@smm/core/types' + +const TV_SHOW_METADATA_TEMPLATE = '天使降临到我身边.metadata.json' + +async function seedRecognizedTvShowFolder( + ctx: { userDataDir: string; appDataDir: string }, + folder: TestFolder, + updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata, +): Promise<{ path: string; cleanup: () => Promise }> { + const media = await setupTestMediaFolders() + const created = media.materialize(folder) + await writeUserConfig(ctx.userDataDir, { folders: [created.path!] }) + await seedMediaMetadata(ctx.appDataDir, created, TV_SHOW_METADATA_TEMPLATE, updateMediaMetadata) + return { path: created.path!, cleanup: () => media.cleanup() } +} + +describe('MCP Other - GetEpisodesTool', () => { + const ctx = useMcpServer() + + it('should return episodes list with mapped video path', async () => { + const seeded = await seedRecognizedTvShowFolder(ctx, { ...folder1 }) + try { + const r = await callTool(ctx.url, 'get-episodes', { + mediaFolderPath: seeded.path, + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc.totalCount).toEqual(13) + // showName comes from the seeded metadata template. + expect(sc.showName).toBeTruthy() + expect(sc.numberOfSeasons).toEqual(2) + } finally { + await seeded.cleanup() + } + }) +}) diff --git a/test/mcp/get-media-folders.test.ts b/test/mcp/get-media-folders.test.ts new file mode 100644 index 00000000..26f2cf8e --- /dev/null +++ b/test/mcp/get-media-folders.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { setupTestMediaFolders, writeUserConfig, folder1 } from './lib/testSetup' + +describe('MCP AppData - GetMediaFoldersTool', () => { + const ctx = useMcpServer() + + it('should return folders field, updated after import', async () => { + let r = await callTool(ctx.url, 'get-media-folders') + expect(r.isError).toBe(false) + expect(r.structuredContent!.folders).toEqual([]) + + const media = await setupTestMediaFolders() + try { + const folder = media.materialize(folder1) + await writeUserConfig(ctx.userDataDir, { folders: [folder.path!] }) + + r = await callTool(ctx.url, 'get-media-folders') + expect(r.isError).toBe(false) + expect(r.structuredContent!.folders).toEqual([folder.path!]) + } finally { + await media.cleanup() + } + }) +}) diff --git a/test/mcp/get-media-metadata.test.ts b/test/mcp/get-media-metadata.test.ts new file mode 100644 index 00000000..08d51aa1 --- /dev/null +++ b/test/mcp/get-media-metadata.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { + folder1, + seedMediaMetadata, + setupTestMediaFolders, + writeUserConfig, + type TestFolder, +} from './lib/testSetup' +import type { MediaMetadata } from '@smm/core/types' + +const TV_SHOW_METADATA_TEMPLATE = '天使降临到我身边.metadata.json' + +async function seedRecognizedTvShowFolder( + ctx: { userDataDir: string; appDataDir: string }, + folder: TestFolder, + updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata, +): Promise<{ path: string; cleanup: () => Promise }> { + const media = await setupTestMediaFolders() + const created = media.materialize(folder) + await writeUserConfig(ctx.userDataDir, { folders: [created.path!] }) + await seedMediaMetadata(ctx.appDataDir, created, TV_SHOW_METADATA_TEMPLATE, updateMediaMetadata) + return { path: created.path!, cleanup: () => media.cleanup() } +} + +describe('MCP Other - GetMediaMetadataTool', () => { + const ctx = useMcpServer() + + it('should return cached metadata for folder', async () => { + const seeded = await seedRecognizedTvShowFolder(ctx, { ...folder1 }, (mm) => { + if (mm.tvShow) { + mm.tvShow.name = folder1.mediaName! + } + return mm + }) + try { + const r = await callTool(ctx.url, 'get-media-metadata', { + mediaFolderPath: seeded.path, + }) + expect(r.isError).toBe(false) + const json = JSON.stringify(r.structuredContent) + expect(json).toContain(folder1.mediaName!) + } finally { + await seeded.cleanup() + } + }) +}) diff --git a/test/mcp/how-to-recognize-episode-video-files.test.ts b/test/mcp/how-to-recognize-episode-video-files.test.ts new file mode 100644 index 00000000..567c9230 --- /dev/null +++ b/test/mcp/how-to-recognize-episode-video-files.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' + +describe('MCP Prompts - HowToRecognizeEpisodeVideoFilesTool', () => { + const ctx = useMcpServer() + + it('should return guideline markdown', async () => { + const r = await callTool(ctx.url, 'how-to-recognize-episode-video-files') + expect(r.isError).toBe(false) + expect(r.structuredContent!.text).toContain('如何使用 SMM MCP tool 识别季集视频文件') + expect(r.structuredContent!.text).toContain('begin-recognize-task') + }) +}) diff --git a/test/mcp/how-to-rename-episode-video-files.test.ts b/test/mcp/how-to-rename-episode-video-files.test.ts new file mode 100644 index 00000000..cc541839 --- /dev/null +++ b/test/mcp/how-to-rename-episode-video-files.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'bun:test' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' + +describe('MCP Prompts - HowToRenameEpisodeVideoFilesTool', () => { + const ctx = useMcpServer() + + it('should return guideline markdown', async () => { + const r = await callTool(ctx.url, 'how-to-rename-episode-video-files') + expect(r.isError).toBe(false) + expect(r.structuredContent!.text).toContain('如何使用 SMM MCP tool 重命名媒体文件') + expect(r.structuredContent!.text).toContain('begin-rename-episode-video-file-task') + }) +}) diff --git a/test/mcp/is-folder-exist.test.ts b/test/mcp/is-folder-exist.test.ts new file mode 100644 index 00000000..33fb91fe --- /dev/null +++ b/test/mcp/is-folder-exist.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'bun:test' +import { join } from 'node:path' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { setupTestMediaFolders, folder1 } from './lib/testSetup' + +describe('MCP Other - IsFolderExistTool', () => { + const ctx = useMcpServer() + + it('should return exists=false then exists=true', async () => { + const media = await setupTestMediaFolders() + try { + const folder = media.materialize(folder1) + + const inexistentPath = join(media.mediaDir, `smm-mcp-inexistent-${Date.now()}`) + let r = await callTool(ctx.url, 'is-folder-exist', { path: inexistentPath }) + expect(r.isError).toBe(false) + expect(r.structuredContent!.exists).toBe(false) + expect(r.structuredContent!.path).toBe(inexistentPath) + + r = await callTool(ctx.url, 'is-folder-exist', { path: folder.path! }) + expect(r.isError).toBe(false) + expect(r.structuredContent!.exists).toBe(true) + expect(r.structuredContent!.path).toBe(folder.path!) + } finally { + await media.cleanup() + } + }) +}) diff --git a/test/mcp/lib/mcpInspectorClient.ts b/test/mcp/lib/mcpInspectorClient.ts new file mode 100644 index 00000000..d967c69b --- /dev/null +++ b/test/mcp/lib/mcpInspectorClient.ts @@ -0,0 +1,108 @@ +import { $ } from 'bun' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Resolve the MCP Inspector CLI launcher installed by + * `@modelcontextprotocol/inspector` (root devDependency). We invoke the + * launcher with `node` directly instead of `npx` because `npx` on Windows + * mangles backslash paths and JSON tool args. + */ +function resolveInspectorLauncher(): string { + const pkgUrl = import.meta.resolve('@modelcontextprotocol/inspector/package.json') + const pkgPath = fileURLToPath(pkgUrl) + return join(dirname(pkgPath), 'clients', 'launcher', 'build', 'index.js') +} + +export interface McpCallResult { + /** `result.structuredContent` from the MCP tool response. */ + structuredContent: Record | null + /** Whether the tool response had `isError: true`. */ + isError: boolean + /** Raw tool response text content (unparsed). */ + textContent: string | null + /** Raw stdout from the inspector CLI. */ + stdout: string + /** Inspector CLI exit code. */ + exitCode: number +} + +/** + * Call an MCP tool through the MCP Inspector CLI (`--cli --method tools/call`). + * + * @param mcpUrl MCP server URL, e.g. `http://127.0.0.1:30001/mcp`. + * @param toolName Kebab-case tool name. + * @param args Optional tool arguments (passed via `--tool-args-json`). + */ +export async function callTool( + mcpUrl: string, + toolName: string, + args?: Record, +): Promise { + const launcher = resolveInspectorLauncher() + const argv = [ + launcher, + '--cli', + '--server-url', + mcpUrl, + '--method', + 'tools/call', + '--tool-name', + toolName, + '--format', + 'json', + ] + if (args !== undefined) { + argv.push('--tool-args-json', JSON.stringify(args)) + } + + const res = await $`node ${argv}`.quiet().nothrow() + const stdout = res.stdout.toString().trim() + const exitCode = res.exitCode + + let parsed: { + result?: { + content?: Array<{ type: string; text?: string }> + structuredContent?: Record + isError?: boolean + } + } | null = null + try { + parsed = JSON.parse(stdout) + } catch { + parsed = null + } + + const result = parsed?.result + const textContent = + result?.content?.find((c) => c.type === 'text' && typeof c.text === 'string')?.text ?? + null + + // The inspector can exit non-zero with empty / unparseable stdout (e.g. the + // Windows libuv assertion crash on tool-error paths). Treat that as an error + // so callers can retry rather than silently getting `isError: false`. + const failedToParse = + parsed === null && (exitCode !== 0 || stdout.length === 0) + + return { + structuredContent: result?.structuredContent ?? null, + isError: (result?.isError ?? false) || failedToParse, + textContent, + stdout, + exitCode, + } +} + +/** List the MCP server's tools via `tools/list`. */ +export async function listTools(mcpUrl: string): Promise { + const launcher = resolveInspectorLauncher() + const argv = [launcher, '--cli', '--server-url', mcpUrl, '--method', 'tools/list', '--format', 'json'] + const res = await $`node ${argv}`.quiet().nothrow() + let parsed: { result?: { tools?: Array<{ name: string }> } } | null = null + try { + parsed = JSON.parse(res.stdout.toString().trim()) + } catch { + parsed = null + } + return (parsed?.result?.tools ?? []).map((t) => t.name) +} diff --git a/test/mcp/lib/mcpServer.ts b/test/mcp/lib/mcpServer.ts new file mode 100644 index 00000000..037a7e14 --- /dev/null +++ b/test/mcp/lib/mcpServer.ts @@ -0,0 +1,124 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { listTools } from './mcpInspectorClient' + +export interface McpServerHandle { + /** MCP server URL, e.g. `http://127.0.0.1:/mcp`. */ + url: string + /** User data dir (`USER_DATA_DIR`) passed to the server process. */ + userDataDir: string + /** App data dir (`APP_DATA_DIR`) passed to the server process. */ + appDataDir: string + /** Stop the server and remove its temp dirs. */ + stop: () => Promise +} + +const READY_TIMEOUT_MS = 30_000 + +/** + * Resolve the repo root from this file (`test/mcp/lib/` → repo root). + */ +export function repoRoot(): string { + return resolve(import.meta.dir, '..', '..', '..') +} + +/** + * Find a free TCP port for the MCP server by binding port 0 and closing. + */ +export function findFreePort(): Promise { + return new Promise((resolvePort, reject) => { + const srv = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: () => new Response('ok'), + }) + const port = srv.port + srv.stop(true) + resolvePort(port) + }) +} + +/** + * Start an isolated SMM MCP server via `smm mcp start`. + * + * Spawns `bun apps/cli/index.ts mcp start --port ` with isolated + * `USER_DATA_DIR` / `APP_DATA_DIR` / `LOG_DIR` temp dirs, waits for the + * server to become ready, and returns a handle for teardown. + */ +export async function startMcpServer(): Promise { + const baseDir = await mkdtemp(join(tmpdir(), 'smm-mcp-')) + // The real app keeps app data (metadata/plans) alongside user config on + // Windows/macOS. Core routes metadata writes to `appDataDir`, while + // `getCore()` resolves `appDataDir = getUserDataDir()` — so point both at + // the same dir to mirror production and keep scrape/metadata consistent. + const userDataDir = join(baseDir, 'user-data') + const appDataDir = userDataDir + const logDir = join(baseDir, 'logs') + + const root = repoRoot() + const port = await findFreePort() + const cliEntry = join(root, 'apps', 'cli', 'index.ts') + + const child = spawn( + process.execPath, + [cliEntry, 'mcp', 'start', '--host', '127.0.0.1', '--port', String(port)], + { + cwd: root, + env: { + ...process.env, + USER_DATA_DIR: userDataDir, + APP_DATA_DIR: appDataDir, + LOG_DIR: logDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + + const url = `http://127.0.0.1:${port}/mcp` + + // Wait until the MCP endpoint answers `tools/list` successfully. + const deadline = Date.now() + READY_TIMEOUT_MS + let ready = false + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `smm mcp start exited early (code ${child.exitCode}): ${stderr.slice(0, 2000)}`, + ) + } + try { + const tools = await listTools(url) + if (tools.length > 0) { + ready = true + break + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 300)) + } + + if (!ready) { + child.kill() + await rm(baseDir, { recursive: true, force: true }) + throw new Error(`smm mcp start did not become ready in ${READY_TIMEOUT_MS}ms: ${stderr.slice(0, 2000)}`) + } + + return { + url, + userDataDir, + appDataDir, + stop: async () => { + child.kill() + await new Promise((r) => child.once('exit', r)) + await rm(baseDir, { recursive: true, force: true }) + }, + } +} diff --git a/test/mcp/lib/testSetup.ts b/test/mcp/lib/testSetup.ts new file mode 100644 index 00000000..1a435fc8 --- /dev/null +++ b/test/mcp/lib/testSetup.ts @@ -0,0 +1,171 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { copyFileSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import type { TestFolder } from '@smm/test' +import { Path } from '@smm/core/path' +import type { MediaMetadata, UserConfig } from '@smm/core/types' + +export { + folder1, + folder2, + folder3, + folder4, + folder5, + folder6, + musicFolder, + tvShowFolder, + movieFolder, + type TestFolder, +} from '@smm/test' + +/** + * Resolve the repo root from this file (`test/mcp/lib/` → repo root). + */ +export function repoRoot(): string { + return resolve(import.meta.dir, '..', '..', '..') +} + +/** + * Write the `smm.json` user config for a test server. + * @param userDataDir The `USER_DATA_DIR` for the spawned MCP server. + * @param userConfig Full user config to write. + */ +export async function writeUserConfig( + userDataDir: string, + userConfig: Partial, +): Promise { + await mkdir(userDataDir, { recursive: true }) + const defaults: UserConfig = { + applicationLanguage: 'en', + tmdb: {}, + tvdb: {}, + folders: [], + renameRules: [], + dryRun: false, + aiProviders: [], + primaryDatabase: 'TMDB', + preferMediaLanguage: undefined, + selectedTMDBIntance: 'public', + selectedFolder: undefined, + selectedRenameRule: 'Plex(TvShow/Anime)', + enableMcpServer: false, + mcpHost: '127.0.0.1', + mcpPort: 30001, + } + await writeFile( + join(userDataDir, 'smm.json'), + JSON.stringify({ ...defaults, ...userConfig }, null, 2), + 'utf-8', + ) +} + +/** + * The metadata cache dir (`{appDataDir}/metadata`), matching + * `packages/core-routes`. + */ +export function metadataDir(appDataDir: string): string { + return join(appDataDir, 'metadata') +} + +/** + * The cache filename for a media folder path, matching + * `metadataCacheFilePath` in `packages/core-routes/src/mediaMetadataCache.ts`. + */ +export function metadataCacheFilePath(appDataDir: string, folderPathInPosix: string): string { + const filename = folderPathInPosix.replace(/[\/\\:?*|<>"]/g, '_') + return join(metadataDir(appDataDir), `${filename}.json`) +} + +/** + * Seed cached media metadata for a folder by writing directly into + * `{appDataDir}/metadata/`. Mirrors `importFolderWithMediaMetadata` from + * `apps/e2e/test/lib/testbed.ts`, but writes to disk instead of the browser. + */ +export async function seedMediaMetadata( + appDataDir: string, + folder: TestFolder, + templateFileName: string, + updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata, +): Promise { + const folderPath = folder.path + if (folderPath === undefined || folderPath === '') { + throw new Error('seedMediaMetadata: folder.path is required') + } + + const templatePath = join(repoRoot(), 'test', 'templates', 'mediaMetadatas', templateFileName) + const template = JSON.parse(await readFile(templatePath, 'utf-8')) as MediaMetadata + + const mediaMetadata: MediaMetadata = { + ...template, + mediaFolderPath: Path.posix(folderPath), + mediaFiles: template.mediaFiles?.map((file) => ({ + ...file, + absolutePath: Path.posix(join(folderPath, file.absolutePath)), + })), + } + const updated = + updateMediaMetadata !== undefined ? updateMediaMetadata(mediaMetadata) : mediaMetadata + + const filePath = metadataCacheFilePath(appDataDir, Path.posix(folderPath)) + await mkdir(metadataDir(appDataDir), { recursive: true }) + await writeFile(filePath, JSON.stringify(updated, null, 2), 'utf-8') +} + +export interface TestMediaFolders { + /** Directory holding all materialized test media folders. */ + mediaDir: string + /** Materialize a fixture under `mediaDir` and return the created folder. */ + materialize: (folder: TestFolder) => TestFolder + /** Remove the temporary media tree created by `setup`. */ + cleanup: () => Promise +} + +function copyRecursiveSync(source: string, destination: string): void { + const stats = statSync(source) + if (stats.isDirectory()) { + mkdirSync(destination, { recursive: true }) + for (const item of readdirSync(source)) { + copyRecursiveSync(join(source, item), join(destination, item)) + } + } else { + copyFileSync(source, destination) + } +} + +function createFolderOnDisk(mediaDir: string, folder: TestFolder): TestFolder { + const testMediaFolder = join(mediaDir, folder.folderName) + mkdirSync(testMediaFolder, { recursive: true }) + for (const file of folder.files) { + writeFileSync(join(testMediaFolder, file), '') + } + return { ...folder, path: testMediaFolder } +} + +/** + * Copy the shared `test/media` fixture tree into a fresh per-test temp dir + * and materialize folders. Unlike `@smm/test`'s `setupTestMediaFolders` + * (which uses a shared `os.tmpdir()/smm-test-media`), each test gets an + * isolated tree so parallel `bun test` files don't clobber each other. + */ +export async function setupTestMediaFolders(): Promise { + const root = repoRoot() + const tmpDir = await mkdtemp(join(tmpdir(), 'smm-mcp-media-')) + const mediaDir = join(tmpDir, 'media') + copyRecursiveSync(join(root, 'test', 'media'), mediaDir) + + const materialize = (folder: TestFolder): TestFolder => createFolderOnDisk(mediaDir, folder) + + return { + mediaDir, + materialize, + cleanup: async () => { + await rm(tmpDir, { recursive: true, force: true }) + }, + } +} + +/** Remove everything under `{appDataDir}/metadata` (for clean slates). */ +export async function clearMetadataDir(appDataDir: string): Promise { + await rm(metadataDir(appDataDir), { recursive: true, force: true }) +} diff --git a/test/mcp/lib/useMcpServer.ts b/test/mcp/lib/useMcpServer.ts new file mode 100644 index 00000000..2bca6003 --- /dev/null +++ b/test/mcp/lib/useMcpServer.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll } from 'bun:test' +import { startMcpServer, type McpServerHandle } from './mcpServer' +import { writeUserConfig } from './testSetup' + +export interface McpTestContext { + server: McpServerHandle + url: string + userDataDir: string + appDataDir: string +} + +/** + * Start an isolated MCP server and reset its user config (folders: []). + * Call from `beforeAll`; the returned context is mutated in place. + */ +export async function setupMcpServer(ctx: McpTestContext): Promise { + const server = await startMcpServer() + ctx.server = server + ctx.url = server.url + ctx.userDataDir = server.userDataDir + ctx.appDataDir = server.appDataDir + await writeUserConfig(server.userDataDir, { folders: [] }) +} + +/** Stop the server started by {@link setupMcpServer}. */ +export async function teardownMcpServer(ctx: McpTestContext): Promise { + if (ctx.server) { + await ctx.server.stop() + } +} + +/** + * Register `beforeAll`/`afterAll` hooks that start an isolated MCP server + * with a reset config and tear it down afterwards. Returns a context object + * populated with the server URL / dirs once hooks run. + */ +export function useMcpServer(): McpTestContext { + const ctx: McpTestContext = { + server: undefined as unknown as McpServerHandle, + url: '', + userDataDir: '', + appDataDir: '', + } + beforeAll(async () => { + await setupMcpServer(ctx) + }) + afterAll(async () => { + await teardownMcpServer(ctx) + }) + return ctx +} diff --git a/test/mcp/list-files.test.ts b/test/mcp/list-files.test.ts new file mode 100644 index 00000000..59a4552a --- /dev/null +++ b/test/mcp/list-files.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { join } from 'node:path' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { setupTestMediaFolders, folder1 } from './lib/testSetup' + +describe('MCP Other - ListFilesTool', () => { + const ctx = useMcpServer() + + it('should list files from target folder', async () => { + const media = await setupTestMediaFolders() + try { + const folder = media.materialize(folder1) + + const r = await callTool(ctx.url, 'list-files', { + folderPath: folder.path!, + recursive: false, + filter: undefined, + videoFileOnly: false, + }) + expect(r.isError).toBe(false) + + const expectedFilePaths = folder1.files.map((file) => join(folder.path!, file)) + expect(r.structuredContent!.files).toHaveLength(expectedFilePaths.length) + expect([...r.structuredContent!.files].sort()).toEqual([...expectedFilePaths].sort()) + } finally { + await media.cleanup() + } + }) +}) diff --git a/test/mcp/readme.test.ts b/test/mcp/readme.test.ts new file mode 100644 index 00000000..ba0c7efc --- /dev/null +++ b/test/mcp/readme.test.ts @@ -0,0 +1,23 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { startMcpServer, type McpServerHandle } from './lib/mcpServer' +import { callTool } from './lib/mcpInspectorClient' + +describe('MCP Server - ReadmeTool', () => { + let server: McpServerHandle + + beforeAll(async () => { + server = await startMcpServer() + }) + + afterAll(async () => { + await server.stop() + }) + + it('should return README markdown', async () => { + const r = await callTool(server.url, 'readme') + expect(r.isError).toBe(false) + expect(r.structuredContent).not.toBeNull() + expect(r.structuredContent!.text).toContain('Simple Media Manager (SMM)') + expect(r.structuredContent!.text).toContain('## 核心概念') + }) +}) diff --git a/test/mcp/rename-folder.test.ts b/test/mcp/rename-folder.test.ts new file mode 100644 index 00000000..5363e93f --- /dev/null +++ b/test/mcp/rename-folder.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'bun:test' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { + folder1, + folder2, + metadataCacheFilePath, + seedMediaMetadata, + setupTestMediaFolders, + writeUserConfig, + type TestFolder, +} from './lib/testSetup' +import type { MediaMetadata } from '@smm/core/types' +import { Path } from '@smm/core/path' + +const TV_SHOW_METADATA_TEMPLATE = '天使降临到我身边.metadata.json' + +interface SeededFolder { + path: string + mediaDir: string + cleanup: () => Promise +} + +async function seedRecognizedFolder( + ctx: { userDataDir: string; appDataDir: string }, + folder: TestFolder, + movie?: { database: 'TMDB' | 'TVDB'; id: string; name: string }, +): Promise { + const media = await setupTestMediaFolders() + const created = media.materialize(folder) + await writeUserConfig(ctx.userDataDir, { folders: [created.path!] }) + await seedMediaMetadata(ctx.appDataDir, created, TV_SHOW_METADATA_TEMPLATE, (mm) => { + if (movie) { + mm.type = 'movie-folder' + mm.tvShow = undefined + mm.mediaFiles = created.files.map((file) => ({ + absolutePath: join(created.path!, file), + })) as MediaMetadata['mediaFiles'] + mm.movie = { + database: movie.database, + id: movie.id, + name: movie.name, + } + } + return mm + }) + return { path: created.path!, mediaDir: media.mediaDir, cleanup: () => media.cleanup() } +} + +function readMetadataCache(appDataDir: string, folderPath: string): MediaMetadata | null { + const filePath = metadataCacheFilePath(appDataDir, Path.posix(folderPath)) + if (!existsSync(filePath)) { + return null + } + return JSON.parse(readFileSync(filePath, 'utf-8')) as MediaMetadata +} + +describe('MCP Other - RenameFolderTool', () => { + const ctx = useMcpServer() + + it('TV Show: renames folder and updates metadata cache', async () => { + const seeded = await seedRecognizedFolder(ctx, { ...folder1 }) + try { + const newFolderName = `new-${folder1.folderName}` + const newFolderPath = join(seeded.mediaDir, newFolderName) + + const r = await callTool(ctx.url, 'rename-folder', { + from: seeded.path, + to: newFolderPath, + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc.renamed).toBe(true) + expect(sc.from).toBe(seeded.path) + expect(sc.to).toBe(newFolderPath) + + expect(existsSync(newFolderPath)).toBe(true) + + const mm = readMetadataCache(ctx.appDataDir, newFolderPath) + expect(mm).not.toBeNull() + expect(mm!.tvShow).toBeDefined() + } finally { + await seeded.cleanup() + } + }) + + it('Movie: renames folder and updates metadata cache', async () => { + const seeded = await seedRecognizedFolder( + ctx, + { ...folder2 }, + { + database: 'TMDB', + id: '1311031', + name: folder2.translations?.title?.['en-US'] ?? folder2.mediaName!, + }, + ) + try { + const newFolderName = `new-${folder2.folderName}` + const newFolderPath = join(seeded.mediaDir, newFolderName) + + const r = await callTool(ctx.url, 'rename-folder', { + from: seeded.path, + to: newFolderPath, + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc.renamed).toBe(true) + expect(sc.from).toBe(seeded.path) + expect(sc.to).toBe(newFolderPath) + + expect(existsSync(newFolderPath)).toBe(true) + + const mm = readMetadataCache(ctx.appDataDir, newFolderPath) + expect(mm).not.toBeNull() + expect(mm!.movie).toBeDefined() + } finally { + await seeded.cleanup() + } + }) +}) diff --git a/test/mcp/scrape.test.ts b/test/mcp/scrape.test.ts new file mode 100644 index 00000000..64debb8c --- /dev/null +++ b/test/mcp/scrape.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'bun:test' +import { SCRAPE_JOB_CREATED_MESSAGE } from '@smm/core/types/ai-tools/scrape' +import { callTool } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { + folder2, + seedMediaMetadata, + setupTestMediaFolders, + writeUserConfig, + type TestFolder, +} from './lib/testSetup' + +const TV_SHOW_METADATA_TEMPLATE = '天使降临到我身边.metadata.json' + +/** + * Movie fixture: Core skips thumbnails so this contract test does not + * hang on episode-still downloads. + */ +describe('MCP Other - ScrapeTool', () => { + const ctx = useMcpServer() + + it('scrape starts a job; get-job polls until terminal', async () => { + const media = await setupTestMediaFolders() + try { + const movieFolder: TestFolder = { + ...folder2, + folderName: '哪吒之魔童降世 (2019) {tmdbid=552524}', + files: ['movie.mkv'], + } + const folder = media.materialize(movieFolder) + await writeUserConfig(ctx.userDataDir, { folders: [folder.path!] }) + await seedMediaMetadata(ctx.appDataDir, folder, TV_SHOW_METADATA_TEMPLATE, (mm) => { + mm.type = 'movie-folder' + mm.tvShow = undefined + mm.movie = { + database: 'TMDB', + id: '552524', + name: '哪吒之魔童降世', + } + return mm + }) + + const scrapeResult = await callTool(ctx.url, 'scrape', { + path: folder.path!, + language: 'zh-CN', + }) + expect(scrapeResult.isError).toBe(false) + const sc = scrapeResult.structuredContent! + expect(sc.id).toBeTruthy() + expect(sc.message).toBe(SCRAPE_JOB_CREATED_MESSAGE) + expect(sc.error).toBeUndefined() + + const jobId = sc.id as string + + let job: Record | null = null + const deadline = Date.now() + 90_000 + while (Date.now() < deadline) { + const jobResult = await callTool(ctx.url, 'get-job', { id: jobId }) + const jobSc = jobResult.structuredContent + const status = (jobSc?.job as { status?: string } | undefined)?.status + job = (jobSc?.job as Record) ?? null + if (status === 'succeeded' || status === 'failed') break + await new Promise((r) => setTimeout(r, 1000)) + } + + expect(job).not.toBeNull() + expect(job!.kind).toBe('scrape') + expect(job!.id).toBe(jobId) + expect(job!.status).toBe('succeeded') + const tasks = job!.tasks as { + poster: { status: string } + fanart: { status: string } + thumbnails: { status: string } + nfo: { status: string } + } + expect(tasks.poster.status).toMatch(/completed|skipped/) + expect(tasks.fanart.status).toMatch(/completed|skipped/) + expect(tasks.thumbnails.status).toBe('skipped') + expect(tasks.nfo.status).toMatch(/completed|skipped/) + } finally { + await media.cleanup() + } + }, 150_000) +}) diff --git a/test/mcp/tmdb-tools.test.ts b/test/mcp/tmdb-tools.test.ts new file mode 100644 index 00000000..0a2cbc15 --- /dev/null +++ b/test/mcp/tmdb-tools.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'bun:test' +import type { UserConfig } from '@smm/core/types' +import { callTool, type McpCallResult } from './lib/mcpInspectorClient' +import { useMcpServer } from './lib/useMcpServer' +import { writeUserConfig } from './lib/testSetup' + +const TV_SEARCH_KEYWORD = '天使降临到我身边' +const TV_TMDB_ID = 84666 +const MOVIE_TMDB_ID = 552524 +const TV_TITLE_PATTERN = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i + +/** + * TMDB upstream calls are network-dependent and can be slow / flaky on the + * first request (failover attempts). Retry a bounded number of times. + */ +async function callTmdbTool( + url: string, + toolName: string, + args: Record, + maxAttempts = 3, +): Promise { + let last: McpCallResult | null = null + for (let attempt = 0; attempt < maxAttempts; attempt++) { + last = await callTool(url, toolName, args) + if (!last.isError) { + return last + } + await new Promise((r) => setTimeout(r, 500)) + } + return last! +} + +function applyTmdbUserConfig(): Partial { + const tmdb: Record = {} + const host = (process.env.TMDB_HOST || '').trim() + const apiKey = (process.env.TMDB_API_KEY || '').trim() + const httpProxy = (process.env.TMDB_HTTP_PROXY || '').trim() + if (host) tmdb.host = host + if (apiKey) tmdb.apiKey = apiKey + // Bun's fetch only supports http/https proxies (not socks5). The SMM MCP + // server runs under Bun, so a socks5 TMDB_HTTP_PROXY would fail every + // request; fall back to direct access in that case. + if (httpProxy && /^https?:\/\//i.test(httpProxy)) { + tmdb.httpProxy = httpProxy + } + return { tmdb } +} + +/** + * MCP TMDB tools (`tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show`). + * + * Requires TMDB network access (see `.env.local` for `TMDB_HOST` / + * `TMDB_API_KEY` / `TMDB_HTTP_PROXY`). + */ +describe('MCP Other - TmdbTools', () => { + const ctx = useMcpServer() + + it('tmdb-search returns TV results', async () => { + await writeUserConfig(ctx.userDataDir, applyTmdbUserConfig()) + const r = await callTmdbTool(ctx.url, 'tmdb-search', { + keyword: TV_SEARCH_KEYWORD, + type: 'tv', + language: 'zh-CN', + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect((sc.results as unknown[]).length).toBeGreaterThan(0) + expect(sc.page).toBeGreaterThanOrEqual(1) + + const first = (sc.results as Array<{ id: number; name?: string }>)[0]! + expect(first.id).toBeGreaterThan(0) + expect(first.name).toMatch(TV_TITLE_PATTERN) + }, 180_000) + + it('tmdb-get-tv-show returns series details', async () => { + await writeUserConfig(ctx.userDataDir, applyTmdbUserConfig()) + const r = await callTmdbTool(ctx.url, 'tmdb-get-tv-show', { + id: TV_TMDB_ID, + language: 'zh-CN', + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc.id).toBe(TV_TMDB_ID) + expect(String(sc.name)).toMatch(TV_TITLE_PATTERN) + }, 180_000) + + it('tmdb-get-movie returns movie details', async () => { + await writeUserConfig(ctx.userDataDir, applyTmdbUserConfig()) + const r = await callTmdbTool(ctx.url, 'tmdb-get-movie', { + id: MOVIE_TMDB_ID, + language: 'zh-CN', + }) + expect(r.isError).toBe(false) + const sc = r.structuredContent! + expect(sc.id).toBe(MOVIE_TMDB_ID) + expect(typeof sc.title).toBe('string') + expect(String(sc.title).length).toBeGreaterThan(0) + }, 180_000) +}) diff --git a/test/mcp/tsconfig.json b/test/mcp/tsconfig.json new file mode 100644 index 00000000..fd3ae36c --- /dev/null +++ b/test/mcp/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "paths": { + "@smm/core": ["../../packages/core/path.ts"], + "@smm/core/*": ["../../packages/core/*"], + "@smm/test": ["../../packages/test/src/index.ts"], + "@smm/test/*": ["../../packages/test/*"] + } + }, + "include": ["./**/*.ts"] +} From 42f69c71737a218919de24f11e27e1621a52acb3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 01:06:01 +0800 Subject: [PATCH 100/214] docs(mcp): add README for test/mcp suite --- test/mcp/README.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 test/mcp/README.md diff --git a/test/mcp/README.md b/test/mcp/README.md new file mode 100644 index 00000000..26e7b4df --- /dev/null +++ b/test/mcp/README.md @@ -0,0 +1,84 @@ +# MCP 端到端测试 + +通过 MCP Inspector CLI 直接对 SMM CLI 的 MCP server 进行测试,不依赖浏览器和 WebdriverIO。 + +## 概述 + +传统 e2e 测试通过 wdio 打开浏览器、在 UI 上启动 MCP server,再用自定义的 `McpClient` 调用工具。本目录的测试改为: + +1. 通过 `smm mcp start` 命令启动独立的 MCP server(每个测试文件一个子进程,使用隔离的临时目录和随机端口) +2. 通过 `@modelcontextprotocol/inspector` 的 CLI 模式(`--cli`)直接调用 MCP 工具 +3. 断言工具的 `result.structuredContent` 返回 + +这样测试完全不依赖浏览器和 UI,运行更快、更稳定。 + +## 前置条件 + +- 已安装 Bun(`bun test`) +- 已安装根目录 devDependencies(`@modelcontextprotocol/inspector`) + +```bash +pnpm install +``` + +## 运行测试 + +在项目根目录执行: + +```bash +bun test test/mcp/ +``` + +运行单个测试文件: + +```bash +bun test test/mcp/readme.test.ts +``` + +## 测试列表 + +| 测试文件 | 覆盖的工具 | +|----------|-----------| +| `readme.test.ts` | `readme` | +| `how-to-rename-episode-video-files.test.ts` | `how-to-rename-episode-video-files` | +| `how-to-recognize-episode-video-files.test.ts` | `how-to-recognize-episode-video-files` | +| `get-app-context.test.ts` | `get-app-context` | +| `get-media-folders.test.ts` | `get-media-folders` | +| `is-folder-exist.test.ts` | `is-folder-exist` | +| `list-files.test.ts` | `list-files` | +| `get-media-metadata.test.ts` | `get-media-metadata` | +| `get-episode.test.ts` | `get-episode` | +| `get-episodes.test.ts` | `get-episodes` | +| `rename-folder.test.ts` | `rename-folder` | +| `scrape.test.ts` | `scrape`, `get-job` | +| `tmdb-tools.test.ts` | `tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show` | + +## 架构 + +``` +test/mcp/ +├── lib/ +│ ├── mcpInspectorClient.ts # 通过 bun $ 调用 MCP Inspector CLI,解析 structuredContent +│ ├── mcpServer.ts # spawn `smm mcp start` 子进程,等待就绪,清理 +│ ├── testSetup.ts # 创建测试媒体目录、写 smm.json、写入元数据缓存 +│ └── useMcpServer.ts # 共享的 beforeAll/afterAll 钩子(启动/停止 server) +└── *.test.ts # 各工具测试 +``` + +### 如何工作 + +1. `mcpServer.ts` 用 `bun apps/cli/index.ts mcp start --host 127.0.0.1 --port <随机端口>` 启动子进程, + 并设置隔离的 `USER_DATA_DIR` / `APP_DATA_DIR` / `LOG_DIR` 环境变量(指向临时目录), + 然后通过 `tools/list` 轮询直到 server 就绪。 +2. `mcpInspectorClient.ts` 解析 `@modelcontextprotocol/inspector` 包的 launcher 路径, + 用 `node --cli --server-url /mcp --method tools/call --tool-name --tool-args-json --format json` 调用工具。 + (不用 `npx`,因为 Windows 上 `npx` 会破坏反斜杠路径和 JSON 参数。) +3. `testSetup.ts` 复用 `test/media` 和 `test/templates/mediaMetadatas/` 的夹具, + 按 `metadataCacheFilePath` 规则直接写入元数据缓存,模拟已识别的媒体文件夹。 + +## 注意事项 + +- TMDB 相关测试(`tmdb-tools.test.ts`、`scrape.test.ts`)依赖网络,需要 `TMDB_HOST` / `TMDB_API_KEY` + 环境变量(读取自 `.env.local`)。 +- Bun 的 fetch 不支持 `socks5://` 代理;当 `TMDB_HTTP_PROXY` 为 socks5 时,测试回退为直连。 +- 首次 TMDB 请求可能较慢(failover),`tmdb-tools.test.ts` 已内置 3 次重试。 From 6daf12a4c221f8dbc1190086e94cb7bce4fc946c Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 12:37:58 +0800 Subject: [PATCH 101/214] ci: add e2e:mcp script to run mcp e2e tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 431e7f68..e31f0812 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "test:e2e": "cd apps/e2e && pnpm run test", "e2e:ai": "bun ci/run-e2e-test.ts --spec \"./test/specs/ai/*.e2e.ts\"", "e2e:config": "bun ci/run-e2e-test.ts --spec \"./common/config/*.e2e.ts\" --spec \"./common/httpproxy/*.e2e.ts\"", - "e2e:mcp": "bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", + "e2e:mcp": "bun test test/mcp/", "e2e:media-folder-initialization": "bun ci/run-e2e-test.ts --spec \"./common/tv/ImportTvShowLibrary.e2e.ts\" --spec \"./common/tv/Initialize*.e2e.ts\" --spec \"./common/movie/ImportMovieLibrary.e2e.ts\" --spec \"./common/movie/Initialize*.e2e.ts\"", "e2e:movie": "bun ci/run-e2e-test.ts --spec \"./common/movie/SearchMovie.e2e.ts\" --spec \"./common/movie/Movie-RenameVideoFile.e2e.ts\"", "e2e:music": "bun ci/run-e2e-test.ts --spec \"./common/music/*.e2e.ts\"", From 62a2e7814f6db06338f0df93b11729803dc2d2bf Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 15:01:34 +0800 Subject: [PATCH 102/214] feat(mcp): move MCP lifecycle into Core and add CLI start/stop tests Core now owns start/stop/status with smm.json persistence; CLI wires BunMcpServerPort and `smm mcp start` per the dev design doc. Co-authored-by: Cursor --- apps/cli/src/cli/runCli.ts | 45 ++- apps/cli/src/core/getCore.ts | 2 + apps/cli/src/mcp/BunMcpServerPort.ts | 103 +++++ apps/cli/src/mcp/bunMcpLifecycleManager.ts | 140 ++----- apps/cli/src/mcp/mcpServerManager.ts | 39 +- apps/core/src/Core.ts | 53 +++ apps/core/src/index.ts | 16 + apps/core/src/pipeline/mcpServer.test.ts | 139 +++++++ apps/core/src/pipeline/mcpServer.ts | 87 +++++ apps/core/src/ports/McpServerPort.ts | 25 ++ docs/dev/mcp.md | 362 ++++++++++++++++++ docs/dev/supported-platform.md | 59 +++ test/mcp/README.md | 1 + test/mcp/start-stop-mcp-server-in-cli.test.ts | 198 ++++++++++ 14 files changed, 1127 insertions(+), 142 deletions(-) create mode 100644 apps/cli/src/mcp/BunMcpServerPort.ts create mode 100644 apps/core/src/pipeline/mcpServer.test.ts create mode 100644 apps/core/src/pipeline/mcpServer.ts create mode 100644 apps/core/src/ports/McpServerPort.ts create mode 100644 docs/dev/mcp.md create mode 100644 docs/dev/supported-platform.md create mode 100644 test/mcp/start-stop-mcp-server-in-cli.test.ts diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index a8a19705..5a153ec0 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -502,37 +502,54 @@ export async function runCli(argv: string[] = process.argv): Promise { .command('start') .description('Start the MCP server and keep running until interrupted') .option('--host ', 'MCP server bind host (default: user config mcpHost or 127.0.0.1)') - .option('--port ', 'MCP server port (default: user config mcpPort or 30001)') + .option('-p, --port ', 'MCP server port (default: user config mcpPort or 30001)') .action(async (opts: { host?: string; port?: string }) => { // Lazy imports: pulling in the MCP lifecycle manager (and thus // `@smm/core-routes`) at module load breaks vitest's CLI unit tests // which don't alias `@smm/core/path`. const { getAppDataDir, getLogDir, getUserDataDir } = await import('@/utils/config') - const { getBunMcpLifecycleManager } = await import('@/mcp/mcpServerManager') await mkdir(getUserDataDir(), { recursive: true }) await mkdir(getAppDataDir(), { recursive: true }) await mkdir(getLogDir(), { recursive: true }) - const manager = getBunMcpLifecycleManager() - await manager.start({ - hostname: opts.host, - port: opts.port ? Number(opts.port) : undefined, - }) - const state = manager.getState() + const core = getCore() + const state = await core.startMcpServer( + { + hostname: opts.host, + port: opts.port ? Number(opts.port) : undefined, + }, + { persistUserConfig: true }, + ) if (state.status !== 'running' || !state.url) { throw new Error(state.error ?? 'MCP server failed to start') } - console.log(`MCP server running at ${state.url}`) + console.log( + `MCP server started at ${state.url} using protocol is Streamable HTTP`, + ) // Keep the process alive until interrupted, then stop the server gracefully. await new Promise((resolve) => { - const stop = async () => { - await manager.stop() - resolve() + let stopping = false + const shutdown = async () => { + if (stopping) { + return + } + stopping = true + try { + await core.stopMcpServer({ persistUserConfig: true }) + } finally { + resolve() + } + } + const onSignal = () => { + void shutdown() + } + process.once('SIGINT', onSignal) + process.once('SIGTERM', onSignal) + if (process.platform === 'win32') { + process.once('SIGBREAK', onSignal) } - process.once('SIGINT', stop) - process.once('SIGTERM', stop) }) }) diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index 94050ee0..b25d88d2 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -6,6 +6,7 @@ import { type LoggerPort, } from 'core-app' import { getUserDataDir } from '@/utils/config' +import { getBunMcpServerPort } from '@/mcp/BunMcpServerPort' import { NodejsNetworkPort } from './NodejsNetworkPort' let instance: Core | undefined @@ -25,6 +26,7 @@ export function getCore(options?: GetCoreOptions): Core { appDataDir: userDataDir, userDataDir, discover: new StaticDiscoverAdapter(), + mcpServer: getBunMcpServerPort(), }) } return instance diff --git a/apps/cli/src/mcp/BunMcpServerPort.ts b/apps/cli/src/mcp/BunMcpServerPort.ts new file mode 100644 index 00000000..deb19e08 --- /dev/null +++ b/apps/cli/src/mcp/BunMcpServerPort.ts @@ -0,0 +1,103 @@ +import { + getMcpStreamableHttpHandler, + resetMcpStreamableHttpHandler, +} from "./mcp"; +import { logger } from "../../lib/logger"; +import type { + McpServerPort, + McpServerStartOptions, + McpServerState, +} from "core-app"; +import { + resolveMcpAdvertisedHost, + resolveMcpBindAddress, +} from "@smm/core-routes"; + +const DEFAULT_MCP_HOST = "127.0.0.1"; +const DEFAULT_MCP_PORT = 30001; + +function buildMcpUrl(host: string, port: number): string { + return `http://${host}:${port}/mcp`; +} + +export class BunMcpServerPort implements McpServerPort { + private mcpServer: ReturnType | null = null; + private lastError: string | null = null; + + setLastError(message: string | null): void { + this.lastError = message; + } + + async start(options: McpServerStartOptions): Promise { + if (this.mcpServer) { + this.mcpServer.stop(); + this.mcpServer = null; + } + this.lastError = null; + resetMcpStreamableHttpHandler(); + + const bindHostname = resolveMcpBindAddress(options.hostname); + const handler = await getMcpStreamableHttpHandler(); + this.mcpServer = Bun.serve({ + hostname: bindHostname, + port: options.port, + fetch: handler, + }); + + const advertisedHost = resolveMcpAdvertisedHost( + this.mcpServer.hostname ?? bindHostname, + ); + const listeningPort = this.mcpServer.port ?? options.port; + logger.info( + { + hostname: bindHostname, + advertisedHost, + port: listeningPort, + url: buildMcpUrl(advertisedHost, listeningPort), + }, + "MCP server started", + ); + } + + async stop(): Promise { + if (this.mcpServer) { + this.mcpServer.stop(); + this.mcpServer = null; + } + this.lastError = null; + resetMcpStreamableHttpHandler(); + logger.info("MCP server stopped"); + } + + getState(): McpServerState { + if (this.mcpServer) { + const bindHost = this.mcpServer.hostname ?? DEFAULT_MCP_HOST; + const advertisedHost = resolveMcpAdvertisedHost(bindHost); + const port = this.mcpServer.port ?? DEFAULT_MCP_PORT; + return { + status: "running", + host: advertisedHost, + port, + url: buildMcpUrl(advertisedHost, port), + }; + } + if (this.lastError) { + return { status: "error", error: this.lastError }; + } + return { status: "stopped" }; + } +} + +let bunMcpServerPort: BunMcpServerPort | undefined; + +export function getBunMcpServerPort(): BunMcpServerPort { + if (!bunMcpServerPort) { + bunMcpServerPort = new BunMcpServerPort(); + } + return bunMcpServerPort; +} + +/** @deprecated Use {@link getBunMcpServerPort}.setLastError */ +export function setBunMcpServerError(message: string | null): void { + getBunMcpServerPort().setLastError(message); +} diff --git a/apps/cli/src/mcp/bunMcpLifecycleManager.ts b/apps/cli/src/mcp/bunMcpLifecycleManager.ts index 3dc80cb6..4db423db 100644 --- a/apps/cli/src/mcp/bunMcpLifecycleManager.ts +++ b/apps/cli/src/mcp/bunMcpLifecycleManager.ts @@ -1,124 +1,50 @@ -import { getUserConfig } from "@/utils/config"; -import { - getMcpStreamableHttpHandler, - resetMcpStreamableHttpHandler, -} from "./mcp"; -import { logger } from "../../lib/logger"; -import type { - McpLifecycleManager, - McpServerState, - StartMcpOptions, -} from "@smm/core-routes"; -import { - resolveMcpAdvertisedHost, - resolveMcpBindAddress, -} from "@smm/core-routes"; +import type { McpLifecycleManager } from "@smm/core-routes"; +import { getBunMcpServerPort, setBunMcpServerError } from "./BunMcpServerPort"; -const DEFAULT_MCP_HOST = "127.0.0.1"; -const DEFAULT_MCP_PORT = 30001; +export type { McpServerState } from "core-app"; +export type McpServerStatus = "running" | "stopped" | "error"; -let mcpServer: ReturnType | null = null; -let mcpServerError: string | null = null; +export { getBunMcpServerPort, setBunMcpServerError }; -function buildMcpUrl(host: string, port: number): string { - return `http://${host}:${port}/mcp`; -} - -function getRunningState(): McpServerState { - const server = mcpServer!; - const bindHost = server.hostname ?? DEFAULT_MCP_HOST; - const advertisedHost = resolveMcpAdvertisedHost(bindHost); - const port = server.port ?? DEFAULT_MCP_PORT; +/** + * MCP lifecycle manager for core-routes HTTP handlers and legacy callers. + * Delegates runtime + UserConfig persistence to Core. + */ +export function getBunMcpLifecycleManager(): McpLifecycleManager { return { - status: "running", - host: advertisedHost, - port, - url: buildMcpUrl(advertisedHost, port), + async start(options) { + const { getCore } = await import("@/core/getCore"); + await getCore().startMcpServer( + { hostname: options?.hostname, port: options?.port }, + { persistUserConfig: true }, + ); + }, + async stop() { + const { getCore } = await import("@/core/getCore"); + await getCore().stopMcpServer({ persistUserConfig: true }); + }, + getState() { + return getBunMcpServerPort().getState(); + }, }; } -const bunMcpLifecycleManager: McpLifecycleManager = { - async start(options?: StartMcpOptions): Promise { - const userConfig = await getUserConfig(); - - if (mcpServer) { - mcpServer.stop(); - mcpServer = null; - } - mcpServerError = null; - resetMcpStreamableHttpHandler(); - - const requestedHostname = - options?.hostname ?? userConfig.mcpHost ?? DEFAULT_MCP_HOST; - const bindHostname = resolveMcpBindAddress(requestedHostname); - const port = options?.port ?? userConfig.mcpPort ?? DEFAULT_MCP_PORT; - - const handler = await getMcpStreamableHttpHandler(); - mcpServer = Bun.serve({ - hostname: bindHostname, - port, - fetch: handler, - }); - const advertisedHost = resolveMcpAdvertisedHost(mcpServer.hostname ?? bindHostname); - const listeningPort = mcpServer.port ?? port; - logger.info( - { - hostname: bindHostname, - advertisedHost, - port: listeningPort, - url: buildMcpUrl(advertisedHost, listeningPort), - }, - "MCP server started", - ); - }, - - async stop(): Promise { - if (mcpServer) { - mcpServer.stop(); - mcpServer = null; - } - mcpServerError = null; - resetMcpStreamableHttpHandler(); - logger.info("MCP server stopped"); - }, - - getState(): McpServerState { - if (mcpServer) { - return getRunningState(); - } - if (mcpServerError) { - return { status: "error", error: mcpServerError }; - } - return { status: "stopped" }; - }, -}; - -/** Record a boot-time start failure for {@link getState}. */ -export function setBunMcpServerError(message: string | null): void { - mcpServerError = message; -} - -export function getBunMcpLifecycleManager(): McpLifecycleManager { - return bunMcpLifecycleManager; +/** @deprecated Use {@link getBunMcpLifecycleManager}.getState() via Core */ +export function getMcpServerState() { + return getBunMcpServerPort().getState(); } -/** @deprecated Use {@link getBunMcpLifecycleManager}.getState() */ -export function getMcpServerState(): McpServerState { - return bunMcpLifecycleManager.getState(); -} - -/** @deprecated Use {@link getBunMcpLifecycleManager}.start() */ +/** @deprecated Use Core.startMcpServer */ export async function startMcpServer(options?: { hostname?: string; port?: number; }): Promise { - await bunMcpLifecycleManager.start(options); + const { getCore } = await import("@/core/getCore"); + await getCore().startMcpServer(options, { persistUserConfig: true }); } -/** @deprecated Use {@link getBunMcpLifecycleManager}.stop() */ +/** @deprecated Use Core.stopMcpServer */ export async function stopMcpServer(): Promise { - await bunMcpLifecycleManager.stop(); + const { getCore } = await import("@/core/getCore"); + await getCore().stopMcpServer({ persistUserConfig: true }); } - -export type { McpServerState }; -export type McpServerStatus = McpServerState["status"]; diff --git a/apps/cli/src/mcp/mcpServerManager.ts b/apps/cli/src/mcp/mcpServerManager.ts index 00135ba4..6ee9e754 100644 --- a/apps/cli/src/mcp/mcpServerManager.ts +++ b/apps/cli/src/mcp/mcpServerManager.ts @@ -1,13 +1,6 @@ -import { applyMcpLifecycleFromConfig } from "@smm/core-routes"; -import { getUserConfig } from "@/utils/config"; +import { getCore } from "@/core/getCore"; import { logger } from "../../lib/logger"; -import { - getBunMcpLifecycleManager, - setBunMcpServerError, - getMcpServerState, - startMcpServer, - stopMcpServer, -} from "./bunMcpLifecycleManager"; +import { setBunMcpServerError } from "./BunMcpServerPort"; export type { McpServerState, McpServerStatus } from "./bunMcpLifecycleManager"; export { @@ -15,28 +8,32 @@ export { startMcpServer, stopMcpServer, getBunMcpLifecycleManager, -}; + getBunMcpServerPort, + setBunMcpServerError, +} from "./bunMcpLifecycleManager"; /** * Reads user config and starts or stops the MCP server accordingly. - * Used at CLI startup to honour the persisted enableMcpServer setting. + * Used at CLI HTTP server startup to honour the persisted enableMcpServer setting. + * Does not rewrite smm.json on boot (config already reflects user intent). */ export async function applyMcpConfig(): Promise { + const core = getCore(); try { - await applyMcpLifecycleFromConfig( - getBunMcpLifecycleManager(), - getUserConfig, - { - debug: (obj, msg) => logger.debug(obj, msg), - info: (obj, msg) => logger.info(obj, msg), - warn: (obj, msg) => logger.warn(obj, msg), - error: (obj, msg) => logger.error(obj, msg), - }, + const userConfig = await core.getUserConfig(); + if (!userConfig.enableMcpServer) { + await core.stopMcpServer({ persistUserConfig: false }); + return; + } + + await core.startMcpServer( + { hostname: userConfig.mcpHost, port: userConfig.mcpPort }, + { persistUserConfig: false }, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); setBunMcpServerError(message); - const userConfig = await getUserConfig().catch(() => null); + const userConfig = await core.getUserConfig().catch(() => null); logger.error( { err, diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 253ef4f4..aacfb4a6 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -15,6 +15,14 @@ import type { FsPort } from "./ports/FsPort"; import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; import type { DiscoverPort } from "./ports/DiscoverPort"; +import type { McpServerPort, McpServerState } from "./ports/McpServerPort"; +import { + getMcpServerStatusWithConfig, + startMcpServerWithConfig, + stopMcpServerWithConfig, + type McpServerOperationOptions, + type StartMcpServerOptions, +} from "./pipeline/mcpServer"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; @@ -79,6 +87,8 @@ export interface CoreOptions { userDataDir?: string; /** Discover hosts for TMDB/TVDB failover. */ discover?: DiscoverPort; + /** MCP HTTP runtime (injected by CLI / OHOS host). */ + mcpServer?: McpServerPort; } export interface ImportFolderHandle { @@ -105,6 +115,7 @@ export class Core { private readonly userDataDir: string; private readonly userConfig: UserConfig; private readonly discover?: DiscoverPort; + private readonly mcpServer?: McpServerPort; constructor(options: CoreOptions) { this.fs = options.fs; @@ -116,6 +127,48 @@ export class Core { this.userDataDir = options.userDataDir ?? options.appDataDir; this.userConfig = new UserConfig(this.fs, this.appDataDir); this.discover = options.discover; + this.mcpServer = options.mcpServer; + } + + private requireMcpServer(): McpServerPort { + if (!this.mcpServer) { + throw new Error("MCP server port is not configured"); + } + return this.mcpServer; + } + + /** Starts the MCP HTTP server and, by default, persists MCP fields in smm.json. */ + async startMcpServer( + options?: StartMcpServerOptions, + operation?: McpServerOperationOptions, + ): Promise { + return startMcpServerWithConfig( + this.requireMcpServer(), + this.userConfig, + options, + operation, + ); + } + + /** Stops the MCP HTTP server and, by default, sets enableMcpServer to false. */ + async stopMcpServer(operation?: McpServerOperationOptions): Promise { + return stopMcpServerWithConfig(this.requireMcpServer(), this.userConfig, operation); + } + + /** Returns runtime MCP state without reconciling smm.json. */ + getMcpServerState(): McpServerState { + return this.mcpServer?.getState() ?? { status: "stopped" }; + } + + /** + * Returns runtime MCP state. When the server is not running but + * enableMcpServer is true, Core corrects smm.json to false. + */ + async getMcpServerStatus(): Promise { + if (!this.mcpServer) { + return { status: "stopped" }; + } + return getMcpServerStatusWithConfig(this.mcpServer, this.userConfig); } /** Starts the import pipeline in the background; returns a job handle immediately. */ diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index a1881f5c..6f2c4b29 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -19,6 +19,22 @@ export type { MediaDatabaseEntry, ReverseProxyEntry, } from "./ports/DiscoverPort"; +export type { + McpServerPort, + McpServerState, + McpServerStartOptions, + McpServerStatus, +} from "./ports/McpServerPort"; +export { + DEFAULT_MCP_HOST, + DEFAULT_MCP_PORT, + getMcpServerStatusWithConfig, + resolveMcpStartOptions, + startMcpServerWithConfig, + stopMcpServerWithConfig, + type McpServerOperationOptions, + type StartMcpServerOptions, +} from "./pipeline/mcpServer"; export { NodejsFsAdapter } from "./adapters/node/NodejsFsAdapter"; export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/network/NetworkFsAdapter"; export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; diff --git a/apps/core/src/pipeline/mcpServer.test.ts b/apps/core/src/pipeline/mcpServer.test.ts new file mode 100644 index 00000000..3b5163e6 --- /dev/null +++ b/apps/core/src/pipeline/mcpServer.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import type { McpServerPort, McpServerState } from "../ports/McpServerPort"; +import { UserConfig } from "./userConfig"; +import { userConfigPath } from "./paths"; +import { + getMcpServerStatusWithConfig, + startMcpServerWithConfig, + stopMcpServerWithConfig, +} from "./mcpServer"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const value = files.get(path); + if (value === undefined) throw new Error("ENOENT: " + path); + return value; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + }; +} + +function createMockPort(initial: McpServerState = { status: "stopped" }): McpServerPort & { + start: ReturnType; + stop: ReturnType; +} { + let state = { ...initial }; + return { + start: vi.fn(async ({ hostname, port }) => { + state = { + status: "running", + host: hostname, + port, + url: `http://${hostname}:${port}/mcp`, + }; + }), + stop: vi.fn(async () => { + state = { status: "stopped" }; + }), + getState: () => ({ ...state }), + }; +} + +describe("mcpServer pipeline", () => { + it("startMcpServerWithConfig persists enableMcpServer and host/port on success", async () => { + const fs = inMemoryFs(); + const appDataDir = "/data"; + const userConfig = new UserConfig(fs, appDataDir); + const port = createMockPort(); + + const state = await startMcpServerWithConfig( + port, + userConfig, + { hostname: "0.0.0.0", port: 8080 }, + { persistUserConfig: true }, + ); + + expect(state.status).toBe("running"); + expect(state.url).toBe("http://0.0.0.0:8080/mcp"); + const saved = JSON.parse( + (await fs.readTextFile(userConfigPath(appDataDir))) as string, + ); + expect(saved.enableMcpServer).toBe(true); + expect(saved.mcpHost).toBe("0.0.0.0"); + expect(saved.mcpPort).toBe(8080); + }); + + it("startMcpServerWithConfig does not persist when start fails", async () => { + const fs = inMemoryFs(); + const appDataDir = "/data"; + const userConfig = new UserConfig(fs, appDataDir); + const port = createMockPort(); + port.start.mockRejectedValueOnce(new Error("port in use")); + + await expect( + startMcpServerWithConfig(port, userConfig, {}, { persistUserConfig: true }), + ).rejects.toThrow("port in use"); + + expect(await fs.exists(userConfigPath(appDataDir))).toBe(false); + }); + + it("startMcpServerWithConfig skips config write when persistUserConfig is false", async () => { + const fs = inMemoryFs(); + const appDataDir = "/data"; + const userConfig = new UserConfig(fs, appDataDir); + const port = createMockPort(); + + await startMcpServerWithConfig(port, userConfig, {}, { persistUserConfig: false }); + + expect(await fs.exists(userConfigPath(appDataDir))).toBe(false); + }); + + it("stopMcpServerWithConfig sets enableMcpServer to false", async () => { + const fs = inMemoryFs(); + const appDataDir = "/data"; + const configPath = userConfigPath(appDataDir); + await fs.writeTextFile( + configPath, + JSON.stringify({ folders: [], enableMcpServer: true, mcpHost: "127.0.0.1", mcpPort: 30001 }), + ); + const userConfig = new UserConfig(fs, appDataDir); + const port = createMockPort({ status: "running", host: "127.0.0.1", port: 30001 }); + + const state = await stopMcpServerWithConfig(port, userConfig, { persistUserConfig: true }); + + expect(state.status).toBe("stopped"); + const saved = JSON.parse((await fs.readTextFile(configPath)) as string); + expect(saved.enableMcpServer).toBe(false); + }); + + it("getMcpServerStatusWithConfig corrects enableMcpServer when server is stopped", async () => { + const fs = inMemoryFs(); + const appDataDir = "/data"; + const configPath = userConfigPath(appDataDir); + await fs.writeTextFile( + configPath, + JSON.stringify({ folders: [], enableMcpServer: true }), + ); + const userConfig = new UserConfig(fs, appDataDir); + const port = createMockPort({ status: "stopped" }); + + const state = await getMcpServerStatusWithConfig(port, userConfig); + + expect(state.status).toBe("stopped"); + const saved = JSON.parse((await fs.readTextFile(configPath)) as string); + expect(saved.enableMcpServer).toBe(false); + }); +}); diff --git a/apps/core/src/pipeline/mcpServer.ts b/apps/core/src/pipeline/mcpServer.ts new file mode 100644 index 00000000..02276944 --- /dev/null +++ b/apps/core/src/pipeline/mcpServer.ts @@ -0,0 +1,87 @@ +import type { UserConfig as UserConfigData } from "@smm/core"; +import type { McpServerPort, McpServerState } from "../ports/McpServerPort"; +import type { UserConfig } from "./userConfig"; + +export const DEFAULT_MCP_HOST = "127.0.0.1"; +export const DEFAULT_MCP_PORT = 30001; + +export interface StartMcpServerOptions { + hostname?: string; + port?: number; +} + +export interface McpServerOperationOptions { + /** When false, runtime is updated but smm.json MCP fields are not written (boot path). */ + persistUserConfig?: boolean; +} + +export function resolveMcpStartOptions( + config: UserConfigData, + options?: StartMcpServerOptions, +): { hostname: string; port: number } { + return { + hostname: options?.hostname ?? config.mcpHost ?? DEFAULT_MCP_HOST, + port: options?.port ?? config.mcpPort ?? DEFAULT_MCP_PORT, + }; +} + +export async function startMcpServerWithConfig( + mcpServer: McpServerPort, + userConfig: UserConfig, + options?: StartMcpServerOptions, + operation?: McpServerOperationOptions, +): Promise { + const config = await userConfig.read(); + const { hostname, port } = resolveMcpStartOptions(config, options); + + await mcpServer.start({ hostname, port }); + + if (operation?.persistUserConfig !== false) { + await userConfig.update((current) => ({ + ...current, + enableMcpServer: true, + mcpHost: hostname, + mcpPort: port, + })); + } + + return mcpServer.getState(); +} + +export async function stopMcpServerWithConfig( + mcpServer: McpServerPort, + userConfig: UserConfig, + operation?: McpServerOperationOptions, +): Promise { + try { + await mcpServer.stop(); + } finally { + if (operation?.persistUserConfig !== false) { + await userConfig.update((current) => ({ + ...current, + enableMcpServer: false, + })); + } + } + + return mcpServer.getState(); +} + +export async function getMcpServerStatusWithConfig( + mcpServer: McpServerPort, + userConfig: UserConfig, +): Promise { + const state = mcpServer.getState(); + + if (state.status !== "running") { + const config = await userConfig.read(); + if (config.enableMcpServer) { + await userConfig.update((current) => ({ + ...current, + enableMcpServer: false, + })); + } + } + + return state; +} diff --git a/apps/core/src/ports/McpServerPort.ts b/apps/core/src/ports/McpServerPort.ts new file mode 100644 index 00000000..a9d9960b --- /dev/null +++ b/apps/core/src/ports/McpServerPort.ts @@ -0,0 +1,25 @@ +export type McpServerStatus = "running" | "stopped" | "error"; + +export interface McpServerState { + status: McpServerStatus; + host?: string; + port?: number; + /** Full MCP client URL (includes /mcp path). */ + url?: string; + error?: string; +} + +export interface McpServerStartOptions { + hostname: string; + port: number; +} + +/** + * Host-specific MCP HTTP runtime. Core owns config persistence; the port + * only starts/stops the listener and reports runtime state. + */ +export interface McpServerPort { + start(options: McpServerStartOptions): Promise; + stop(): Promise; + getState(): McpServerState; +} diff --git a/docs/dev/mcp.md b/docs/dev/mcp.md new file mode 100644 index 00000000..ce33bee4 --- /dev/null +++ b/docs/dev/mcp.md @@ -0,0 +1,362 @@ +# MCP Server + +**Supported Platform** MCP tools, Web UI, ohos +**Status** draft + +This document describes the **future architecture** for turning the MCP server on and off (not the current implementation). + +## Layering + +| Layer | Responsibility | +|-------|----------------| +| **UI** | TanStack Query hooks; display state; trigger start/stop mutations (**does not** write `enableMcpServer` directly) | +| **Server** | Thin Hono routes; forward to Core | +| **Core** | `getMcpServerStatus` / `startMcpServer` / `stopMcpServer`; start/stop MCP and **persist** MCP fields in `UserConfig` | + +HTTP APIs follow project RPC naming and the `{ data, error }` response shape. + +| Method | Endpoint | Core | +|--------|----------|------| +| `GET` | `/api/get-mcp-server-status` | `getMcpServerStatus` | +| `POST` | `/api/start-mcp-server` | `startMcpServer` | +| `POST` | `/api/stop-mcp-server` | `stopMcpServer` | + +`data` shape (`McpServerState`): + +```json +{ + "status": "running", + "host": "127.0.0.1", + "port": 30001, + "url": "http://127.0.0.1:30001/mcp" +} +``` + +`status` is one of: `running` | `stopped` | `error` (with `error` carrying the failure reason). + +UI hooks: + +| Hook | Purpose | +|------|---------| +| `useMcpServerStatusQuery` | Fetch runtime status on mount and cache it | +| `useStartMcpServerMutation` | Start the MCP server | +| `useStopMcpServerMutation` | Stop the MCP server | + +User config (`smm.json`, written by **Core** on start/stop): + +| Field | Description | +|-------|-------------| +| `enableMcpServer` | Whether MCP is enabled | +| `mcpHost` | Bind address; default `127.0.0.1` | +| `mcpPort` | Port; default `30001` | + +`get-mcp-server-status` returns **runtime state**. Core keeps MCP fields in `UserConfig` in sync after a successful start/stop. On start failure, Core **does not** set `enableMcpServer` to `true`. + +## MCP tool + +### Start MCP Server + +```bash +$ smm mcp start +MCP server started at http://127.0.0.1:30010 using protocol is Streamable HTTP + +# Or +$ smm mcp start -p 8080 --host 0.0.0.0 +MCP server started at http://0.0.0.0:8080 using protocol is Streamable HTTP +``` + +```mermaid +sequenceDiagram + participant CLI + participant Core + + CLI->>CLI: smm mcp start + CLI->>Core: startMcpServer() + CLI->>Core: getMcpServerStatus() + CLI->>CLI: print message +``` + +### Stop MCP Server + +Press Ctrl+C to stop the `smm` CLI process. + +```mermaid +sequenceDiagram + participant CLI + participant Core + + CLI->>CLI: receive CTRL+C + CLI->>Core: stopMcpServer() + CLI->>CLI: exit +``` + +## Web UI + +### Turn on/off MCP server + +#### Query status (initial load / polling) + +```mermaid +sequenceDiagram + participant UI + participant Server + participant Core + + UI->>UI: useMcpServerStatusQuery + UI->>Server: GET /api/get-mcp-server-status + Server->>Core: getMcpServerStatus() + Core-->>Server: McpServerState + Server-->>UI: { data: { status: "stopped" }, error: null } + UI->>UI: cache query data; drive status-bar toggle and address display +``` + +#### Start MCP Server + +Entry points: status-bar `McpIndicator` toggle OFF → ON, or General Settings checkbox on Save. + +```mermaid +sequenceDiagram + participant User + participant UI + participant Server + participant Core + participant Config as smm.json + + User->>UI: toggle ON + UI->>UI: useStartMcpServerMutation + UI->>Server: POST /api/start-mcp-server { host?, port? } + Server->>Core: startMcpServer(options) + Core->>Core: stop existing instance if any; create Streamable HTTP handler; listen on host:port + Core->>Config: write enableMcpServer: true; optionally update mcpHost / mcpPort + Core-->>Server: McpServerState { status: "running", host, port, url } + Server-->>UI: { data: { status: "running", ... }, error: null } + UI->>UI: update useMcpServerStatusQuery cache; invalidate userConfig + + alt start fails + Core->>Core: do not write enableMcpServer: true + Core-->>Server: McpServerState { status: "error", error } + Server-->>UI: { data: { status: "error", error }, error: "Error Reason: ..." } + UI->>User: toast error message + end +``` + +The UI only calls the start API; Core atomically starts the server and persists config. On failure, `enableMcpServer` is not updated. + +#### Stop MCP Server + +Entry points: status-bar toggle ON → OFF, or General Settings checkbox cleared on Save. + +```mermaid +sequenceDiagram + participant User + participant UI + participant Server + participant Core + participant Config as smm.json + + User->>UI: toggle OFF + UI->>UI: useStopMcpServerMutation + UI->>Server: POST /api/stop-mcp-server + Server->>Core: stopMcpServer() + Core->>Core: stop listener; release handler + Core->>Config: write enableMcpServer: false + Core-->>Server: McpServerState { status: "stopped" } + Server-->>UI: { data: { status: "stopped" }, error: null } + UI->>UI: update useMcpServerStatusQuery cache; invalidate userConfig + + alt stop fails + Note over Core,Config: still attempt to write enableMcpServer: false + Core-->>Server: { status: "error", error } + Server-->>UI: { data, error: "Error Reason: ..." } + UI->>UI: log error; invalidate userConfig to fetch Core-written state + end +``` + +The UI only calls the stop API; Core stops the server and persists `enableMcpServer: false`. + +#### App startup (auto start/stop from config) + +```mermaid +sequenceDiagram + participant Server + participant Core + participant Config as smm.json + + Server->>Server: HTTP server listen complete + Server->>Core: applyMcpLifecycleFromConfig() + Core->>Config: read enableMcpServer, mcpHost, mcpPort + + alt enableMcpServer === true + Core->>Core: startMcpServer({ host: mcpHost, port: mcpPort }) + Note over Core,Config: on success do not rewrite config; on failure do not set enableMcpServer: true + Core-->>Server: running | error + else enableMcpServer === false + Core->>Core: stopMcpServer() + Core-->>Server: stopped + end +``` + +On boot failure, `getMcpServerStatus` returns `status: "error"`. Core may sync `enableMcpServer` to `false` during boot or inside `getMcpServerStatus` so the UI sees a consistent state on first query. + +#### Initial load: align config with runtime + +```mermaid +sequenceDiagram + participant UI + participant Server + participant Core + participant Config as smm.json + + UI->>UI: useMcpServerStatusQuery + useConfig (wait for both) + UI->>Server: GET /api/get-mcp-server-status + Server->>Core: getMcpServerStatus() + Core->>Config: if enableMcpServer is true but process not running, correct to false + Core-->>Server: McpServerState + Server-->>UI: { data, error: null } + UI->>UI: invalidate userConfig (fetch Core-corrected config) + + alt was inconsistent and Core corrected + UI->>UI: toast warning or error (notify only; do not write config) + end +``` + +Config correction is done by **Core**; the UI only shows a toast and never calls `setAndSaveUserConfig` for MCP fields. + +#### General Settings save + +MCP changes (toggle, host, port) are **not** written to `smm.json` from the UI; they go through Core APIs: + +```mermaid +sequenceDiagram + participant User + participant UI + participant Server + participant Core + participant Config as smm.json + + User->>UI: Save (includes MCP field changes) + UI->>UI: setAndSaveUserConfig (non-MCP fields only, e.g. language, telemetry) + + alt OFF to ON + UI->>Server: POST /api/start-mcp-server { host, port } + Server->>Core: startMcpServer(options) + Core->>Config: enableMcpServer: true; mcpHost; mcpPort + else ON to OFF + UI->>Server: POST /api/stop-mcp-server + Server->>Core: stopMcpServer() + Core->>Config: enableMcpServer: false + else stays ON and host/port changed + UI->>Server: POST /api/stop-mcp-server + Server->>Core: stopMcpServer() + UI->>Server: POST /api/start-mcp-server { host, port } + Server->>Core: startMcpServer(options) + Core->>Config: update mcpHost / mcpPort + end + + Core-->>UI: McpServerState (wrapped by Server as { data, error }) + UI->>UI: invalidate useMcpServerStatusQuery + userConfig + + alt start fails + Note over UI: do not roll back form; useMcpServerStatusQuery shows error + end +``` + +#### Overview (one full on/off cycle) + +```mermaid +sequenceDiagram + participant UI + participant Server + participant Core + participant Config as smm.json + + UI->>UI: useMcpServerStatusQuery + UI->>Server: GET /api/get-mcp-server-status + Server->>Core: getMcpServerStatus() + Core-->>Server: { status: "stopped" } + Server-->>UI: { data: { status: "stopped" }, error: null } + + Note over UI,Core: user turns on + + UI->>UI: useStartMcpServerMutation + UI->>Server: POST /api/start-mcp-server + Server->>Core: startMcpServer() + Core->>Config: enableMcpServer: true + Core-->>Server: { status: "running", ... } + Server-->>UI: { data: { status: "running", ... }, error: null } + + Note over UI,Core: user turns off + + UI->>UI: useStopMcpServerMutation + UI->>Server: POST /api/stop-mcp-server + Server->>Core: stopMcpServer() + Core->>Config: enableMcpServer: false + Core-->>Server: { status: "stopped" } + Server-->>UI: { data: { status: "stopped" }, error: null } +``` + +--- + +## HarmonyOS (ohos) + +On HarmonyOS, the Web UI runs in the Electron renderer; HTTP routes are served by the **Electron main process** (Node.js side with Core and core-routes). The UI talks to the main process instead of a separate `apps/cli` Hono server. + +### Start MCP Server + +```mermaid +sequenceDiagram + participant User + participant UI + participant Electron as Electron Main Process + participant Core + participant Config as smm.json + + User->>UI: toggle ON + UI->>UI: useStartMcpServerMutation + UI->>Electron: POST /api/start-mcp-server { host?, port? } + Electron->>Core: startMcpServer(options) + Core->>Core: stop existing instance if any; create Streamable HTTP handler; listen on host:port + Core->>Config: write enableMcpServer: true; optionally update mcpHost / mcpPort + Core-->>Electron: McpServerState { status: "running", host, port, url } + Electron-->>UI: { data: { status: "running", ... }, error: null } + UI->>UI: update useMcpServerStatusQuery cache; invalidate userConfig + + alt start fails + Core->>Core: do not write enableMcpServer: true + Core-->>Electron: McpServerState { status: "error", error } + Electron-->>UI: { data: { status: "error", error }, error: "Error Reason: ..." } + UI->>User: toast error message + end +``` + +### Stop MCP Server + +```mermaid +sequenceDiagram + participant User + participant UI + participant Electron as Electron Main Process + participant Core + participant Config as smm.json + + User->>UI: toggle OFF + UI->>UI: useStopMcpServerMutation + UI->>Electron: POST /api/stop-mcp-server + Electron->>Core: stopMcpServer() + Core->>Core: stop listener; release handler + Core->>Config: write enableMcpServer: false + Core-->>Electron: McpServerState { status: "stopped" } + Electron-->>UI: { data: { status: "stopped" }, error: null } + UI->>UI: update useMcpServerStatusQuery cache; invalidate userConfig + + alt stop fails + Note over Core,Config: still attempt to write enableMcpServer: false + Core-->>Electron: { status: "error", error } + Electron-->>UI: { data, error: "Error Reason: ..." } + UI->>UI: log error; invalidate userConfig to fetch Core-written state + end +``` + + +## References +[Supported Platform](./supported-platform.md) \ No newline at end of file diff --git a/docs/dev/supported-platform.md b/docs/dev/supported-platform.md new file mode 100644 index 00000000..bf2186e6 --- /dev/null +++ b/docs/dev/supported-platform.md @@ -0,0 +1,59 @@ +# Supported Platform + +**CLI** + +User use SMM as command line interface which implemented in apps/cli. + +For example +``` +smm --help +smm list +smm tmdb search "Batman" +``` + +NOTE: the binary executable smm works as cli, web server and mcp server depends on the command line arguments. + +The term "Server", "HTTP Server", "CLI" both refer to code in apps/cli. + +**Web UI** + +User operates in web UI which implemented in apps/ui. + +The web server is provided in cli as well: +``` +# run smm without any arguments +smm +Server is listening at http://localhost:8080 +``` + +**HarmonyOS/ohos** + +User operates in HarmonyOS app, which is a Electron desktop application ported to HarmonyOS. + +See https://gitcode.com/openharmony-sig/electron. + +``` +HarmonyOS App + |-- Electron + |-- Main Process (node.js side) + |-- core (built from apps/core) + |-- core-routes (built from packages/core-routes) + |-- UI (browser side, built from apps/ui) + |-- ArkTS +``` + +**AI tool** + +User operates in built-in AI Agent chat window. +AI Agent call tools to perform SMM functions. + +**MCP tool** + +User operates in external AI Agent(such as claude code, opencode, Cherry Studio). + +External AI Agent talk to SMM via MCP using Streamable HTTP protocol. + +User starts MCP server by: + +1. Start web server and turn on MCP server using web UI. +2. Start MCP server using CLI \ No newline at end of file diff --git a/test/mcp/README.md b/test/mcp/README.md index 26e7b4df..6bdd9e40 100644 --- a/test/mcp/README.md +++ b/test/mcp/README.md @@ -52,6 +52,7 @@ bun test test/mcp/readme.test.ts | `rename-folder.test.ts` | `rename-folder` | | `scrape.test.ts` | `scrape`, `get-job` | | `tmdb-tools.test.ts` | `tmdb-search`, `tmdb-get-movie`, `tmdb-get-tv-show` | +| `start-stop-mcp-server-in-cli.test.ts` | `smm mcp start` / stop (SIGINT on Unix) | ## 架构 diff --git a/test/mcp/start-stop-mcp-server-in-cli.test.ts b/test/mcp/start-stop-mcp-server-in-cli.test.ts new file mode 100644 index 00000000..7df74a25 --- /dev/null +++ b/test/mcp/start-stop-mcp-server-in-cli.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { spawn, type ChildProcess } from 'node:child_process' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { UserConfig } from '@smm/core/types' +import { listTools } from './lib/mcpInspectorClient' +import { findFreePort, repoRoot } from './lib/mcpServer' + +const READY_TIMEOUT_MS = 30_000 +const STOP_TIMEOUT_MS = 10_000 + +interface CliMcpProcess { + child: ChildProcess + baseDir: string + userDataDir: string + host: string + port: number + url: string + stdout: string + cleanup: () => Promise +} + +async function readUserConfig(userDataDir: string): Promise { + const raw = await readFile(join(userDataDir, 'smm.json'), 'utf-8') + return JSON.parse(raw) as UserConfig +} + +async function waitForMcpReady( + child: ChildProcess, + url: string, + getStdout: () => string, + getStderr: () => string, +): Promise { + const deadline = Date.now() + READY_TIMEOUT_MS + while (Date.now() < deadline) { + const stdout = getStdout() + const stderr = getStderr() + if (child.exitCode !== null) { + throw new Error( + `smm mcp start exited early (code ${child.exitCode}): ${stderr.slice(0, 2000)}`, + ) + } + if (stdout.includes('MCP server started at')) { + try { + const tools = await listTools(url) + if (tools.length > 0) { + return + } + } catch { + // server not ready yet + } + } + await new Promise((r) => setTimeout(r, 300)) + } + throw new Error( + `smm mcp start did not become ready in ${READY_TIMEOUT_MS}ms: stdout=${getStdout().slice(-1000)} stderr=${getStderr().slice(-1000)}`, + ) +} + +/** + * Spawn `bun apps/cli/index.ts mcp start` with an isolated user data dir. + */ +async function spawnMcpStart(options?: { + host?: string + port?: number +}): Promise { + const baseDir = await mkdtemp(join(tmpdir(), 'smm-mcp-cli-')) + const userDataDir = join(baseDir, 'user-data') + const logDir = join(baseDir, 'logs') + const host = options?.host ?? '127.0.0.1' + const port = options?.port ?? (await findFreePort()) + + const root = repoRoot() + const cliEntry = join(root, 'apps', 'cli', 'index.ts') + + let stdout = '' + let stderr = '' + + const child = spawn( + process.execPath, + [cliEntry, 'mcp', 'start', '--host', host, '-p', String(port)], + { + cwd: root, + env: { + ...process.env, + USER_DATA_DIR: userDataDir, + APP_DATA_DIR: userDataDir, + LOG_DIR: logDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + + const url = `http://${host}:${port}/mcp` + await waitForMcpReady(child, url, () => stdout, () => stderr) + + return { + child, + baseDir, + userDataDir, + host, + port, + url, + stdout, + cleanup: async () => { + if (child.exitCode === null) { + child.kill() + await Promise.race([ + new Promise((resolve) => child.once('exit', resolve)), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]) + } + await rm(baseDir, { recursive: true, force: true }) + }, + } +} + +async function stopMcpProcess(proc: CliMcpProcess): Promise { + if (proc.child.exitCode !== null) { + return proc.child.exitCode + } + proc.child.kill('SIGINT') + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('timed out waiting for smm mcp start to exit after SIGINT')), + STOP_TIMEOUT_MS, + ) + proc.child.once('exit', (code) => { + clearTimeout(timer) + resolve(code) + }) + }) +} + +describe('CLI smm mcp start/stop', () => { + let proc: CliMcpProcess | undefined + + afterEach(async () => { + if (proc) { + await proc.cleanup() + proc = undefined + } + }) + + it('starts the MCP server, prints the startup message, and persists enableMcpServer', async () => { + proc = await spawnMcpStart() + + expect(proc.stdout).toContain(`MCP server started at ${proc.url}`) + expect(proc.stdout).toContain('using protocol is Streamable HTTP') + + const config = await readUserConfig(proc.userDataDir) + expect(config.enableMcpServer).toBe(true) + expect(config.mcpHost).toBe('127.0.0.1') + expect(config.mcpPort).toBe(proc.port) + + const tools = await listTools(proc.url) + expect(tools).toContain('readme') + }) + + it('persists custom --host and -p into smm.json', async () => { + const port = await findFreePort() + proc = await spawnMcpStart({ host: '127.0.0.1', port }) + + expect(proc.stdout).toContain(`http://127.0.0.1:${port}/mcp`) + + const config = await readUserConfig(proc.userDataDir) + expect(config.mcpHost).toBe('127.0.0.1') + expect(config.mcpPort).toBe(port) + }) + + it.skipIf(process.platform === 'win32')( + 'sets enableMcpServer to false after SIGINT (graceful stop)', + async () => { + proc = await spawnMcpStart() + + const runningConfig = await readUserConfig(proc.userDataDir) + expect(runningConfig.enableMcpServer).toBe(true) + + await stopMcpProcess(proc) + + const stoppedConfig = await readUserConfig(proc.userDataDir) + expect(stoppedConfig.enableMcpServer).toBe(false) + + proc.cleanup = async () => { + await rm(proc!.baseDir, { recursive: true, force: true }) + } + }, + ) +}) From 8d4b1953ed193e2bb189071b61873dfbc8d54d88 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 17:19:24 +0800 Subject: [PATCH 103/214] feat(mcp): align Web UI with Core MCP APIs and honor local e2e ports Route MCP start/stop/status through Core-backed RPC endpoints so the UI no longer writes enableMcpServer itself, and load UI_PORT/CLI_PORT from .env.local in the e2e runner to avoid default port conflicts. Co-authored-by: Cursor --- apps/cli/src/coreRoutesPort.test.ts | 30 +++++ apps/cli/src/coreRoutesPort.ts | 31 ++++++ apps/cli/src/coreRoutesServer.ts | 5 +- apps/cli/src/route/Mcp.ts | 104 +++++++++++++++++- apps/cli/tasks/HelloTask.ts | 3 +- apps/e2e/test/lib/ui-page-url.test.ts | 21 +++- apps/e2e/test/lib/ui-page-url.ts | 25 ++++- apps/ui/src/api/mcp.ts | 57 ++++++---- apps/ui/src/components/mcp/McpIndicator.tsx | 82 ++++++-------- .../ui/settings/GeneralSettings.tsx | 29 ++--- apps/ui/src/hooks/useMcpServerStatus.ts | 53 ++++++--- apps/ui/vite.config.ts | 19 +++- ci/load-env-local.test.ts | 35 ++++++ ci/load-env-local.ts | 64 +++++++++++ ci/read-ui-dev-port.test.ts | 48 +++++++- ci/read-ui-dev-port.ts | 26 ++++- ci/run-e2e-test-lib.ts | 19 ++++ ci/run-e2e-test.test.ts | 36 ++++++ ci/run-e2e-test.ts | 15 +++ ci/wait-for-e2e-ready.ts | 23 +++- package.json | 3 +- 21 files changed, 608 insertions(+), 120 deletions(-) create mode 100644 apps/cli/src/coreRoutesPort.test.ts create mode 100644 apps/cli/src/coreRoutesPort.ts create mode 100644 ci/load-env-local.test.ts create mode 100644 ci/load-env-local.ts diff --git a/apps/cli/src/coreRoutesPort.test.ts b/apps/cli/src/coreRoutesPort.test.ts new file mode 100644 index 00000000..9e6091f4 --- /dev/null +++ b/apps/cli/src/coreRoutesPort.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_CORE_ROUTES_PORT, resolveCoreRoutesPort } from './coreRoutesPort' + +describe('resolveCoreRoutesPort', () => { + it('defaults to 3001 when no env is set', () => { + expect(resolveCoreRoutesPort({})).toBe(DEFAULT_CORE_ROUTES_PORT) + expect(DEFAULT_CORE_ROUTES_PORT).toBe(3001) + }) + + it('uses CLI_PORT when set', () => { + expect(resolveCoreRoutesPort({ CLI_PORT: '3002' })).toBe(3002) + }) + + it('falls back to CORE_ROUTES_PORT when CLI_PORT is unset', () => { + expect(resolveCoreRoutesPort({ CORE_ROUTES_PORT: '4001' })).toBe(4001) + }) + + it('prefers CLI_PORT over CORE_ROUTES_PORT', () => { + expect( + resolveCoreRoutesPort({ CLI_PORT: '3002', CORE_ROUTES_PORT: '4001' }), + ).toBe(3002) + }) + + it('falls back to default for invalid values', () => { + expect(resolveCoreRoutesPort({ CLI_PORT: 'abc' })).toBe(DEFAULT_CORE_ROUTES_PORT) + expect(resolveCoreRoutesPort({ CLI_PORT: '0' })).toBe(DEFAULT_CORE_ROUTES_PORT) + expect(resolveCoreRoutesPort({ CLI_PORT: '-1' })).toBe(DEFAULT_CORE_ROUTES_PORT) + expect(resolveCoreRoutesPort({ CLI_PORT: '' })).toBe(DEFAULT_CORE_ROUTES_PORT) + }) +}) diff --git a/apps/cli/src/coreRoutesPort.ts b/apps/cli/src/coreRoutesPort.ts new file mode 100644 index 00000000..7497b6e6 --- /dev/null +++ b/apps/cli/src/coreRoutesPort.ts @@ -0,0 +1,31 @@ +export const DEFAULT_CORE_ROUTES_PORT = 3001 + +function parsePositivePort(raw: string | undefined): number | undefined { + if (raw === undefined) { + return undefined + } + const trimmed = raw.trim() + if (trimmed === '') { + return undefined + } + const port = Number.parseInt(trimmed, 10) + if (!Number.isFinite(port) || port <= 0) { + return undefined + } + return port +} + +/** + * Resolve the core-routes HTTP listen port. + * + * Order: `CLI_PORT` → `CORE_ROUTES_PORT` → 3001. + */ +export function resolveCoreRoutesPort( + env: Record = process.env, +): number { + return ( + parsePositivePort(env.CLI_PORT) ?? + parsePositivePort(env.CORE_ROUTES_PORT) ?? + DEFAULT_CORE_ROUTES_PORT + ) +} diff --git a/apps/cli/src/coreRoutesServer.ts b/apps/cli/src/coreRoutesServer.ts index 3118f83d..eb2c346b 100644 --- a/apps/cli/src/coreRoutesServer.ts +++ b/apps/cli/src/coreRoutesServer.ts @@ -9,8 +9,7 @@ import { getAppDataDir } from "@/utils/config"; import { buildHelloOptions } from "../tasks/HelloTask"; import { logger } from "../lib/logger"; import { broadcast } from "@/utils/socketIO"; - -const DEFAULT_PORT = 3001; +import { resolveCoreRoutesPort } from "@/coreRoutesPort"; function createCoreRoutesLogger(): CoreRoutesLogger { return { @@ -24,7 +23,7 @@ function createCoreRoutesLogger(): CoreRoutesLogger { export async function startCoreRoutesServer( auth?: CoreRoutesAuthConfig, ): Promise { - const port = parseInt(process.env.CORE_ROUTES_PORT ?? String(DEFAULT_PORT), 10); + const port = resolveCoreRoutesPort(); const allowlist = await buildAllowlist(); const appDataDir = getAppDataDir(); const helloOptions = { ...buildHelloOptions(null), coreRoutesPort: port }; diff --git a/apps/cli/src/route/Mcp.ts b/apps/cli/src/route/Mcp.ts index a6d3f503..c442965d 100644 --- a/apps/cli/src/route/Mcp.ts +++ b/apps/cli/src/route/Mcp.ts @@ -1,18 +1,114 @@ import { Hono } from "hono"; +import type { McpServerState } from "core-app"; import { doMcpGetStatus, doMcpStart, doMcpStop } from "@smm/core-routes"; +import { getCore } from "@/core/getCore"; import { getBunMcpLifecycleManager } from "@/mcp/bunMcpLifecycleManager"; import { logger } from "../../lib/logger"; +interface McpServerStateResponse { + data?: McpServerState; + error?: string | null; +} + +function mcpErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseStartBody(body: unknown): { hostname?: string; port?: number } { + if (!body || typeof body !== "object") { + return {}; + } + const record = body as { host?: unknown; port?: unknown }; + const options: { hostname?: string; port?: number } = {}; + if (typeof record.host === "string" && record.host.trim() !== "") { + options.hostname = record.host; + } + if (typeof record.port === "number" && Number.isFinite(record.port)) { + options.port = record.port; + } + return options; +} + /** - * Registers MCP server lifecycle routes via shared core-routes logic: + * Registers MCP server lifecycle routes. + * + * RPC (Web UI): + * GET /api/get-mcp-server-status + * POST /api/start-mcp-server + * POST /api/stop-mcp-server * - * PUT /api/mcp/start — starts the MCP HTTP server - * PUT /api/mcp/stop — stops the MCP HTTP server - * GET /api/mcp/status — returns the current server state + * Legacy (OHOS / older clients): + * PUT /api/mcp/start | /api/mcp/stop + * GET /api/mcp/status */ export function handleMcpRoutes(app: Hono): void { const manager = getBunMcpLifecycleManager(); + app.get("/api/get-mcp-server-status", async (c) => { + try { + const state = await getCore().getMcpServerStatus(); + const ok: McpServerStateResponse = { data: state, error: null }; + return c.json(ok, 200); + } catch (error) { + logger.error({ error }, "GET /api/get-mcp-server-status failed"); + const err: McpServerStateResponse = { + error: `Error Reason: ${mcpErrorMessage(error)}`, + }; + return c.json(err, 200); + } + }); + + app.post("/api/start-mcp-server", async (c) => { + const body = await c.req.json().catch(() => ({})); + const options = parseStartBody(body); + try { + const state = await getCore().startMcpServer(options, { persistUserConfig: true }); + if (state.status === "error") { + const err: McpServerStateResponse = { + data: state, + error: `Error Reason: ${state.error ?? "Failed to start MCP server"}`, + }; + logger.error({ state }, "POST /api/start-mcp-server failed"); + return c.json(err, 200); + } + const ok: McpServerStateResponse = { data: state, error: null }; + return c.json(ok, 200); + } catch (error) { + const message = mcpErrorMessage(error); + logger.error({ error: message }, "POST /api/start-mcp-server failed"); + const state = getCore().getMcpServerState(); + const err: McpServerStateResponse = { + data: { ...state, status: "error", error: message }, + error: `Error Reason: ${message}`, + }; + return c.json(err, 200); + } + }); + + app.post("/api/stop-mcp-server", async (c) => { + try { + const state = await getCore().stopMcpServer({ persistUserConfig: true }); + if (state.status === "error") { + const err: McpServerStateResponse = { + data: state, + error: `Error Reason: ${state.error ?? "Failed to stop MCP server"}`, + }; + logger.error({ state }, "POST /api/stop-mcp-server failed"); + return c.json(err, 200); + } + const ok: McpServerStateResponse = { data: state, error: null }; + return c.json(ok, 200); + } catch (error) { + const message = mcpErrorMessage(error); + logger.error({ error: message }, "POST /api/stop-mcp-server failed"); + const err: McpServerStateResponse = { + data: { status: "error", error: message }, + error: `Error Reason: ${message}`, + }; + return c.json(err, 200); + } + }); + app.put("/api/mcp/start", async (c) => { const body = await c.req.json().catch(() => ({})); const result = await doMcpStart(manager, body); diff --git a/apps/cli/tasks/HelloTask.ts b/apps/cli/tasks/HelloTask.ts index 56f3612a..0d79965b 100644 --- a/apps/cli/tasks/HelloTask.ts +++ b/apps/cli/tasks/HelloTask.ts @@ -4,10 +4,11 @@ import { detectOsLocale } from '@core/locale'; import { doHello, type HelloOptions } from '@smm/core-routes'; import { getLogDir, getUserDataDir, getAppDataDir, getTmpDir } from '@/utils/config'; import { logger } from '../lib/logger'; +import { resolveCoreRoutesPort } from '@/coreRoutesPort'; export function buildHelloOptions( reverseProxyUrl: string | null = null, - coreRoutesPort = parseInt(process.env.CORE_ROUTES_PORT ?? '3001', 10), + coreRoutesPort = resolveCoreRoutesPort(), ): HelloOptions { return { version: APP_VERSION, diff --git a/apps/e2e/test/lib/ui-page-url.test.ts b/apps/e2e/test/lib/ui-page-url.test.ts index 61e30787..632acb9e 100644 --- a/apps/e2e/test/lib/ui-page-url.test.ts +++ b/apps/e2e/test/lib/ui-page-url.test.ts @@ -14,6 +14,7 @@ function withEnv( E2E_PLATFORM?: string | undefined; E2E_DOCKER_UI_ORIGIN?: string | undefined; E2E_HTTP_PROXY_PROBE_URL?: string | undefined; + UI_PORT?: string | undefined; }, fn: () => void, ): void { @@ -21,6 +22,7 @@ function withEnv( const prevPlatform = process.env.E2E_PLATFORM; const prevOrigin = process.env.E2E_DOCKER_UI_ORIGIN; const prevProbe = process.env.E2E_HTTP_PROXY_PROBE_URL; + const prevUiPort = process.env.UI_PORT; if ('SMM_AUTH_TOKEN' in overrides) { if (overrides.SMM_AUTH_TOKEN === undefined) delete process.env.SMM_AUTH_TOKEN; @@ -41,6 +43,10 @@ function withEnv( process.env.E2E_HTTP_PROXY_PROBE_URL = overrides.E2E_HTTP_PROXY_PROBE_URL; } } + if ('UI_PORT' in overrides) { + if (overrides.UI_PORT === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = overrides.UI_PORT; + } try { fn(); @@ -53,17 +59,28 @@ function withEnv( else process.env.E2E_DOCKER_UI_ORIGIN = prevOrigin; if (prevProbe === undefined) delete process.env.E2E_HTTP_PROXY_PROBE_URL; else process.env.E2E_HTTP_PROXY_PROBE_URL = prevProbe; + if (prevUiPort === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prevUiPort; } } describe('resolveUiPageUrl', () => { test('defaults to localhost with port from apps/ui/vite.config.ts', () => { - withEnv({ SMM_AUTH_TOKEN: undefined, E2E_PLATFORM: undefined }, () => { + withEnv({ SMM_AUTH_TOKEN: undefined, E2E_PLATFORM: undefined, UI_PORT: undefined }, () => { expect(resolveUiPageUrl()).toBe('http://localhost:8000'); expect(resolveUiPageUrl(undefined, 'general')).toBe('http://localhost:8000'); }); }); + test('honors UI_PORT env', () => { + withEnv( + { SMM_AUTH_TOKEN: undefined, E2E_PLATFORM: undefined, UI_PORT: '9001' }, + () => { + expect(resolveUiPageUrl()).toBe('http://localhost:9001'); + }, + ); + }); + test('HarmonyOS uses device-local MAIN_HTTP_ORIGIN', () => { withEnv({ SMM_AUTH_TOKEN: undefined, E2E_PLATFORM: undefined }, () => { expect(resolveUiPageUrl(undefined, 'HarmonyOS')).toBe(HARMONYOS_UI_ORIGIN); @@ -104,7 +121,7 @@ describe('resolveUiPageUrl', () => { }); test('appends SMM_AUTH_TOKEN as query param', () => { - withEnv({ SMM_AUTH_TOKEN: 'ChangeMe123', E2E_PLATFORM: undefined }, () => { + withEnv({ SMM_AUTH_TOKEN: 'ChangeMe123', E2E_PLATFORM: undefined, UI_PORT: undefined }, () => { expect(resolveUiPageUrl()).toBe('http://localhost:8000?token=ChangeMe123'); expect(resolveUiPageUrl(undefined, 'HarmonyOS')).toBe( `${HARMONYOS_UI_ORIGIN}?token=ChangeMe123`, diff --git a/apps/e2e/test/lib/ui-page-url.ts b/apps/e2e/test/lib/ui-page-url.ts index e730798a..1140bcca 100644 --- a/apps/e2e/test/lib/ui-page-url.ts +++ b/apps/e2e/test/lib/ui-page-url.ts @@ -43,16 +43,37 @@ function parseViteDevServerPort(source: string): number { const serverBlock = source.match(/server\s*:\s*\{([\s\S]*?)\}(?:\s*,|\s*\n)/) const block = serverBlock?.[1] ?? source const portMatch = block.match(/\bport\s*:\s*(\d+)\b/) - if (!portMatch) { + const defaultMatch = source.match(/\bDEFAULT_UI_DEV_PORT\s*=\s*(\d+)\b/) + const raw = portMatch?.[1] ?? defaultMatch?.[1] + if (!raw) { return VITE_DEFAULT_DEV_PORT } - const port = Number(portMatch[1]) + const port = Number(raw) return Number.isFinite(port) && port > 0 ? port : VITE_DEFAULT_DEV_PORT } +function parseEnvPort(raw: string | undefined): number | undefined { + if (raw === undefined) { + return undefined + } + const trimmed = raw.trim() + if (trimmed === '') { + return undefined + } + const port = Number.parseInt(trimmed, 10) + if (!Number.isFinite(port) || port <= 0) { + return undefined + } + return port +} + export function readUiDevServerPort( viteConfigPath: string = UI_VITE_CONFIG, ): number { + const fromEnv = parseEnvPort(process.env.UI_PORT) + if (fromEnv !== undefined) { + return fromEnv + } const source = fs.readFileSync(path.resolve(viteConfigPath), 'utf8') return parseViteDevServerPort(source) } diff --git a/apps/ui/src/api/mcp.ts b/apps/ui/src/api/mcp.ts index 478bef3f..4450733b 100644 --- a/apps/ui/src/api/mcp.ts +++ b/apps/ui/src/api/mcp.ts @@ -1,7 +1,5 @@ import { apiFetch } from '@/lib/apiFetch'; -/** - * Mirrors the McpServerState from apps/cli/src/mcp/mcpServerManager.ts - */ + export interface McpServerState { status: "running" | "stopped" | "error"; host?: string; @@ -11,43 +9,60 @@ export interface McpServerState { error?: string; } +interface McpServerStateResponse { + data?: McpServerState; + error?: string | null; +} + +async function parseMcpResponse(resp: Response): Promise { + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`); + } + + const body = (await resp.json()) as McpServerStateResponse; + if (body.error) { + throw new Error(body.error); + } + if (!body.data) { + throw new Error("Error Reason: MCP server response missing data"); + } + if (body.data.status === "error") { + throw new Error(body.data.error ?? body.error ?? "Error Reason: MCP server error"); + } + return body.data; +} + /** * Starts the MCP server on the backend. - * Optionally overrides host/port (falls back to user config values otherwise). - * The promise resolves when the server is fully started or rejects on failure. + * Core persists enableMcpServer / mcpHost / mcpPort in smm.json on success. */ export async function startMcpServer(options?: { host?: string; port?: number; }): Promise { - const resp = await apiFetch("/api/mcp/start", { - method: "PUT", + const resp = await apiFetch("/api/start-mcp-server", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(options ?? {}), }); - const data: McpServerState = await resp.json(); - if (!resp.ok) { - throw new Error(data.error || "Failed to start MCP server"); - } - return data; + return parseMcpResponse(resp); } /** * Stops the MCP server on the backend. + * Core persists enableMcpServer: false in smm.json. */ export async function stopMcpServer(): Promise { - const resp = await apiFetch("/api/mcp/stop", { method: "PUT" }); - const data: McpServerState = await resp.json(); - if (!resp.ok) { - throw new Error(data.error || "Failed to stop MCP server"); - } - return data; + const resp = await apiFetch("/api/stop-mcp-server", { + method: "POST", + }); + return parseMcpResponse(resp); } /** - * Returns the current MCP server runtime state as tracked by the backend. + * Returns MCP runtime state. Core may reconcile smm.json when config disagrees with runtime. */ export async function getMcpServerStatus(): Promise { - const resp = await apiFetch("/api/mcp/status"); - return resp.json() as Promise; + const resp = await apiFetch("/api/get-mcp-server-status"); + return parseMcpResponse(resp); } diff --git a/apps/ui/src/components/mcp/McpIndicator.tsx b/apps/ui/src/components/mcp/McpIndicator.tsx index 3387597e..091b31ae 100644 --- a/apps/ui/src/components/mcp/McpIndicator.tsx +++ b/apps/ui/src/components/mcp/McpIndicator.tsx @@ -1,11 +1,14 @@ import { Plug, Globe, FileText, ExternalLink } from "lucide-react" import { useEffect, useRef } from "react" -import { useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" import { useConfig } from "@/hooks/userConfig" -import { useMcpServerStatus, doStartMcpServer, doStopMcpServer } from "@/hooks/useMcpServerStatus" +import { + useMcpServerStatusQuery, + useStartMcpServerMutation, + useStopMcpServerMutation, +} from "@/hooks/useMcpServerStatus" +import { useRefreshUserConfig } from "@/hooks/userConfig/useRefreshUserConfig" import { useTranslation } from "@/lib/i18n" -import { nextTraceId } from "@/lib/utils" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils" @@ -17,9 +20,11 @@ interface McpIndicatorProps { } export function McpIndicator({ className }: McpIndicatorProps) { - const { userConfig, setAndSaveUserConfig } = useConfig() - const { data: serverState } = useMcpServerStatus() - const queryClient = useQueryClient() + const { userConfig } = useConfig() + const { data: serverState } = useMcpServerStatusQuery() + const startMcpServerMutation = useStartMcpServerMutation() + const stopMcpServerMutation = useStopMcpServerMutation() + const refreshUserConfig = useRefreshUserConfig() const { t } = useTranslation('components') const mcpEnabled = userConfig.enableMcpServer === true @@ -28,29 +33,20 @@ export function McpIndicator({ className }: McpIndicatorProps) { const mcpAddress = serverState?.url ?? `http://${mcpHost}:${mcpPort}/mcp` const isRunning = serverState?.status === "running" + const isToggling = startMcpServerMutation.isPending || stopMcpServerMutation.isPending - // On initial mount only: if config says enabled but server is not running, - // revert the config so the toggle reflects reality and notify the user. - // - // We depend on `userConfig` (not just `serverState`) because the closure - // captures `mcpEnabled` / `userConfig`. If `serverState` resolves before - // `userConfig` finishes loading, the captured `mcpEnabled` is `false` and - // we'd bail out with `initialCheckDone.current` already set — silently - // skipping the reconciliation. Waiting for both queries ensures we - // evaluate with consistent state. + // On initial load: Core reconciles smm.json via getMcpServerStatus. + // UI only refreshes cached config and shows a toast — never writes MCP fields. const initialCheckDone = useRef(false) useEffect(() => { if (!serverState || !userConfig || initialCheckDone.current) return initialCheckDone.current = true - console.log(`[McpIndicator-init] mcpEnabled=${mcpEnabled} isRunning=${isRunning} serverState.status=${serverState.status} initialCheckDone=${initialCheckDone.current}`) - if (!mcpEnabled || isRunning) { - console.log(`[McpIndicator-init] bailed out: mcpEnabled=${mcpEnabled} isRunning=${isRunning} serverState.status=${serverState.status}`) return } - const traceId = `McpIndicator-init-${nextTraceId()}` + void refreshUserConfig() if (serverState.status === "error") { toast.error(t('statusBar.mcp.serverError'), { @@ -61,47 +57,31 @@ export function McpIndicator({ className }: McpIndicatorProps) { description: t('statusBar.mcp.startFailedOnLoad'), }) } - - console.log(`[McpIndicator-init] reverting enableMcpServer to false because serverState.status=${serverState.status}`) - setAndSaveUserConfig(traceId, { ...userConfig, enableMcpServer: false }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [serverState, userConfig]) + }, [serverState, userConfig, mcpEnabled, isRunning, refreshUserConfig, t]) const handleMcpToggle = async () => { - const traceId = `McpIndicator-MCP-toggle-${nextTraceId()}` - console.log(`[McpIndicator] handleMcpToggle called: mcpEnabled=${mcpEnabled} host=${mcpHost} port=${mcpPort}`) + if (isToggling) return if (mcpEnabled) { - // ── Turn OFF ──────────────────────────────────────── - try { - await doStopMcpServer(queryClient) - } catch (e) { - console.error(`[${traceId}] MCP stop failed`, e) - } - await setAndSaveUserConfig(traceId, { ...userConfig, enableMcpServer: false }) - } else { - // ── Turn ON ───────────────────────────────────────── - // 1. Write config (optimistic — UI immediately shows ON) - await setAndSaveUserConfig(traceId, { ...userConfig, enableMcpServer: true }) - // 2. Start server — may fail try { - console.log(`[${traceId}] Calling doStartMcpServer host=${mcpHost} port=${mcpPort}`) - await doStartMcpServer(queryClient, { host: mcpHost, port: mcpPort }) - console.log(`[${traceId}] doStartMcpServer succeeded`) + await stopMcpServerMutation.mutateAsync() } catch (e) { const msg = e instanceof Error ? e.message : String(e) - console.error(`[${traceId}] MCP start failed:`, msg) - // Revert config so the toggle goes back to OFF - await setAndSaveUserConfig(`${traceId}-revert`, { ...userConfig, enableMcpServer: false }) - // Notify the user - toast.error(t('statusBar.mcp.serverError'), { - description: msg, - }) + console.error("[McpIndicator] MCP stop failed", msg) + toast.error(t('statusBar.mcp.serverError'), { description: msg }) } + return + } + + try { + await startMcpServerMutation.mutateAsync({ host: mcpHost, port: mcpPort }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + console.error("[McpIndicator] MCP start failed:", msg) + toast.error(t('statusBar.mcp.serverError'), { description: msg }) } } - // Plug icon colour const iconClass = cn( "flex items-center justify-center rounded p-0.5 transition-colors hover:bg-muted", mcpEnabled ? "text-primary" : "text-muted-foreground/50", @@ -138,10 +118,12 @@ export function McpIndicator({ className }: McpIndicatorProps) { role="switch" data-testid="mcp-switch" aria-checked={mcpEnabled} + aria-disabled={isToggling} aria-label={mcpEnabled ? t('statusBar.mcp.turnOff') : t('statusBar.mcp.turnOn')} onClick={handleMcpToggle} + disabled={isToggling} className={cn( - "relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", + "relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50", mcpEnabled ? "bg-primary" : "bg-muted", )} > diff --git a/apps/ui/src/components/ui/settings/GeneralSettings.tsx b/apps/ui/src/components/ui/settings/GeneralSettings.tsx index 52e6c715..3cebe643 100644 --- a/apps/ui/src/components/ui/settings/GeneralSettings.tsx +++ b/apps/ui/src/components/ui/settings/GeneralSettings.tsx @@ -11,7 +11,10 @@ import { useTheme } from "@/providers/theme-provider" import type { PreferMediaLanguage } from "@core/types" import { resolveAppLanguage } from "@core/locale" import { useHelloQuery } from "@/hooks/userConfig/useHelloQuery" -import { startMcpServer, stopMcpServer } from "@/api/mcp" +import { + useStartMcpServerMutation, + useStopMcpServerMutation, +} from "@/hooks/useMcpServerStatus" // eslint-disable-next-line @typescript-eslint/no-unused-vars const THEME_OPTIONS = ["light", "dark", "system"] as const @@ -49,6 +52,8 @@ export function GeneralSettings() { const { theme, setTheme } = useTheme() const { userConfig, setAndSaveUserConfig } = useConfig() const helloQuery = useHelloQuery() + const startMcpServerMutation = useStartMcpServerMutation() + const stopMcpServerMutation = useStopMcpServerMutation() const { t } = useTranslation(['settings', 'common']) // Track initial values @@ -124,18 +129,18 @@ export function GeneralSettings() { } const parsedMcpPort = Number(mcpPort) - const updatedConfig = { + const resolvedMcpPort = + Number.isNaN(parsedMcpPort) || parsedMcpPort <= 0 ? 30001 : parsedMcpPort + + const nonMcpConfig = { ...userConfig, applicationLanguage: savedApplicationLanguage, preferMediaLanguage: preferMediaLanguage === PREFER_MEDIA_LANGUAGE_UNSET ? undefined : preferMediaLanguage, anonymousTelemetryConsent, - enableMcpServer, - mcpHost: mcpHost || undefined, - mcpPort: Number.isNaN(parsedMcpPort) || parsedMcpPort <= 0 ? 30001 : parsedMcpPort, } - await setAndSaveUserConfig(traceId, updatedConfig) + await setAndSaveUserConfig(traceId, nonMcpConfig) - // ── Sync MCP server after config save ────────────────── + // MCP changes go through Core APIs; Core persists MCP fields in smm.json. const mcpToggledOn = enableMcpServer && !initialValues.enableMcpServer const mcpToggledOff = !enableMcpServer && initialValues.enableMcpServer const mcpHostOrPortChanged = @@ -144,18 +149,16 @@ export function GeneralSettings() { try { if (mcpToggledOff) { - await stopMcpServer() + await stopMcpServerMutation.mutateAsync() } else if (mcpToggledOn) { - await startMcpServer({ host: mcpHost, port: parsedMcpPort }) + await startMcpServerMutation.mutateAsync({ host: mcpHost, port: resolvedMcpPort }) } else if (enableMcpServer && mcpHostOrPortChanged) { - // Restart with new host/port - await stopMcpServer() - await startMcpServer({ host: mcpHost, port: parsedMcpPort }) + await stopMcpServerMutation.mutateAsync() + await startMcpServerMutation.mutateAsync({ host: mcpHost, port: resolvedMcpPort }) } } catch (err) { const msg = err instanceof Error ? err.message : String(err) console.error(`[${traceId}] MCP server sync failed:`, msg) - // Toggle stays ON per design — user can view error in McpIndicator popover } } diff --git a/apps/ui/src/hooks/useMcpServerStatus.ts b/apps/ui/src/hooks/useMcpServerStatus.ts index ee765510..0ff89d67 100644 --- a/apps/ui/src/hooks/useMcpServerStatus.ts +++ b/apps/ui/src/hooks/useMcpServerStatus.ts @@ -1,6 +1,7 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getMcpServerStatus, startMcpServer, stopMcpServer } from "@/api/mcp"; import type { McpServerState } from "@/api/mcp"; +import { useRefreshUserConfig } from "@/hooks/userConfig/useRefreshUserConfig"; export const mcpServerStatusQueryKey = ["mcp", "serverStatus"] as const; @@ -8,7 +9,7 @@ export const mcpServerStatusQueryKey = ["mcp", "serverStatus"] as const; * Fetches the MCP server runtime state from the backend. * Used on initial load to reconcile the UI toggle with real server state. */ -export function useMcpServerStatus() { +export function useMcpServerStatusQuery() { return useQuery({ queryKey: mcpServerStatusQueryKey, queryFn: getMcpServerStatus, @@ -17,21 +18,47 @@ export function useMcpServerStatus() { }); } +/** @deprecated Use {@link useMcpServerStatusQuery} */ +export const useMcpServerStatus = useMcpServerStatusQuery; + +function useInvalidateMcpCaches() { + const queryClient = useQueryClient(); + const refreshUserConfig = useRefreshUserConfig(); + + return async (state: McpServerState) => { + queryClient.setQueryData(mcpServerStatusQueryKey, state); + await refreshUserConfig(); + }; +} + /** - * Starts the MCP server and updates the query cache with the result. - * Returns the new state. Throws on failure so callers can handle errors. + * Starts the MCP server. Core persists MCP fields in smm.json. */ -export async function doStartMcpServer(client: ReturnType, options?: { host?: string; port?: number }): Promise { - const state = await startMcpServer(options); - client.setQueryData(mcpServerStatusQueryKey, state); - return state; +export function useStartMcpServerMutation() { + const invalidateMcpCaches = useInvalidateMcpCaches(); + + return useMutation({ + mutationFn: startMcpServer, + onSuccess: (state) => { + void invalidateMcpCaches(state); + }, + }); } /** - * Stops the MCP server and updates the query cache with "stopped". + * Stops the MCP server. Core persists enableMcpServer: false in smm.json. */ -export async function doStopMcpServer(client: ReturnType): Promise { - const state = await stopMcpServer(); - client.setQueryData(mcpServerStatusQueryKey, state); - return state; +export function useStopMcpServerMutation() { + const invalidateMcpCaches = useInvalidateMcpCaches(); + const refreshUserConfig = useRefreshUserConfig(); + + return useMutation({ + mutationFn: stopMcpServer, + onSuccess: (state) => { + void invalidateMcpCaches(state); + }, + onError: () => { + void refreshUserConfig(); + }, + }); } diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts index b610f74f..35b81975 100644 --- a/apps/ui/vite.config.ts +++ b/apps/ui/vite.config.ts @@ -12,6 +12,23 @@ const pkg = JSON.parse( readFileSync(path.resolve(__dirname, "package.json"), "utf8"), ) as { version: string } +const DEFAULT_UI_DEV_PORT = 8000 + +function resolveUiDevPort(raw: string | undefined = process.env.UI_PORT): number { + if (raw === undefined) { + return DEFAULT_UI_DEV_PORT + } + const trimmed = raw.trim() + if (trimmed === "") { + return DEFAULT_UI_DEV_PORT + } + const port = Number.parseInt(trimmed, 10) + if (!Number.isFinite(port) || port <= 0) { + return DEFAULT_UI_DEV_PORT + } + return port +} + // https://vite.dev/config/ export default defineConfig({ envDir: path.resolve(__dirname, "../.."), @@ -28,7 +45,7 @@ export default defineConfig({ tailwindcss(), ], server: { - port: 8000, + port: resolveUiDevPort(), proxy: { '/api': { target: 'http://localhost:30000', diff --git a/ci/load-env-local.test.ts b/ci/load-env-local.test.ts new file mode 100644 index 00000000..1de7f556 --- /dev/null +++ b/ci/load-env-local.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { findEnvLocalFiles, loadEnvLocal } from './load-env-local.ts'; + +describe('loadEnvLocal (ci)', () => { + it('loads UI_PORT and CLI_PORT from repo .env.local', () => { + const root = mkdtempSync(join(tmpdir(), 'smm-ci-env-')); + writeFileSync( + join(root, '.env.local'), + '# --- Ports ---\nUI_PORT=8081\nCLI_PORT=8082\n', + ); + + const prevUi = process.env.UI_PORT; + const prevCli = process.env.CLI_PORT; + delete process.env.UI_PORT; + delete process.env.CLI_PORT; + + try { + const merged = loadEnvLocal(root); + expect(merged.UI_PORT).toBe('8081'); + expect(merged.CLI_PORT).toBe('8082'); + expect(process.env.UI_PORT).toBe('8081'); + expect(process.env.CLI_PORT).toBe('8082'); + expect(findEnvLocalFiles(root)).toEqual([join(root, '.env.local')]); + } finally { + rmSync(root, { recursive: true, force: true }); + if (prevUi === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prevUi; + if (prevCli === undefined) delete process.env.CLI_PORT; + else process.env.CLI_PORT = prevCli; + } + }); +}); diff --git a/ci/load-env-local.ts b/ci/load-env-local.ts new file mode 100644 index 00000000..d0c83222 --- /dev/null +++ b/ci/load-env-local.ts @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +/** + * `.env.local` paths from `startDir` up to the filesystem root (nearest first). + */ +export function findEnvLocalFiles(startDir: string): string[] { + const files: string[] = []; + let dir = resolve(startDir); + while (true) { + const candidate = join(dir, '.env.local'); + if (existsSync(candidate)) files.push(candidate); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return files; +} + +/** Minimal dotenv-style parser (no external dependency in ci/). */ +export function parseEnvLocalFile(content: string): Record { + const result: Record = {}; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq === -1) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + if (!key) { + continue; + } + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + result[key] = value; + } + return result; +} + +/** + * Load every `.env.local` from `startDir` to the repo/filesystem root. + * Nearer files override farther ones. Existing `process.env` keys are not overwritten. + */ +export function loadEnvLocal(startDir: string = process.cwd()): Record { + const files = findEnvLocalFiles(startDir); + const merged: Record = {}; + for (const file of [...files].reverse()) { + Object.assign(merged, parseEnvLocalFile(readFileSync(file, 'utf8'))); + } + for (const [key, value] of Object.entries(merged)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + return merged; +} diff --git a/ci/read-ui-dev-port.test.ts b/ci/read-ui-dev-port.test.ts index 3de37694..a783e5fb 100644 --- a/ci/read-ui-dev-port.test.ts +++ b/ci/read-ui-dev-port.test.ts @@ -22,15 +22,43 @@ describe('parseViteDevServerPort', () => { test('falls back when port is omitted', () => { expect(parseViteDevServerPort(`export default defineConfig({})`)).toBe(5173); }); + + test('extracts DEFAULT_UI_DEV_PORT when port is computed', () => { + expect( + parseViteDevServerPort( + `const DEFAULT_UI_DEV_PORT = 8000\nexport default defineConfig({ server: { port: resolveUiDevPort() } })`, + ), + ).toBe(8000); + }); }); describe('readUiDevServerPort', () => { + test('prefers UI_PORT env over vite.config.ts', () => { + const prev = process.env.UI_PORT; + process.env.UI_PORT = '9001'; + try { + expect(readUiDevServerPort(path.join(ROOT, 'apps/ui/vite.config.ts'))).toBe(9001); + } finally { + if (prev === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prev; + } + }); + test('reads server.port from apps/ui/vite.config.ts', () => { - const port = readUiDevServerPort(path.join(ROOT, 'apps/ui/vite.config.ts')); - expect(port).toBe(8000); + const prev = process.env.UI_PORT; + delete process.env.UI_PORT; + try { + const port = readUiDevServerPort(path.join(ROOT, 'apps/ui/vite.config.ts')); + expect(port).toBe(8000); + } finally { + if (prev === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prev; + } }); test('reads server.port from a temporary vite config', () => { + const prev = process.env.UI_PORT; + delete process.env.UI_PORT; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-port-')); const configPath = path.join(dir, 'vite.config.ts'); fs.writeFileSync( @@ -38,10 +66,17 @@ describe('readUiDevServerPort', () => { `import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 4321 } })\n`, ); - expect(readUiDevServerPort(configPath)).toBe(4321); + try { + expect(readUiDevServerPort(configPath)).toBe(4321); + } finally { + if (prev === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prev; + } }); test('falls back to Vite default 5173 when port is omitted', () => { + const prev = process.env.UI_PORT; + delete process.env.UI_PORT; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-port-')); const configPath = path.join(dir, 'vite.config.ts'); fs.writeFileSync( @@ -49,6 +84,11 @@ describe('readUiDevServerPort', () => { `import { defineConfig } from 'vite'\nexport default defineConfig({})\n`, ); - expect(readUiDevServerPort(configPath)).toBe(5173); + try { + expect(readUiDevServerPort(configPath)).toBe(5173); + } finally { + if (prev === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prev; + } }); }); diff --git a/ci/read-ui-dev-port.ts b/ci/read-ui-dev-port.ts index 353e4dbc..7d165047 100644 --- a/ci/read-ui-dev-port.ts +++ b/ci/read-ui-dev-port.ts @@ -15,6 +15,7 @@ export const DEFAULT_UI_VITE_CONFIG = path.join(ROOT, 'apps', 'ui', 'vite.config /** * Parse `server.port` from a Vite config source string. + * Also accepts `DEFAULT_UI_DEV_PORT = 8000` when `port` is computed from env. */ export function parseViteDevServerPort( source: string, @@ -23,16 +24,37 @@ export function parseViteDevServerPort( const serverBlock = source.match(/server\s*:\s*\{([\s\S]*?)\}(?:\s*,|\s*\n)/); const block = serverBlock?.[1] ?? source; const portMatch = block.match(/\bport\s*:\s*(\d+)\b/); - if (!portMatch) { + const defaultMatch = source.match(/\bDEFAULT_UI_DEV_PORT\s*=\s*(\d+)\b/); + const raw = portMatch?.[1] ?? defaultMatch?.[1]; + if (!raw) { return fallback; } - const port = Number(portMatch[1]); + const port = Number(raw); return Number.isFinite(port) && port > 0 ? port : fallback; } +function parseEnvPort(raw: string | undefined): number | undefined { + if (raw === undefined) { + return undefined; + } + const trimmed = raw.trim(); + if (trimmed === '') { + return undefined; + } + const port = Number.parseInt(trimmed, 10); + if (!Number.isFinite(port) || port <= 0) { + return undefined; + } + return port; +} + export function readUiDevServerPort( viteConfigPath: string = DEFAULT_UI_VITE_CONFIG, ): number { + const fromEnv = parseEnvPort(process.env.UI_PORT); + if (fromEnv !== undefined) { + return fromEnv; + } const resolved = path.resolve(viteConfigPath); const source = fs.readFileSync(resolved, 'utf8'); return parseViteDevServerPort(source); diff --git a/ci/run-e2e-test-lib.ts b/ci/run-e2e-test-lib.ts index fab6a80b..bbd064e1 100644 --- a/ci/run-e2e-test-lib.ts +++ b/ci/run-e2e-test-lib.ts @@ -36,6 +36,24 @@ export const USAGE = const PLATFORMS = new Set(['desktop', 'ohos', 'electron', 'docker']); +/** Port env keys from repo `.env.local` (see `# --- Ports ---` section). */ +export const E2E_PORT_ENV_KEYS = ['UI_PORT', 'CLI_PORT', 'PORT'] as const; + +export type E2ePortEnvKey = (typeof E2E_PORT_ENV_KEYS)[number]; + +/** + * Forward dev-server port overrides into cicd task/background env. + * Values come from `loadEnvLocal()` in run-e2e-test.ts (or the caller's shell). + */ +export function assignE2eLocalPortEnv(env: Record): void { + for (const key of E2E_PORT_ENV_KEYS) { + const value = process.env[key]?.trim(); + if (value) { + env[key] = value; + } + } +} + /** Opt-in UI v3 for e2e (`localStorage smm.v3.enabled`). Production default stays off. */ function assignE2eSmmV3Env(env: Record): void { if (process.env.E2E_SMM_V3 === 'true') { @@ -194,6 +212,7 @@ export function buildDesktopConfig(specs: string[]): CicdConfig { env.EXTERNAL_CONFIG_FILE_URL = process.env.EXTERNAL_CONFIG_FILE_URL; } assignE2eSmmV3Env(env); + assignE2eLocalPortEnv(env); return { name: 'smm-e2e', diff --git a/ci/run-e2e-test.test.ts b/ci/run-e2e-test.test.ts index db4a8444..9c740d8f 100644 --- a/ci/run-e2e-test.test.ts +++ b/ci/run-e2e-test.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { assertSpecsMatchPlatform, + assignE2eLocalPortEnv, buildConfig, dockerHttpProxyEnvForContainer, parseArgv, @@ -136,4 +137,39 @@ describe('run-e2e-test docker platform', () => { else process.env.E2E_SMM_V3 = prev; } }); + + test('buildConfig desktop forwards UI_PORT and CLI_PORT from process.env', () => { + const prevUi = process.env.UI_PORT; + const prevCli = process.env.CLI_PORT; + const prevPort = process.env.PORT; + process.env.UI_PORT = '8081'; + process.env.CLI_PORT = '8082'; + process.env.PORT = '30000'; + try { + const config = buildConfig('desktop', ['common/mcp/McpOther-RenameTaskFlow.e2e.ts']); + expect(config.env.UI_PORT).toBe('8081'); + expect(config.env.CLI_PORT).toBe('8082'); + expect(config.env.PORT).toBe('30000'); + } finally { + if (prevUi === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prevUi; + if (prevCli === undefined) delete process.env.CLI_PORT; + else process.env.CLI_PORT = prevCli; + if (prevPort === undefined) delete process.env.PORT; + else process.env.PORT = prevPort; + } + }); + + test('assignE2eLocalPortEnv skips empty values', () => { + const prevUi = process.env.UI_PORT; + process.env.UI_PORT = ' '; + try { + const env: Record = {}; + assignE2eLocalPortEnv(env); + expect(env.UI_PORT).toBeUndefined(); + } finally { + if (prevUi === undefined) delete process.env.UI_PORT; + else process.env.UI_PORT = prevUi; + } + }); }); diff --git a/ci/run-e2e-test.ts b/ci/run-e2e-test.ts index 13b1f47b..e94b910d 100644 --- a/ci/run-e2e-test.ts +++ b/ci/run-e2e-test.ts @@ -10,6 +10,8 @@ * * Config: artifacts/e2e/config.json * Logs and run summary: apps/cicd/run.ts + * + * Loads repo `.env.local` (including `UI_PORT` / `CLI_PORT`) before starting services. */ import { $ } from 'bun'; import * as fs from 'node:fs'; @@ -17,6 +19,7 @@ import * as path from 'node:path'; import { CONFIG_PATH, CONFIG_REL_PATH, + E2E_PORT_ENV_KEYS, ROOT, assertSpecsMatchPlatform, buildConfig, @@ -26,8 +29,20 @@ import { specFiles, } from './run-e2e-test-lib.ts'; import { stopDockerE2eContainer } from './e2e-docker-container.ts'; +import { loadEnvLocal } from './load-env-local.ts'; + +function logLoadedE2ePorts(): void { + const parts = E2E_PORT_ENV_KEYS.map((key) => { + const value = process.env[key]?.trim(); + return `${key}=${value ?? '(default)'}`; + }); + console.log(`[run-e2e-test] ports from .env.local / shell: ${parts.join(', ')}`); +} async function main(): Promise { + loadEnvLocal(ROOT); + logLoadedE2ePorts(); + const argv = process.argv.slice(2); const { platform, patterns } = parseArgv(argv); requireSpecsForPlatform(platform, patterns); diff --git a/ci/wait-for-e2e-ready.ts b/ci/wait-for-e2e-ready.ts index 975987c3..e468b45c 100644 --- a/ci/wait-for-e2e-ready.ts +++ b/ci/wait-for-e2e-ready.ts @@ -4,11 +4,24 @@ * * UI port is read from apps/ui/vite.config.ts (not hard-coded). */ +import { loadEnvLocal } from './load-env-local.ts'; import { readUiDevServerPort } from './read-ui-dev-port.ts'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; -const CLI_READY_URL = 'http://localhost:30000/api/hello'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_CLI_PORT = 30000; const CLI_AUTH_TOKEN = 'ChangeMe123'; +function resolveMainCliPort(): number { + const raw = process.env.PORT?.trim(); + if (!raw) { + return DEFAULT_CLI_PORT; + } + const port = Number.parseInt(raw, 10); + return Number.isFinite(port) && port > 0 ? port : DEFAULT_CLI_PORT; +} + async function waitForHttp( url: string, options: { @@ -52,8 +65,12 @@ async function waitForHttp( } async function main(): Promise { - console.log('[wait-for-e2e-ready] waiting for CLI (http://localhost:30000)'); - await waitForHttp(CLI_READY_URL, { + loadEnvLocal(ROOT); + + const cliPort = resolveMainCliPort(); + const cliReadyUrl = `http://localhost:${cliPort}/api/hello`; + console.log(`[wait-for-e2e-ready] waiting for CLI (${cliReadyUrl})`); + await waitForHttp(cliReadyUrl, { method: 'POST', headers: { Authorization: `Bearer ${CLI_AUTH_TOKEN}` }, }); diff --git a/package.json b/package.json index e31f0812..6cfd768c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "videocaptioner_version": "1.0.0" }, "scripts": { - "dev": "npm-run-all2 --parallel dev:ui dev:cli", + "dev": "cross-env UI_PORT=8081 CLI_PORT=8082 npm-run-all2 --parallel dev:ui dev:cli", "dev:ui": "pnpm --filter ui dev", "dev:cli": "cross-env SMM_AUTH_ENABLED=true SMM_AUTH_TOKEN=ChangeMe123 pnpm --filter cli dev", "e2e:cli": "cross-env SMM_AUTH_ENABLED=true SMM_AUTH_TOKEN=ChangeMe123 pnpm --filter cli start", @@ -35,6 +35,7 @@ "e2e:ai": "bun ci/run-e2e-test.ts --spec \"./test/specs/ai/*.e2e.ts\"", "e2e:config": "bun ci/run-e2e-test.ts --spec \"./common/config/*.e2e.ts\" --spec \"./common/httpproxy/*.e2e.ts\"", "e2e:mcp": "bun test test/mcp/", + "e2e:mcp:ui": "bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", "e2e:media-folder-initialization": "bun ci/run-e2e-test.ts --spec \"./common/tv/ImportTvShowLibrary.e2e.ts\" --spec \"./common/tv/Initialize*.e2e.ts\" --spec \"./common/movie/ImportMovieLibrary.e2e.ts\" --spec \"./common/movie/Initialize*.e2e.ts\"", "e2e:movie": "bun ci/run-e2e-test.ts --spec \"./common/movie/SearchMovie.e2e.ts\" --spec \"./common/movie/Movie-RenameVideoFile.e2e.ts\"", "e2e:music": "bun ci/run-e2e-test.ts --spec \"./common/music/*.e2e.ts\"", From 7f7edc6973ed2b9822348ff5c176d61450c4465f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 17:34:10 +0800 Subject: [PATCH 104/214] feat(mcp): add Core-style MCP RPC routes for HarmonyOS via core-routes Expose get/start/stop-mcp-server with { data, error } and smm.json persistence so the shared Web UI works on OHOS's gate-based MCP endpoint. Co-authored-by: Cursor --- .../resfile/resources/app/core-routes.js | 72956 +--------------- packages/core-routes/src/index.ts | 15 + packages/core-routes/src/mcp/index.ts | 10 + packages/core-routes/src/mcp/lifecycle.ts | 5 + .../src/mcp/mcpServerConfig.test.ts | 117 + .../core-routes/src/mcp/mcpServerConfig.ts | 124 + packages/core-routes/src/register.ts | 8 + .../src/routes/mcpServerRpcRoute.test.ts | 146 + .../src/routes/mcpServerRpcRoute.ts | 86 + 9 files changed, 736 insertions(+), 72731 deletions(-) create mode 100644 packages/core-routes/src/mcp/mcpServerConfig.test.ts create mode 100644 packages/core-routes/src/mcp/mcpServerConfig.ts create mode 100644 packages/core-routes/src/routes/mcpServerRpcRoute.test.ts create mode 100644 packages/core-routes/src/routes/mcpServerRpcRoute.ts diff --git a/apps/ohos/web_engine/src/main/resources/resfile/resources/app/core-routes.js b/apps/ohos/web_engine/src/main/resources/resfile/resources/app/core-routes.js index 6fc6369f..0126f629 100644 --- a/apps/ohos/web_engine/src/main/resources/resfile/resources/app/core-routes.js +++ b/apps/ohos/web_engine/src/main/resources/resfile/resources/app/core-routes.js @@ -1,72707 +1,284 @@ -var __create = Object.create; -var __getProtoOf = Object.getPrototypeOf; -var __defProp = Object.defineProperty; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __hasOwnProp = Object.prototype.hasOwnProperty; -function __accessProp(key) { - return this[key]; -} -var __toESMCache_node; -var __toESMCache_esm; -var __toESM = (mod, isNodeMode, target) => { - var canCache = mod != null && typeof mod === "object"; - if (canCache) { - var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap; - var cached = cache.get(mod); - if (cached) - return cached; - } - target = mod != null ? __create(__getProtoOf(mod)) : {}; - const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; - for (let key of __getOwnPropNames(mod)) - if (!__hasOwnProp.call(to, key)) - __defProp(to, key, { - get: __accessProp.bind(mod, key), - enumerable: true - }); - if (canCache) - cache.set(mod, to); - return to; -}; -var __toCommonJS = (from) => { - var entry = (__moduleCache ??= new WeakMap).get(from), desc; - if (entry) - return entry; - entry = __defProp({}, "__esModule", { value: true }); - if (from && typeof from === "object" || typeof from === "function") { - for (var key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(entry, key)) - __defProp(entry, key, { - get: __accessProp.bind(from, key), - enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable - }); - } - __moduleCache.set(from, entry); - return entry; -}; -var __moduleCache; -var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); -var __returnValue = (v) => v; -function __exportSetter(name, newValue) { - this[name] = __returnValue.bind(null, newValue); -} -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { - get: all[name], - enumerable: true, - configurable: true, - set: __exportSetter.bind(all, name) - }); -}; - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/get-context.js -var require_get_context = __commonJS((exports2, module2) => { - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var get_context_exports = {}; - __export2(get_context_exports, { - SYMBOL_FOR_REQ_CONTEXT: () => SYMBOL_FOR_REQ_CONTEXT, - getContext: () => getContext2 - }); - module2.exports = __toCommonJS2(get_context_exports); - var SYMBOL_FOR_REQ_CONTEXT = Symbol.for("@vercel/request-context"); - function getContext2() { - const fromSymbol = globalThis; - return fromSymbol[SYMBOL_FOR_REQ_CONTEXT]?.get?.() ?? {}; - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/token-error.js -var require_token_error = __commonJS((exports2, module2) => { - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var token_error_exports = {}; - __export2(token_error_exports, { - VercelOidcTokenError: () => VercelOidcTokenError - }); - module2.exports = __toCommonJS2(token_error_exports); - - class VercelOidcTokenError extends Error { - constructor(message, cause) { - super(message); - this.name = "VercelOidcTokenError"; - this.cause = cause; - } - toString() { - if (this.cause) { - return `${this.name}: ${this.message}: ${this.cause}`; - } - return `${this.name}: ${this.message}`; - } - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/token-io.js -var require_token_io = __commonJS((exports2, module2) => { - var __create2 = Object.create; - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __getProtoOf2 = Object.getPrototypeOf; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__getProtoOf2(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp2(target, "default", { value: mod, enumerable: true }) : target, mod)); - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var token_io_exports = {}; - __export2(token_io_exports, { - findRootDir: () => findRootDir, - getUserDataDir: () => getUserDataDir - }); - module2.exports = __toCommonJS2(token_io_exports); - var import_path = __toESM2(require("path")); - var import_fs = __toESM2(require("fs")); - var import_os = __toESM2(require("os")); - var import_token_error = require_token_error(); - function findRootDir() { - try { - let dir = process.cwd(); - while (dir !== import_path.default.dirname(dir)) { - const pkgPath = import_path.default.join(dir, ".vercel"); - if (import_fs.default.existsSync(pkgPath)) { - return dir; - } - dir = import_path.default.dirname(dir); - } - } catch (e) { - throw new import_token_error.VercelOidcTokenError("Token refresh only supported in node server environments"); - } - return null; - } - function getUserDataDir() { - if (process.env.XDG_DATA_HOME) { - return process.env.XDG_DATA_HOME; - } - switch (import_os.default.platform()) { - case "darwin": - return import_path.default.join(import_os.default.homedir(), "Library/Application Support"); - case "linux": - return import_path.default.join(import_os.default.homedir(), ".local/share"); - case "win32": - if (process.env.LOCALAPPDATA) { - return process.env.LOCALAPPDATA; - } - return null; - default: - return null; - } - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/auth-config.js -var require_auth_config = __commonJS((exports2, module2) => { - var __create2 = Object.create; - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __getProtoOf2 = Object.getPrototypeOf; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__getProtoOf2(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp2(target, "default", { value: mod, enumerable: true }) : target, mod)); - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var auth_config_exports = {}; - __export2(auth_config_exports, { - isValidAccessToken: () => isValidAccessToken, - readAuthConfig: () => readAuthConfig, - writeAuthConfig: () => writeAuthConfig - }); - module2.exports = __toCommonJS2(auth_config_exports); - var fs = __toESM2(require("fs")); - var path = __toESM2(require("path")); - var import_token_util = require_token_util(); - function getAuthConfigPath() { - const dataDir = (0, import_token_util.getVercelDataDir)(); - if (!dataDir) { - throw new Error(`Unable to find Vercel CLI data directory. Your platform: ${process.platform}. Supported: darwin, linux, win32.`); - } - return path.join(dataDir, "auth.json"); - } - function readAuthConfig() { - try { - const authPath = getAuthConfigPath(); - if (!fs.existsSync(authPath)) { - return null; - } - const content = fs.readFileSync(authPath, "utf8"); - if (!content) { - return null; - } - return JSON.parse(content); - } catch (error48) { - return null; - } - } - function writeAuthConfig(config2) { - const authPath = getAuthConfigPath(); - const authDir = path.dirname(authPath); - if (!fs.existsSync(authDir)) { - fs.mkdirSync(authDir, { mode: 504, recursive: true }); - } - fs.writeFileSync(authPath, JSON.stringify(config2, null, 2), { mode: 384 }); - } - function isValidAccessToken(authConfig) { - if (!authConfig.token) - return false; - if (typeof authConfig.expiresAt !== "number") - return true; - const nowInSeconds = Math.floor(Date.now() / 1000); - return authConfig.expiresAt >= nowInSeconds; - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/oauth.js -var require_oauth = __commonJS((exports2, module2) => { - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var oauth_exports = {}; - __export2(oauth_exports, { - processTokenResponse: () => processTokenResponse, - refreshTokenRequest: () => refreshTokenRequest - }); - module2.exports = __toCommonJS2(oauth_exports); - var import_os = require("os"); - var VERCEL_ISSUER = "https://vercel.com"; - var VERCEL_CLI_CLIENT_ID = "cl_HYyOPBNtFMfHhaUn9L4QPfTZz6TP47bp"; - var userAgent = `@vercel/oidc node-${process.version} ${(0, import_os.platform)()} (${(0, import_os.arch)()}) ${(0, import_os.hostname)()}`; - var _tokenEndpoint = null; - async function getTokenEndpoint() { - if (_tokenEndpoint) { - return _tokenEndpoint; - } - const discoveryUrl = `${VERCEL_ISSUER}/.well-known/openid-configuration`; - const response = await fetch(discoveryUrl, { - headers: { "user-agent": userAgent } - }); - if (!response.ok) { - throw new Error("Failed to discover OAuth endpoints"); - } - const metadata = await response.json(); - if (!metadata || typeof metadata.token_endpoint !== "string") { - throw new Error("Invalid OAuth discovery response"); - } - const endpoint = metadata.token_endpoint; - _tokenEndpoint = endpoint; - return endpoint; - } - async function refreshTokenRequest(options) { - const tokenEndpoint = await getTokenEndpoint(); - return await fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "user-agent": userAgent - }, - body: new URLSearchParams({ - client_id: VERCEL_CLI_CLIENT_ID, - grant_type: "refresh_token", - ...options - }) - }); - } - async function processTokenResponse(response) { - const json2 = await response.json(); - if (!response.ok) { - const errorMsg = typeof json2 === "object" && json2 && "error" in json2 ? String(json2.error) : "Token refresh failed"; - return [new Error(errorMsg)]; - } - if (typeof json2 !== "object" || json2 === null) { - return [new Error("Invalid token response")]; - } - if (typeof json2.access_token !== "string") { - return [new Error("Missing access_token in response")]; - } - if (json2.token_type !== "Bearer") { - return [new Error("Invalid token_type in response")]; - } - if (typeof json2.expires_in !== "number") { - return [new Error("Missing expires_in in response")]; - } - return [null, json2]; - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/token-util.js -var require_token_util = __commonJS((exports2, module2) => { - var __create2 = Object.create; - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __getProtoOf2 = Object.getPrototypeOf; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toESM2 = (mod, isNodeMode, target) => (target = mod != null ? __create2(__getProtoOf2(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp2(target, "default", { value: mod, enumerable: true }) : target, mod)); - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var token_util_exports = {}; - __export2(token_util_exports, { - assertVercelOidcTokenResponse: () => assertVercelOidcTokenResponse, - findProjectInfo: () => findProjectInfo, - getTokenPayload: () => getTokenPayload, - getVercelCliToken: () => getVercelCliToken, - getVercelDataDir: () => getVercelDataDir, - getVercelOidcToken: () => getVercelOidcToken2, - isExpired: () => isExpired, - loadToken: () => loadToken, - saveToken: () => saveToken - }); - module2.exports = __toCommonJS2(token_util_exports); - var path = __toESM2(require("path")); - var fs = __toESM2(require("fs")); - var import_token_error = require_token_error(); - var import_token_io = require_token_io(); - var import_auth_config = require_auth_config(); - var import_oauth = require_oauth(); - function getVercelDataDir() { - const vercelFolder = "com.vercel.cli"; - const dataDir = (0, import_token_io.getUserDataDir)(); - if (!dataDir) { - return null; - } - return path.join(dataDir, vercelFolder); - } - async function getVercelCliToken() { - const authConfig = (0, import_auth_config.readAuthConfig)(); - if (!authConfig) { - return null; - } - if ((0, import_auth_config.isValidAccessToken)(authConfig)) { - return authConfig.token || null; - } - if (!authConfig.refreshToken) { - (0, import_auth_config.writeAuthConfig)({}); - return null; - } - try { - const tokenResponse = await (0, import_oauth.refreshTokenRequest)({ - refresh_token: authConfig.refreshToken - }); - const [tokensError, tokens] = await (0, import_oauth.processTokenResponse)(tokenResponse); - if (tokensError || !tokens) { - (0, import_auth_config.writeAuthConfig)({}); - return null; - } - const updatedConfig = { - token: tokens.access_token, - expiresAt: Math.floor(Date.now() / 1000) + tokens.expires_in - }; - if (tokens.refresh_token) { - updatedConfig.refreshToken = tokens.refresh_token; - } - (0, import_auth_config.writeAuthConfig)(updatedConfig); - return updatedConfig.token ?? null; - } catch (error48) { - (0, import_auth_config.writeAuthConfig)({}); - return null; - } - } - async function getVercelOidcToken2(authToken, projectId, teamId) { - const url2 = `https://api.vercel.com/v1/projects/${projectId}/token?source=vercel-oidc-refresh${teamId ? `&teamId=${teamId}` : ""}`; - const res = await fetch(url2, { - method: "POST", - headers: { - Authorization: `Bearer ${authToken}` - } - }); - if (!res.ok) { - throw new import_token_error.VercelOidcTokenError(`Failed to refresh OIDC token: ${res.statusText}`); - } - const tokenRes = await res.json(); - assertVercelOidcTokenResponse(tokenRes); - return tokenRes; - } - function assertVercelOidcTokenResponse(res) { - if (!res || typeof res !== "object") { - throw new TypeError("Vercel OIDC token is malformed. Expected an object. Please run `vc env pull` and try again"); - } - if (!("token" in res) || typeof res.token !== "string") { - throw new TypeError("Vercel OIDC token is malformed. Expected a string-valued token property. Please run `vc env pull` and try again"); - } - } - function findProjectInfo() { - const dir = (0, import_token_io.findRootDir)(); - if (!dir) { - throw new import_token_error.VercelOidcTokenError("Unable to find project root directory. Have you linked your project with `vc link?`"); - } - const prjPath = path.join(dir, ".vercel", "project.json"); - if (!fs.existsSync(prjPath)) { - throw new import_token_error.VercelOidcTokenError("project.json not found, have you linked your project with `vc link?`"); - } - const prj = JSON.parse(fs.readFileSync(prjPath, "utf8")); - if (typeof prj.projectId !== "string" && typeof prj.orgId !== "string") { - throw new TypeError("Expected a string-valued projectId property. Try running `vc link` to re-link your project."); - } - return { projectId: prj.projectId, teamId: prj.orgId }; - } - function saveToken(token, projectId) { - const dir = (0, import_token_io.getUserDataDir)(); - if (!dir) { - throw new import_token_error.VercelOidcTokenError("Unable to find user data directory. Please reach out to Vercel support."); - } - const tokenPath = path.join(dir, "com.vercel.token", `${projectId}.json`); - const tokenJson = JSON.stringify(token); - fs.mkdirSync(path.dirname(tokenPath), { mode: 504, recursive: true }); - fs.writeFileSync(tokenPath, tokenJson); - fs.chmodSync(tokenPath, 432); - return; - } - function loadToken(projectId) { - const dir = (0, import_token_io.getUserDataDir)(); - if (!dir) { - throw new import_token_error.VercelOidcTokenError("Unable to find user data directory. Please reach out to Vercel support."); - } - const tokenPath = path.join(dir, "com.vercel.token", `${projectId}.json`); - if (!fs.existsSync(tokenPath)) { - return null; - } - const token = JSON.parse(fs.readFileSync(tokenPath, "utf8")); - assertVercelOidcTokenResponse(token); - return token; - } - function getTokenPayload(token) { - const tokenParts = token.split("."); - if (tokenParts.length !== 3) { - throw new import_token_error.VercelOidcTokenError("Invalid token. Please run `vc env pull` and try again"); - } - const base643 = tokenParts[1].replace(/-/g, "+").replace(/_/g, "/"); - const padded = base643.padEnd(base643.length + (4 - base643.length % 4) % 4, "="); - return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); - } - function isExpired(token) { - return token.exp * 1000 < Date.now(); - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/token.js -var require_token = __commonJS((exports2, module2) => { - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var token_exports = {}; - __export2(token_exports, { - refreshToken: () => refreshToken - }); - module2.exports = __toCommonJS2(token_exports); - var import_token_error = require_token_error(); - var import_token_util = require_token_util(); - async function refreshToken() { - const { projectId, teamId } = (0, import_token_util.findProjectInfo)(); - let maybeToken = (0, import_token_util.loadToken)(projectId); - if (!maybeToken || (0, import_token_util.isExpired)((0, import_token_util.getTokenPayload)(maybeToken.token))) { - const authToken = await (0, import_token_util.getVercelCliToken)(); - if (!authToken) { - throw new import_token_error.VercelOidcTokenError("Failed to refresh OIDC token: Log in to Vercel CLI and link your project with `vc link`"); - } - if (!projectId) { - throw new import_token_error.VercelOidcTokenError("Failed to refresh OIDC token: Try re-linking your project with `vc link`"); - } - maybeToken = await (0, import_token_util.getVercelOidcToken)(authToken, projectId, teamId); - if (!maybeToken) { - throw new import_token_error.VercelOidcTokenError("Failed to refresh OIDC token"); - } - (0, import_token_util.saveToken)(maybeToken, projectId); - } - process.env.VERCEL_OIDC_TOKEN = maybeToken.token; - return; - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/get-vercel-oidc-token.js -var require_get_vercel_oidc_token = __commonJS((exports2, module2) => { - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var get_vercel_oidc_token_exports = {}; - __export2(get_vercel_oidc_token_exports, { - getVercelOidcToken: () => getVercelOidcToken2, - getVercelOidcTokenSync: () => getVercelOidcTokenSync2 - }); - module2.exports = __toCommonJS2(get_vercel_oidc_token_exports); - var import_get_context = require_get_context(); - var import_token_error = require_token_error(); - async function getVercelOidcToken2() { - let token = ""; - let err; - try { - token = getVercelOidcTokenSync2(); - } catch (error48) { - err = error48; - } - try { - const [{ getTokenPayload, isExpired }, { refreshToken }] = await Promise.all([ - await Promise.resolve().then(() => __toESM(require_token_util())), - await Promise.resolve().then(() => __toESM(require_token())) - ]); - if (!token || isExpired(getTokenPayload(token))) { - await refreshToken(); - token = getVercelOidcTokenSync2(); - } - } catch (error48) { - let message = err instanceof Error ? err.message : ""; - if (error48 instanceof Error) { - message = `${message} -${error48.message}`; - } - if (message) { - throw new import_token_error.VercelOidcTokenError(message); - } - throw error48; - } - return token; - } - function getVercelOidcTokenSync2() { - const token = (0, import_get_context.getContext)().headers?.["x-vercel-oidc-token"] ?? process.env.VERCEL_OIDC_TOKEN; - if (!token) { - throw new Error(`The 'x-vercel-oidc-token' header is missing from the request. Do you have the OIDC option enabled in the Vercel project settings?`); - } - return token; - } -}); - -// ../../node_modules/.pnpm/@vercel+oidc@3.1.0/node_modules/@vercel/oidc/dist/index.js -var require_dist = __commonJS((exports2, module2) => { - var __defProp2 = Object.defineProperty; - var __getOwnPropDesc2 = Object.getOwnPropertyDescriptor; - var __getOwnPropNames2 = Object.getOwnPropertyNames; - var __hasOwnProp2 = Object.prototype.hasOwnProperty; - var __export2 = (target, all) => { - for (var name15 in all) - __defProp2(target, name15, { get: all[name15], enumerable: true }); - }; - var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames2(from)) - if (!__hasOwnProp2.call(to, key) && key !== except) - __defProp2(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc2(from, key)) || desc.enumerable }); - } - return to; - }; - var __toCommonJS2 = (mod) => __copyProps(__defProp2({}, "__esModule", { value: true }), mod); - var src_exports = {}; - __export2(src_exports, { - getContext: () => import_get_context.getContext, - getVercelOidcToken: () => import_get_vercel_oidc_token.getVercelOidcToken, - getVercelOidcTokenSync: () => import_get_vercel_oidc_token.getVercelOidcTokenSync - }); - module2.exports = __toCommonJS2(src_exports); - var import_get_vercel_oidc_token = require_get_vercel_oidc_token(); - var import_get_context = require_get_context(); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/platform/node/globalThis.js -var require_globalThis = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2._globalThis = undefined; - exports2._globalThis = typeof globalThis === "object" ? globalThis : global; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/platform/node/index.js -var require_node = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { - return m[k]; - } }); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) { - for (var p in m) - if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) - __createBinding(exports3, m, p); - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - __exportStar(require_globalThis(), exports2); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/platform/index.js -var require_platform = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { - return m[k]; - } }); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) { - for (var p in m) - if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) - __createBinding(exports3, m, p); - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - __exportStar(require_node(), exports2); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/version.js -var require_version = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.VERSION = undefined; - exports2.VERSION = "1.9.0"; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/internal/semver.js -var require_semver = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.isCompatible = exports2._makeCompatibilityCheck = undefined; - var version_1 = require_version(); - var re = /^(\d+)\.(\d+)\.(\d+)(-(.+))?$/; - function _makeCompatibilityCheck(ownVersion) { - const acceptedVersions = new Set([ownVersion]); - const rejectedVersions = new Set; - const myVersionMatch = ownVersion.match(re); - if (!myVersionMatch) { - return () => false; - } - const ownVersionParsed = { - major: +myVersionMatch[1], - minor: +myVersionMatch[2], - patch: +myVersionMatch[3], - prerelease: myVersionMatch[4] - }; - if (ownVersionParsed.prerelease != null) { - return function isExactmatch(globalVersion) { - return globalVersion === ownVersion; - }; - } - function _reject(v) { - rejectedVersions.add(v); - return false; - } - function _accept(v) { - acceptedVersions.add(v); - return true; - } - return function isCompatible(globalVersion) { - if (acceptedVersions.has(globalVersion)) { - return true; - } - if (rejectedVersions.has(globalVersion)) { - return false; - } - const globalVersionMatch = globalVersion.match(re); - if (!globalVersionMatch) { - return _reject(globalVersion); - } - const globalVersionParsed = { - major: +globalVersionMatch[1], - minor: +globalVersionMatch[2], - patch: +globalVersionMatch[3], - prerelease: globalVersionMatch[4] - }; - if (globalVersionParsed.prerelease != null) { - return _reject(globalVersion); - } - if (ownVersionParsed.major !== globalVersionParsed.major) { - return _reject(globalVersion); - } - if (ownVersionParsed.major === 0) { - if (ownVersionParsed.minor === globalVersionParsed.minor && ownVersionParsed.patch <= globalVersionParsed.patch) { - return _accept(globalVersion); - } - return _reject(globalVersion); - } - if (ownVersionParsed.minor <= globalVersionParsed.minor) { - return _accept(globalVersion); - } - return _reject(globalVersion); - }; - } - exports2._makeCompatibilityCheck = _makeCompatibilityCheck; - exports2.isCompatible = _makeCompatibilityCheck(version_1.VERSION); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/internal/global-utils.js -var require_global_utils = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.unregisterGlobal = exports2.getGlobal = exports2.registerGlobal = undefined; - var platform_1 = require_platform(); - var version_1 = require_version(); - var semver_1 = require_semver(); - var major = version_1.VERSION.split(".")[0]; - var GLOBAL_OPENTELEMETRY_API_KEY = Symbol.for(`opentelemetry.js.api.${major}`); - var _global = platform_1._globalThis; - function registerGlobal(type, instance, diag, allowOverride = false) { - var _a16; - const api2 = _global[GLOBAL_OPENTELEMETRY_API_KEY] = (_a16 = _global[GLOBAL_OPENTELEMETRY_API_KEY]) !== null && _a16 !== undefined ? _a16 : { - version: version_1.VERSION - }; - if (!allowOverride && api2[type]) { - const err = new Error(`@opentelemetry/api: Attempted duplicate registration of API: ${type}`); - diag.error(err.stack || err.message); - return false; - } - if (api2.version !== version_1.VERSION) { - const err = new Error(`@opentelemetry/api: Registration of version v${api2.version} for ${type} does not match previously registered API v${version_1.VERSION}`); - diag.error(err.stack || err.message); - return false; - } - api2[type] = instance; - diag.debug(`@opentelemetry/api: Registered a global for ${type} v${version_1.VERSION}.`); - return true; - } - exports2.registerGlobal = registerGlobal; - function getGlobal(type) { - var _a16, _b16; - const globalVersion = (_a16 = _global[GLOBAL_OPENTELEMETRY_API_KEY]) === null || _a16 === undefined ? undefined : _a16.version; - if (!globalVersion || !(0, semver_1.isCompatible)(globalVersion)) { - return; - } - return (_b16 = _global[GLOBAL_OPENTELEMETRY_API_KEY]) === null || _b16 === undefined ? undefined : _b16[type]; - } - exports2.getGlobal = getGlobal; - function unregisterGlobal(type, diag) { - diag.debug(`@opentelemetry/api: Unregistering a global for ${type} v${version_1.VERSION}.`); - const api2 = _global[GLOBAL_OPENTELEMETRY_API_KEY]; - if (api2) { - delete api2[type]; - } - } - exports2.unregisterGlobal = unregisterGlobal; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/diag/ComponentLogger.js -var require_ComponentLogger = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.DiagComponentLogger = undefined; - var global_utils_1 = require_global_utils(); - - class DiagComponentLogger { - constructor(props) { - this._namespace = props.namespace || "DiagComponentLogger"; - } - debug(...args) { - return logProxy("debug", this._namespace, args); - } - error(...args) { - return logProxy("error", this._namespace, args); - } - info(...args) { - return logProxy("info", this._namespace, args); - } - warn(...args) { - return logProxy("warn", this._namespace, args); - } - verbose(...args) { - return logProxy("verbose", this._namespace, args); - } - } - exports2.DiagComponentLogger = DiagComponentLogger; - function logProxy(funcName, namespace, args) { - const logger = (0, global_utils_1.getGlobal)("diag"); - if (!logger) { - return; - } - args.unshift(namespace); - return logger[funcName](...args); - } -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/diag/types.js -var require_types = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.DiagLogLevel = undefined; - var DiagLogLevel; - (function(DiagLogLevel2) { - DiagLogLevel2[DiagLogLevel2["NONE"] = 0] = "NONE"; - DiagLogLevel2[DiagLogLevel2["ERROR"] = 30] = "ERROR"; - DiagLogLevel2[DiagLogLevel2["WARN"] = 50] = "WARN"; - DiagLogLevel2[DiagLogLevel2["INFO"] = 60] = "INFO"; - DiagLogLevel2[DiagLogLevel2["DEBUG"] = 70] = "DEBUG"; - DiagLogLevel2[DiagLogLevel2["VERBOSE"] = 80] = "VERBOSE"; - DiagLogLevel2[DiagLogLevel2["ALL"] = 9999] = "ALL"; - })(DiagLogLevel = exports2.DiagLogLevel || (exports2.DiagLogLevel = {})); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/diag/internal/logLevelLogger.js -var require_logLevelLogger = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.createLogLevelDiagLogger = undefined; - var types_1 = require_types(); - function createLogLevelDiagLogger(maxLevel, logger) { - if (maxLevel < types_1.DiagLogLevel.NONE) { - maxLevel = types_1.DiagLogLevel.NONE; - } else if (maxLevel > types_1.DiagLogLevel.ALL) { - maxLevel = types_1.DiagLogLevel.ALL; - } - logger = logger || {}; - function _filterFunc(funcName, theLevel) { - const theFunc = logger[funcName]; - if (typeof theFunc === "function" && maxLevel >= theLevel) { - return theFunc.bind(logger); - } - return function() {}; - } - return { - error: _filterFunc("error", types_1.DiagLogLevel.ERROR), - warn: _filterFunc("warn", types_1.DiagLogLevel.WARN), - info: _filterFunc("info", types_1.DiagLogLevel.INFO), - debug: _filterFunc("debug", types_1.DiagLogLevel.DEBUG), - verbose: _filterFunc("verbose", types_1.DiagLogLevel.VERBOSE) - }; - } - exports2.createLogLevelDiagLogger = createLogLevelDiagLogger; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/api/diag.js -var require_diag = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.DiagAPI = undefined; - var ComponentLogger_1 = require_ComponentLogger(); - var logLevelLogger_1 = require_logLevelLogger(); - var types_1 = require_types(); - var global_utils_1 = require_global_utils(); - var API_NAME = "diag"; - - class DiagAPI { - constructor() { - function _logProxy(funcName) { - return function(...args) { - const logger = (0, global_utils_1.getGlobal)("diag"); - if (!logger) - return; - return logger[funcName](...args); - }; - } - const self = this; - const setLogger = (logger, optionsOrLogLevel = { logLevel: types_1.DiagLogLevel.INFO }) => { - var _a16, _b16, _c; - if (logger === self) { - const err = new Error("Cannot use diag as the logger for itself. Please use a DiagLogger implementation like ConsoleDiagLogger or a custom implementation"); - self.error((_a16 = err.stack) !== null && _a16 !== undefined ? _a16 : err.message); - return false; - } - if (typeof optionsOrLogLevel === "number") { - optionsOrLogLevel = { - logLevel: optionsOrLogLevel - }; - } - const oldLogger = (0, global_utils_1.getGlobal)("diag"); - const newLogger = (0, logLevelLogger_1.createLogLevelDiagLogger)((_b16 = optionsOrLogLevel.logLevel) !== null && _b16 !== undefined ? _b16 : types_1.DiagLogLevel.INFO, logger); - if (oldLogger && !optionsOrLogLevel.suppressOverrideMessage) { - const stack = (_c = new Error().stack) !== null && _c !== undefined ? _c : ""; - oldLogger.warn(`Current logger will be overwritten from ${stack}`); - newLogger.warn(`Current logger will overwrite one already registered from ${stack}`); - } - return (0, global_utils_1.registerGlobal)("diag", newLogger, self, true); - }; - self.setLogger = setLogger; - self.disable = () => { - (0, global_utils_1.unregisterGlobal)(API_NAME, self); - }; - self.createComponentLogger = (options) => { - return new ComponentLogger_1.DiagComponentLogger(options); - }; - self.verbose = _logProxy("verbose"); - self.debug = _logProxy("debug"); - self.info = _logProxy("info"); - self.warn = _logProxy("warn"); - self.error = _logProxy("error"); - } - static instance() { - if (!this._instance) { - this._instance = new DiagAPI; - } - return this._instance; - } - } - exports2.DiagAPI = DiagAPI; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/baggage/internal/baggage-impl.js -var require_baggage_impl = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.BaggageImpl = undefined; - - class BaggageImpl { - constructor(entries) { - this._entries = entries ? new Map(entries) : new Map; - } - getEntry(key) { - const entry = this._entries.get(key); - if (!entry) { - return; - } - return Object.assign({}, entry); - } - getAllEntries() { - return Array.from(this._entries.entries()).map(([k, v]) => [k, v]); - } - setEntry(key, entry) { - const newBaggage = new BaggageImpl(this._entries); - newBaggage._entries.set(key, entry); - return newBaggage; - } - removeEntry(key) { - const newBaggage = new BaggageImpl(this._entries); - newBaggage._entries.delete(key); - return newBaggage; - } - removeEntries(...keys) { - const newBaggage = new BaggageImpl(this._entries); - for (const key of keys) { - newBaggage._entries.delete(key); - } - return newBaggage; - } - clear() { - return new BaggageImpl; - } - } - exports2.BaggageImpl = BaggageImpl; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/baggage/internal/symbol.js -var require_symbol = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.baggageEntryMetadataSymbol = undefined; - exports2.baggageEntryMetadataSymbol = Symbol("BaggageEntryMetadata"); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/baggage/utils.js -var require_utils = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.baggageEntryMetadataFromString = exports2.createBaggage = undefined; - var diag_1 = require_diag(); - var baggage_impl_1 = require_baggage_impl(); - var symbol_1 = require_symbol(); - var diag = diag_1.DiagAPI.instance(); - function createBaggage(entries = {}) { - return new baggage_impl_1.BaggageImpl(new Map(Object.entries(entries))); - } - exports2.createBaggage = createBaggage; - function baggageEntryMetadataFromString(str) { - if (typeof str !== "string") { - diag.error(`Cannot create baggage metadata from unknown type: ${typeof str}`); - str = ""; - } - return { - __TYPE__: symbol_1.baggageEntryMetadataSymbol, - toString() { - return str; - } - }; - } - exports2.baggageEntryMetadataFromString = baggageEntryMetadataFromString; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/context/context.js -var require_context = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ROOT_CONTEXT = exports2.createContextKey = undefined; - function createContextKey(description) { - return Symbol.for(description); - } - exports2.createContextKey = createContextKey; - - class BaseContext { - constructor(parentContext) { - const self = this; - self._currentContext = parentContext ? new Map(parentContext) : new Map; - self.getValue = (key) => self._currentContext.get(key); - self.setValue = (key, value) => { - const context = new BaseContext(self._currentContext); - context._currentContext.set(key, value); - return context; - }; - self.deleteValue = (key) => { - const context = new BaseContext(self._currentContext); - context._currentContext.delete(key); - return context; - }; - } - } - exports2.ROOT_CONTEXT = new BaseContext; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/diag/consoleLogger.js -var require_consoleLogger = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.DiagConsoleLogger = undefined; - var consoleMap = [ - { n: "error", c: "error" }, - { n: "warn", c: "warn" }, - { n: "info", c: "info" }, - { n: "debug", c: "debug" }, - { n: "verbose", c: "trace" } - ]; - - class DiagConsoleLogger { - constructor() { - function _consoleFunc(funcName) { - return function(...args) { - if (console) { - let theFunc = console[funcName]; - if (typeof theFunc !== "function") { - theFunc = console.log; - } - if (typeof theFunc === "function") { - return theFunc.apply(console, args); - } - } - }; - } - for (let i = 0;i < consoleMap.length; i++) { - this[consoleMap[i].n] = _consoleFunc(consoleMap[i].c); - } - } - } - exports2.DiagConsoleLogger = DiagConsoleLogger; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/metrics/NoopMeter.js -var require_NoopMeter = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.createNoopMeter = exports2.NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC = exports2.NOOP_OBSERVABLE_GAUGE_METRIC = exports2.NOOP_OBSERVABLE_COUNTER_METRIC = exports2.NOOP_UP_DOWN_COUNTER_METRIC = exports2.NOOP_HISTOGRAM_METRIC = exports2.NOOP_GAUGE_METRIC = exports2.NOOP_COUNTER_METRIC = exports2.NOOP_METER = exports2.NoopObservableUpDownCounterMetric = exports2.NoopObservableGaugeMetric = exports2.NoopObservableCounterMetric = exports2.NoopObservableMetric = exports2.NoopHistogramMetric = exports2.NoopGaugeMetric = exports2.NoopUpDownCounterMetric = exports2.NoopCounterMetric = exports2.NoopMetric = exports2.NoopMeter = undefined; - - class NoopMeter { - constructor() {} - createGauge(_name, _options) { - return exports2.NOOP_GAUGE_METRIC; - } - createHistogram(_name, _options) { - return exports2.NOOP_HISTOGRAM_METRIC; - } - createCounter(_name, _options) { - return exports2.NOOP_COUNTER_METRIC; - } - createUpDownCounter(_name, _options) { - return exports2.NOOP_UP_DOWN_COUNTER_METRIC; - } - createObservableGauge(_name, _options) { - return exports2.NOOP_OBSERVABLE_GAUGE_METRIC; - } - createObservableCounter(_name, _options) { - return exports2.NOOP_OBSERVABLE_COUNTER_METRIC; - } - createObservableUpDownCounter(_name, _options) { - return exports2.NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC; - } - addBatchObservableCallback(_callback, _observables) {} - removeBatchObservableCallback(_callback) {} - } - exports2.NoopMeter = NoopMeter; - - class NoopMetric { - } - exports2.NoopMetric = NoopMetric; - - class NoopCounterMetric extends NoopMetric { - add(_value, _attributes) {} - } - exports2.NoopCounterMetric = NoopCounterMetric; - - class NoopUpDownCounterMetric extends NoopMetric { - add(_value, _attributes) {} - } - exports2.NoopUpDownCounterMetric = NoopUpDownCounterMetric; - - class NoopGaugeMetric extends NoopMetric { - record(_value, _attributes) {} - } - exports2.NoopGaugeMetric = NoopGaugeMetric; - - class NoopHistogramMetric extends NoopMetric { - record(_value, _attributes) {} - } - exports2.NoopHistogramMetric = NoopHistogramMetric; - - class NoopObservableMetric { - addCallback(_callback) {} - removeCallback(_callback) {} - } - exports2.NoopObservableMetric = NoopObservableMetric; - - class NoopObservableCounterMetric extends NoopObservableMetric { - } - exports2.NoopObservableCounterMetric = NoopObservableCounterMetric; - - class NoopObservableGaugeMetric extends NoopObservableMetric { - } - exports2.NoopObservableGaugeMetric = NoopObservableGaugeMetric; - - class NoopObservableUpDownCounterMetric extends NoopObservableMetric { - } - exports2.NoopObservableUpDownCounterMetric = NoopObservableUpDownCounterMetric; - exports2.NOOP_METER = new NoopMeter; - exports2.NOOP_COUNTER_METRIC = new NoopCounterMetric; - exports2.NOOP_GAUGE_METRIC = new NoopGaugeMetric; - exports2.NOOP_HISTOGRAM_METRIC = new NoopHistogramMetric; - exports2.NOOP_UP_DOWN_COUNTER_METRIC = new NoopUpDownCounterMetric; - exports2.NOOP_OBSERVABLE_COUNTER_METRIC = new NoopObservableCounterMetric; - exports2.NOOP_OBSERVABLE_GAUGE_METRIC = new NoopObservableGaugeMetric; - exports2.NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC = new NoopObservableUpDownCounterMetric; - function createNoopMeter() { - return exports2.NOOP_METER; - } - exports2.createNoopMeter = createNoopMeter; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/metrics/Metric.js -var require_Metric = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ValueType = undefined; - var ValueType; - (function(ValueType2) { - ValueType2[ValueType2["INT"] = 0] = "INT"; - ValueType2[ValueType2["DOUBLE"] = 1] = "DOUBLE"; - })(ValueType = exports2.ValueType || (exports2.ValueType = {})); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/propagation/TextMapPropagator.js -var require_TextMapPropagator = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.defaultTextMapSetter = exports2.defaultTextMapGetter = undefined; - exports2.defaultTextMapGetter = { - get(carrier, key) { - if (carrier == null) { - return; - } - return carrier[key]; - }, - keys(carrier) { - if (carrier == null) { - return []; - } - return Object.keys(carrier); - } - }; - exports2.defaultTextMapSetter = { - set(carrier, key, value) { - if (carrier == null) { - return; - } - carrier[key] = value; - } - }; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/context/NoopContextManager.js -var require_NoopContextManager = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.NoopContextManager = undefined; - var context_1 = require_context(); - - class NoopContextManager { - active() { - return context_1.ROOT_CONTEXT; - } - with(_context, fn, thisArg, ...args) { - return fn.call(thisArg, ...args); - } - bind(_context, target) { - return target; - } - enable() { - return this; - } - disable() { - return this; - } - } - exports2.NoopContextManager = NoopContextManager; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/api/context.js -var require_context2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ContextAPI = undefined; - var NoopContextManager_1 = require_NoopContextManager(); - var global_utils_1 = require_global_utils(); - var diag_1 = require_diag(); - var API_NAME = "context"; - var NOOP_CONTEXT_MANAGER = new NoopContextManager_1.NoopContextManager; - - class ContextAPI { - constructor() {} - static getInstance() { - if (!this._instance) { - this._instance = new ContextAPI; - } - return this._instance; - } - setGlobalContextManager(contextManager) { - return (0, global_utils_1.registerGlobal)(API_NAME, contextManager, diag_1.DiagAPI.instance()); - } - active() { - return this._getContextManager().active(); - } - with(context, fn, thisArg, ...args) { - return this._getContextManager().with(context, fn, thisArg, ...args); - } - bind(context, target) { - return this._getContextManager().bind(context, target); - } - _getContextManager() { - return (0, global_utils_1.getGlobal)(API_NAME) || NOOP_CONTEXT_MANAGER; - } - disable() { - this._getContextManager().disable(); - (0, global_utils_1.unregisterGlobal)(API_NAME, diag_1.DiagAPI.instance()); - } - } - exports2.ContextAPI = ContextAPI; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/trace_flags.js -var require_trace_flags = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.TraceFlags = undefined; - var TraceFlags; - (function(TraceFlags2) { - TraceFlags2[TraceFlags2["NONE"] = 0] = "NONE"; - TraceFlags2[TraceFlags2["SAMPLED"] = 1] = "SAMPLED"; - })(TraceFlags = exports2.TraceFlags || (exports2.TraceFlags = {})); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/invalid-span-constants.js -var require_invalid_span_constants = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.INVALID_SPAN_CONTEXT = exports2.INVALID_TRACEID = exports2.INVALID_SPANID = undefined; - var trace_flags_1 = require_trace_flags(); - exports2.INVALID_SPANID = "0000000000000000"; - exports2.INVALID_TRACEID = "00000000000000000000000000000000"; - exports2.INVALID_SPAN_CONTEXT = { - traceId: exports2.INVALID_TRACEID, - spanId: exports2.INVALID_SPANID, - traceFlags: trace_flags_1.TraceFlags.NONE - }; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/NonRecordingSpan.js -var require_NonRecordingSpan = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.NonRecordingSpan = undefined; - var invalid_span_constants_1 = require_invalid_span_constants(); - - class NonRecordingSpan { - constructor(_spanContext = invalid_span_constants_1.INVALID_SPAN_CONTEXT) { - this._spanContext = _spanContext; - } - spanContext() { - return this._spanContext; - } - setAttribute(_key, _value) { - return this; - } - setAttributes(_attributes) { - return this; - } - addEvent(_name, _attributes) { - return this; - } - addLink(_link) { - return this; - } - addLinks(_links) { - return this; - } - setStatus(_status) { - return this; - } - updateName(_name) { - return this; - } - end(_endTime) {} - isRecording() { - return false; - } - recordException(_exception, _time) {} - } - exports2.NonRecordingSpan = NonRecordingSpan; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/context-utils.js -var require_context_utils = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.getSpanContext = exports2.setSpanContext = exports2.deleteSpan = exports2.setSpan = exports2.getActiveSpan = exports2.getSpan = undefined; - var context_1 = require_context(); - var NonRecordingSpan_1 = require_NonRecordingSpan(); - var context_2 = require_context2(); - var SPAN_KEY = (0, context_1.createContextKey)("OpenTelemetry Context Key SPAN"); - function getSpan(context) { - return context.getValue(SPAN_KEY) || undefined; - } - exports2.getSpan = getSpan; - function getActiveSpan() { - return getSpan(context_2.ContextAPI.getInstance().active()); - } - exports2.getActiveSpan = getActiveSpan; - function setSpan(context, span) { - return context.setValue(SPAN_KEY, span); - } - exports2.setSpan = setSpan; - function deleteSpan(context) { - return context.deleteValue(SPAN_KEY); - } - exports2.deleteSpan = deleteSpan; - function setSpanContext(context, spanContext) { - return setSpan(context, new NonRecordingSpan_1.NonRecordingSpan(spanContext)); - } - exports2.setSpanContext = setSpanContext; - function getSpanContext(context) { - var _a16; - return (_a16 = getSpan(context)) === null || _a16 === undefined ? undefined : _a16.spanContext(); - } - exports2.getSpanContext = getSpanContext; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/spancontext-utils.js -var require_spancontext_utils = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.wrapSpanContext = exports2.isSpanContextValid = exports2.isValidSpanId = exports2.isValidTraceId = undefined; - var invalid_span_constants_1 = require_invalid_span_constants(); - var NonRecordingSpan_1 = require_NonRecordingSpan(); - var VALID_TRACEID_REGEX = /^([0-9a-f]{32})$/i; - var VALID_SPANID_REGEX = /^[0-9a-f]{16}$/i; - function isValidTraceId(traceId) { - return VALID_TRACEID_REGEX.test(traceId) && traceId !== invalid_span_constants_1.INVALID_TRACEID; - } - exports2.isValidTraceId = isValidTraceId; - function isValidSpanId(spanId) { - return VALID_SPANID_REGEX.test(spanId) && spanId !== invalid_span_constants_1.INVALID_SPANID; - } - exports2.isValidSpanId = isValidSpanId; - function isSpanContextValid(spanContext) { - return isValidTraceId(spanContext.traceId) && isValidSpanId(spanContext.spanId); - } - exports2.isSpanContextValid = isSpanContextValid; - function wrapSpanContext(spanContext) { - return new NonRecordingSpan_1.NonRecordingSpan(spanContext); - } - exports2.wrapSpanContext = wrapSpanContext; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/NoopTracer.js -var require_NoopTracer = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.NoopTracer = undefined; - var context_1 = require_context2(); - var context_utils_1 = require_context_utils(); - var NonRecordingSpan_1 = require_NonRecordingSpan(); - var spancontext_utils_1 = require_spancontext_utils(); - var contextApi = context_1.ContextAPI.getInstance(); - - class NoopTracer { - startSpan(name15, options, context = contextApi.active()) { - const root = Boolean(options === null || options === undefined ? undefined : options.root); - if (root) { - return new NonRecordingSpan_1.NonRecordingSpan; - } - const parentFromContext = context && (0, context_utils_1.getSpanContext)(context); - if (isSpanContext(parentFromContext) && (0, spancontext_utils_1.isSpanContextValid)(parentFromContext)) { - return new NonRecordingSpan_1.NonRecordingSpan(parentFromContext); - } else { - return new NonRecordingSpan_1.NonRecordingSpan; - } - } - startActiveSpan(name15, arg2, arg3, arg4) { - let opts; - let ctx; - let fn; - if (arguments.length < 2) { - return; - } else if (arguments.length === 2) { - fn = arg2; - } else if (arguments.length === 3) { - opts = arg2; - fn = arg3; - } else { - opts = arg2; - ctx = arg3; - fn = arg4; - } - const parentContext = ctx !== null && ctx !== undefined ? ctx : contextApi.active(); - const span = this.startSpan(name15, opts, parentContext); - const contextWithSpanSet = (0, context_utils_1.setSpan)(parentContext, span); - return contextApi.with(contextWithSpanSet, fn, undefined, span); - } - } - exports2.NoopTracer = NoopTracer; - function isSpanContext(spanContext) { - return typeof spanContext === "object" && typeof spanContext["spanId"] === "string" && typeof spanContext["traceId"] === "string" && typeof spanContext["traceFlags"] === "number"; - } -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/ProxyTracer.js -var require_ProxyTracer = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ProxyTracer = undefined; - var NoopTracer_1 = require_NoopTracer(); - var NOOP_TRACER = new NoopTracer_1.NoopTracer; - - class ProxyTracer { - constructor(_provider, name15, version2, options) { - this._provider = _provider; - this.name = name15; - this.version = version2; - this.options = options; - } - startSpan(name15, options, context) { - return this._getTracer().startSpan(name15, options, context); - } - startActiveSpan(_name, _options, _context, _fn) { - const tracer = this._getTracer(); - return Reflect.apply(tracer.startActiveSpan, tracer, arguments); - } - _getTracer() { - if (this._delegate) { - return this._delegate; - } - const tracer = this._provider.getDelegateTracer(this.name, this.version, this.options); - if (!tracer) { - return NOOP_TRACER; - } - this._delegate = tracer; - return this._delegate; - } - } - exports2.ProxyTracer = ProxyTracer; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/NoopTracerProvider.js -var require_NoopTracerProvider = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.NoopTracerProvider = undefined; - var NoopTracer_1 = require_NoopTracer(); - - class NoopTracerProvider { - getTracer(_name, _version, _options) { - return new NoopTracer_1.NoopTracer; - } - } - exports2.NoopTracerProvider = NoopTracerProvider; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/ProxyTracerProvider.js -var require_ProxyTracerProvider = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ProxyTracerProvider = undefined; - var ProxyTracer_1 = require_ProxyTracer(); - var NoopTracerProvider_1 = require_NoopTracerProvider(); - var NOOP_TRACER_PROVIDER = new NoopTracerProvider_1.NoopTracerProvider; - - class ProxyTracerProvider { - getTracer(name15, version2, options) { - var _a16; - return (_a16 = this.getDelegateTracer(name15, version2, options)) !== null && _a16 !== undefined ? _a16 : new ProxyTracer_1.ProxyTracer(this, name15, version2, options); - } - getDelegate() { - var _a16; - return (_a16 = this._delegate) !== null && _a16 !== undefined ? _a16 : NOOP_TRACER_PROVIDER; - } - setDelegate(delegate) { - this._delegate = delegate; - } - getDelegateTracer(name15, version2, options) { - var _a16; - return (_a16 = this._delegate) === null || _a16 === undefined ? undefined : _a16.getTracer(name15, version2, options); - } - } - exports2.ProxyTracerProvider = ProxyTracerProvider; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/SamplingResult.js -var require_SamplingResult = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SamplingDecision = undefined; - var SamplingDecision; - (function(SamplingDecision2) { - SamplingDecision2[SamplingDecision2["NOT_RECORD"] = 0] = "NOT_RECORD"; - SamplingDecision2[SamplingDecision2["RECORD"] = 1] = "RECORD"; - SamplingDecision2[SamplingDecision2["RECORD_AND_SAMPLED"] = 2] = "RECORD_AND_SAMPLED"; - })(SamplingDecision = exports2.SamplingDecision || (exports2.SamplingDecision = {})); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/span_kind.js -var require_span_kind = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SpanKind = undefined; - var SpanKind; - (function(SpanKind2) { - SpanKind2[SpanKind2["INTERNAL"] = 0] = "INTERNAL"; - SpanKind2[SpanKind2["SERVER"] = 1] = "SERVER"; - SpanKind2[SpanKind2["CLIENT"] = 2] = "CLIENT"; - SpanKind2[SpanKind2["PRODUCER"] = 3] = "PRODUCER"; - SpanKind2[SpanKind2["CONSUMER"] = 4] = "CONSUMER"; - })(SpanKind = exports2.SpanKind || (exports2.SpanKind = {})); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/status.js -var require_status = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SpanStatusCode = undefined; - var SpanStatusCode; - (function(SpanStatusCode2) { - SpanStatusCode2[SpanStatusCode2["UNSET"] = 0] = "UNSET"; - SpanStatusCode2[SpanStatusCode2["OK"] = 1] = "OK"; - SpanStatusCode2[SpanStatusCode2["ERROR"] = 2] = "ERROR"; - })(SpanStatusCode = exports2.SpanStatusCode || (exports2.SpanStatusCode = {})); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/internal/tracestate-validators.js -var require_tracestate_validators = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.validateValue = exports2.validateKey = undefined; - var VALID_KEY_CHAR_RANGE = "[_0-9a-z-*/]"; - var VALID_KEY = `[a-z]${VALID_KEY_CHAR_RANGE}{0,255}`; - var VALID_VENDOR_KEY = `[a-z0-9]${VALID_KEY_CHAR_RANGE}{0,240}@[a-z]${VALID_KEY_CHAR_RANGE}{0,13}`; - var VALID_KEY_REGEX = new RegExp(`^(?:${VALID_KEY}|${VALID_VENDOR_KEY})$`); - var VALID_VALUE_BASE_REGEX = /^[ -~]{0,255}[!-~]$/; - var INVALID_VALUE_COMMA_EQUAL_REGEX = /,|=/; - function validateKey(key) { - return VALID_KEY_REGEX.test(key); - } - exports2.validateKey = validateKey; - function validateValue(value) { - return VALID_VALUE_BASE_REGEX.test(value) && !INVALID_VALUE_COMMA_EQUAL_REGEX.test(value); - } - exports2.validateValue = validateValue; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/internal/tracestate-impl.js -var require_tracestate_impl = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.TraceStateImpl = undefined; - var tracestate_validators_1 = require_tracestate_validators(); - var MAX_TRACE_STATE_ITEMS = 32; - var MAX_TRACE_STATE_LEN = 512; - var LIST_MEMBERS_SEPARATOR = ","; - var LIST_MEMBER_KEY_VALUE_SPLITTER = "="; - - class TraceStateImpl { - constructor(rawTraceState) { - this._internalState = new Map; - if (rawTraceState) - this._parse(rawTraceState); - } - set(key, value) { - const traceState = this._clone(); - if (traceState._internalState.has(key)) { - traceState._internalState.delete(key); - } - traceState._internalState.set(key, value); - return traceState; - } - unset(key) { - const traceState = this._clone(); - traceState._internalState.delete(key); - return traceState; - } - get(key) { - return this._internalState.get(key); - } - serialize() { - return this._keys().reduce((agg, key) => { - agg.push(key + LIST_MEMBER_KEY_VALUE_SPLITTER + this.get(key)); - return agg; - }, []).join(LIST_MEMBERS_SEPARATOR); - } - _parse(rawTraceState) { - if (rawTraceState.length > MAX_TRACE_STATE_LEN) - return; - this._internalState = rawTraceState.split(LIST_MEMBERS_SEPARATOR).reverse().reduce((agg, part) => { - const listMember = part.trim(); - const i = listMember.indexOf(LIST_MEMBER_KEY_VALUE_SPLITTER); - if (i !== -1) { - const key = listMember.slice(0, i); - const value = listMember.slice(i + 1, part.length); - if ((0, tracestate_validators_1.validateKey)(key) && (0, tracestate_validators_1.validateValue)(value)) { - agg.set(key, value); - } - } - return agg; - }, new Map); - if (this._internalState.size > MAX_TRACE_STATE_ITEMS) { - this._internalState = new Map(Array.from(this._internalState.entries()).reverse().slice(0, MAX_TRACE_STATE_ITEMS)); - } - } - _keys() { - return Array.from(this._internalState.keys()).reverse(); - } - _clone() { - const traceState = new TraceStateImpl; - traceState._internalState = new Map(this._internalState); - return traceState; - } - } - exports2.TraceStateImpl = TraceStateImpl; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace/internal/utils.js -var require_utils2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.createTraceState = undefined; - var tracestate_impl_1 = require_tracestate_impl(); - function createTraceState(rawTraceState) { - return new tracestate_impl_1.TraceStateImpl(rawTraceState); - } - exports2.createTraceState = createTraceState; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/context-api.js -var require_context_api = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.context = undefined; - var context_1 = require_context2(); - exports2.context = context_1.ContextAPI.getInstance(); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/diag-api.js -var require_diag_api = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.diag = undefined; - var diag_1 = require_diag(); - exports2.diag = diag_1.DiagAPI.instance(); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/metrics/NoopMeterProvider.js -var require_NoopMeterProvider = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.NOOP_METER_PROVIDER = exports2.NoopMeterProvider = undefined; - var NoopMeter_1 = require_NoopMeter(); - - class NoopMeterProvider { - getMeter(_name, _version, _options) { - return NoopMeter_1.NOOP_METER; - } - } - exports2.NoopMeterProvider = NoopMeterProvider; - exports2.NOOP_METER_PROVIDER = new NoopMeterProvider; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/api/metrics.js -var require_metrics = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.MetricsAPI = undefined; - var NoopMeterProvider_1 = require_NoopMeterProvider(); - var global_utils_1 = require_global_utils(); - var diag_1 = require_diag(); - var API_NAME = "metrics"; - - class MetricsAPI { - constructor() {} - static getInstance() { - if (!this._instance) { - this._instance = new MetricsAPI; - } - return this._instance; - } - setGlobalMeterProvider(provider) { - return (0, global_utils_1.registerGlobal)(API_NAME, provider, diag_1.DiagAPI.instance()); - } - getMeterProvider() { - return (0, global_utils_1.getGlobal)(API_NAME) || NoopMeterProvider_1.NOOP_METER_PROVIDER; - } - getMeter(name15, version2, options) { - return this.getMeterProvider().getMeter(name15, version2, options); - } - disable() { - (0, global_utils_1.unregisterGlobal)(API_NAME, diag_1.DiagAPI.instance()); - } - } - exports2.MetricsAPI = MetricsAPI; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/metrics-api.js -var require_metrics_api = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.metrics = undefined; - var metrics_1 = require_metrics(); - exports2.metrics = metrics_1.MetricsAPI.getInstance(); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/propagation/NoopTextMapPropagator.js -var require_NoopTextMapPropagator = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.NoopTextMapPropagator = undefined; - - class NoopTextMapPropagator { - inject(_context, _carrier) {} - extract(context, _carrier) { - return context; - } - fields() { - return []; - } - } - exports2.NoopTextMapPropagator = NoopTextMapPropagator; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/baggage/context-helpers.js -var require_context_helpers = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.deleteBaggage = exports2.setBaggage = exports2.getActiveBaggage = exports2.getBaggage = undefined; - var context_1 = require_context2(); - var context_2 = require_context(); - var BAGGAGE_KEY = (0, context_2.createContextKey)("OpenTelemetry Baggage Key"); - function getBaggage(context) { - return context.getValue(BAGGAGE_KEY) || undefined; - } - exports2.getBaggage = getBaggage; - function getActiveBaggage() { - return getBaggage(context_1.ContextAPI.getInstance().active()); - } - exports2.getActiveBaggage = getActiveBaggage; - function setBaggage(context, baggage) { - return context.setValue(BAGGAGE_KEY, baggage); - } - exports2.setBaggage = setBaggage; - function deleteBaggage(context) { - return context.deleteValue(BAGGAGE_KEY); - } - exports2.deleteBaggage = deleteBaggage; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/api/propagation.js -var require_propagation = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.PropagationAPI = undefined; - var global_utils_1 = require_global_utils(); - var NoopTextMapPropagator_1 = require_NoopTextMapPropagator(); - var TextMapPropagator_1 = require_TextMapPropagator(); - var context_helpers_1 = require_context_helpers(); - var utils_1 = require_utils(); - var diag_1 = require_diag(); - var API_NAME = "propagation"; - var NOOP_TEXT_MAP_PROPAGATOR = new NoopTextMapPropagator_1.NoopTextMapPropagator; - - class PropagationAPI { - constructor() { - this.createBaggage = utils_1.createBaggage; - this.getBaggage = context_helpers_1.getBaggage; - this.getActiveBaggage = context_helpers_1.getActiveBaggage; - this.setBaggage = context_helpers_1.setBaggage; - this.deleteBaggage = context_helpers_1.deleteBaggage; - } - static getInstance() { - if (!this._instance) { - this._instance = new PropagationAPI; - } - return this._instance; - } - setGlobalPropagator(propagator) { - return (0, global_utils_1.registerGlobal)(API_NAME, propagator, diag_1.DiagAPI.instance()); - } - inject(context, carrier, setter = TextMapPropagator_1.defaultTextMapSetter) { - return this._getGlobalPropagator().inject(context, carrier, setter); - } - extract(context, carrier, getter = TextMapPropagator_1.defaultTextMapGetter) { - return this._getGlobalPropagator().extract(context, carrier, getter); - } - fields() { - return this._getGlobalPropagator().fields(); - } - disable() { - (0, global_utils_1.unregisterGlobal)(API_NAME, diag_1.DiagAPI.instance()); - } - _getGlobalPropagator() { - return (0, global_utils_1.getGlobal)(API_NAME) || NOOP_TEXT_MAP_PROPAGATOR; - } - } - exports2.PropagationAPI = PropagationAPI; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/propagation-api.js -var require_propagation_api = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.propagation = undefined; - var propagation_1 = require_propagation(); - exports2.propagation = propagation_1.PropagationAPI.getInstance(); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/api/trace.js -var require_trace = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.TraceAPI = undefined; - var global_utils_1 = require_global_utils(); - var ProxyTracerProvider_1 = require_ProxyTracerProvider(); - var spancontext_utils_1 = require_spancontext_utils(); - var context_utils_1 = require_context_utils(); - var diag_1 = require_diag(); - var API_NAME = "trace"; - - class TraceAPI { - constructor() { - this._proxyTracerProvider = new ProxyTracerProvider_1.ProxyTracerProvider; - this.wrapSpanContext = spancontext_utils_1.wrapSpanContext; - this.isSpanContextValid = spancontext_utils_1.isSpanContextValid; - this.deleteSpan = context_utils_1.deleteSpan; - this.getSpan = context_utils_1.getSpan; - this.getActiveSpan = context_utils_1.getActiveSpan; - this.getSpanContext = context_utils_1.getSpanContext; - this.setSpan = context_utils_1.setSpan; - this.setSpanContext = context_utils_1.setSpanContext; - } - static getInstance() { - if (!this._instance) { - this._instance = new TraceAPI; - } - return this._instance; - } - setGlobalTracerProvider(provider) { - const success2 = (0, global_utils_1.registerGlobal)(API_NAME, this._proxyTracerProvider, diag_1.DiagAPI.instance()); - if (success2) { - this._proxyTracerProvider.setDelegate(provider); - } - return success2; - } - getTracerProvider() { - return (0, global_utils_1.getGlobal)(API_NAME) || this._proxyTracerProvider; - } - getTracer(name15, version2) { - return this.getTracerProvider().getTracer(name15, version2); - } - disable() { - (0, global_utils_1.unregisterGlobal)(API_NAME, diag_1.DiagAPI.instance()); - this._proxyTracerProvider = new ProxyTracerProvider_1.ProxyTracerProvider; - } - } - exports2.TraceAPI = TraceAPI; -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/trace-api.js -var require_trace_api = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.trace = undefined; - var trace_1 = require_trace(); - exports2.trace = trace_1.TraceAPI.getInstance(); -}); - -// ../../node_modules/.pnpm/@opentelemetry+api@1.9.0/node_modules/@opentelemetry/api/build/src/index.js -var require_src = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.trace = exports2.propagation = exports2.metrics = exports2.diag = exports2.context = exports2.INVALID_SPAN_CONTEXT = exports2.INVALID_TRACEID = exports2.INVALID_SPANID = exports2.isValidSpanId = exports2.isValidTraceId = exports2.isSpanContextValid = exports2.createTraceState = exports2.TraceFlags = exports2.SpanStatusCode = exports2.SpanKind = exports2.SamplingDecision = exports2.ProxyTracerProvider = exports2.ProxyTracer = exports2.defaultTextMapSetter = exports2.defaultTextMapGetter = exports2.ValueType = exports2.createNoopMeter = exports2.DiagLogLevel = exports2.DiagConsoleLogger = exports2.ROOT_CONTEXT = exports2.createContextKey = exports2.baggageEntryMetadataFromString = undefined; - var utils_1 = require_utils(); - Object.defineProperty(exports2, "baggageEntryMetadataFromString", { enumerable: true, get: function() { - return utils_1.baggageEntryMetadataFromString; - } }); - var context_1 = require_context(); - Object.defineProperty(exports2, "createContextKey", { enumerable: true, get: function() { - return context_1.createContextKey; - } }); - Object.defineProperty(exports2, "ROOT_CONTEXT", { enumerable: true, get: function() { - return context_1.ROOT_CONTEXT; - } }); - var consoleLogger_1 = require_consoleLogger(); - Object.defineProperty(exports2, "DiagConsoleLogger", { enumerable: true, get: function() { - return consoleLogger_1.DiagConsoleLogger; - } }); - var types_1 = require_types(); - Object.defineProperty(exports2, "DiagLogLevel", { enumerable: true, get: function() { - return types_1.DiagLogLevel; - } }); - var NoopMeter_1 = require_NoopMeter(); - Object.defineProperty(exports2, "createNoopMeter", { enumerable: true, get: function() { - return NoopMeter_1.createNoopMeter; - } }); - var Metric_1 = require_Metric(); - Object.defineProperty(exports2, "ValueType", { enumerable: true, get: function() { - return Metric_1.ValueType; - } }); - var TextMapPropagator_1 = require_TextMapPropagator(); - Object.defineProperty(exports2, "defaultTextMapGetter", { enumerable: true, get: function() { - return TextMapPropagator_1.defaultTextMapGetter; - } }); - Object.defineProperty(exports2, "defaultTextMapSetter", { enumerable: true, get: function() { - return TextMapPropagator_1.defaultTextMapSetter; - } }); - var ProxyTracer_1 = require_ProxyTracer(); - Object.defineProperty(exports2, "ProxyTracer", { enumerable: true, get: function() { - return ProxyTracer_1.ProxyTracer; - } }); - var ProxyTracerProvider_1 = require_ProxyTracerProvider(); - Object.defineProperty(exports2, "ProxyTracerProvider", { enumerable: true, get: function() { - return ProxyTracerProvider_1.ProxyTracerProvider; - } }); - var SamplingResult_1 = require_SamplingResult(); - Object.defineProperty(exports2, "SamplingDecision", { enumerable: true, get: function() { - return SamplingResult_1.SamplingDecision; - } }); - var span_kind_1 = require_span_kind(); - Object.defineProperty(exports2, "SpanKind", { enumerable: true, get: function() { - return span_kind_1.SpanKind; - } }); - var status_1 = require_status(); - Object.defineProperty(exports2, "SpanStatusCode", { enumerable: true, get: function() { - return status_1.SpanStatusCode; - } }); - var trace_flags_1 = require_trace_flags(); - Object.defineProperty(exports2, "TraceFlags", { enumerable: true, get: function() { - return trace_flags_1.TraceFlags; - } }); - var utils_2 = require_utils2(); - Object.defineProperty(exports2, "createTraceState", { enumerable: true, get: function() { - return utils_2.createTraceState; - } }); - var spancontext_utils_1 = require_spancontext_utils(); - Object.defineProperty(exports2, "isSpanContextValid", { enumerable: true, get: function() { - return spancontext_utils_1.isSpanContextValid; - } }); - Object.defineProperty(exports2, "isValidTraceId", { enumerable: true, get: function() { - return spancontext_utils_1.isValidTraceId; - } }); - Object.defineProperty(exports2, "isValidSpanId", { enumerable: true, get: function() { - return spancontext_utils_1.isValidSpanId; - } }); - var invalid_span_constants_1 = require_invalid_span_constants(); - Object.defineProperty(exports2, "INVALID_SPANID", { enumerable: true, get: function() { - return invalid_span_constants_1.INVALID_SPANID; - } }); - Object.defineProperty(exports2, "INVALID_TRACEID", { enumerable: true, get: function() { - return invalid_span_constants_1.INVALID_TRACEID; - } }); - Object.defineProperty(exports2, "INVALID_SPAN_CONTEXT", { enumerable: true, get: function() { - return invalid_span_constants_1.INVALID_SPAN_CONTEXT; - } }); - var context_api_1 = require_context_api(); - Object.defineProperty(exports2, "context", { enumerable: true, get: function() { - return context_api_1.context; - } }); - var diag_api_1 = require_diag_api(); - Object.defineProperty(exports2, "diag", { enumerable: true, get: function() { - return diag_api_1.diag; - } }); - var metrics_api_1 = require_metrics_api(); - Object.defineProperty(exports2, "metrics", { enumerable: true, get: function() { - return metrics_api_1.metrics; - } }); - var propagation_api_1 = require_propagation_api(); - Object.defineProperty(exports2, "propagation", { enumerable: true, get: function() { - return propagation_api_1.propagation; - } }); - var trace_api_1 = require_trace_api(); - Object.defineProperty(exports2, "trace", { enumerable: true, get: function() { - return trace_api_1.trace; - } }); - exports2.default = { - context: context_api_1.context, - diag: diag_api_1.diag, - metrics: metrics_api_1.metrics, - propagation: propagation_api_1.propagation, - trace: trace_api_1.trace - }; -}); - -// ../../node_modules/.pnpm/ms@2.1.3/node_modules/ms/index.js -var require_ms = __commonJS((exports2, module2) => { - var s = 1000; - var m = s * 60; - var h = m * 60; - var d = h * 24; - var w = d * 7; - var y = d * 365.25; - module2.exports = function(val, options) { - options = options || {}; - var type = typeof val; - if (type === "string" && val.length > 0) { - return parse5(val); - } else if (type === "number" && isFinite(val)) { - return options.long ? fmtLong(val) : fmtShort(val); - } - throw new Error("val is not a non-empty string or a valid number. val=" + JSON.stringify(val)); - }; - function parse5(str) { - str = String(str); - if (str.length > 100) { - return; - } - var match = /^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(str); - if (!match) { - return; - } - var n = parseFloat(match[1]); - var type = (match[2] || "ms").toLowerCase(); - switch (type) { - case "years": - case "year": - case "yrs": - case "yr": - case "y": - return n * y; - case "weeks": - case "week": - case "w": - return n * w; - case "days": - case "day": - case "d": - return n * d; - case "hours": - case "hour": - case "hrs": - case "hr": - case "h": - return n * h; - case "minutes": - case "minute": - case "mins": - case "min": - case "m": - return n * m; - case "seconds": - case "second": - case "secs": - case "sec": - case "s": - return n * s; - case "milliseconds": - case "millisecond": - case "msecs": - case "msec": - case "ms": - return n; - default: - return; - } - } - function fmtShort(ms) { - var msAbs = Math.abs(ms); - if (msAbs >= d) { - return Math.round(ms / d) + "d"; - } - if (msAbs >= h) { - return Math.round(ms / h) + "h"; - } - if (msAbs >= m) { - return Math.round(ms / m) + "m"; - } - if (msAbs >= s) { - return Math.round(ms / s) + "s"; - } - return ms + "ms"; - } - function fmtLong(ms) { - var msAbs = Math.abs(ms); - if (msAbs >= d) { - return plural(ms, msAbs, d, "day"); - } - if (msAbs >= h) { - return plural(ms, msAbs, h, "hour"); - } - if (msAbs >= m) { - return plural(ms, msAbs, m, "minute"); - } - if (msAbs >= s) { - return plural(ms, msAbs, s, "second"); - } - return ms + " ms"; - } - function plural(ms, msAbs, n, name21) { - var isPlural = msAbs >= n * 1.5; - return Math.round(ms / n) + " " + name21 + (isPlural ? "s" : ""); - } -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/common.js -var require_common = __commonJS((exports2, module2) => { - function setup(env) { - createDebug.debug = createDebug; - createDebug.default = createDebug; - createDebug.coerce = coerce2; - createDebug.disable = disable; - createDebug.enable = enable; - createDebug.enabled = enabled; - createDebug.humanize = require_ms(); - createDebug.destroy = destroy; - Object.keys(env).forEach((key) => { - createDebug[key] = env[key]; - }); - createDebug.names = []; - createDebug.skips = []; - createDebug.formatters = {}; - function selectColor(namespace) { - let hash2 = 0; - for (let i = 0;i < namespace.length; i++) { - hash2 = (hash2 << 5) - hash2 + namespace.charCodeAt(i); - hash2 |= 0; - } - return createDebug.colors[Math.abs(hash2) % createDebug.colors.length]; - } - createDebug.selectColor = selectColor; - function createDebug(namespace) { - let prevTime; - let enableOverride = null; - let namespacesCache; - let enabledCache; - function debug(...args) { - if (!debug.enabled) { - return; - } - const self = debug; - const curr = Number(new Date); - const ms = curr - (prevTime || curr); - self.diff = ms; - self.prev = prevTime; - self.curr = curr; - prevTime = curr; - args[0] = createDebug.coerce(args[0]); - if (typeof args[0] !== "string") { - args.unshift("%O"); - } - let index = 0; - args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => { - if (match === "%%") { - return "%"; - } - index++; - const formatter = createDebug.formatters[format]; - if (typeof formatter === "function") { - const val = args[index]; - match = formatter.call(self, val); - args.splice(index, 1); - index--; - } - return match; - }); - createDebug.formatArgs.call(self, args); - const logFn = self.log || createDebug.log; - logFn.apply(self, args); - } - debug.namespace = namespace; - debug.useColors = createDebug.useColors(); - debug.color = createDebug.selectColor(namespace); - debug.extend = extend2; - debug.destroy = createDebug.destroy; - Object.defineProperty(debug, "enabled", { - enumerable: true, - configurable: false, - get: () => { - if (enableOverride !== null) { - return enableOverride; - } - if (namespacesCache !== createDebug.namespaces) { - namespacesCache = createDebug.namespaces; - enabledCache = createDebug.enabled(namespace); - } - return enabledCache; - }, - set: (v) => { - enableOverride = v; - } - }); - if (typeof createDebug.init === "function") { - createDebug.init(debug); - } - return debug; - } - function extend2(namespace, delimiter) { - const newDebug = createDebug(this.namespace + (typeof delimiter === "undefined" ? ":" : delimiter) + namespace); - newDebug.log = this.log; - return newDebug; - } - function enable(namespaces) { - createDebug.save(namespaces); - createDebug.namespaces = namespaces; - createDebug.names = []; - createDebug.skips = []; - const split2 = (typeof namespaces === "string" ? namespaces : "").trim().replace(/\s+/g, ",").split(",").filter(Boolean); - for (const ns of split2) { - if (ns[0] === "-") { - createDebug.skips.push(ns.slice(1)); - } else { - createDebug.names.push(ns); - } - } - } - function matchesTemplate(search, template) { - let searchIndex = 0; - let templateIndex = 0; - let starIndex = -1; - let matchIndex = 0; - while (searchIndex < search.length) { - if (templateIndex < template.length && (template[templateIndex] === search[searchIndex] || template[templateIndex] === "*")) { - if (template[templateIndex] === "*") { - starIndex = templateIndex; - matchIndex = searchIndex; - templateIndex++; - } else { - searchIndex++; - templateIndex++; - } - } else if (starIndex !== -1) { - templateIndex = starIndex + 1; - matchIndex++; - searchIndex = matchIndex; - } else { - return false; - } - } - while (templateIndex < template.length && template[templateIndex] === "*") { - templateIndex++; - } - return templateIndex === template.length; - } - function disable() { - const namespaces = [ - ...createDebug.names, - ...createDebug.skips.map((namespace) => "-" + namespace) - ].join(","); - createDebug.enable(""); - return namespaces; - } - function enabled(name21) { - for (const skip of createDebug.skips) { - if (matchesTemplate(name21, skip)) { - return false; - } - } - for (const ns of createDebug.names) { - if (matchesTemplate(name21, ns)) { - return true; - } - } - return false; - } - function coerce2(val) { - if (val instanceof Error) { - return val.stack || val.message; - } - return val; - } - function destroy() { - console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); - } - createDebug.enable(createDebug.load()); - return createDebug; - } - module2.exports = setup; -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/browser.js -var require_browser = __commonJS((exports2, module2) => { - exports2.formatArgs = formatArgs; - exports2.save = save; - exports2.load = load; - exports2.useColors = useColors; - exports2.storage = localstorage(); - exports2.destroy = (() => { - let warned = false; - return () => { - if (!warned) { - warned = true; - console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); - } - }; - })(); - exports2.colors = [ - "#0000CC", - "#0000FF", - "#0033CC", - "#0033FF", - "#0066CC", - "#0066FF", - "#0099CC", - "#0099FF", - "#00CC00", - "#00CC33", - "#00CC66", - "#00CC99", - "#00CCCC", - "#00CCFF", - "#3300CC", - "#3300FF", - "#3333CC", - "#3333FF", - "#3366CC", - "#3366FF", - "#3399CC", - "#3399FF", - "#33CC00", - "#33CC33", - "#33CC66", - "#33CC99", - "#33CCCC", - "#33CCFF", - "#6600CC", - "#6600FF", - "#6633CC", - "#6633FF", - "#66CC00", - "#66CC33", - "#9900CC", - "#9900FF", - "#9933CC", - "#9933FF", - "#99CC00", - "#99CC33", - "#CC0000", - "#CC0033", - "#CC0066", - "#CC0099", - "#CC00CC", - "#CC00FF", - "#CC3300", - "#CC3333", - "#CC3366", - "#CC3399", - "#CC33CC", - "#CC33FF", - "#CC6600", - "#CC6633", - "#CC9900", - "#CC9933", - "#CCCC00", - "#CCCC33", - "#FF0000", - "#FF0033", - "#FF0066", - "#FF0099", - "#FF00CC", - "#FF00FF", - "#FF3300", - "#FF3333", - "#FF3366", - "#FF3399", - "#FF33CC", - "#FF33FF", - "#FF6600", - "#FF6633", - "#FF9900", - "#FF9933", - "#FFCC00", - "#FFCC33" - ]; - function useColors() { - if (typeof window !== "undefined" && window.process && (window.process.type === "renderer" || window.process.__nwjs)) { - return true; - } - if (typeof navigator !== "undefined" && navigator.userAgent && navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/)) { - return false; - } - let m; - return typeof document !== "undefined" && document.documentElement && document.documentElement.style && document.documentElement.style.WebkitAppearance || typeof window !== "undefined" && window.console && (window.console.firebug || window.console.exception && window.console.table) || typeof navigator !== "undefined" && navigator.userAgent && (m = navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/)) && parseInt(m[1], 10) >= 31 || typeof navigator !== "undefined" && navigator.userAgent && navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/); - } - function formatArgs(args) { - args[0] = (this.useColors ? "%c" : "") + this.namespace + (this.useColors ? " %c" : " ") + args[0] + (this.useColors ? "%c " : " ") + "+" + module2.exports.humanize(this.diff); - if (!this.useColors) { - return; - } - const c = "color: " + this.color; - args.splice(1, 0, c, "color: inherit"); - let index = 0; - let lastC = 0; - args[0].replace(/%[a-zA-Z%]/g, (match) => { - if (match === "%%") { - return; - } - index++; - if (match === "%c") { - lastC = index; - } - }); - args.splice(lastC, 0, c); - } - exports2.log = console.debug || console.log || (() => {}); - function save(namespaces) { - try { - if (namespaces) { - exports2.storage.setItem("debug", namespaces); - } else { - exports2.storage.removeItem("debug"); - } - } catch (error48) {} - } - function load() { - let r; - try { - r = exports2.storage.getItem("debug") || exports2.storage.getItem("DEBUG"); - } catch (error48) {} - if (!r && typeof process !== "undefined" && "env" in process) { - r = process.env.DEBUG; - } - return r; - } - function localstorage() { - try { - return localStorage; - } catch (error48) {} - } - module2.exports = require_common()(exports2); - var { formatters } = module2.exports; - formatters.j = function(v) { - try { - return JSON.stringify(v); - } catch (error48) { - return "[UnexpectedJSONParseError]: " + error48.message; - } - }; -}); - -// ../../node_modules/.pnpm/has-flag@4.0.0/node_modules/has-flag/index.js -var require_has_flag = __commonJS((exports2, module2) => { - module2.exports = (flag, argv = process.argv) => { - const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--"; - const position = argv.indexOf(prefix + flag); - const terminatorPosition = argv.indexOf("--"); - return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); - }; -}); - -// ../../node_modules/.pnpm/supports-color@8.1.1/node_modules/supports-color/index.js -var require_supports_color = __commonJS((exports2, module2) => { - var os2 = require("os"); - var tty = require("tty"); - var hasFlag = require_has_flag(); - var { env } = process; - var flagForceColor; - if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) { - flagForceColor = 0; - } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) { - flagForceColor = 1; - } - function envForceColor() { - if ("FORCE_COLOR" in env) { - if (env.FORCE_COLOR === "true") { - return 1; - } - if (env.FORCE_COLOR === "false") { - return 0; - } - return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3); - } - } - function translateLevel(level) { - if (level === 0) { - return false; - } - return { - level, - hasBasic: true, - has256: level >= 2, - has16m: level >= 3 - }; - } - function supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) { - const noFlagForceColor = envForceColor(); - if (noFlagForceColor !== undefined) { - flagForceColor = noFlagForceColor; - } - const forceColor = sniffFlags ? flagForceColor : noFlagForceColor; - if (forceColor === 0) { - return 0; - } - if (sniffFlags) { - if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) { - return 3; - } - if (hasFlag("color=256")) { - return 2; - } - } - if (haveStream && !streamIsTTY && forceColor === undefined) { - return 0; - } - const min = forceColor || 0; - if (env.TERM === "dumb") { - return min; - } - if (process.platform === "win32") { - const osRelease = os2.release().split("."); - if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) { - return Number(osRelease[2]) >= 14931 ? 3 : 2; - } - return 1; - } - if ("CI" in env) { - if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE", "DRONE"].some((sign) => (sign in env)) || env.CI_NAME === "codeship") { - return 1; - } - return min; - } - if ("TEAMCITY_VERSION" in env) { - return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0; - } - if (env.COLORTERM === "truecolor") { - return 3; - } - if ("TERM_PROGRAM" in env) { - const version2 = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10); - switch (env.TERM_PROGRAM) { - case "iTerm.app": - return version2 >= 3 ? 3 : 2; - case "Apple_Terminal": - return 2; - } - } - if (/-256(color)?$/i.test(env.TERM)) { - return 2; - } - if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) { - return 1; - } - if ("COLORTERM" in env) { - return 1; - } - return min; - } - function getSupportLevel(stream, options = {}) { - const level = supportsColor(stream, { - streamIsTTY: stream && stream.isTTY, - ...options - }); - return translateLevel(level); - } - module2.exports = { - supportsColor: getSupportLevel, - stdout: getSupportLevel({ isTTY: tty.isatty(1) }), - stderr: getSupportLevel({ isTTY: tty.isatty(2) }) - }; -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/node.js -var require_node2 = __commonJS((exports2, module2) => { - var tty = require("tty"); - var util3 = require("util"); - exports2.init = init; - exports2.log = log; - exports2.formatArgs = formatArgs; - exports2.save = save; - exports2.load = load; - exports2.useColors = useColors; - exports2.destroy = util3.deprecate(() => {}, "Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."); - exports2.colors = [6, 2, 3, 4, 5, 1]; - try { - const supportsColor = require_supports_color(); - if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) { - exports2.colors = [ - 20, - 21, - 26, - 27, - 32, - 33, - 38, - 39, - 40, - 41, - 42, - 43, - 44, - 45, - 56, - 57, - 62, - 63, - 68, - 69, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 92, - 93, - 98, - 99, - 112, - 113, - 128, - 129, - 134, - 135, - 148, - 149, - 160, - 161, - 162, - 163, - 164, - 165, - 166, - 167, - 168, - 169, - 170, - 171, - 172, - 173, - 178, - 179, - 184, - 185, - 196, - 197, - 198, - 199, - 200, - 201, - 202, - 203, - 204, - 205, - 206, - 207, - 208, - 209, - 214, - 215, - 220, - 221 - ]; - } - } catch (error48) {} - exports2.inspectOpts = Object.keys(process.env).filter((key) => { - return /^debug_/i.test(key); - }).reduce((obj, key) => { - const prop = key.substring(6).toLowerCase().replace(/_([a-z])/g, (_, k) => { - return k.toUpperCase(); - }); - let val = process.env[key]; - if (/^(yes|on|true|enabled)$/i.test(val)) { - val = true; - } else if (/^(no|off|false|disabled)$/i.test(val)) { - val = false; - } else if (val === "null") { - val = null; - } else { - val = Number(val); - } - obj[prop] = val; - return obj; - }, {}); - function useColors() { - return "colors" in exports2.inspectOpts ? Boolean(exports2.inspectOpts.colors) : tty.isatty(process.stderr.fd); - } - function formatArgs(args) { - const { namespace: name21, useColors: useColors2 } = this; - if (useColors2) { - const c = this.color; - const colorCode = "\x1B[3" + (c < 8 ? c : "8;5;" + c); - const prefix = ` ${colorCode};1m${name21} \x1B[0m`; - args[0] = prefix + args[0].split(` -`).join(` -` + prefix); - args.push(colorCode + "m+" + module2.exports.humanize(this.diff) + "\x1B[0m"); - } else { - args[0] = getDate() + name21 + " " + args[0]; - } - } - function getDate() { - if (exports2.inspectOpts.hideDate) { - return ""; - } - return new Date().toISOString() + " "; - } - function log(...args) { - return process.stderr.write(util3.formatWithOptions(exports2.inspectOpts, ...args) + ` -`); - } - function save(namespaces) { - if (namespaces) { - process.env.DEBUG = namespaces; - } else { - delete process.env.DEBUG; - } - } - function load() { - return process.env.DEBUG; - } - function init(debug) { - debug.inspectOpts = {}; - const keys = Object.keys(exports2.inspectOpts); - for (let i = 0;i < keys.length; i++) { - debug.inspectOpts[keys[i]] = exports2.inspectOpts[keys[i]]; - } - } - module2.exports = require_common()(exports2); - var { formatters } = module2.exports; - formatters.o = function(v) { - this.inspectOpts.colors = this.useColors; - return util3.inspect(v, this.inspectOpts).split(` -`).map((str) => str.trim()).join(" "); - }; - formatters.O = function(v) { - this.inspectOpts.colors = this.useColors; - return util3.inspect(v, this.inspectOpts); - }; -}); - -// ../../node_modules/.pnpm/debug@4.4.3_supports-color@8.1.1/node_modules/debug/src/index.js -var require_src2 = __commonJS((exports2, module2) => { - if (typeof process === "undefined" || process.type === "renderer" || false || process.__nwjs) { - module2.exports = require_browser(); - } else { - module2.exports = require_node2(); - } -}); - -// ../../node_modules/.pnpm/agent-base@7.1.4/node_modules/agent-base/dist/helpers.js -var require_helpers = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.req = exports2.json = exports2.toBuffer = undefined; - var http = __importStar(require("http")); - var https = __importStar(require("https")); - async function toBuffer(stream) { - let length = 0; - const chunks = []; - for await (const chunk2 of stream) { - length += chunk2.length; - chunks.push(chunk2); - } - return Buffer.concat(chunks, length); - } - exports2.toBuffer = toBuffer; - async function json3(stream) { - const buf = await toBuffer(stream); - const str = buf.toString("utf8"); - try { - return JSON.parse(str); - } catch (_err) { - const err = _err; - err.message += ` (input: ${str})`; - throw err; - } - } - exports2.json = json3; - function req(url2, opts = {}) { - const href = typeof url2 === "string" ? url2 : url2.href; - const req2 = (href.startsWith("https:") ? https : http).request(url2, opts); - const promise2 = new Promise((resolve2, reject) => { - req2.once("response", resolve2).once("error", reject).end(); - }); - req2.then = promise2.then.bind(promise2); - return req2; - } - exports2.req = req; -}); - -// ../../node_modules/.pnpm/agent-base@7.1.4/node_modules/agent-base/dist/index.js -var require_dist2 = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) { - for (var p in m) - if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) - __createBinding(exports3, m, p); - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Agent = undefined; - var net = __importStar(require("net")); - var http = __importStar(require("http")); - var https_1 = require("https"); - __exportStar(require_helpers(), exports2); - var INTERNAL = Symbol("AgentBaseInternalState"); - - class Agent extends http.Agent { - constructor(opts) { - super(opts); - this[INTERNAL] = {}; - } - isSecureEndpoint(options) { - if (options) { - if (typeof options.secureEndpoint === "boolean") { - return options.secureEndpoint; - } - if (typeof options.protocol === "string") { - return options.protocol === "https:"; - } - } - const { stack } = new Error; - if (typeof stack !== "string") - return false; - return stack.split(` -`).some((l) => l.indexOf("(https.js:") !== -1 || l.indexOf("node:https:") !== -1); - } - incrementSockets(name21) { - if (this.maxSockets === Infinity && this.maxTotalSockets === Infinity) { - return null; - } - if (!this.sockets[name21]) { - this.sockets[name21] = []; - } - const fakeSocket = new net.Socket({ writable: false }); - this.sockets[name21].push(fakeSocket); - this.totalSocketCount++; - return fakeSocket; - } - decrementSockets(name21, socket) { - if (!this.sockets[name21] || socket === null) { - return; - } - const sockets = this.sockets[name21]; - const index = sockets.indexOf(socket); - if (index !== -1) { - sockets.splice(index, 1); - this.totalSocketCount--; - if (sockets.length === 0) { - delete this.sockets[name21]; - } - } - } - getName(options) { - const secureEndpoint = this.isSecureEndpoint(options); - if (secureEndpoint) { - return https_1.Agent.prototype.getName.call(this, options); - } - return super.getName(options); - } - createSocket(req, options, cb) { - const connectOpts = { - ...options, - secureEndpoint: this.isSecureEndpoint(options) - }; - const name21 = this.getName(connectOpts); - const fakeSocket = this.incrementSockets(name21); - Promise.resolve().then(() => this.connect(req, connectOpts)).then((socket) => { - this.decrementSockets(name21, fakeSocket); - if (socket instanceof http.Agent) { - try { - return socket.addRequest(req, connectOpts); - } catch (err) { - return cb(err); - } - } - this[INTERNAL].currentSocket = socket; - super.createSocket(req, options, cb); - }, (err) => { - this.decrementSockets(name21, fakeSocket); - cb(err); - }); - } - createConnection() { - const socket = this[INTERNAL].currentSocket; - this[INTERNAL].currentSocket = undefined; - if (!socket) { - throw new Error("No socket was returned in the `connect()` function"); - } - return socket; - } - get defaultPort() { - return this[INTERNAL].defaultPort ?? (this.protocol === "https:" ? 443 : 80); - } - set defaultPort(v) { - if (this[INTERNAL]) { - this[INTERNAL].defaultPort = v; - } - } - get protocol() { - return this[INTERNAL].protocol ?? (this.isSecureEndpoint() ? "https:" : "http:"); - } - set protocol(v) { - if (this[INTERNAL]) { - this[INTERNAL].protocol = v; - } - } - } - exports2.Agent = Agent; -}); - -// ../../node_modules/.pnpm/http-proxy-agent@7.0.2/node_modules/http-proxy-agent/dist/index.js -var require_dist3 = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.HttpProxyAgent = undefined; - var net = __importStar(require("net")); - var tls = __importStar(require("tls")); - var debug_1 = __importDefault(require_src2()); - var events_1 = require("events"); - var agent_base_1 = require_dist2(); - var url_1 = require("url"); - var debug = (0, debug_1.default)("http-proxy-agent"); - - class HttpProxyAgent extends agent_base_1.Agent { - constructor(proxy, opts) { - super(opts); - this.proxy = typeof proxy === "string" ? new url_1.URL(proxy) : proxy; - this.proxyHeaders = opts?.headers ?? {}; - debug("Creating new HttpProxyAgent instance: %o", this.proxy.href); - const host = (this.proxy.hostname || this.proxy.host).replace(/^\[|\]$/g, ""); - const port = this.proxy.port ? parseInt(this.proxy.port, 10) : this.proxy.protocol === "https:" ? 443 : 80; - this.connectOpts = { - ...opts ? omit3(opts, "headers") : null, - host, - port - }; - } - addRequest(req, opts) { - req._header = null; - this.setRequestProps(req, opts); - super.addRequest(req, opts); - } - setRequestProps(req, opts) { - const { proxy } = this; - const protocol = opts.secureEndpoint ? "https:" : "http:"; - const hostname3 = req.getHeader("host") || "localhost"; - const base = `${protocol}//${hostname3}`; - const url2 = new url_1.URL(req.path, base); - if (opts.port !== 80) { - url2.port = String(opts.port); - } - req.path = String(url2); - const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders }; - if (proxy.username || proxy.password) { - const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; - headers["Proxy-Authorization"] = `Basic ${Buffer.from(auth).toString("base64")}`; - } - if (!headers["Proxy-Connection"]) { - headers["Proxy-Connection"] = this.keepAlive ? "Keep-Alive" : "close"; - } - for (const name21 of Object.keys(headers)) { - const value = headers[name21]; - if (value) { - req.setHeader(name21, value); - } - } - } - async connect(req, opts) { - req._header = null; - if (!req.path.includes("://")) { - this.setRequestProps(req, opts); - } - let first; - let endOfHeaders; - debug("Regenerating stored HTTP header string for request"); - req._implicitHeader(); - if (req.outputData && req.outputData.length > 0) { - debug("Patching connection write() output buffer with updated header"); - first = req.outputData[0].data; - endOfHeaders = first.indexOf(`\r -\r -`) + 4; - req.outputData[0].data = req._header + first.substring(endOfHeaders); - debug("Output buffer: %o", req.outputData[0].data); - } - let socket; - if (this.proxy.protocol === "https:") { - debug("Creating `tls.Socket`: %o", this.connectOpts); - socket = tls.connect(this.connectOpts); - } else { - debug("Creating `net.Socket`: %o", this.connectOpts); - socket = net.connect(this.connectOpts); - } - await (0, events_1.once)(socket, "connect"); - return socket; - } - } - HttpProxyAgent.protocols = ["http", "https"]; - exports2.HttpProxyAgent = HttpProxyAgent; - function omit3(obj, ...keys) { - const ret = {}; - let key; - for (key in obj) { - if (!keys.includes(key)) { - ret[key] = obj[key]; - } - } - return ret; - } -}); - -// ../../node_modules/.pnpm/https-proxy-agent@7.0.6/node_modules/https-proxy-agent/dist/parse-proxy-response.js -var require_parse_proxy_response = __commonJS((exports2) => { - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.parseProxyResponse = undefined; - var debug_1 = __importDefault(require_src2()); - var debug = (0, debug_1.default)("https-proxy-agent:parse-proxy-response"); - function parseProxyResponse(socket) { - return new Promise((resolve2, reject) => { - let buffersLength = 0; - const buffers = []; - function read() { - const b = socket.read(); - if (b) - ondata(b); - else - socket.once("readable", read); - } - function cleanup() { - socket.removeListener("end", onend); - socket.removeListener("error", onerror); - socket.removeListener("readable", read); - } - function onend() { - cleanup(); - debug("onend"); - reject(new Error("Proxy connection ended before receiving CONNECT response")); - } - function onerror(err) { - cleanup(); - debug("onerror %o", err); - reject(err); - } - function ondata(b) { - buffers.push(b); - buffersLength += b.length; - const buffered = Buffer.concat(buffers, buffersLength); - const endOfHeaders = buffered.indexOf(`\r -\r -`); - if (endOfHeaders === -1) { - debug("have not received end of HTTP headers yet..."); - read(); - return; - } - const headerParts = buffered.slice(0, endOfHeaders).toString("ascii").split(`\r -`); - const firstLine = headerParts.shift(); - if (!firstLine) { - socket.destroy(); - return reject(new Error("No header received from proxy CONNECT response")); - } - const firstLineParts = firstLine.split(" "); - const statusCode = +firstLineParts[1]; - const statusText = firstLineParts.slice(2).join(" "); - const headers = {}; - for (const header of headerParts) { - if (!header) - continue; - const firstColon = header.indexOf(":"); - if (firstColon === -1) { - socket.destroy(); - return reject(new Error(`Invalid header from proxy CONNECT response: "${header}"`)); - } - const key = header.slice(0, firstColon).toLowerCase(); - const value = header.slice(firstColon + 1).trimStart(); - const current = headers[key]; - if (typeof current === "string") { - headers[key] = [current, value]; - } else if (Array.isArray(current)) { - current.push(value); - } else { - headers[key] = value; - } - } - debug("got proxy server response: %o %o", firstLine, headers); - cleanup(); - resolve2({ - connect: { - statusCode, - statusText, - headers - }, - buffered - }); - } - socket.on("error", onerror); - socket.on("end", onend); - read(); - }); - } - exports2.parseProxyResponse = parseProxyResponse; -}); - -// ../../node_modules/.pnpm/https-proxy-agent@7.0.6/node_modules/https-proxy-agent/dist/index.js -var require_dist4 = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.HttpsProxyAgent = undefined; - var net = __importStar(require("net")); - var tls = __importStar(require("tls")); - var assert_1 = __importDefault(require("assert")); - var debug_1 = __importDefault(require_src2()); - var agent_base_1 = require_dist2(); - var url_1 = require("url"); - var parse_proxy_response_1 = require_parse_proxy_response(); - var debug = (0, debug_1.default)("https-proxy-agent"); - var setServernameFromNonIpHost = (options) => { - if (options.servername === undefined && options.host && !net.isIP(options.host)) { - return { - ...options, - servername: options.host - }; - } - return options; - }; - - class HttpsProxyAgent extends agent_base_1.Agent { - constructor(proxy, opts) { - super(opts); - this.options = { path: undefined }; - this.proxy = typeof proxy === "string" ? new url_1.URL(proxy) : proxy; - this.proxyHeaders = opts?.headers ?? {}; - debug("Creating new HttpsProxyAgent instance: %o", this.proxy.href); - const host = (this.proxy.hostname || this.proxy.host).replace(/^\[|\]$/g, ""); - const port = this.proxy.port ? parseInt(this.proxy.port, 10) : this.proxy.protocol === "https:" ? 443 : 80; - this.connectOpts = { - ALPNProtocols: ["http/1.1"], - ...opts ? omit3(opts, "headers") : null, - host, - port - }; - } - async connect(req, opts) { - const { proxy } = this; - if (!opts.host) { - throw new TypeError('No "host" provided'); - } - let socket; - if (proxy.protocol === "https:") { - debug("Creating `tls.Socket`: %o", this.connectOpts); - socket = tls.connect(setServernameFromNonIpHost(this.connectOpts)); - } else { - debug("Creating `net.Socket`: %o", this.connectOpts); - socket = net.connect(this.connectOpts); - } - const headers = typeof this.proxyHeaders === "function" ? this.proxyHeaders() : { ...this.proxyHeaders }; - const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host; - let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r -`; - if (proxy.username || proxy.password) { - const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; - headers["Proxy-Authorization"] = `Basic ${Buffer.from(auth).toString("base64")}`; - } - headers.Host = `${host}:${opts.port}`; - if (!headers["Proxy-Connection"]) { - headers["Proxy-Connection"] = this.keepAlive ? "Keep-Alive" : "close"; - } - for (const name21 of Object.keys(headers)) { - payload += `${name21}: ${headers[name21]}\r -`; - } - const proxyResponsePromise = (0, parse_proxy_response_1.parseProxyResponse)(socket); - socket.write(`${payload}\r -`); - const { connect, buffered } = await proxyResponsePromise; - req.emit("proxyConnect", connect); - this.emit("proxyConnect", connect, req); - if (connect.statusCode === 200) { - req.once("socket", resume); - if (opts.secureEndpoint) { - debug("Upgrading socket connection to TLS"); - return tls.connect({ - ...omit3(setServernameFromNonIpHost(opts), "host", "path", "port"), - socket - }); - } - return socket; - } - socket.destroy(); - const fakeSocket = new net.Socket({ writable: false }); - fakeSocket.readable = true; - req.once("socket", (s) => { - debug("Replaying proxy buffer for failed request"); - (0, assert_1.default)(s.listenerCount("data") > 0); - s.push(buffered); - s.push(null); - }); - return fakeSocket; - } - } - HttpsProxyAgent.protocols = ["http", "https"]; - exports2.HttpsProxyAgent = HttpsProxyAgent; - function resume(socket) { - socket.resume(); - } - function omit3(obj, ...keys) { - const ret = {}; - let key; - for (key in obj) { - if (!keys.includes(key)) { - ret[key] = obj[key]; - } - } - return ret; - } -}); - -// ../../node_modules/.pnpm/smart-buffer@4.2.0/node_modules/smart-buffer/build/utils.js -var require_utils3 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var buffer_1 = require("buffer"); - var ERRORS = { - INVALID_ENCODING: "Invalid encoding provided. Please specify a valid encoding the internal Node.js Buffer supports.", - INVALID_SMARTBUFFER_SIZE: "Invalid size provided. Size must be a valid integer greater than zero.", - INVALID_SMARTBUFFER_BUFFER: "Invalid Buffer provided in SmartBufferOptions.", - INVALID_SMARTBUFFER_OBJECT: "Invalid SmartBufferOptions object supplied to SmartBuffer constructor or factory methods.", - INVALID_OFFSET: "An invalid offset value was provided.", - INVALID_OFFSET_NON_NUMBER: "An invalid offset value was provided. A numeric value is required.", - INVALID_LENGTH: "An invalid length value was provided.", - INVALID_LENGTH_NON_NUMBER: "An invalid length value was provived. A numeric value is required.", - INVALID_TARGET_OFFSET: "Target offset is beyond the bounds of the internal SmartBuffer data.", - INVALID_TARGET_LENGTH: "Specified length value moves cursor beyong the bounds of the internal SmartBuffer data.", - INVALID_READ_BEYOND_BOUNDS: "Attempted to read beyond the bounds of the managed data.", - INVALID_WRITE_BEYOND_BOUNDS: "Attempted to write beyond the bounds of the managed data." - }; - exports2.ERRORS = ERRORS; - function checkEncoding(encoding) { - if (!buffer_1.Buffer.isEncoding(encoding)) { - throw new Error(ERRORS.INVALID_ENCODING); - } - } - exports2.checkEncoding = checkEncoding; - function isFiniteInteger(value) { - return typeof value === "number" && isFinite(value) && isInteger(value); - } - exports2.isFiniteInteger = isFiniteInteger; - function checkOffsetOrLengthValue(value, offset) { - if (typeof value === "number") { - if (!isFiniteInteger(value) || value < 0) { - throw new Error(offset ? ERRORS.INVALID_OFFSET : ERRORS.INVALID_LENGTH); - } - } else { - throw new Error(offset ? ERRORS.INVALID_OFFSET_NON_NUMBER : ERRORS.INVALID_LENGTH_NON_NUMBER); - } - } - function checkLengthValue(length) { - checkOffsetOrLengthValue(length, false); - } - exports2.checkLengthValue = checkLengthValue; - function checkOffsetValue(offset) { - checkOffsetOrLengthValue(offset, true); - } - exports2.checkOffsetValue = checkOffsetValue; - function checkTargetOffset(offset, buff) { - if (offset < 0 || offset > buff.length) { - throw new Error(ERRORS.INVALID_TARGET_OFFSET); - } - } - exports2.checkTargetOffset = checkTargetOffset; - function isInteger(value) { - return typeof value === "number" && isFinite(value) && Math.floor(value) === value; - } - function bigIntAndBufferInt64Check(bufferMethod) { - if (typeof BigInt === "undefined") { - throw new Error("Platform does not support JS BigInt type."); - } - if (typeof buffer_1.Buffer.prototype[bufferMethod] === "undefined") { - throw new Error(`Platform does not support Buffer.prototype.${bufferMethod}.`); - } - } - exports2.bigIntAndBufferInt64Check = bigIntAndBufferInt64Check; -}); - -// ../../node_modules/.pnpm/smart-buffer@4.2.0/node_modules/smart-buffer/build/smartbuffer.js -var require_smartbuffer = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var utils_1 = require_utils3(); - var DEFAULT_SMARTBUFFER_SIZE = 4096; - var DEFAULT_SMARTBUFFER_ENCODING = "utf8"; - - class SmartBuffer { - constructor(options) { - this.length = 0; - this._encoding = DEFAULT_SMARTBUFFER_ENCODING; - this._writeOffset = 0; - this._readOffset = 0; - if (SmartBuffer.isSmartBufferOptions(options)) { - if (options.encoding) { - utils_1.checkEncoding(options.encoding); - this._encoding = options.encoding; - } - if (options.size) { - if (utils_1.isFiniteInteger(options.size) && options.size > 0) { - this._buff = Buffer.allocUnsafe(options.size); - } else { - throw new Error(utils_1.ERRORS.INVALID_SMARTBUFFER_SIZE); - } - } else if (options.buff) { - if (Buffer.isBuffer(options.buff)) { - this._buff = options.buff; - this.length = options.buff.length; - } else { - throw new Error(utils_1.ERRORS.INVALID_SMARTBUFFER_BUFFER); - } - } else { - this._buff = Buffer.allocUnsafe(DEFAULT_SMARTBUFFER_SIZE); - } - } else { - if (typeof options !== "undefined") { - throw new Error(utils_1.ERRORS.INVALID_SMARTBUFFER_OBJECT); - } - this._buff = Buffer.allocUnsafe(DEFAULT_SMARTBUFFER_SIZE); - } - } - static fromSize(size, encoding) { - return new this({ - size, - encoding - }); - } - static fromBuffer(buff, encoding) { - return new this({ - buff, - encoding - }); - } - static fromOptions(options) { - return new this(options); - } - static isSmartBufferOptions(options) { - const castOptions = options; - return castOptions && (castOptions.encoding !== undefined || castOptions.size !== undefined || castOptions.buff !== undefined); - } - readInt8(offset) { - return this._readNumberValue(Buffer.prototype.readInt8, 1, offset); - } - readInt16BE(offset) { - return this._readNumberValue(Buffer.prototype.readInt16BE, 2, offset); - } - readInt16LE(offset) { - return this._readNumberValue(Buffer.prototype.readInt16LE, 2, offset); - } - readInt32BE(offset) { - return this._readNumberValue(Buffer.prototype.readInt32BE, 4, offset); - } - readInt32LE(offset) { - return this._readNumberValue(Buffer.prototype.readInt32LE, 4, offset); - } - readBigInt64BE(offset) { - utils_1.bigIntAndBufferInt64Check("readBigInt64BE"); - return this._readNumberValue(Buffer.prototype.readBigInt64BE, 8, offset); - } - readBigInt64LE(offset) { - utils_1.bigIntAndBufferInt64Check("readBigInt64LE"); - return this._readNumberValue(Buffer.prototype.readBigInt64LE, 8, offset); - } - writeInt8(value, offset) { - this._writeNumberValue(Buffer.prototype.writeInt8, 1, value, offset); - return this; - } - insertInt8(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeInt8, 1, value, offset); - } - writeInt16BE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeInt16BE, 2, value, offset); - } - insertInt16BE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeInt16BE, 2, value, offset); - } - writeInt16LE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeInt16LE, 2, value, offset); - } - insertInt16LE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeInt16LE, 2, value, offset); - } - writeInt32BE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeInt32BE, 4, value, offset); - } - insertInt32BE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeInt32BE, 4, value, offset); - } - writeInt32LE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeInt32LE, 4, value, offset); - } - insertInt32LE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeInt32LE, 4, value, offset); - } - writeBigInt64BE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigInt64BE"); - return this._writeNumberValue(Buffer.prototype.writeBigInt64BE, 8, value, offset); - } - insertBigInt64BE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigInt64BE"); - return this._insertNumberValue(Buffer.prototype.writeBigInt64BE, 8, value, offset); - } - writeBigInt64LE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigInt64LE"); - return this._writeNumberValue(Buffer.prototype.writeBigInt64LE, 8, value, offset); - } - insertBigInt64LE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigInt64LE"); - return this._insertNumberValue(Buffer.prototype.writeBigInt64LE, 8, value, offset); - } - readUInt8(offset) { - return this._readNumberValue(Buffer.prototype.readUInt8, 1, offset); - } - readUInt16BE(offset) { - return this._readNumberValue(Buffer.prototype.readUInt16BE, 2, offset); - } - readUInt16LE(offset) { - return this._readNumberValue(Buffer.prototype.readUInt16LE, 2, offset); - } - readUInt32BE(offset) { - return this._readNumberValue(Buffer.prototype.readUInt32BE, 4, offset); - } - readUInt32LE(offset) { - return this._readNumberValue(Buffer.prototype.readUInt32LE, 4, offset); - } - readBigUInt64BE(offset) { - utils_1.bigIntAndBufferInt64Check("readBigUInt64BE"); - return this._readNumberValue(Buffer.prototype.readBigUInt64BE, 8, offset); - } - readBigUInt64LE(offset) { - utils_1.bigIntAndBufferInt64Check("readBigUInt64LE"); - return this._readNumberValue(Buffer.prototype.readBigUInt64LE, 8, offset); - } - writeUInt8(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeUInt8, 1, value, offset); - } - insertUInt8(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeUInt8, 1, value, offset); - } - writeUInt16BE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeUInt16BE, 2, value, offset); - } - insertUInt16BE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeUInt16BE, 2, value, offset); - } - writeUInt16LE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeUInt16LE, 2, value, offset); - } - insertUInt16LE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeUInt16LE, 2, value, offset); - } - writeUInt32BE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeUInt32BE, 4, value, offset); - } - insertUInt32BE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeUInt32BE, 4, value, offset); - } - writeUInt32LE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeUInt32LE, 4, value, offset); - } - insertUInt32LE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeUInt32LE, 4, value, offset); - } - writeBigUInt64BE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigUInt64BE"); - return this._writeNumberValue(Buffer.prototype.writeBigUInt64BE, 8, value, offset); - } - insertBigUInt64BE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigUInt64BE"); - return this._insertNumberValue(Buffer.prototype.writeBigUInt64BE, 8, value, offset); - } - writeBigUInt64LE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigUInt64LE"); - return this._writeNumberValue(Buffer.prototype.writeBigUInt64LE, 8, value, offset); - } - insertBigUInt64LE(value, offset) { - utils_1.bigIntAndBufferInt64Check("writeBigUInt64LE"); - return this._insertNumberValue(Buffer.prototype.writeBigUInt64LE, 8, value, offset); - } - readFloatBE(offset) { - return this._readNumberValue(Buffer.prototype.readFloatBE, 4, offset); - } - readFloatLE(offset) { - return this._readNumberValue(Buffer.prototype.readFloatLE, 4, offset); - } - writeFloatBE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeFloatBE, 4, value, offset); - } - insertFloatBE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeFloatBE, 4, value, offset); - } - writeFloatLE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeFloatLE, 4, value, offset); - } - insertFloatLE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeFloatLE, 4, value, offset); - } - readDoubleBE(offset) { - return this._readNumberValue(Buffer.prototype.readDoubleBE, 8, offset); - } - readDoubleLE(offset) { - return this._readNumberValue(Buffer.prototype.readDoubleLE, 8, offset); - } - writeDoubleBE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeDoubleBE, 8, value, offset); - } - insertDoubleBE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeDoubleBE, 8, value, offset); - } - writeDoubleLE(value, offset) { - return this._writeNumberValue(Buffer.prototype.writeDoubleLE, 8, value, offset); - } - insertDoubleLE(value, offset) { - return this._insertNumberValue(Buffer.prototype.writeDoubleLE, 8, value, offset); - } - readString(arg1, encoding) { - let lengthVal; - if (typeof arg1 === "number") { - utils_1.checkLengthValue(arg1); - lengthVal = Math.min(arg1, this.length - this._readOffset); - } else { - encoding = arg1; - lengthVal = this.length - this._readOffset; - } - if (typeof encoding !== "undefined") { - utils_1.checkEncoding(encoding); - } - const value = this._buff.slice(this._readOffset, this._readOffset + lengthVal).toString(encoding || this._encoding); - this._readOffset += lengthVal; - return value; - } - insertString(value, offset, encoding) { - utils_1.checkOffsetValue(offset); - return this._handleString(value, true, offset, encoding); - } - writeString(value, arg2, encoding) { - return this._handleString(value, false, arg2, encoding); - } - readStringNT(encoding) { - if (typeof encoding !== "undefined") { - utils_1.checkEncoding(encoding); - } - let nullPos = this.length; - for (let i = this._readOffset;i < this.length; i++) { - if (this._buff[i] === 0) { - nullPos = i; - break; - } - } - const value = this._buff.slice(this._readOffset, nullPos); - this._readOffset = nullPos + 1; - return value.toString(encoding || this._encoding); - } - insertStringNT(value, offset, encoding) { - utils_1.checkOffsetValue(offset); - this.insertString(value, offset, encoding); - this.insertUInt8(0, offset + value.length); - return this; - } - writeStringNT(value, arg2, encoding) { - this.writeString(value, arg2, encoding); - this.writeUInt8(0, typeof arg2 === "number" ? arg2 + value.length : this.writeOffset); - return this; - } - readBuffer(length) { - if (typeof length !== "undefined") { - utils_1.checkLengthValue(length); - } - const lengthVal = typeof length === "number" ? length : this.length; - const endPoint = Math.min(this.length, this._readOffset + lengthVal); - const value = this._buff.slice(this._readOffset, endPoint); - this._readOffset = endPoint; - return value; - } - insertBuffer(value, offset) { - utils_1.checkOffsetValue(offset); - return this._handleBuffer(value, true, offset); - } - writeBuffer(value, offset) { - return this._handleBuffer(value, false, offset); - } - readBufferNT() { - let nullPos = this.length; - for (let i = this._readOffset;i < this.length; i++) { - if (this._buff[i] === 0) { - nullPos = i; - break; - } - } - const value = this._buff.slice(this._readOffset, nullPos); - this._readOffset = nullPos + 1; - return value; - } - insertBufferNT(value, offset) { - utils_1.checkOffsetValue(offset); - this.insertBuffer(value, offset); - this.insertUInt8(0, offset + value.length); - return this; - } - writeBufferNT(value, offset) { - if (typeof offset !== "undefined") { - utils_1.checkOffsetValue(offset); - } - this.writeBuffer(value, offset); - this.writeUInt8(0, typeof offset === "number" ? offset + value.length : this._writeOffset); - return this; - } - clear() { - this._writeOffset = 0; - this._readOffset = 0; - this.length = 0; - return this; - } - remaining() { - return this.length - this._readOffset; - } - get readOffset() { - return this._readOffset; - } - set readOffset(offset) { - utils_1.checkOffsetValue(offset); - utils_1.checkTargetOffset(offset, this); - this._readOffset = offset; - } - get writeOffset() { - return this._writeOffset; - } - set writeOffset(offset) { - utils_1.checkOffsetValue(offset); - utils_1.checkTargetOffset(offset, this); - this._writeOffset = offset; - } - get encoding() { - return this._encoding; - } - set encoding(encoding) { - utils_1.checkEncoding(encoding); - this._encoding = encoding; - } - get internalBuffer() { - return this._buff; - } - toBuffer() { - return this._buff.slice(0, this.length); - } - toString(encoding) { - const encodingVal = typeof encoding === "string" ? encoding : this._encoding; - utils_1.checkEncoding(encodingVal); - return this._buff.toString(encodingVal, 0, this.length); - } - destroy() { - this.clear(); - return this; - } - _handleString(value, isInsert, arg3, encoding) { - let offsetVal = this._writeOffset; - let encodingVal = this._encoding; - if (typeof arg3 === "number") { - offsetVal = arg3; - } else if (typeof arg3 === "string") { - utils_1.checkEncoding(arg3); - encodingVal = arg3; - } - if (typeof encoding === "string") { - utils_1.checkEncoding(encoding); - encodingVal = encoding; - } - const byteLength = Buffer.byteLength(value, encodingVal); - if (isInsert) { - this.ensureInsertable(byteLength, offsetVal); - } else { - this._ensureWriteable(byteLength, offsetVal); - } - this._buff.write(value, offsetVal, byteLength, encodingVal); - if (isInsert) { - this._writeOffset += byteLength; - } else { - if (typeof arg3 === "number") { - this._writeOffset = Math.max(this._writeOffset, offsetVal + byteLength); - } else { - this._writeOffset += byteLength; - } - } - return this; - } - _handleBuffer(value, isInsert, offset) { - const offsetVal = typeof offset === "number" ? offset : this._writeOffset; - if (isInsert) { - this.ensureInsertable(value.length, offsetVal); - } else { - this._ensureWriteable(value.length, offsetVal); - } - value.copy(this._buff, offsetVal); - if (isInsert) { - this._writeOffset += value.length; - } else { - if (typeof offset === "number") { - this._writeOffset = Math.max(this._writeOffset, offsetVal + value.length); - } else { - this._writeOffset += value.length; - } - } - return this; - } - ensureReadable(length, offset) { - let offsetVal = this._readOffset; - if (typeof offset !== "undefined") { - utils_1.checkOffsetValue(offset); - offsetVal = offset; - } - if (offsetVal < 0 || offsetVal + length > this.length) { - throw new Error(utils_1.ERRORS.INVALID_READ_BEYOND_BOUNDS); - } - } - ensureInsertable(dataLength, offset) { - utils_1.checkOffsetValue(offset); - this._ensureCapacity(this.length + dataLength); - if (offset < this.length) { - this._buff.copy(this._buff, offset + dataLength, offset, this._buff.length); - } - if (offset + dataLength > this.length) { - this.length = offset + dataLength; - } else { - this.length += dataLength; - } - } - _ensureWriteable(dataLength, offset) { - const offsetVal = typeof offset === "number" ? offset : this._writeOffset; - this._ensureCapacity(offsetVal + dataLength); - if (offsetVal + dataLength > this.length) { - this.length = offsetVal + dataLength; - } - } - _ensureCapacity(minLength) { - const oldLength = this._buff.length; - if (minLength > oldLength) { - let data = this._buff; - let newLength = oldLength * 3 / 2 + 1; - if (newLength < minLength) { - newLength = minLength; - } - this._buff = Buffer.allocUnsafe(newLength); - data.copy(this._buff, 0, 0, oldLength); - } - } - _readNumberValue(func, byteSize, offset) { - this.ensureReadable(byteSize, offset); - const value = func.call(this._buff, typeof offset === "number" ? offset : this._readOffset); - if (typeof offset === "undefined") { - this._readOffset += byteSize; - } - return value; - } - _insertNumberValue(func, byteSize, value, offset) { - utils_1.checkOffsetValue(offset); - this.ensureInsertable(byteSize, offset); - func.call(this._buff, value, offset); - this._writeOffset += byteSize; - return this; - } - _writeNumberValue(func, byteSize, value, offset) { - if (typeof offset === "number") { - if (offset < 0) { - throw new Error(utils_1.ERRORS.INVALID_WRITE_BEYOND_BOUNDS); - } - utils_1.checkOffsetValue(offset); - } - const offsetVal = typeof offset === "number" ? offset : this._writeOffset; - this._ensureWriteable(byteSize, offsetVal); - func.call(this._buff, value, offsetVal); - if (typeof offset === "number") { - this._writeOffset = Math.max(this._writeOffset, offsetVal + byteSize); - } else { - this._writeOffset += byteSize; - } - return this; - } - } - exports2.SmartBuffer = SmartBuffer; -}); - -// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/constants.js -var require_constants = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SOCKS5_NO_ACCEPTABLE_AUTH = exports2.SOCKS5_CUSTOM_AUTH_END = exports2.SOCKS5_CUSTOM_AUTH_START = exports2.SOCKS_INCOMING_PACKET_SIZES = exports2.SocksClientState = exports2.Socks5Response = exports2.Socks5HostType = exports2.Socks5Auth = exports2.Socks4Response = exports2.SocksCommand = exports2.ERRORS = exports2.DEFAULT_TIMEOUT = undefined; - var DEFAULT_TIMEOUT = 30000; - exports2.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT; - var ERRORS = { - InvalidSocksCommand: "An invalid SOCKS command was provided. Valid options are connect, bind, and associate.", - InvalidSocksCommandForOperation: "An invalid SOCKS command was provided. Only a subset of commands are supported for this operation.", - InvalidSocksCommandChain: "An invalid SOCKS command was provided. Chaining currently only supports the connect command.", - InvalidSocksClientOptionsDestination: "An invalid destination host was provided.", - InvalidSocksClientOptionsExistingSocket: "An invalid existing socket was provided. This should be an instance of stream.Duplex.", - InvalidSocksClientOptionsProxy: "Invalid SOCKS proxy details were provided.", - InvalidSocksClientOptionsTimeout: "An invalid timeout value was provided. Please enter a value above 0 (in ms).", - InvalidSocksClientOptionsProxiesLength: "At least two socks proxies must be provided for chaining.", - InvalidSocksClientOptionsCustomAuthRange: "Custom auth must be a value between 0x80 and 0xFE.", - InvalidSocksClientOptionsCustomAuthOptions: "When a custom_auth_method is provided, custom_auth_request_handler, custom_auth_response_size, and custom_auth_response_handler must also be provided and valid.", - NegotiationError: "Negotiation error", - SocketClosed: "Socket closed", - ProxyConnectionTimedOut: "Proxy connection timed out", - InternalError: "SocksClient internal error (this should not happen)", - InvalidSocks4HandshakeResponse: "Received invalid Socks4 handshake response", - Socks4ProxyRejectedConnection: "Socks4 Proxy rejected connection", - InvalidSocks4IncomingConnectionResponse: "Socks4 invalid incoming connection response", - Socks4ProxyRejectedIncomingBoundConnection: "Socks4 Proxy rejected incoming bound connection", - InvalidSocks5InitialHandshakeResponse: "Received invalid Socks5 initial handshake response", - InvalidSocks5IntiailHandshakeSocksVersion: "Received invalid Socks5 initial handshake (invalid socks version)", - InvalidSocks5InitialHandshakeNoAcceptedAuthType: "Received invalid Socks5 initial handshake (no accepted authentication type)", - InvalidSocks5InitialHandshakeUnknownAuthType: "Received invalid Socks5 initial handshake (unknown authentication type)", - Socks5AuthenticationFailed: "Socks5 Authentication failed", - InvalidSocks5FinalHandshake: "Received invalid Socks5 final handshake response", - InvalidSocks5FinalHandshakeRejected: "Socks5 proxy rejected connection", - InvalidSocks5IncomingConnectionResponse: "Received invalid Socks5 incoming connection response", - Socks5ProxyRejectedIncomingBoundConnection: "Socks5 Proxy rejected incoming bound connection" - }; - exports2.ERRORS = ERRORS; - var SOCKS_INCOMING_PACKET_SIZES = { - Socks5InitialHandshakeResponse: 2, - Socks5UserPassAuthenticationResponse: 2, - Socks5ResponseHeader: 5, - Socks5ResponseIPv4: 10, - Socks5ResponseIPv6: 22, - Socks5ResponseHostname: (hostNameLength) => hostNameLength + 7, - Socks4Response: 8 - }; - exports2.SOCKS_INCOMING_PACKET_SIZES = SOCKS_INCOMING_PACKET_SIZES; - var SocksCommand; - (function(SocksCommand2) { - SocksCommand2[SocksCommand2["connect"] = 1] = "connect"; - SocksCommand2[SocksCommand2["bind"] = 2] = "bind"; - SocksCommand2[SocksCommand2["associate"] = 3] = "associate"; - })(SocksCommand || (exports2.SocksCommand = SocksCommand = {})); - var Socks4Response; - (function(Socks4Response2) { - Socks4Response2[Socks4Response2["Granted"] = 90] = "Granted"; - Socks4Response2[Socks4Response2["Failed"] = 91] = "Failed"; - Socks4Response2[Socks4Response2["Rejected"] = 92] = "Rejected"; - Socks4Response2[Socks4Response2["RejectedIdent"] = 93] = "RejectedIdent"; - })(Socks4Response || (exports2.Socks4Response = Socks4Response = {})); - var Socks5Auth; - (function(Socks5Auth2) { - Socks5Auth2[Socks5Auth2["NoAuth"] = 0] = "NoAuth"; - Socks5Auth2[Socks5Auth2["GSSApi"] = 1] = "GSSApi"; - Socks5Auth2[Socks5Auth2["UserPass"] = 2] = "UserPass"; - })(Socks5Auth || (exports2.Socks5Auth = Socks5Auth = {})); - var SOCKS5_CUSTOM_AUTH_START = 128; - exports2.SOCKS5_CUSTOM_AUTH_START = SOCKS5_CUSTOM_AUTH_START; - var SOCKS5_CUSTOM_AUTH_END = 254; - exports2.SOCKS5_CUSTOM_AUTH_END = SOCKS5_CUSTOM_AUTH_END; - var SOCKS5_NO_ACCEPTABLE_AUTH = 255; - exports2.SOCKS5_NO_ACCEPTABLE_AUTH = SOCKS5_NO_ACCEPTABLE_AUTH; - var Socks5Response; - (function(Socks5Response2) { - Socks5Response2[Socks5Response2["Granted"] = 0] = "Granted"; - Socks5Response2[Socks5Response2["Failure"] = 1] = "Failure"; - Socks5Response2[Socks5Response2["NotAllowed"] = 2] = "NotAllowed"; - Socks5Response2[Socks5Response2["NetworkUnreachable"] = 3] = "NetworkUnreachable"; - Socks5Response2[Socks5Response2["HostUnreachable"] = 4] = "HostUnreachable"; - Socks5Response2[Socks5Response2["ConnectionRefused"] = 5] = "ConnectionRefused"; - Socks5Response2[Socks5Response2["TTLExpired"] = 6] = "TTLExpired"; - Socks5Response2[Socks5Response2["CommandNotSupported"] = 7] = "CommandNotSupported"; - Socks5Response2[Socks5Response2["AddressNotSupported"] = 8] = "AddressNotSupported"; - })(Socks5Response || (exports2.Socks5Response = Socks5Response = {})); - var Socks5HostType; - (function(Socks5HostType2) { - Socks5HostType2[Socks5HostType2["IPv4"] = 1] = "IPv4"; - Socks5HostType2[Socks5HostType2["Hostname"] = 3] = "Hostname"; - Socks5HostType2[Socks5HostType2["IPv6"] = 4] = "IPv6"; - })(Socks5HostType || (exports2.Socks5HostType = Socks5HostType = {})); - var SocksClientState; - (function(SocksClientState2) { - SocksClientState2[SocksClientState2["Created"] = 0] = "Created"; - SocksClientState2[SocksClientState2["Connecting"] = 1] = "Connecting"; - SocksClientState2[SocksClientState2["Connected"] = 2] = "Connected"; - SocksClientState2[SocksClientState2["SentInitialHandshake"] = 3] = "SentInitialHandshake"; - SocksClientState2[SocksClientState2["ReceivedInitialHandshakeResponse"] = 4] = "ReceivedInitialHandshakeResponse"; - SocksClientState2[SocksClientState2["SentAuthentication"] = 5] = "SentAuthentication"; - SocksClientState2[SocksClientState2["ReceivedAuthenticationResponse"] = 6] = "ReceivedAuthenticationResponse"; - SocksClientState2[SocksClientState2["SentFinalHandshake"] = 7] = "SentFinalHandshake"; - SocksClientState2[SocksClientState2["ReceivedFinalResponse"] = 8] = "ReceivedFinalResponse"; - SocksClientState2[SocksClientState2["BoundWaitingForConnection"] = 9] = "BoundWaitingForConnection"; - SocksClientState2[SocksClientState2["Established"] = 10] = "Established"; - SocksClientState2[SocksClientState2["Disconnected"] = 11] = "Disconnected"; - SocksClientState2[SocksClientState2["Error"] = 99] = "Error"; - })(SocksClientState || (exports2.SocksClientState = SocksClientState = {})); -}); - -// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/util.js -var require_util = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.shuffleArray = exports2.SocksClientError = undefined; - - class SocksClientError extends Error { - constructor(message, options) { - super(message); - this.options = options; - } - } - exports2.SocksClientError = SocksClientError; - function shuffleArray(array3) { - for (let i = array3.length - 1;i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array3[i], array3[j]] = [array3[j], array3[i]]; - } - } - exports2.shuffleArray = shuffleArray; -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/common.js -var require_common2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.isInSubnet = isInSubnet; - exports2.isCorrect = isCorrect; - exports2.numberToPaddedHex = numberToPaddedHex; - exports2.stringToPaddedHex = stringToPaddedHex; - exports2.testBit = testBit; - function isInSubnet(address) { - if (this.subnetMask < address.subnetMask) { - return false; - } - if (this.mask(address.subnetMask) === address.mask()) { - return true; - } - return false; - } - function isCorrect(defaultBits) { - return function() { - if (this.addressMinusSuffix !== this.correctForm()) { - return false; - } - if (this.subnetMask === defaultBits && !this.parsedSubnet) { - return true; - } - return this.parsedSubnet === String(this.subnetMask); - }; - } - function numberToPaddedHex(number4) { - return number4.toString(16).padStart(2, "0"); - } - function stringToPaddedHex(numberString) { - return numberToPaddedHex(parseInt(numberString, 10)); - } - function testBit(binaryValue, position) { - const { length } = binaryValue; - if (position > length) { - return false; - } - const positionInString = length - position; - return binaryValue.substring(positionInString, positionInString + 1) === "1"; - } -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v4/constants.js -var require_constants2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.RE_SUBNET_STRING = exports2.RE_ADDRESS = exports2.GROUPS = exports2.BITS = undefined; - exports2.BITS = 32; - exports2.GROUPS = 4; - exports2.RE_ADDRESS = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/g; - exports2.RE_SUBNET_STRING = /\/\d{1,2}$/; -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/address-error.js -var require_address_error = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.AddressError = undefined; - - class AddressError extends Error { - constructor(message, parseMessage) { - super(message); - this.name = "AddressError"; - this.parseMessage = parseMessage; - } - } - exports2.AddressError = AddressError; -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/ipv4.js -var require_ipv4 = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Address4 = undefined; - var common = __importStar(require_common2()); - var constants = __importStar(require_constants2()); - var address_error_1 = require_address_error(); - - class Address4 { - constructor(address) { - this.groups = constants.GROUPS; - this.parsedAddress = []; - this.parsedSubnet = ""; - this.subnet = "/32"; - this.subnetMask = 32; - this.v4 = true; - this.isCorrect = common.isCorrect(constants.BITS); - this.isInSubnet = common.isInSubnet; - this.address = address; - const subnet = constants.RE_SUBNET_STRING.exec(address); - if (subnet) { - this.parsedSubnet = subnet[0].replace("/", ""); - this.subnetMask = parseInt(this.parsedSubnet, 10); - this.subnet = `/${this.subnetMask}`; - if (this.subnetMask < 0 || this.subnetMask > constants.BITS) { - throw new address_error_1.AddressError("Invalid subnet mask."); - } - address = address.replace(constants.RE_SUBNET_STRING, ""); - } - this.addressMinusSuffix = address; - this.parsedAddress = this.parse(address); - } - static isValid(address) { - try { - new Address4(address); - return true; - } catch (e) { - return false; - } - } - parse(address) { - const groups = address.split("."); - if (!address.match(constants.RE_ADDRESS)) { - throw new address_error_1.AddressError("Invalid IPv4 address."); - } - return groups; - } - correctForm() { - return this.parsedAddress.map((part) => parseInt(part, 10)).join("."); - } - static fromHex(hex3) { - const padded = hex3.replace(/:/g, "").padStart(8, "0"); - const groups = []; - let i; - for (i = 0;i < 8; i += 2) { - const h = padded.slice(i, i + 2); - groups.push(parseInt(h, 16)); - } - return new Address4(groups.join(".")); - } - static fromInteger(integer2) { - return Address4.fromHex(integer2.toString(16)); - } - static fromArpa(arpaFormAddress) { - const leader = arpaFormAddress.replace(/(\.in-addr\.arpa)?\.$/, ""); - const address = leader.split(".").reverse().join("."); - return new Address4(address); - } - toHex() { - return this.parsedAddress.map((part) => common.stringToPaddedHex(part)).join(":"); - } - toArray() { - return this.parsedAddress.map((part) => parseInt(part, 10)); - } - toGroup6() { - const output = []; - let i; - for (i = 0;i < constants.GROUPS; i += 2) { - output.push(`${common.stringToPaddedHex(this.parsedAddress[i])}${common.stringToPaddedHex(this.parsedAddress[i + 1])}`); - } - return output.join(":"); - } - bigInt() { - return BigInt(`0x${this.parsedAddress.map((n) => common.stringToPaddedHex(n)).join("")}`); - } - _startAddress() { - return BigInt(`0b${this.mask() + "0".repeat(constants.BITS - this.subnetMask)}`); - } - startAddress() { - return Address4.fromBigInt(this._startAddress()); - } - startAddressExclusive() { - const adjust = BigInt("1"); - return Address4.fromBigInt(this._startAddress() + adjust); - } - _endAddress() { - return BigInt(`0b${this.mask() + "1".repeat(constants.BITS - this.subnetMask)}`); - } - endAddress() { - return Address4.fromBigInt(this._endAddress()); - } - endAddressExclusive() { - const adjust = BigInt("1"); - return Address4.fromBigInt(this._endAddress() - adjust); - } - static fromBigInt(bigInt) { - return Address4.fromHex(bigInt.toString(16)); - } - static fromByteArray(bytes) { - if (bytes.length !== 4) { - throw new address_error_1.AddressError("IPv4 addresses require exactly 4 bytes"); - } - for (let i = 0;i < bytes.length; i++) { - if (!Number.isInteger(bytes[i]) || bytes[i] < 0 || bytes[i] > 255) { - throw new address_error_1.AddressError("All bytes must be integers between 0 and 255"); - } - } - return this.fromUnsignedByteArray(bytes); - } - static fromUnsignedByteArray(bytes) { - if (bytes.length !== 4) { - throw new address_error_1.AddressError("IPv4 addresses require exactly 4 bytes"); - } - const address = bytes.join("."); - return new Address4(address); - } - mask(mask) { - if (mask === undefined) { - mask = this.subnetMask; - } - return this.getBitsBase2(0, mask); - } - getBitsBase2(start, end) { - return this.binaryZeroPad().slice(start, end); - } - reverseForm(options) { - if (!options) { - options = {}; - } - const reversed = this.correctForm().split(".").reverse().join("."); - if (options.omitSuffix) { - return reversed; - } - return `${reversed}.in-addr.arpa.`; - } - isMulticast() { - return this.isInSubnet(new Address4("224.0.0.0/4")); - } - binaryZeroPad() { - return this.bigInt().toString(2).padStart(constants.BITS, "0"); - } - groupForV6() { - const segments = this.parsedAddress; - return this.address.replace(constants.RE_ADDRESS, `${segments.slice(0, 2).join(".")}.${segments.slice(2, 4).join(".")}`); - } - } - exports2.Address4 = Address4; -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v6/constants.js -var require_constants3 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.RE_URL_WITH_PORT = exports2.RE_URL = exports2.RE_ZONE_STRING = exports2.RE_SUBNET_STRING = exports2.RE_BAD_ADDRESS = exports2.RE_BAD_CHARACTERS = exports2.TYPES = exports2.SCOPES = exports2.GROUPS = exports2.BITS = undefined; - exports2.BITS = 128; - exports2.GROUPS = 8; - exports2.SCOPES = { - 0: "Reserved", - 1: "Interface local", - 2: "Link local", - 4: "Admin local", - 5: "Site local", - 8: "Organization local", - 14: "Global", - 15: "Reserved" - }; - exports2.TYPES = { - "ff01::1/128": "Multicast (All nodes on this interface)", - "ff01::2/128": "Multicast (All routers on this interface)", - "ff02::1/128": "Multicast (All nodes on this link)", - "ff02::2/128": "Multicast (All routers on this link)", - "ff05::2/128": "Multicast (All routers in this site)", - "ff02::5/128": "Multicast (OSPFv3 AllSPF routers)", - "ff02::6/128": "Multicast (OSPFv3 AllDR routers)", - "ff02::9/128": "Multicast (RIP routers)", - "ff02::a/128": "Multicast (EIGRP routers)", - "ff02::d/128": "Multicast (PIM routers)", - "ff02::16/128": "Multicast (MLDv2 reports)", - "ff01::fb/128": "Multicast (mDNSv6)", - "ff02::fb/128": "Multicast (mDNSv6)", - "ff05::fb/128": "Multicast (mDNSv6)", - "ff02::1:2/128": "Multicast (All DHCP servers and relay agents on this link)", - "ff05::1:2/128": "Multicast (All DHCP servers and relay agents in this site)", - "ff02::1:3/128": "Multicast (All DHCP servers on this link)", - "ff05::1:3/128": "Multicast (All DHCP servers in this site)", - "::/128": "Unspecified", - "::1/128": "Loopback", - "ff00::/8": "Multicast", - "fe80::/10": "Link-local unicast" - }; - exports2.RE_BAD_CHARACTERS = /([^0-9a-f:/%])/gi; - exports2.RE_BAD_ADDRESS = /([0-9a-f]{5,}|:{3,}|[^:]:$|^:[^:]|\/$)/gi; - exports2.RE_SUBNET_STRING = /\/\d{1,3}(?=%|$)/; - exports2.RE_ZONE_STRING = /%.*$/; - exports2.RE_URL = /^\[{0,1}([0-9a-f:]+)\]{0,1}/; - exports2.RE_URL_WITH_PORT = /\[([0-9a-f:]+)\]:([0-9]{1,5})/; -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v6/helpers.js -var require_helpers2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.spanAllZeroes = spanAllZeroes; - exports2.spanAll = spanAll; - exports2.spanLeadingZeroes = spanLeadingZeroes; - exports2.simpleGroup = simpleGroup; - function spanAllZeroes(s) { - return s.replace(/(0+)/g, '$1'); - } - function spanAll(s, offset = 0) { - const letters = s.split(""); - return letters.map((n, i) => `${spanAllZeroes(n)}`).join(""); - } - function spanLeadingZeroesSimple(group) { - return group.replace(/^(0+)/, '$1'); - } - function spanLeadingZeroes(address) { - const groups = address.split(":"); - return groups.map((g) => spanLeadingZeroesSimple(g)).join(":"); - } - function simpleGroup(addressString, offset = 0) { - const groups = addressString.split(":"); - return groups.map((g, i) => { - if (/group-v4/.test(g)) { - return g; - } - return `${spanLeadingZeroesSimple(g)}`; - }); - } -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/v6/regular-expressions.js -var require_regular_expressions = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ADDRESS_BOUNDARY = undefined; - exports2.groupPossibilities = groupPossibilities; - exports2.padGroup = padGroup; - exports2.simpleRegularExpression = simpleRegularExpression; - exports2.possibleElisions = possibleElisions; - var v6 = __importStar(require_constants3()); - function groupPossibilities(possibilities) { - return `(${possibilities.join("|")})`; - } - function padGroup(group) { - if (group.length < 4) { - return `0{0,${4 - group.length}}${group}`; - } - return group; - } - exports2.ADDRESS_BOUNDARY = "[^A-Fa-f0-9:]"; - function simpleRegularExpression(groups) { - const zeroIndexes = []; - groups.forEach((group, i) => { - const groupInteger = parseInt(group, 16); - if (groupInteger === 0) { - zeroIndexes.push(i); - } - }); - const possibilities = zeroIndexes.map((zeroIndex) => groups.map((group, i) => { - if (i === zeroIndex) { - const elision = i === 0 || i === v6.GROUPS - 1 ? ":" : ""; - return groupPossibilities([padGroup(group), elision]); - } - return padGroup(group); - }).join(":")); - possibilities.push(groups.map(padGroup).join(":")); - return groupPossibilities(possibilities); - } - function possibleElisions(elidedGroups, moreLeft, moreRight) { - const left = moreLeft ? "" : ":"; - const right = moreRight ? "" : ":"; - const possibilities = []; - if (!moreLeft && !moreRight) { - possibilities.push("::"); - } - if (moreLeft && moreRight) { - possibilities.push(""); - } - if (moreRight && !moreLeft || !moreRight && moreLeft) { - possibilities.push(":"); - } - possibilities.push(`${left}(:0{1,4}){1,${elidedGroups - 1}}`); - possibilities.push(`(0{1,4}:){1,${elidedGroups - 1}}${right}`); - possibilities.push(`(0{1,4}:){${elidedGroups - 1}}0{1,4}`); - for (let groups = 1;groups < elidedGroups - 1; groups++) { - for (let position = 1;position < elidedGroups - groups; position++) { - possibilities.push(`(0{1,4}:){${position}}:(0{1,4}:){${elidedGroups - position - groups - 1}}0{1,4}`); - } - } - return groupPossibilities(possibilities); - } -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/ipv6.js -var require_ipv6 = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Address6 = undefined; - var common = __importStar(require_common2()); - var constants4 = __importStar(require_constants2()); - var constants6 = __importStar(require_constants3()); - var helpers = __importStar(require_helpers2()); - var ipv4_1 = require_ipv4(); - var regular_expressions_1 = require_regular_expressions(); - var address_error_1 = require_address_error(); - var common_1 = require_common2(); - function assert2(condition) { - if (!condition) { - throw new Error("Assertion failed."); - } - } - function addCommas(number4) { - const r = /(\d+)(\d{3})/; - while (r.test(number4)) { - number4 = number4.replace(r, "$1,$2"); - } - return number4; - } - function spanLeadingZeroes4(n) { - n = n.replace(/^(0{1,})([1-9]+)$/, '$1$2'); - n = n.replace(/^(0{1,})(0)$/, '$1$2'); - return n; - } - function compact2(address, slice) { - const s1 = []; - const s2 = []; - let i; - for (i = 0;i < address.length; i++) { - if (i < slice[0]) { - s1.push(address[i]); - } else if (i > slice[1]) { - s2.push(address[i]); - } - } - return s1.concat(["compact"]).concat(s2); - } - function paddedHex(octet) { - return parseInt(octet, 16).toString(16).padStart(4, "0"); - } - function unsignByte(b) { - return b & 255; - } - - class Address6 { - constructor(address, optionalGroups) { - this.addressMinusSuffix = ""; - this.parsedSubnet = ""; - this.subnet = "/128"; - this.subnetMask = 128; - this.v4 = false; - this.zone = ""; - this.isInSubnet = common.isInSubnet; - this.isCorrect = common.isCorrect(constants6.BITS); - if (optionalGroups === undefined) { - this.groups = constants6.GROUPS; - } else { - this.groups = optionalGroups; - } - this.address = address; - const subnet = constants6.RE_SUBNET_STRING.exec(address); - if (subnet) { - this.parsedSubnet = subnet[0].replace("/", ""); - this.subnetMask = parseInt(this.parsedSubnet, 10); - this.subnet = `/${this.subnetMask}`; - if (Number.isNaN(this.subnetMask) || this.subnetMask < 0 || this.subnetMask > constants6.BITS) { - throw new address_error_1.AddressError("Invalid subnet mask."); - } - address = address.replace(constants6.RE_SUBNET_STRING, ""); - } else if (/\//.test(address)) { - throw new address_error_1.AddressError("Invalid subnet mask."); - } - const zone = constants6.RE_ZONE_STRING.exec(address); - if (zone) { - this.zone = zone[0]; - address = address.replace(constants6.RE_ZONE_STRING, ""); - } - this.addressMinusSuffix = address; - this.parsedAddress = this.parse(this.addressMinusSuffix); - } - static isValid(address) { - try { - new Address6(address); - return true; - } catch (e) { - return false; - } - } - static fromBigInt(bigInt) { - const hex3 = bigInt.toString(16).padStart(32, "0"); - const groups = []; - let i; - for (i = 0;i < constants6.GROUPS; i++) { - groups.push(hex3.slice(i * 4, (i + 1) * 4)); - } - return new Address6(groups.join(":")); - } - static fromURL(url2) { - let host; - let port = null; - let result; - if (url2.indexOf("[") !== -1 && url2.indexOf("]:") !== -1) { - result = constants6.RE_URL_WITH_PORT.exec(url2); - if (result === null) { - return { - error: "failed to parse address with port", - address: null, - port: null - }; - } - host = result[1]; - port = result[2]; - } else if (url2.indexOf("/") !== -1) { - url2 = url2.replace(/^[a-z0-9]+:\/\//, ""); - result = constants6.RE_URL.exec(url2); - if (result === null) { - return { - error: "failed to parse address from URL", - address: null, - port: null - }; - } - host = result[1]; - } else { - host = url2; - } - if (port) { - port = parseInt(port, 10); - if (port < 0 || port > 65536) { - port = null; - } - } else { - port = null; - } - return { - address: new Address6(host), - port - }; - } - static fromAddress4(address) { - const address4 = new ipv4_1.Address4(address); - const mask6 = constants6.BITS - (constants4.BITS - address4.subnetMask); - return new Address6(`::ffff:${address4.correctForm()}/${mask6}`); - } - static fromArpa(arpaFormAddress) { - let address = arpaFormAddress.replace(/(\.ip6\.arpa)?\.$/, ""); - const semicolonAmount = 7; - if (address.length !== 63) { - throw new address_error_1.AddressError("Invalid 'ip6.arpa' form."); - } - const parts = address.split(".").reverse(); - for (let i = semicolonAmount;i > 0; i--) { - const insertIndex = i * 4; - parts.splice(insertIndex, 0, ":"); - } - address = parts.join(""); - return new Address6(address); - } - microsoftTranscription() { - return `${this.correctForm().replace(/:/g, "-")}.ipv6-literal.net`; - } - mask(mask = this.subnetMask) { - return this.getBitsBase2(0, mask); - } - possibleSubnets(subnetSize = 128) { - const availableBits = constants6.BITS - this.subnetMask; - const subnetBits = Math.abs(subnetSize - constants6.BITS); - const subnetPowers = availableBits - subnetBits; - if (subnetPowers < 0) { - return "0"; - } - return addCommas((BigInt("2") ** BigInt(subnetPowers)).toString(10)); - } - _startAddress() { - return BigInt(`0b${this.mask() + "0".repeat(constants6.BITS - this.subnetMask)}`); - } - startAddress() { - return Address6.fromBigInt(this._startAddress()); - } - startAddressExclusive() { - const adjust = BigInt("1"); - return Address6.fromBigInt(this._startAddress() + adjust); - } - _endAddress() { - return BigInt(`0b${this.mask() + "1".repeat(constants6.BITS - this.subnetMask)}`); - } - endAddress() { - return Address6.fromBigInt(this._endAddress()); - } - endAddressExclusive() { - const adjust = BigInt("1"); - return Address6.fromBigInt(this._endAddress() - adjust); - } - getScope() { - let scope = constants6.SCOPES[parseInt(this.getBits(12, 16).toString(10), 10)]; - if (this.getType() === "Global unicast" && scope !== "Link local") { - scope = "Global"; - } - return scope || "Unknown"; - } - getType() { - for (const subnet of Object.keys(constants6.TYPES)) { - if (this.isInSubnet(new Address6(subnet))) { - return constants6.TYPES[subnet]; - } - } - return "Global unicast"; - } - getBits(start, end) { - return BigInt(`0b${this.getBitsBase2(start, end)}`); - } - getBitsBase2(start, end) { - return this.binaryZeroPad().slice(start, end); - } - getBitsBase16(start, end) { - const length = end - start; - if (length % 4 !== 0) { - throw new Error("Length of bits to retrieve must be divisible by four"); - } - return this.getBits(start, end).toString(16).padStart(length / 4, "0"); - } - getBitsPastSubnet() { - return this.getBitsBase2(this.subnetMask, constants6.BITS); - } - reverseForm(options) { - if (!options) { - options = {}; - } - const characters = Math.floor(this.subnetMask / 4); - const reversed = this.canonicalForm().replace(/:/g, "").split("").slice(0, characters).reverse().join("."); - if (characters > 0) { - if (options.omitSuffix) { - return reversed; - } - return `${reversed}.ip6.arpa.`; - } - if (options.omitSuffix) { - return ""; - } - return "ip6.arpa."; - } - correctForm() { - let i; - let groups = []; - let zeroCounter = 0; - const zeroes = []; - for (i = 0;i < this.parsedAddress.length; i++) { - const value = parseInt(this.parsedAddress[i], 16); - if (value === 0) { - zeroCounter++; - } - if (value !== 0 && zeroCounter > 0) { - if (zeroCounter > 1) { - zeroes.push([i - zeroCounter, i - 1]); - } - zeroCounter = 0; - } - } - if (zeroCounter > 1) { - zeroes.push([this.parsedAddress.length - zeroCounter, this.parsedAddress.length - 1]); - } - const zeroLengths = zeroes.map((n) => n[1] - n[0] + 1); - if (zeroes.length > 0) { - const index = zeroLengths.indexOf(Math.max(...zeroLengths)); - groups = compact2(this.parsedAddress, zeroes[index]); - } else { - groups = this.parsedAddress; - } - for (i = 0;i < groups.length; i++) { - if (groups[i] !== "compact") { - groups[i] = parseInt(groups[i], 16).toString(16); - } - } - let correct = groups.join(":"); - correct = correct.replace(/^compact$/, "::"); - correct = correct.replace(/(^compact)|(compact$)/, ":"); - correct = correct.replace(/compact/, ""); - return correct; - } - binaryZeroPad() { - return this.bigInt().toString(2).padStart(constants6.BITS, "0"); - } - parse4in6(address) { - const groups = address.split(":"); - const lastGroup = groups.slice(-1)[0]; - const address4 = lastGroup.match(constants4.RE_ADDRESS); - if (address4) { - this.parsedAddress4 = address4[0]; - this.address4 = new ipv4_1.Address4(this.parsedAddress4); - for (let i = 0;i < this.address4.groups; i++) { - if (/^0[0-9]+/.test(this.address4.parsedAddress[i])) { - throw new address_error_1.AddressError("IPv4 addresses can't have leading zeroes.", address.replace(constants4.RE_ADDRESS, this.address4.parsedAddress.map(spanLeadingZeroes4).join("."))); - } - } - this.v4 = true; - groups[groups.length - 1] = this.address4.toGroup6(); - address = groups.join(":"); - } - return address; - } - parse(address) { - address = this.parse4in6(address); - const badCharacters = address.match(constants6.RE_BAD_CHARACTERS); - if (badCharacters) { - throw new address_error_1.AddressError(`Bad character${badCharacters.length > 1 ? "s" : ""} detected in address: ${badCharacters.join("")}`, address.replace(constants6.RE_BAD_CHARACTERS, '$1')); - } - const badAddress = address.match(constants6.RE_BAD_ADDRESS); - if (badAddress) { - throw new address_error_1.AddressError(`Address failed regex: ${badAddress.join("")}`, address.replace(constants6.RE_BAD_ADDRESS, '$1')); - } - let groups = []; - const halves = address.split("::"); - if (halves.length === 2) { - let first = halves[0].split(":"); - let last2 = halves[1].split(":"); - if (first.length === 1 && first[0] === "") { - first = []; - } - if (last2.length === 1 && last2[0] === "") { - last2 = []; - } - const remaining = this.groups - (first.length + last2.length); - if (!remaining) { - throw new address_error_1.AddressError("Error parsing groups"); - } - this.elidedGroups = remaining; - this.elisionBegin = first.length; - this.elisionEnd = first.length + this.elidedGroups; - groups = groups.concat(first); - for (let i = 0;i < remaining; i++) { - groups.push("0"); - } - groups = groups.concat(last2); - } else if (halves.length === 1) { - groups = address.split(":"); - this.elidedGroups = 0; - } else { - throw new address_error_1.AddressError("Too many :: groups found"); - } - groups = groups.map((group) => parseInt(group, 16).toString(16)); - if (groups.length !== this.groups) { - throw new address_error_1.AddressError("Incorrect number of groups found"); - } - return groups; - } - canonicalForm() { - return this.parsedAddress.map(paddedHex).join(":"); - } - decimal() { - return this.parsedAddress.map((n) => parseInt(n, 16).toString(10).padStart(5, "0")).join(":"); - } - bigInt() { - return BigInt(`0x${this.parsedAddress.map(paddedHex).join("")}`); - } - to4() { - const binary = this.binaryZeroPad().split(""); - return ipv4_1.Address4.fromHex(BigInt(`0b${binary.slice(96, 128).join("")}`).toString(16)); - } - to4in6() { - const address4 = this.to4(); - const address6 = new Address6(this.parsedAddress.slice(0, 6).join(":"), 6); - const correct = address6.correctForm(); - let infix = ""; - if (!/:$/.test(correct)) { - infix = ":"; - } - return correct + infix + address4.address; - } - inspectTeredo() { - const prefix = this.getBitsBase16(0, 32); - const bitsForUdpPort = this.getBits(80, 96); - const udpPort = (bitsForUdpPort ^ BigInt("0xffff")).toString(); - const server4 = ipv4_1.Address4.fromHex(this.getBitsBase16(32, 64)); - const bitsForClient4 = this.getBits(96, 128); - const client4 = ipv4_1.Address4.fromHex((bitsForClient4 ^ BigInt("0xffffffff")).toString(16)); - const flagsBase2 = this.getBitsBase2(64, 80); - const coneNat = (0, common_1.testBit)(flagsBase2, 15); - const reserved = (0, common_1.testBit)(flagsBase2, 14); - const groupIndividual = (0, common_1.testBit)(flagsBase2, 8); - const universalLocal = (0, common_1.testBit)(flagsBase2, 9); - const nonce = BigInt(`0b${flagsBase2.slice(2, 6) + flagsBase2.slice(8, 16)}`).toString(10); - return { - prefix: `${prefix.slice(0, 4)}:${prefix.slice(4, 8)}`, - server4: server4.address, - client4: client4.address, - flags: flagsBase2, - coneNat, - microsoft: { - reserved, - universalLocal, - groupIndividual, - nonce - }, - udpPort - }; - } - inspect6to4() { - const prefix = this.getBitsBase16(0, 16); - const gateway2 = ipv4_1.Address4.fromHex(this.getBitsBase16(16, 48)); - return { - prefix: prefix.slice(0, 4), - gateway: gateway2.address - }; - } - to6to4() { - if (!this.is4()) { - return null; - } - const addr6to4 = [ - "2002", - this.getBitsBase16(96, 112), - this.getBitsBase16(112, 128), - "", - "/16" - ].join(":"); - return new Address6(addr6to4); - } - toByteArray() { - const valueWithoutPadding = this.bigInt().toString(16); - const leadingPad = "0".repeat(valueWithoutPadding.length % 2); - const value = `${leadingPad}${valueWithoutPadding}`; - const bytes = []; - for (let i = 0, length = value.length;i < length; i += 2) { - bytes.push(parseInt(value.substring(i, i + 2), 16)); - } - return bytes; - } - toUnsignedByteArray() { - return this.toByteArray().map(unsignByte); - } - static fromByteArray(bytes) { - return this.fromUnsignedByteArray(bytes.map(unsignByte)); - } - static fromUnsignedByteArray(bytes) { - const BYTE_MAX = BigInt("256"); - let result = BigInt("0"); - let multiplier = BigInt("1"); - for (let i = bytes.length - 1;i >= 0; i--) { - result += multiplier * BigInt(bytes[i].toString(10)); - multiplier *= BYTE_MAX; - } - return Address6.fromBigInt(result); - } - isCanonical() { - return this.addressMinusSuffix === this.canonicalForm(); - } - isLinkLocal() { - if (this.getBitsBase2(0, 64) === "1111111010000000000000000000000000000000000000000000000000000000") { - return true; - } - return false; - } - isMulticast() { - return this.getType() === "Multicast"; - } - is4() { - return this.v4; - } - isTeredo() { - return this.isInSubnet(new Address6("2001::/32")); - } - is6to4() { - return this.isInSubnet(new Address6("2002::/16")); - } - isLoopback() { - return this.getType() === "Loopback"; - } - href(optionalPort) { - if (optionalPort === undefined) { - optionalPort = ""; - } else { - optionalPort = `:${optionalPort}`; - } - return `http://[${this.correctForm()}]${optionalPort}/`; - } - link(options) { - if (!options) { - options = {}; - } - if (options.className === undefined) { - options.className = ""; - } - if (options.prefix === undefined) { - options.prefix = "/#address="; - } - if (options.v4 === undefined) { - options.v4 = false; - } - let formFunction = this.correctForm; - if (options.v4) { - formFunction = this.to4in6; - } - const form = formFunction.call(this); - if (options.className) { - return `${form}`; - } - return `${form}`; - } - group() { - if (this.elidedGroups === 0) { - return helpers.simpleGroup(this.address).join(":"); - } - assert2(typeof this.elidedGroups === "number"); - assert2(typeof this.elisionBegin === "number"); - const output = []; - const [left, right] = this.address.split("::"); - if (left.length) { - output.push(...helpers.simpleGroup(left)); - } else { - output.push(""); - } - const classes = ["hover-group"]; - for (let i = this.elisionBegin;i < this.elisionBegin + this.elidedGroups; i++) { - classes.push(`group-${i}`); - } - output.push(``); - if (right.length) { - output.push(...helpers.simpleGroup(right, this.elisionEnd)); - } else { - output.push(""); - } - if (this.is4()) { - assert2(this.address4 instanceof ipv4_1.Address4); - output.pop(); - output.push(this.address4.groupForV6()); - } - return output.join(":"); - } - regularExpressionString(substringSearch = false) { - let output = []; - const address6 = new Address6(this.correctForm()); - if (address6.elidedGroups === 0) { - output.push((0, regular_expressions_1.simpleRegularExpression)(address6.parsedAddress)); - } else if (address6.elidedGroups === constants6.GROUPS) { - output.push((0, regular_expressions_1.possibleElisions)(constants6.GROUPS)); - } else { - const halves = address6.address.split("::"); - if (halves[0].length) { - output.push((0, regular_expressions_1.simpleRegularExpression)(halves[0].split(":"))); - } - assert2(typeof address6.elidedGroups === "number"); - output.push((0, regular_expressions_1.possibleElisions)(address6.elidedGroups, halves[0].length !== 0, halves[1].length !== 0)); - if (halves[1].length) { - output.push((0, regular_expressions_1.simpleRegularExpression)(halves[1].split(":"))); - } - output = [output.join(":")]; - } - if (!substringSearch) { - output = [ - "(?=^|", - regular_expressions_1.ADDRESS_BOUNDARY, - "|[^\\w\\:])(", - ...output, - ")(?=[^\\w\\:]|", - regular_expressions_1.ADDRESS_BOUNDARY, - "|$)" - ]; - } - return output.join(""); - } - regularExpression(substringSearch = false) { - return new RegExp(this.regularExpressionString(substringSearch), "i"); - } - } - exports2.Address6 = Address6; -}); - -// ../../node_modules/.pnpm/ip-address@10.1.0/node_modules/ip-address/dist/ip-address.js -var require_ip_address = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.v6 = exports2.AddressError = exports2.Address6 = exports2.Address4 = undefined; - var ipv4_1 = require_ipv4(); - Object.defineProperty(exports2, "Address4", { enumerable: true, get: function() { - return ipv4_1.Address4; - } }); - var ipv6_1 = require_ipv6(); - Object.defineProperty(exports2, "Address6", { enumerable: true, get: function() { - return ipv6_1.Address6; - } }); - var address_error_1 = require_address_error(); - Object.defineProperty(exports2, "AddressError", { enumerable: true, get: function() { - return address_error_1.AddressError; - } }); - var helpers = __importStar(require_helpers2()); - exports2.v6 = { helpers }; -}); - -// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/helpers.js -var require_helpers3 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ipToBuffer = exports2.int32ToIpv4 = exports2.ipv4ToInt32 = exports2.validateSocksClientChainOptions = exports2.validateSocksClientOptions = undefined; - var util_1 = require_util(); - var constants_1 = require_constants(); - var stream = require("stream"); - var ip_address_1 = require_ip_address(); - var net = require("net"); - function validateSocksClientOptions(options, acceptedCommands = ["connect", "bind", "associate"]) { - if (!constants_1.SocksCommand[options.command]) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksCommand, options); - } - if (acceptedCommands.indexOf(options.command) === -1) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksCommandForOperation, options); - } - if (!isValidSocksRemoteHost(options.destination)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsDestination, options); - } - if (!isValidSocksProxy(options.proxy)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsProxy, options); - } - validateCustomProxyAuth(options.proxy, options); - if (options.timeout && !isValidTimeoutValue(options.timeout)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsTimeout, options); - } - if (options.existing_socket && !(options.existing_socket instanceof stream.Duplex)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsExistingSocket, options); - } - } - exports2.validateSocksClientOptions = validateSocksClientOptions; - function validateSocksClientChainOptions(options) { - if (options.command !== "connect") { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksCommandChain, options); - } - if (!isValidSocksRemoteHost(options.destination)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsDestination, options); - } - if (!(options.proxies && Array.isArray(options.proxies) && options.proxies.length >= 2)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsProxiesLength, options); - } - options.proxies.forEach((proxy) => { - if (!isValidSocksProxy(proxy)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsProxy, options); - } - validateCustomProxyAuth(proxy, options); - }); - if (options.timeout && !isValidTimeoutValue(options.timeout)) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsTimeout, options); - } - } - exports2.validateSocksClientChainOptions = validateSocksClientChainOptions; - function validateCustomProxyAuth(proxy, options) { - if (proxy.custom_auth_method !== undefined) { - if (proxy.custom_auth_method < constants_1.SOCKS5_CUSTOM_AUTH_START || proxy.custom_auth_method > constants_1.SOCKS5_CUSTOM_AUTH_END) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthRange, options); - } - if (proxy.custom_auth_request_handler === undefined || typeof proxy.custom_auth_request_handler !== "function") { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthOptions, options); - } - if (proxy.custom_auth_response_size === undefined) { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthOptions, options); - } - if (proxy.custom_auth_response_handler === undefined || typeof proxy.custom_auth_response_handler !== "function") { - throw new util_1.SocksClientError(constants_1.ERRORS.InvalidSocksClientOptionsCustomAuthOptions, options); - } - } - } - function isValidSocksRemoteHost(remoteHost) { - return remoteHost && typeof remoteHost.host === "string" && Buffer.byteLength(remoteHost.host) < 256 && typeof remoteHost.port === "number" && remoteHost.port >= 0 && remoteHost.port <= 65535; - } - function isValidSocksProxy(proxy) { - return proxy && (typeof proxy.host === "string" || typeof proxy.ipaddress === "string") && typeof proxy.port === "number" && proxy.port >= 0 && proxy.port <= 65535 && (proxy.type === 4 || proxy.type === 5); - } - function isValidTimeoutValue(value) { - return typeof value === "number" && value > 0; - } - function ipv4ToInt32(ip) { - const address = new ip_address_1.Address4(ip); - return address.toArray().reduce((acc, part) => (acc << 8) + part, 0) >>> 0; - } - exports2.ipv4ToInt32 = ipv4ToInt32; - function int32ToIpv4(int322) { - const octet1 = int322 >>> 24 & 255; - const octet2 = int322 >>> 16 & 255; - const octet3 = int322 >>> 8 & 255; - const octet4 = int322 & 255; - return [octet1, octet2, octet3, octet4].join("."); - } - exports2.int32ToIpv4 = int32ToIpv4; - function ipToBuffer(ip) { - if (net.isIPv4(ip)) { - const address = new ip_address_1.Address4(ip); - return Buffer.from(address.toArray()); - } else if (net.isIPv6(ip)) { - const address = new ip_address_1.Address6(ip); - return Buffer.from(address.canonicalForm().split(":").map((segment) => segment.padStart(4, "0")).join(""), "hex"); - } else { - throw new Error("Invalid IP address format"); - } - } - exports2.ipToBuffer = ipToBuffer; -}); - -// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/common/receivebuffer.js -var require_receivebuffer = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ReceiveBuffer = undefined; - - class ReceiveBuffer { - constructor(size = 4096) { - this.buffer = Buffer.allocUnsafe(size); - this.offset = 0; - this.originalSize = size; - } - get length() { - return this.offset; - } - append(data) { - if (!Buffer.isBuffer(data)) { - throw new Error("Attempted to append a non-buffer instance to ReceiveBuffer."); - } - if (this.offset + data.length >= this.buffer.length) { - const tmp = this.buffer; - this.buffer = Buffer.allocUnsafe(Math.max(this.buffer.length + this.originalSize, this.buffer.length + data.length)); - tmp.copy(this.buffer); - } - data.copy(this.buffer, this.offset); - return this.offset += data.length; - } - peek(length) { - if (length > this.offset) { - throw new Error("Attempted to read beyond the bounds of the managed internal data."); - } - return this.buffer.slice(0, length); - } - get(length) { - if (length > this.offset) { - throw new Error("Attempted to read beyond the bounds of the managed internal data."); - } - const value = Buffer.allocUnsafe(length); - this.buffer.slice(0, length).copy(value); - this.buffer.copyWithin(0, length, length + this.offset - length); - this.offset -= length; - return value; - } - } - exports2.ReceiveBuffer = ReceiveBuffer; -}); - -// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/client/socksclient.js -var require_socksclient = __commonJS((exports2) => { - var __awaiter = exports2 && exports2.__awaiter || function(thisArg, _arguments, P, generator) { - function adopt(value) { - return value instanceof P ? value : new P(function(resolve2) { - resolve2(value); - }); - } - return new (P || (P = Promise))(function(resolve2, reject) { - function fulfilled(value) { - try { - step(generator.next(value)); - } catch (e) { - reject(e); - } - } - function rejected(value) { - try { - step(generator["throw"](value)); - } catch (e) { - reject(e); - } - } - function step(result) { - result.done ? resolve2(result.value) : adopt(result.value).then(fulfilled, rejected); - } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SocksClientError = exports2.SocksClient = undefined; - var events_1 = require("events"); - var net = require("net"); - var smart_buffer_1 = require_smartbuffer(); - var constants_1 = require_constants(); - var helpers_1 = require_helpers3(); - var receivebuffer_1 = require_receivebuffer(); - var util_1 = require_util(); - Object.defineProperty(exports2, "SocksClientError", { enumerable: true, get: function() { - return util_1.SocksClientError; - } }); - var ip_address_1 = require_ip_address(); - - class SocksClient extends events_1.EventEmitter { - constructor(options) { - super(); - this.options = Object.assign({}, options); - (0, helpers_1.validateSocksClientOptions)(options); - this.setState(constants_1.SocksClientState.Created); - } - static createConnection(options, callback) { - return new Promise((resolve2, reject) => { - try { - (0, helpers_1.validateSocksClientOptions)(options, ["connect"]); - } catch (err) { - if (typeof callback === "function") { - callback(err); - return resolve2(err); - } else { - return reject(err); - } - } - const client = new SocksClient(options); - client.connect(options.existing_socket); - client.once("established", (info) => { - client.removeAllListeners(); - if (typeof callback === "function") { - callback(null, info); - resolve2(info); - } else { - resolve2(info); - } - }); - client.once("error", (err) => { - client.removeAllListeners(); - if (typeof callback === "function") { - callback(err); - resolve2(err); - } else { - reject(err); - } - }); - }); - } - static createConnectionChain(options, callback) { - return new Promise((resolve2, reject) => __awaiter(this, undefined, undefined, function* () { - try { - (0, helpers_1.validateSocksClientChainOptions)(options); - } catch (err) { - if (typeof callback === "function") { - callback(err); - return resolve2(err); - } else { - return reject(err); - } - } - if (options.randomizeChain) { - (0, util_1.shuffleArray)(options.proxies); - } - try { - let sock; - for (let i = 0;i < options.proxies.length; i++) { - const nextProxy = options.proxies[i]; - const nextDestination = i === options.proxies.length - 1 ? options.destination : { - host: options.proxies[i + 1].host || options.proxies[i + 1].ipaddress, - port: options.proxies[i + 1].port - }; - const result = yield SocksClient.createConnection({ - command: "connect", - proxy: nextProxy, - destination: nextDestination, - existing_socket: sock - }); - sock = sock || result.socket; - } - if (typeof callback === "function") { - callback(null, { socket: sock }); - resolve2({ socket: sock }); - } else { - resolve2({ socket: sock }); - } - } catch (err) { - if (typeof callback === "function") { - callback(err); - resolve2(err); - } else { - reject(err); - } - } - })); - } - static createUDPFrame(options) { - const buff = new smart_buffer_1.SmartBuffer; - buff.writeUInt16BE(0); - buff.writeUInt8(options.frameNumber || 0); - if (net.isIPv4(options.remoteHost.host)) { - buff.writeUInt8(constants_1.Socks5HostType.IPv4); - buff.writeUInt32BE((0, helpers_1.ipv4ToInt32)(options.remoteHost.host)); - } else if (net.isIPv6(options.remoteHost.host)) { - buff.writeUInt8(constants_1.Socks5HostType.IPv6); - buff.writeBuffer((0, helpers_1.ipToBuffer)(options.remoteHost.host)); - } else { - buff.writeUInt8(constants_1.Socks5HostType.Hostname); - buff.writeUInt8(Buffer.byteLength(options.remoteHost.host)); - buff.writeString(options.remoteHost.host); - } - buff.writeUInt16BE(options.remoteHost.port); - buff.writeBuffer(options.data); - return buff.toBuffer(); - } - static parseUDPFrame(data) { - const buff = smart_buffer_1.SmartBuffer.fromBuffer(data); - buff.readOffset = 2; - const frameNumber = buff.readUInt8(); - const hostType = buff.readUInt8(); - let remoteHost; - if (hostType === constants_1.Socks5HostType.IPv4) { - remoteHost = (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()); - } else if (hostType === constants_1.Socks5HostType.IPv6) { - remoteHost = ip_address_1.Address6.fromByteArray(Array.from(buff.readBuffer(16))).canonicalForm(); - } else { - remoteHost = buff.readString(buff.readUInt8()); - } - const remotePort = buff.readUInt16BE(); - return { - frameNumber, - remoteHost: { - host: remoteHost, - port: remotePort - }, - data: buff.readBuffer() - }; - } - setState(newState) { - if (this.state !== constants_1.SocksClientState.Error) { - this.state = newState; - } - } - connect(existingSocket) { - this.onDataReceived = (data) => this.onDataReceivedHandler(data); - this.onClose = () => this.onCloseHandler(); - this.onError = (err) => this.onErrorHandler(err); - this.onConnect = () => this.onConnectHandler(); - const timer = setTimeout(() => this.onEstablishedTimeout(), this.options.timeout || constants_1.DEFAULT_TIMEOUT); - if (timer.unref && typeof timer.unref === "function") { - timer.unref(); - } - if (existingSocket) { - this.socket = existingSocket; - } else { - this.socket = new net.Socket; - } - this.socket.once("close", this.onClose); - this.socket.once("error", this.onError); - this.socket.once("connect", this.onConnect); - this.socket.on("data", this.onDataReceived); - this.setState(constants_1.SocksClientState.Connecting); - this.receiveBuffer = new receivebuffer_1.ReceiveBuffer; - if (existingSocket) { - this.socket.emit("connect"); - } else { - this.socket.connect(this.getSocketOptions()); - if (this.options.set_tcp_nodelay !== undefined && this.options.set_tcp_nodelay !== null) { - this.socket.setNoDelay(!!this.options.set_tcp_nodelay); - } - } - this.prependOnceListener("established", (info) => { - setImmediate(() => { - if (this.receiveBuffer.length > 0) { - const excessData = this.receiveBuffer.get(this.receiveBuffer.length); - info.socket.emit("data", excessData); - } - info.socket.resume(); - }); - }); - } - getSocketOptions() { - return Object.assign(Object.assign({}, this.options.socket_options), { host: this.options.proxy.host || this.options.proxy.ipaddress, port: this.options.proxy.port }); - } - onEstablishedTimeout() { - if (this.state !== constants_1.SocksClientState.Established && this.state !== constants_1.SocksClientState.BoundWaitingForConnection) { - this.closeSocket(constants_1.ERRORS.ProxyConnectionTimedOut); - } - } - onConnectHandler() { - this.setState(constants_1.SocksClientState.Connected); - if (this.options.proxy.type === 4) { - this.sendSocks4InitialHandshake(); - } else { - this.sendSocks5InitialHandshake(); - } - this.setState(constants_1.SocksClientState.SentInitialHandshake); - } - onDataReceivedHandler(data) { - this.receiveBuffer.append(data); - this.processData(); - } - processData() { - while (this.state !== constants_1.SocksClientState.Established && this.state !== constants_1.SocksClientState.Error && this.receiveBuffer.length >= this.nextRequiredPacketBufferSize) { - if (this.state === constants_1.SocksClientState.SentInitialHandshake) { - if (this.options.proxy.type === 4) { - this.handleSocks4FinalHandshakeResponse(); - } else { - this.handleInitialSocks5HandshakeResponse(); - } - } else if (this.state === constants_1.SocksClientState.SentAuthentication) { - this.handleInitialSocks5AuthenticationHandshakeResponse(); - } else if (this.state === constants_1.SocksClientState.SentFinalHandshake) { - this.handleSocks5FinalHandshakeResponse(); - } else if (this.state === constants_1.SocksClientState.BoundWaitingForConnection) { - if (this.options.proxy.type === 4) { - this.handleSocks4IncomingConnectionResponse(); - } else { - this.handleSocks5IncomingConnectionResponse(); - } - } else { - this.closeSocket(constants_1.ERRORS.InternalError); - break; - } - } - } - onCloseHandler() { - this.closeSocket(constants_1.ERRORS.SocketClosed); - } - onErrorHandler(err) { - this.closeSocket(err.message); - } - removeInternalSocketHandlers() { - this.socket.pause(); - this.socket.removeListener("data", this.onDataReceived); - this.socket.removeListener("close", this.onClose); - this.socket.removeListener("error", this.onError); - this.socket.removeListener("connect", this.onConnect); - } - closeSocket(err) { - if (this.state !== constants_1.SocksClientState.Error) { - this.setState(constants_1.SocksClientState.Error); - this.socket.destroy(); - this.removeInternalSocketHandlers(); - this.emit("error", new util_1.SocksClientError(err, this.options)); - } - } - sendSocks4InitialHandshake() { - const userId = this.options.proxy.userId || ""; - const buff = new smart_buffer_1.SmartBuffer; - buff.writeUInt8(4); - buff.writeUInt8(constants_1.SocksCommand[this.options.command]); - buff.writeUInt16BE(this.options.destination.port); - if (net.isIPv4(this.options.destination.host)) { - buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host)); - buff.writeStringNT(userId); - } else { - buff.writeUInt8(0); - buff.writeUInt8(0); - buff.writeUInt8(0); - buff.writeUInt8(1); - buff.writeStringNT(userId); - buff.writeStringNT(this.options.destination.host); - } - this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks4Response; - this.socket.write(buff.toBuffer()); - } - handleSocks4FinalHandshakeResponse() { - const data = this.receiveBuffer.get(8); - if (data[1] !== constants_1.Socks4Response.Granted) { - this.closeSocket(`${constants_1.ERRORS.Socks4ProxyRejectedConnection} - (${constants_1.Socks4Response[data[1]]})`); - } else { - if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.bind) { - const buff = smart_buffer_1.SmartBuffer.fromBuffer(data); - buff.readOffset = 2; - const remoteHost = { - port: buff.readUInt16BE(), - host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()) - }; - if (remoteHost.host === "0.0.0.0") { - remoteHost.host = this.options.proxy.ipaddress; - } - this.setState(constants_1.SocksClientState.BoundWaitingForConnection); - this.emit("bound", { remoteHost, socket: this.socket }); - } else { - this.setState(constants_1.SocksClientState.Established); - this.removeInternalSocketHandlers(); - this.emit("established", { socket: this.socket }); - } - } - } - handleSocks4IncomingConnectionResponse() { - const data = this.receiveBuffer.get(8); - if (data[1] !== constants_1.Socks4Response.Granted) { - this.closeSocket(`${constants_1.ERRORS.Socks4ProxyRejectedIncomingBoundConnection} - (${constants_1.Socks4Response[data[1]]})`); - } else { - const buff = smart_buffer_1.SmartBuffer.fromBuffer(data); - buff.readOffset = 2; - const remoteHost = { - port: buff.readUInt16BE(), - host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()) - }; - this.setState(constants_1.SocksClientState.Established); - this.removeInternalSocketHandlers(); - this.emit("established", { remoteHost, socket: this.socket }); - } - } - sendSocks5InitialHandshake() { - const buff = new smart_buffer_1.SmartBuffer; - const supportedAuthMethods = [constants_1.Socks5Auth.NoAuth]; - if (this.options.proxy.userId || this.options.proxy.password) { - supportedAuthMethods.push(constants_1.Socks5Auth.UserPass); - } - if (this.options.proxy.custom_auth_method !== undefined) { - supportedAuthMethods.push(this.options.proxy.custom_auth_method); - } - buff.writeUInt8(5); - buff.writeUInt8(supportedAuthMethods.length); - for (const authMethod of supportedAuthMethods) { - buff.writeUInt8(authMethod); - } - this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5InitialHandshakeResponse; - this.socket.write(buff.toBuffer()); - this.setState(constants_1.SocksClientState.SentInitialHandshake); - } - handleInitialSocks5HandshakeResponse() { - const data = this.receiveBuffer.get(2); - if (data[0] !== 5) { - this.closeSocket(constants_1.ERRORS.InvalidSocks5IntiailHandshakeSocksVersion); - } else if (data[1] === constants_1.SOCKS5_NO_ACCEPTABLE_AUTH) { - this.closeSocket(constants_1.ERRORS.InvalidSocks5InitialHandshakeNoAcceptedAuthType); - } else { - if (data[1] === constants_1.Socks5Auth.NoAuth) { - this.socks5ChosenAuthType = constants_1.Socks5Auth.NoAuth; - this.sendSocks5CommandRequest(); - } else if (data[1] === constants_1.Socks5Auth.UserPass) { - this.socks5ChosenAuthType = constants_1.Socks5Auth.UserPass; - this.sendSocks5UserPassAuthentication(); - } else if (data[1] === this.options.proxy.custom_auth_method) { - this.socks5ChosenAuthType = this.options.proxy.custom_auth_method; - this.sendSocks5CustomAuthentication(); - } else { - this.closeSocket(constants_1.ERRORS.InvalidSocks5InitialHandshakeUnknownAuthType); - } - } - } - sendSocks5UserPassAuthentication() { - const userId = this.options.proxy.userId || ""; - const password = this.options.proxy.password || ""; - const buff = new smart_buffer_1.SmartBuffer; - buff.writeUInt8(1); - buff.writeUInt8(Buffer.byteLength(userId)); - buff.writeString(userId); - buff.writeUInt8(Buffer.byteLength(password)); - buff.writeString(password); - this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5UserPassAuthenticationResponse; - this.socket.write(buff.toBuffer()); - this.setState(constants_1.SocksClientState.SentAuthentication); - } - sendSocks5CustomAuthentication() { - return __awaiter(this, undefined, undefined, function* () { - this.nextRequiredPacketBufferSize = this.options.proxy.custom_auth_response_size; - this.socket.write(yield this.options.proxy.custom_auth_request_handler()); - this.setState(constants_1.SocksClientState.SentAuthentication); - }); - } - handleSocks5CustomAuthHandshakeResponse(data) { - return __awaiter(this, undefined, undefined, function* () { - return yield this.options.proxy.custom_auth_response_handler(data); - }); - } - handleSocks5AuthenticationNoAuthHandshakeResponse(data) { - return __awaiter(this, undefined, undefined, function* () { - return data[1] === 0; - }); - } - handleSocks5AuthenticationUserPassHandshakeResponse(data) { - return __awaiter(this, undefined, undefined, function* () { - return data[1] === 0; - }); - } - handleInitialSocks5AuthenticationHandshakeResponse() { - return __awaiter(this, undefined, undefined, function* () { - this.setState(constants_1.SocksClientState.ReceivedAuthenticationResponse); - let authResult = false; - if (this.socks5ChosenAuthType === constants_1.Socks5Auth.NoAuth) { - authResult = yield this.handleSocks5AuthenticationNoAuthHandshakeResponse(this.receiveBuffer.get(2)); - } else if (this.socks5ChosenAuthType === constants_1.Socks5Auth.UserPass) { - authResult = yield this.handleSocks5AuthenticationUserPassHandshakeResponse(this.receiveBuffer.get(2)); - } else if (this.socks5ChosenAuthType === this.options.proxy.custom_auth_method) { - authResult = yield this.handleSocks5CustomAuthHandshakeResponse(this.receiveBuffer.get(this.options.proxy.custom_auth_response_size)); - } - if (!authResult) { - this.closeSocket(constants_1.ERRORS.Socks5AuthenticationFailed); - } else { - this.sendSocks5CommandRequest(); - } - }); - } - sendSocks5CommandRequest() { - const buff = new smart_buffer_1.SmartBuffer; - buff.writeUInt8(5); - buff.writeUInt8(constants_1.SocksCommand[this.options.command]); - buff.writeUInt8(0); - if (net.isIPv4(this.options.destination.host)) { - buff.writeUInt8(constants_1.Socks5HostType.IPv4); - buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host)); - } else if (net.isIPv6(this.options.destination.host)) { - buff.writeUInt8(constants_1.Socks5HostType.IPv6); - buff.writeBuffer((0, helpers_1.ipToBuffer)(this.options.destination.host)); - } else { - buff.writeUInt8(constants_1.Socks5HostType.Hostname); - buff.writeUInt8(this.options.destination.host.length); - buff.writeString(this.options.destination.host); - } - buff.writeUInt16BE(this.options.destination.port); - this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHeader; - this.socket.write(buff.toBuffer()); - this.setState(constants_1.SocksClientState.SentFinalHandshake); - } - handleSocks5FinalHandshakeResponse() { - const header = this.receiveBuffer.peek(5); - if (header[0] !== 5 || header[1] !== constants_1.Socks5Response.Granted) { - this.closeSocket(`${constants_1.ERRORS.InvalidSocks5FinalHandshakeRejected} - ${constants_1.Socks5Response[header[1]]}`); - } else { - const addressType = header[3]; - let remoteHost; - let buff; - if (addressType === constants_1.Socks5HostType.IPv4) { - const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv4; - if (this.receiveBuffer.length < dataNeeded) { - this.nextRequiredPacketBufferSize = dataNeeded; - return; - } - buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); - remoteHost = { - host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()), - port: buff.readUInt16BE() - }; - if (remoteHost.host === "0.0.0.0") { - remoteHost.host = this.options.proxy.ipaddress; - } - } else if (addressType === constants_1.Socks5HostType.Hostname) { - const hostLength = header[4]; - const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHostname(hostLength); - if (this.receiveBuffer.length < dataNeeded) { - this.nextRequiredPacketBufferSize = dataNeeded; - return; - } - buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(5)); - remoteHost = { - host: buff.readString(hostLength), - port: buff.readUInt16BE() - }; - } else if (addressType === constants_1.Socks5HostType.IPv6) { - const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv6; - if (this.receiveBuffer.length < dataNeeded) { - this.nextRequiredPacketBufferSize = dataNeeded; - return; - } - buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); - remoteHost = { - host: ip_address_1.Address6.fromByteArray(Array.from(buff.readBuffer(16))).canonicalForm(), - port: buff.readUInt16BE() - }; - } - this.setState(constants_1.SocksClientState.ReceivedFinalResponse); - if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.connect) { - this.setState(constants_1.SocksClientState.Established); - this.removeInternalSocketHandlers(); - this.emit("established", { remoteHost, socket: this.socket }); - } else if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.bind) { - this.setState(constants_1.SocksClientState.BoundWaitingForConnection); - this.nextRequiredPacketBufferSize = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHeader; - this.emit("bound", { remoteHost, socket: this.socket }); - } else if (constants_1.SocksCommand[this.options.command] === constants_1.SocksCommand.associate) { - this.setState(constants_1.SocksClientState.Established); - this.removeInternalSocketHandlers(); - this.emit("established", { - remoteHost, - socket: this.socket - }); - } - } - } - handleSocks5IncomingConnectionResponse() { - const header = this.receiveBuffer.peek(5); - if (header[0] !== 5 || header[1] !== constants_1.Socks5Response.Granted) { - this.closeSocket(`${constants_1.ERRORS.Socks5ProxyRejectedIncomingBoundConnection} - ${constants_1.Socks5Response[header[1]]}`); - } else { - const addressType = header[3]; - let remoteHost; - let buff; - if (addressType === constants_1.Socks5HostType.IPv4) { - const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv4; - if (this.receiveBuffer.length < dataNeeded) { - this.nextRequiredPacketBufferSize = dataNeeded; - return; - } - buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); - remoteHost = { - host: (0, helpers_1.int32ToIpv4)(buff.readUInt32BE()), - port: buff.readUInt16BE() - }; - if (remoteHost.host === "0.0.0.0") { - remoteHost.host = this.options.proxy.ipaddress; - } - } else if (addressType === constants_1.Socks5HostType.Hostname) { - const hostLength = header[4]; - const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHostname(hostLength); - if (this.receiveBuffer.length < dataNeeded) { - this.nextRequiredPacketBufferSize = dataNeeded; - return; - } - buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(5)); - remoteHost = { - host: buff.readString(hostLength), - port: buff.readUInt16BE() - }; - } else if (addressType === constants_1.Socks5HostType.IPv6) { - const dataNeeded = constants_1.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv6; - if (this.receiveBuffer.length < dataNeeded) { - this.nextRequiredPacketBufferSize = dataNeeded; - return; - } - buff = smart_buffer_1.SmartBuffer.fromBuffer(this.receiveBuffer.get(dataNeeded).slice(4)); - remoteHost = { - host: ip_address_1.Address6.fromByteArray(Array.from(buff.readBuffer(16))).canonicalForm(), - port: buff.readUInt16BE() - }; - } - this.setState(constants_1.SocksClientState.Established); - this.removeInternalSocketHandlers(); - this.emit("established", { remoteHost, socket: this.socket }); - } - } - get socksClientOptions() { - return Object.assign({}, this.options); - } - } - exports2.SocksClient = SocksClient; -}); - -// ../../node_modules/.pnpm/socks@2.8.7/node_modules/socks/build/index.js -var require_build = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __exportStar = exports2 && exports2.__exportStar || function(m, exports3) { - for (var p in m) - if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports3, p)) - __createBinding(exports3, m, p); - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - __exportStar(require_socksclient(), exports2); -}); - -// ../../node_modules/.pnpm/socks-proxy-agent@8.0.5/node_modules/socks-proxy-agent/dist/index.js -var require_dist5 = __commonJS((exports2) => { - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SocksProxyAgent = undefined; - var socks_1 = require_build(); - var agent_base_1 = require_dist2(); - var debug_1 = __importDefault(require_src2()); - var dns = __importStar(require("dns")); - var net = __importStar(require("net")); - var tls = __importStar(require("tls")); - var url_1 = require("url"); - var debug = (0, debug_1.default)("socks-proxy-agent"); - var setServernameFromNonIpHost = (options) => { - if (options.servername === undefined && options.host && !net.isIP(options.host)) { - return { - ...options, - servername: options.host - }; - } - return options; - }; - function parseSocksURL(url2) { - let lookup = false; - let type = 5; - const host = url2.hostname; - const port = parseInt(url2.port, 10) || 1080; - switch (url2.protocol.replace(":", "")) { - case "socks4": - lookup = true; - type = 4; - break; - case "socks4a": - type = 4; - break; - case "socks5": - lookup = true; - type = 5; - break; - case "socks": - type = 5; - break; - case "socks5h": - type = 5; - break; - default: - throw new TypeError(`A "socks" protocol must be specified! Got: ${String(url2.protocol)}`); - } - const proxy = { - host, - port, - type - }; - if (url2.username) { - Object.defineProperty(proxy, "userId", { - value: decodeURIComponent(url2.username), - enumerable: false - }); - } - if (url2.password != null) { - Object.defineProperty(proxy, "password", { - value: decodeURIComponent(url2.password), - enumerable: false - }); - } - return { lookup, proxy }; - } - - class SocksProxyAgent extends agent_base_1.Agent { - constructor(uri, opts) { - super(opts); - const url2 = typeof uri === "string" ? new url_1.URL(uri) : uri; - const { proxy, lookup } = parseSocksURL(url2); - this.shouldLookup = lookup; - this.proxy = proxy; - this.timeout = opts?.timeout ?? null; - this.socketOptions = opts?.socketOptions ?? null; - } - async connect(req, opts) { - const { shouldLookup, proxy, timeout: timeout2 } = this; - if (!opts.host) { - throw new Error("No `host` defined!"); - } - let { host } = opts; - const { port, lookup: lookupFn = dns.lookup } = opts; - if (shouldLookup) { - host = await new Promise((resolve2, reject) => { - lookupFn(host, {}, (err, res) => { - if (err) { - reject(err); - } else { - resolve2(res); - } - }); - }); - } - const socksOpts = { - proxy, - destination: { - host, - port: typeof port === "number" ? port : parseInt(port, 10) - }, - command: "connect", - timeout: timeout2 ?? undefined, - socket_options: this.socketOptions ?? undefined - }; - const cleanup = (tlsSocket) => { - req.destroy(); - socket.destroy(); - if (tlsSocket) - tlsSocket.destroy(); - }; - debug("Creating socks proxy connection: %o", socksOpts); - const { socket } = await socks_1.SocksClient.createConnection(socksOpts); - debug("Successfully created socks proxy connection"); - if (timeout2 !== null) { - socket.setTimeout(timeout2); - socket.on("timeout", () => cleanup()); - } - if (opts.secureEndpoint) { - debug("Upgrading socket connection to TLS"); - const tlsSocket = tls.connect({ - ...omit3(setServernameFromNonIpHost(opts), "host", "path", "port"), - socket - }); - tlsSocket.once("error", (error48) => { - debug("Socket TLS error", error48.message); - cleanup(tlsSocket); - }); - return tlsSocket; - } - return socket; - } - } - SocksProxyAgent.protocols = [ - "socks", - "socks4", - "socks4a", - "socks5", - "socks5h" - ]; - exports2.SocksProxyAgent = SocksProxyAgent; - function omit3(obj, ...keys) { - const ret = {}; - let key; - for (key in obj) { - if (!keys.includes(key)) { - ret[key] = obj[key]; - } - } - return ret; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/charset.js -var require_charset = __commonJS((exports2, module2) => { - module2.exports = preferredCharsets; - module2.exports.preferredCharsets = preferredCharsets; - var simpleCharsetRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; - function parseAcceptCharset(accept) { - var accepts = accept.split(","); - for (var i = 0, j = 0;i < accepts.length; i++) { - var charset = parseCharset(accepts[i].trim(), i); - if (charset) { - accepts[j++] = charset; - } - } - accepts.length = j; - return accepts; - } - function parseCharset(str, i) { - var match = simpleCharsetRegExp.exec(str); - if (!match) - return null; - var charset = match[1]; - var q = 1; - if (match[2]) { - var params = match[2].split(";"); - for (var j = 0;j < params.length; j++) { - var p = params[j].trim().split("="); - if (p[0] === "q") { - q = parseFloat(p[1]); - break; - } - } - } - return { - charset, - q, - i - }; - } - function getCharsetPriority(charset, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(charset, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; - } - } - return priority; - } - function specify(charset, spec, index) { - var s = 0; - if (spec.charset.toLowerCase() === charset.toLowerCase()) { - s |= 1; - } else if (spec.charset !== "*") { - return null; - } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; - } - function preferredCharsets(accept, provided) { - var accepts = parseAcceptCharset(accept === undefined ? "*" : accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullCharset); - } - var priorities = provided.map(function getPriority(type, index) { - return getCharsetPriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getCharset(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; - } - function getFullCharset(spec) { - return spec.charset; - } - function isQuality(spec) { - return spec.q > 0; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/encoding.js -var require_encoding = __commonJS((exports2, module2) => { - module2.exports = preferredEncodings; - module2.exports.preferredEncodings = preferredEncodings; - var simpleEncodingRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; - function parseAcceptEncoding(accept) { - var accepts = accept.split(","); - var hasIdentity = false; - var minQuality = 1; - for (var i = 0, j = 0;i < accepts.length; i++) { - var encoding = parseEncoding(accepts[i].trim(), i); - if (encoding) { - accepts[j++] = encoding; - hasIdentity = hasIdentity || specify("identity", encoding); - minQuality = Math.min(minQuality, encoding.q || 1); - } - } - if (!hasIdentity) { - accepts[j++] = { - encoding: "identity", - q: minQuality, - i - }; - } - accepts.length = j; - return accepts; - } - function parseEncoding(str, i) { - var match = simpleEncodingRegExp.exec(str); - if (!match) - return null; - var encoding = match[1]; - var q = 1; - if (match[2]) { - var params = match[2].split(";"); - for (var j = 0;j < params.length; j++) { - var p = params[j].trim().split("="); - if (p[0] === "q") { - q = parseFloat(p[1]); - break; - } - } - } - return { - encoding, - q, - i - }; - } - function getEncodingPriority(encoding, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(encoding, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; - } - } - return priority; - } - function specify(encoding, spec, index) { - var s = 0; - if (spec.encoding.toLowerCase() === encoding.toLowerCase()) { - s |= 1; - } else if (spec.encoding !== "*") { - return null; - } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; - } - function preferredEncodings(accept, provided) { - var accepts = parseAcceptEncoding(accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullEncoding); - } - var priorities = provided.map(function getPriority(type, index) { - return getEncodingPriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getEncoding(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; - } - function getFullEncoding(spec) { - return spec.encoding; - } - function isQuality(spec) { - return spec.q > 0; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/language.js -var require_language = __commonJS((exports2, module2) => { - module2.exports = preferredLanguages; - module2.exports.preferredLanguages = preferredLanguages; - var simpleLanguageRegExp = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; - function parseAcceptLanguage(accept) { - var accepts = accept.split(","); - for (var i = 0, j = 0;i < accepts.length; i++) { - var language = parseLanguage(accepts[i].trim(), i); - if (language) { - accepts[j++] = language; - } - } - accepts.length = j; - return accepts; - } - function parseLanguage(str, i) { - var match = simpleLanguageRegExp.exec(str); - if (!match) - return null; - var prefix = match[1]; - var suffix = match[2]; - var full = prefix; - if (suffix) - full += "-" + suffix; - var q = 1; - if (match[3]) { - var params = match[3].split(";"); - for (var j = 0;j < params.length; j++) { - var p = params[j].split("="); - if (p[0] === "q") - q = parseFloat(p[1]); - } - } - return { - prefix, - suffix, - q, - i, - full - }; - } - function getLanguagePriority(language, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(language, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; - } - } - return priority; - } - function specify(language, spec, index) { - var p = parseLanguage(language); - if (!p) - return null; - var s = 0; - if (spec.full.toLowerCase() === p.full.toLowerCase()) { - s |= 4; - } else if (spec.prefix.toLowerCase() === p.full.toLowerCase()) { - s |= 2; - } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { - s |= 1; - } else if (spec.full !== "*") { - return null; - } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; - } - function preferredLanguages(accept, provided) { - var accepts = parseAcceptLanguage(accept === undefined ? "*" : accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullLanguage); - } - var priorities = provided.map(function getPriority(type, index) { - return getLanguagePriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getLanguage(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; - } - function getFullLanguage(spec) { - return spec.full; - } - function isQuality(spec) { - return spec.q > 0; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/lib/mediaType.js -var require_mediaType = __commonJS((exports2, module2) => { - module2.exports = preferredMediaTypes; - module2.exports.preferredMediaTypes = preferredMediaTypes; - var simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/; - function parseAccept(accept) { - var accepts = splitMediaTypes(accept); - for (var i = 0, j = 0;i < accepts.length; i++) { - var mediaType = parseMediaType(accepts[i].trim(), i); - if (mediaType) { - accepts[j++] = mediaType; - } - } - accepts.length = j; - return accepts; - } - function parseMediaType(str, i) { - var match = simpleMediaTypeRegExp.exec(str); - if (!match) - return null; - var params = Object.create(null); - var q = 1; - var subtype = match[2]; - var type = match[1]; - if (match[3]) { - var kvps = splitParameters(match[3]).map(splitKeyValuePair); - for (var j = 0;j < kvps.length; j++) { - var pair = kvps[j]; - var key = pair[0].toLowerCase(); - var val = pair[1]; - var value = val && val[0] === '"' && val[val.length - 1] === '"' ? val.substr(1, val.length - 2) : val; - if (key === "q") { - q = parseFloat(value); - break; - } - params[key] = value; - } - } - return { - type, - subtype, - params, - q, - i - }; - } - function getMediaTypePriority(type, accepted, index) { - var priority = { o: -1, q: 0, s: 0 }; - for (var i = 0;i < accepted.length; i++) { - var spec = specify(type, accepted[i], index); - if (spec && (priority.s - spec.s || priority.q - spec.q || priority.o - spec.o) < 0) { - priority = spec; - } - } - return priority; - } - function specify(type, spec, index) { - var p = parseMediaType(type); - var s = 0; - if (!p) { - return null; - } - if (spec.type.toLowerCase() == p.type.toLowerCase()) { - s |= 4; - } else if (spec.type != "*") { - return null; - } - if (spec.subtype.toLowerCase() == p.subtype.toLowerCase()) { - s |= 2; - } else if (spec.subtype != "*") { - return null; - } - var keys = Object.keys(spec.params); - if (keys.length > 0) { - if (keys.every(function(k) { - return spec.params[k] == "*" || (spec.params[k] || "").toLowerCase() == (p.params[k] || "").toLowerCase(); - })) { - s |= 1; - } else { - return null; - } - } - return { - i: index, - o: spec.i, - q: spec.q, - s - }; - } - function preferredMediaTypes(accept, provided) { - var accepts = parseAccept(accept === undefined ? "*/*" : accept || ""); - if (!provided) { - return accepts.filter(isQuality).sort(compareSpecs).map(getFullType); - } - var priorities = provided.map(function getPriority(type, index) { - return getMediaTypePriority(type, accepts, index); - }); - return priorities.filter(isQuality).sort(compareSpecs).map(function getType(priority) { - return provided[priorities.indexOf(priority)]; - }); - } - function compareSpecs(a, b) { - return b.q - a.q || b.s - a.s || a.o - b.o || a.i - b.i || 0; - } - function getFullType(spec) { - return spec.type + "/" + spec.subtype; - } - function isQuality(spec) { - return spec.q > 0; - } - function quoteCount(string4) { - var count = 0; - var index = 0; - while ((index = string4.indexOf('"', index)) !== -1) { - count++; - index++; - } - return count; - } - function splitKeyValuePair(str) { - var index = str.indexOf("="); - var key; - var val; - if (index === -1) { - key = str; - } else { - key = str.substr(0, index); - val = str.substr(index + 1); - } - return [key, val]; - } - function splitMediaTypes(accept) { - var accepts = accept.split(","); - for (var i = 1, j = 0;i < accepts.length; i++) { - if (quoteCount(accepts[j]) % 2 == 0) { - accepts[++j] = accepts[i]; - } else { - accepts[j] += "," + accepts[i]; - } - } - accepts.length = j + 1; - return accepts; - } - function splitParameters(str) { - var parameters = str.split(";"); - for (var i = 1, j = 0;i < parameters.length; i++) { - if (quoteCount(parameters[j]) % 2 == 0) { - parameters[++j] = parameters[i]; - } else { - parameters[j] += ";" + parameters[i]; - } - } - parameters.length = j + 1; - for (var i = 0;i < parameters.length; i++) { - parameters[i] = parameters[i].trim(); - } - return parameters; - } -}); - -// ../../node_modules/.pnpm/negotiator@0.6.3/node_modules/negotiator/index.js -var require_negotiator = __commonJS((exports2, module2) => { - /*! - * negotiator - * Copyright(c) 2012 Federico Romero - * Copyright(c) 2012-2014 Isaac Z. Schlueter - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - var preferredCharsets = require_charset(); - var preferredEncodings = require_encoding(); - var preferredLanguages = require_language(); - var preferredMediaTypes = require_mediaType(); - module2.exports = Negotiator; - module2.exports.Negotiator = Negotiator; - function Negotiator(request) { - if (!(this instanceof Negotiator)) { - return new Negotiator(request); - } - this.request = request; - } - Negotiator.prototype.charset = function charset(available) { - var set2 = this.charsets(available); - return set2 && set2[0]; - }; - Negotiator.prototype.charsets = function charsets(available) { - return preferredCharsets(this.request.headers["accept-charset"], available); - }; - Negotiator.prototype.encoding = function encoding(available) { - var set2 = this.encodings(available); - return set2 && set2[0]; - }; - Negotiator.prototype.encodings = function encodings(available) { - return preferredEncodings(this.request.headers["accept-encoding"], available); - }; - Negotiator.prototype.language = function language(available) { - var set2 = this.languages(available); - return set2 && set2[0]; - }; - Negotiator.prototype.languages = function languages(available) { - return preferredLanguages(this.request.headers["accept-language"], available); - }; - Negotiator.prototype.mediaType = function mediaType(available) { - var set2 = this.mediaTypes(available); - return set2 && set2[0]; - }; - Negotiator.prototype.mediaTypes = function mediaTypes(available) { - return preferredMediaTypes(this.request.headers.accept, available); - }; - Negotiator.prototype.preferredCharset = Negotiator.prototype.charset; - Negotiator.prototype.preferredCharsets = Negotiator.prototype.charsets; - Negotiator.prototype.preferredEncoding = Negotiator.prototype.encoding; - Negotiator.prototype.preferredEncodings = Negotiator.prototype.encodings; - Negotiator.prototype.preferredLanguage = Negotiator.prototype.language; - Negotiator.prototype.preferredLanguages = Negotiator.prototype.languages; - Negotiator.prototype.preferredMediaType = Negotiator.prototype.mediaType; - Negotiator.prototype.preferredMediaTypes = Negotiator.prototype.mediaTypes; -}); - -// ../../node_modules/.pnpm/mime-db@1.52.0/node_modules/mime-db/db.json -var require_db = __commonJS((exports2, module2) => { - module2.exports = { - "application/1d-interleaved-parityfec": { - source: "iana" - }, - "application/3gpdash-qoe-report+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/3gpp-ims+xml": { - source: "iana", - compressible: true - }, - "application/3gpphal+json": { - source: "iana", - compressible: true - }, - "application/3gpphalforms+json": { - source: "iana", - compressible: true - }, - "application/a2l": { - source: "iana" - }, - "application/ace+cbor": { - source: "iana" - }, - "application/activemessage": { - source: "iana" - }, - "application/activity+json": { - source: "iana", - compressible: true - }, - "application/alto-costmap+json": { - source: "iana", - compressible: true - }, - "application/alto-costmapfilter+json": { - source: "iana", - compressible: true - }, - "application/alto-directory+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointcost+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointcostparams+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointprop+json": { - source: "iana", - compressible: true - }, - "application/alto-endpointpropparams+json": { - source: "iana", - compressible: true - }, - "application/alto-error+json": { - source: "iana", - compressible: true - }, - "application/alto-networkmap+json": { - source: "iana", - compressible: true - }, - "application/alto-networkmapfilter+json": { - source: "iana", - compressible: true - }, - "application/alto-updatestreamcontrol+json": { - source: "iana", - compressible: true - }, - "application/alto-updatestreamparams+json": { - source: "iana", - compressible: true - }, - "application/aml": { - source: "iana" - }, - "application/andrew-inset": { - source: "iana", - extensions: ["ez"] - }, - "application/applefile": { - source: "iana" - }, - "application/applixware": { - source: "apache", - extensions: ["aw"] - }, - "application/at+jwt": { - source: "iana" - }, - "application/atf": { - source: "iana" - }, - "application/atfx": { - source: "iana" - }, - "application/atom+xml": { - source: "iana", - compressible: true, - extensions: ["atom"] - }, - "application/atomcat+xml": { - source: "iana", - compressible: true, - extensions: ["atomcat"] - }, - "application/atomdeleted+xml": { - source: "iana", - compressible: true, - extensions: ["atomdeleted"] - }, - "application/atomicmail": { - source: "iana" - }, - "application/atomsvc+xml": { - source: "iana", - compressible: true, - extensions: ["atomsvc"] - }, - "application/atsc-dwd+xml": { - source: "iana", - compressible: true, - extensions: ["dwd"] - }, - "application/atsc-dynamic-event-message": { - source: "iana" - }, - "application/atsc-held+xml": { - source: "iana", - compressible: true, - extensions: ["held"] - }, - "application/atsc-rdt+json": { - source: "iana", - compressible: true - }, - "application/atsc-rsat+xml": { - source: "iana", - compressible: true, - extensions: ["rsat"] - }, - "application/atxml": { - source: "iana" - }, - "application/auth-policy+xml": { - source: "iana", - compressible: true - }, - "application/bacnet-xdd+zip": { - source: "iana", - compressible: false - }, - "application/batch-smtp": { - source: "iana" - }, - "application/bdoc": { - compressible: false, - extensions: ["bdoc"] - }, - "application/beep+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/calendar+json": { - source: "iana", - compressible: true - }, - "application/calendar+xml": { - source: "iana", - compressible: true, - extensions: ["xcs"] - }, - "application/call-completion": { - source: "iana" - }, - "application/cals-1840": { - source: "iana" - }, - "application/captive+json": { - source: "iana", - compressible: true - }, - "application/cbor": { - source: "iana" - }, - "application/cbor-seq": { - source: "iana" - }, - "application/cccex": { - source: "iana" - }, - "application/ccmp+xml": { - source: "iana", - compressible: true - }, - "application/ccxml+xml": { - source: "iana", - compressible: true, - extensions: ["ccxml"] - }, - "application/cdfx+xml": { - source: "iana", - compressible: true, - extensions: ["cdfx"] - }, - "application/cdmi-capability": { - source: "iana", - extensions: ["cdmia"] - }, - "application/cdmi-container": { - source: "iana", - extensions: ["cdmic"] - }, - "application/cdmi-domain": { - source: "iana", - extensions: ["cdmid"] - }, - "application/cdmi-object": { - source: "iana", - extensions: ["cdmio"] - }, - "application/cdmi-queue": { - source: "iana", - extensions: ["cdmiq"] - }, - "application/cdni": { - source: "iana" - }, - "application/cea": { - source: "iana" - }, - "application/cea-2018+xml": { - source: "iana", - compressible: true - }, - "application/cellml+xml": { - source: "iana", - compressible: true - }, - "application/cfw": { - source: "iana" - }, - "application/city+json": { - source: "iana", - compressible: true - }, - "application/clr": { - source: "iana" - }, - "application/clue+xml": { - source: "iana", - compressible: true - }, - "application/clue_info+xml": { - source: "iana", - compressible: true - }, - "application/cms": { - source: "iana" - }, - "application/cnrp+xml": { - source: "iana", - compressible: true - }, - "application/coap-group+json": { - source: "iana", - compressible: true - }, - "application/coap-payload": { - source: "iana" - }, - "application/commonground": { - source: "iana" - }, - "application/conference-info+xml": { - source: "iana", - compressible: true - }, - "application/cose": { - source: "iana" - }, - "application/cose-key": { - source: "iana" - }, - "application/cose-key-set": { - source: "iana" - }, - "application/cpl+xml": { - source: "iana", - compressible: true, - extensions: ["cpl"] - }, - "application/csrattrs": { - source: "iana" - }, - "application/csta+xml": { - source: "iana", - compressible: true - }, - "application/cstadata+xml": { - source: "iana", - compressible: true - }, - "application/csvm+json": { - source: "iana", - compressible: true - }, - "application/cu-seeme": { - source: "apache", - extensions: ["cu"] - }, - "application/cwt": { - source: "iana" - }, - "application/cybercash": { - source: "iana" - }, - "application/dart": { - compressible: true - }, - "application/dash+xml": { - source: "iana", - compressible: true, - extensions: ["mpd"] - }, - "application/dash-patch+xml": { - source: "iana", - compressible: true, - extensions: ["mpp"] - }, - "application/dashdelta": { - source: "iana" - }, - "application/davmount+xml": { - source: "iana", - compressible: true, - extensions: ["davmount"] - }, - "application/dca-rft": { - source: "iana" - }, - "application/dcd": { - source: "iana" - }, - "application/dec-dx": { - source: "iana" - }, - "application/dialog-info+xml": { - source: "iana", - compressible: true - }, - "application/dicom": { - source: "iana" - }, - "application/dicom+json": { - source: "iana", - compressible: true - }, - "application/dicom+xml": { - source: "iana", - compressible: true - }, - "application/dii": { - source: "iana" - }, - "application/dit": { - source: "iana" - }, - "application/dns": { - source: "iana" - }, - "application/dns+json": { - source: "iana", - compressible: true - }, - "application/dns-message": { - source: "iana" - }, - "application/docbook+xml": { - source: "apache", - compressible: true, - extensions: ["dbk"] - }, - "application/dots+cbor": { - source: "iana" - }, - "application/dskpp+xml": { - source: "iana", - compressible: true - }, - "application/dssc+der": { - source: "iana", - extensions: ["dssc"] - }, - "application/dssc+xml": { - source: "iana", - compressible: true, - extensions: ["xdssc"] - }, - "application/dvcs": { - source: "iana" - }, - "application/ecmascript": { - source: "iana", - compressible: true, - extensions: ["es", "ecma"] - }, - "application/edi-consent": { - source: "iana" - }, - "application/edi-x12": { - source: "iana", - compressible: false - }, - "application/edifact": { - source: "iana", - compressible: false - }, - "application/efi": { - source: "iana" - }, - "application/elm+json": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/elm+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.cap+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/emergencycalldata.comment+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.control+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.deviceinfo+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.ecall.msd": { - source: "iana" - }, - "application/emergencycalldata.providerinfo+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.serviceinfo+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.subscriberinfo+xml": { - source: "iana", - compressible: true - }, - "application/emergencycalldata.veds+xml": { - source: "iana", - compressible: true - }, - "application/emma+xml": { - source: "iana", - compressible: true, - extensions: ["emma"] - }, - "application/emotionml+xml": { - source: "iana", - compressible: true, - extensions: ["emotionml"] - }, - "application/encaprtp": { - source: "iana" - }, - "application/epp+xml": { - source: "iana", - compressible: true - }, - "application/epub+zip": { - source: "iana", - compressible: false, - extensions: ["epub"] - }, - "application/eshop": { - source: "iana" - }, - "application/exi": { - source: "iana", - extensions: ["exi"] - }, - "application/expect-ct-report+json": { - source: "iana", - compressible: true - }, - "application/express": { - source: "iana", - extensions: ["exp"] - }, - "application/fastinfoset": { - source: "iana" - }, - "application/fastsoap": { - source: "iana" - }, - "application/fdt+xml": { - source: "iana", - compressible: true, - extensions: ["fdt"] - }, - "application/fhir+json": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/fhir+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/fido.trusted-apps+json": { - compressible: true - }, - "application/fits": { - source: "iana" - }, - "application/flexfec": { - source: "iana" - }, - "application/font-sfnt": { - source: "iana" - }, - "application/font-tdpfr": { - source: "iana", - extensions: ["pfr"] - }, - "application/font-woff": { - source: "iana", - compressible: false - }, - "application/framework-attributes+xml": { - source: "iana", - compressible: true - }, - "application/geo+json": { - source: "iana", - compressible: true, - extensions: ["geojson"] - }, - "application/geo+json-seq": { - source: "iana" - }, - "application/geopackage+sqlite3": { - source: "iana" - }, - "application/geoxacml+xml": { - source: "iana", - compressible: true - }, - "application/gltf-buffer": { - source: "iana" - }, - "application/gml+xml": { - source: "iana", - compressible: true, - extensions: ["gml"] - }, - "application/gpx+xml": { - source: "apache", - compressible: true, - extensions: ["gpx"] - }, - "application/gxf": { - source: "apache", - extensions: ["gxf"] - }, - "application/gzip": { - source: "iana", - compressible: false, - extensions: ["gz"] - }, - "application/h224": { - source: "iana" - }, - "application/held+xml": { - source: "iana", - compressible: true - }, - "application/hjson": { - extensions: ["hjson"] - }, - "application/http": { - source: "iana" - }, - "application/hyperstudio": { - source: "iana", - extensions: ["stk"] - }, - "application/ibe-key-request+xml": { - source: "iana", - compressible: true - }, - "application/ibe-pkg-reply+xml": { - source: "iana", - compressible: true - }, - "application/ibe-pp-data": { - source: "iana" - }, - "application/iges": { - source: "iana" - }, - "application/im-iscomposing+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/index": { - source: "iana" - }, - "application/index.cmd": { - source: "iana" - }, - "application/index.obj": { - source: "iana" - }, - "application/index.response": { - source: "iana" - }, - "application/index.vnd": { - source: "iana" - }, - "application/inkml+xml": { - source: "iana", - compressible: true, - extensions: ["ink", "inkml"] - }, - "application/iotp": { - source: "iana" - }, - "application/ipfix": { - source: "iana", - extensions: ["ipfix"] - }, - "application/ipp": { - source: "iana" - }, - "application/isup": { - source: "iana" - }, - "application/its+xml": { - source: "iana", - compressible: true, - extensions: ["its"] - }, - "application/java-archive": { - source: "apache", - compressible: false, - extensions: ["jar", "war", "ear"] - }, - "application/java-serialized-object": { - source: "apache", - compressible: false, - extensions: ["ser"] - }, - "application/java-vm": { - source: "apache", - compressible: false, - extensions: ["class"] - }, - "application/javascript": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["js", "mjs"] - }, - "application/jf2feed+json": { - source: "iana", - compressible: true - }, - "application/jose": { - source: "iana" - }, - "application/jose+json": { - source: "iana", - compressible: true - }, - "application/jrd+json": { - source: "iana", - compressible: true - }, - "application/jscalendar+json": { - source: "iana", - compressible: true - }, - "application/json": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["json", "map"] - }, - "application/json-patch+json": { - source: "iana", - compressible: true - }, - "application/json-seq": { - source: "iana" - }, - "application/json5": { - extensions: ["json5"] - }, - "application/jsonml+json": { - source: "apache", - compressible: true, - extensions: ["jsonml"] - }, - "application/jwk+json": { - source: "iana", - compressible: true - }, - "application/jwk-set+json": { - source: "iana", - compressible: true - }, - "application/jwt": { - source: "iana" - }, - "application/kpml-request+xml": { - source: "iana", - compressible: true - }, - "application/kpml-response+xml": { - source: "iana", - compressible: true - }, - "application/ld+json": { - source: "iana", - compressible: true, - extensions: ["jsonld"] - }, - "application/lgr+xml": { - source: "iana", - compressible: true, - extensions: ["lgr"] - }, - "application/link-format": { - source: "iana" - }, - "application/load-control+xml": { - source: "iana", - compressible: true - }, - "application/lost+xml": { - source: "iana", - compressible: true, - extensions: ["lostxml"] - }, - "application/lostsync+xml": { - source: "iana", - compressible: true - }, - "application/lpf+zip": { - source: "iana", - compressible: false - }, - "application/lxf": { - source: "iana" - }, - "application/mac-binhex40": { - source: "iana", - extensions: ["hqx"] - }, - "application/mac-compactpro": { - source: "apache", - extensions: ["cpt"] - }, - "application/macwriteii": { - source: "iana" - }, - "application/mads+xml": { - source: "iana", - compressible: true, - extensions: ["mads"] - }, - "application/manifest+json": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["webmanifest"] - }, - "application/marc": { - source: "iana", - extensions: ["mrc"] - }, - "application/marcxml+xml": { - source: "iana", - compressible: true, - extensions: ["mrcx"] - }, - "application/mathematica": { - source: "iana", - extensions: ["ma", "nb", "mb"] - }, - "application/mathml+xml": { - source: "iana", - compressible: true, - extensions: ["mathml"] - }, - "application/mathml-content+xml": { - source: "iana", - compressible: true - }, - "application/mathml-presentation+xml": { - source: "iana", - compressible: true - }, - "application/mbms-associated-procedure-description+xml": { - source: "iana", - compressible: true - }, - "application/mbms-deregister+xml": { - source: "iana", - compressible: true - }, - "application/mbms-envelope+xml": { - source: "iana", - compressible: true - }, - "application/mbms-msk+xml": { - source: "iana", - compressible: true - }, - "application/mbms-msk-response+xml": { - source: "iana", - compressible: true - }, - "application/mbms-protection-description+xml": { - source: "iana", - compressible: true - }, - "application/mbms-reception-report+xml": { - source: "iana", - compressible: true - }, - "application/mbms-register+xml": { - source: "iana", - compressible: true - }, - "application/mbms-register-response+xml": { - source: "iana", - compressible: true - }, - "application/mbms-schedule+xml": { - source: "iana", - compressible: true - }, - "application/mbms-user-service-description+xml": { - source: "iana", - compressible: true - }, - "application/mbox": { - source: "iana", - extensions: ["mbox"] - }, - "application/media-policy-dataset+xml": { - source: "iana", - compressible: true, - extensions: ["mpf"] - }, - "application/media_control+xml": { - source: "iana", - compressible: true - }, - "application/mediaservercontrol+xml": { - source: "iana", - compressible: true, - extensions: ["mscml"] - }, - "application/merge-patch+json": { - source: "iana", - compressible: true - }, - "application/metalink+xml": { - source: "apache", - compressible: true, - extensions: ["metalink"] - }, - "application/metalink4+xml": { - source: "iana", - compressible: true, - extensions: ["meta4"] - }, - "application/mets+xml": { - source: "iana", - compressible: true, - extensions: ["mets"] - }, - "application/mf4": { - source: "iana" - }, - "application/mikey": { - source: "iana" - }, - "application/mipc": { - source: "iana" - }, - "application/missing-blocks+cbor-seq": { - source: "iana" - }, - "application/mmt-aei+xml": { - source: "iana", - compressible: true, - extensions: ["maei"] - }, - "application/mmt-usd+xml": { - source: "iana", - compressible: true, - extensions: ["musd"] - }, - "application/mods+xml": { - source: "iana", - compressible: true, - extensions: ["mods"] - }, - "application/moss-keys": { - source: "iana" - }, - "application/moss-signature": { - source: "iana" - }, - "application/mosskey-data": { - source: "iana" - }, - "application/mosskey-request": { - source: "iana" - }, - "application/mp21": { - source: "iana", - extensions: ["m21", "mp21"] - }, - "application/mp4": { - source: "iana", - extensions: ["mp4s", "m4p"] - }, - "application/mpeg4-generic": { - source: "iana" - }, - "application/mpeg4-iod": { - source: "iana" - }, - "application/mpeg4-iod-xmt": { - source: "iana" - }, - "application/mrb-consumer+xml": { - source: "iana", - compressible: true - }, - "application/mrb-publish+xml": { - source: "iana", - compressible: true - }, - "application/msc-ivr+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/msc-mixer+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/msword": { - source: "iana", - compressible: false, - extensions: ["doc", "dot"] - }, - "application/mud+json": { - source: "iana", - compressible: true - }, - "application/multipart-core": { - source: "iana" - }, - "application/mxf": { - source: "iana", - extensions: ["mxf"] - }, - "application/n-quads": { - source: "iana", - extensions: ["nq"] - }, - "application/n-triples": { - source: "iana", - extensions: ["nt"] - }, - "application/nasdata": { - source: "iana" - }, - "application/news-checkgroups": { - source: "iana", - charset: "US-ASCII" - }, - "application/news-groupinfo": { - source: "iana", - charset: "US-ASCII" - }, - "application/news-transmission": { - source: "iana" - }, - "application/nlsml+xml": { - source: "iana", - compressible: true - }, - "application/node": { - source: "iana", - extensions: ["cjs"] - }, - "application/nss": { - source: "iana" - }, - "application/oauth-authz-req+jwt": { - source: "iana" - }, - "application/oblivious-dns-message": { - source: "iana" - }, - "application/ocsp-request": { - source: "iana" - }, - "application/ocsp-response": { - source: "iana" - }, - "application/octet-stream": { - source: "iana", - compressible: false, - extensions: ["bin", "dms", "lrf", "mar", "so", "dist", "distz", "pkg", "bpk", "dump", "elc", "deploy", "exe", "dll", "deb", "dmg", "iso", "img", "msi", "msp", "msm", "buffer"] - }, - "application/oda": { - source: "iana", - extensions: ["oda"] - }, - "application/odm+xml": { - source: "iana", - compressible: true - }, - "application/odx": { - source: "iana" - }, - "application/oebps-package+xml": { - source: "iana", - compressible: true, - extensions: ["opf"] - }, - "application/ogg": { - source: "iana", - compressible: false, - extensions: ["ogx"] - }, - "application/omdoc+xml": { - source: "apache", - compressible: true, - extensions: ["omdoc"] - }, - "application/onenote": { - source: "apache", - extensions: ["onetoc", "onetoc2", "onetmp", "onepkg"] - }, - "application/opc-nodeset+xml": { - source: "iana", - compressible: true - }, - "application/oscore": { - source: "iana" - }, - "application/oxps": { - source: "iana", - extensions: ["oxps"] - }, - "application/p21": { - source: "iana" - }, - "application/p21+zip": { - source: "iana", - compressible: false - }, - "application/p2p-overlay+xml": { - source: "iana", - compressible: true, - extensions: ["relo"] - }, - "application/parityfec": { - source: "iana" - }, - "application/passport": { - source: "iana" - }, - "application/patch-ops-error+xml": { - source: "iana", - compressible: true, - extensions: ["xer"] - }, - "application/pdf": { - source: "iana", - compressible: false, - extensions: ["pdf"] - }, - "application/pdx": { - source: "iana" - }, - "application/pem-certificate-chain": { - source: "iana" - }, - "application/pgp-encrypted": { - source: "iana", - compressible: false, - extensions: ["pgp"] - }, - "application/pgp-keys": { - source: "iana", - extensions: ["asc"] - }, - "application/pgp-signature": { - source: "iana", - extensions: ["asc", "sig"] - }, - "application/pics-rules": { - source: "apache", - extensions: ["prf"] - }, - "application/pidf+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/pidf-diff+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/pkcs10": { - source: "iana", - extensions: ["p10"] - }, - "application/pkcs12": { - source: "iana" - }, - "application/pkcs7-mime": { - source: "iana", - extensions: ["p7m", "p7c"] - }, - "application/pkcs7-signature": { - source: "iana", - extensions: ["p7s"] - }, - "application/pkcs8": { - source: "iana", - extensions: ["p8"] - }, - "application/pkcs8-encrypted": { - source: "iana" - }, - "application/pkix-attr-cert": { - source: "iana", - extensions: ["ac"] - }, - "application/pkix-cert": { - source: "iana", - extensions: ["cer"] - }, - "application/pkix-crl": { - source: "iana", - extensions: ["crl"] - }, - "application/pkix-pkipath": { - source: "iana", - extensions: ["pkipath"] - }, - "application/pkixcmp": { - source: "iana", - extensions: ["pki"] - }, - "application/pls+xml": { - source: "iana", - compressible: true, - extensions: ["pls"] - }, - "application/poc-settings+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/postscript": { - source: "iana", - compressible: true, - extensions: ["ai", "eps", "ps"] - }, - "application/ppsp-tracker+json": { - source: "iana", - compressible: true - }, - "application/problem+json": { - source: "iana", - compressible: true - }, - "application/problem+xml": { - source: "iana", - compressible: true - }, - "application/provenance+xml": { - source: "iana", - compressible: true, - extensions: ["provx"] - }, - "application/prs.alvestrand.titrax-sheet": { - source: "iana" - }, - "application/prs.cww": { - source: "iana", - extensions: ["cww"] - }, - "application/prs.cyn": { - source: "iana", - charset: "7-BIT" - }, - "application/prs.hpub+zip": { - source: "iana", - compressible: false - }, - "application/prs.nprend": { - source: "iana" - }, - "application/prs.plucker": { - source: "iana" - }, - "application/prs.rdf-xml-crypt": { - source: "iana" - }, - "application/prs.xsf+xml": { - source: "iana", - compressible: true - }, - "application/pskc+xml": { - source: "iana", - compressible: true, - extensions: ["pskcxml"] - }, - "application/pvd+json": { - source: "iana", - compressible: true - }, - "application/qsig": { - source: "iana" - }, - "application/raml+yaml": { - compressible: true, - extensions: ["raml"] - }, - "application/raptorfec": { - source: "iana" - }, - "application/rdap+json": { - source: "iana", - compressible: true - }, - "application/rdf+xml": { - source: "iana", - compressible: true, - extensions: ["rdf", "owl"] - }, - "application/reginfo+xml": { - source: "iana", - compressible: true, - extensions: ["rif"] - }, - "application/relax-ng-compact-syntax": { - source: "iana", - extensions: ["rnc"] - }, - "application/remote-printing": { - source: "iana" - }, - "application/reputon+json": { - source: "iana", - compressible: true - }, - "application/resource-lists+xml": { - source: "iana", - compressible: true, - extensions: ["rl"] - }, - "application/resource-lists-diff+xml": { - source: "iana", - compressible: true, - extensions: ["rld"] - }, - "application/rfc+xml": { - source: "iana", - compressible: true - }, - "application/riscos": { - source: "iana" - }, - "application/rlmi+xml": { - source: "iana", - compressible: true - }, - "application/rls-services+xml": { - source: "iana", - compressible: true, - extensions: ["rs"] - }, - "application/route-apd+xml": { - source: "iana", - compressible: true, - extensions: ["rapd"] - }, - "application/route-s-tsid+xml": { - source: "iana", - compressible: true, - extensions: ["sls"] - }, - "application/route-usd+xml": { - source: "iana", - compressible: true, - extensions: ["rusd"] - }, - "application/rpki-ghostbusters": { - source: "iana", - extensions: ["gbr"] - }, - "application/rpki-manifest": { - source: "iana", - extensions: ["mft"] - }, - "application/rpki-publication": { - source: "iana" - }, - "application/rpki-roa": { - source: "iana", - extensions: ["roa"] - }, - "application/rpki-updown": { - source: "iana" - }, - "application/rsd+xml": { - source: "apache", - compressible: true, - extensions: ["rsd"] - }, - "application/rss+xml": { - source: "apache", - compressible: true, - extensions: ["rss"] - }, - "application/rtf": { - source: "iana", - compressible: true, - extensions: ["rtf"] - }, - "application/rtploopback": { - source: "iana" - }, - "application/rtx": { - source: "iana" - }, - "application/samlassertion+xml": { - source: "iana", - compressible: true - }, - "application/samlmetadata+xml": { - source: "iana", - compressible: true - }, - "application/sarif+json": { - source: "iana", - compressible: true - }, - "application/sarif-external-properties+json": { - source: "iana", - compressible: true - }, - "application/sbe": { - source: "iana" - }, - "application/sbml+xml": { - source: "iana", - compressible: true, - extensions: ["sbml"] - }, - "application/scaip+xml": { - source: "iana", - compressible: true - }, - "application/scim+json": { - source: "iana", - compressible: true - }, - "application/scvp-cv-request": { - source: "iana", - extensions: ["scq"] - }, - "application/scvp-cv-response": { - source: "iana", - extensions: ["scs"] - }, - "application/scvp-vp-request": { - source: "iana", - extensions: ["spq"] - }, - "application/scvp-vp-response": { - source: "iana", - extensions: ["spp"] - }, - "application/sdp": { - source: "iana", - extensions: ["sdp"] - }, - "application/secevent+jwt": { - source: "iana" - }, - "application/senml+cbor": { - source: "iana" - }, - "application/senml+json": { - source: "iana", - compressible: true - }, - "application/senml+xml": { - source: "iana", - compressible: true, - extensions: ["senmlx"] - }, - "application/senml-etch+cbor": { - source: "iana" - }, - "application/senml-etch+json": { - source: "iana", - compressible: true - }, - "application/senml-exi": { - source: "iana" - }, - "application/sensml+cbor": { - source: "iana" - }, - "application/sensml+json": { - source: "iana", - compressible: true - }, - "application/sensml+xml": { - source: "iana", - compressible: true, - extensions: ["sensmlx"] - }, - "application/sensml-exi": { - source: "iana" - }, - "application/sep+xml": { - source: "iana", - compressible: true - }, - "application/sep-exi": { - source: "iana" - }, - "application/session-info": { - source: "iana" - }, - "application/set-payment": { - source: "iana" - }, - "application/set-payment-initiation": { - source: "iana", - extensions: ["setpay"] - }, - "application/set-registration": { - source: "iana" - }, - "application/set-registration-initiation": { - source: "iana", - extensions: ["setreg"] - }, - "application/sgml": { - source: "iana" - }, - "application/sgml-open-catalog": { - source: "iana" - }, - "application/shf+xml": { - source: "iana", - compressible: true, - extensions: ["shf"] - }, - "application/sieve": { - source: "iana", - extensions: ["siv", "sieve"] - }, - "application/simple-filter+xml": { - source: "iana", - compressible: true - }, - "application/simple-message-summary": { - source: "iana" - }, - "application/simplesymbolcontainer": { - source: "iana" - }, - "application/sipc": { - source: "iana" - }, - "application/slate": { - source: "iana" - }, - "application/smil": { - source: "iana" - }, - "application/smil+xml": { - source: "iana", - compressible: true, - extensions: ["smi", "smil"] - }, - "application/smpte336m": { - source: "iana" - }, - "application/soap+fastinfoset": { - source: "iana" - }, - "application/soap+xml": { - source: "iana", - compressible: true - }, - "application/sparql-query": { - source: "iana", - extensions: ["rq"] - }, - "application/sparql-results+xml": { - source: "iana", - compressible: true, - extensions: ["srx"] - }, - "application/spdx+json": { - source: "iana", - compressible: true - }, - "application/spirits-event+xml": { - source: "iana", - compressible: true - }, - "application/sql": { - source: "iana" - }, - "application/srgs": { - source: "iana", - extensions: ["gram"] - }, - "application/srgs+xml": { - source: "iana", - compressible: true, - extensions: ["grxml"] - }, - "application/sru+xml": { - source: "iana", - compressible: true, - extensions: ["sru"] - }, - "application/ssdl+xml": { - source: "apache", - compressible: true, - extensions: ["ssdl"] - }, - "application/ssml+xml": { - source: "iana", - compressible: true, - extensions: ["ssml"] - }, - "application/stix+json": { - source: "iana", - compressible: true - }, - "application/swid+xml": { - source: "iana", - compressible: true, - extensions: ["swidtag"] - }, - "application/tamp-apex-update": { - source: "iana" - }, - "application/tamp-apex-update-confirm": { - source: "iana" - }, - "application/tamp-community-update": { - source: "iana" - }, - "application/tamp-community-update-confirm": { - source: "iana" - }, - "application/tamp-error": { - source: "iana" - }, - "application/tamp-sequence-adjust": { - source: "iana" - }, - "application/tamp-sequence-adjust-confirm": { - source: "iana" - }, - "application/tamp-status-query": { - source: "iana" - }, - "application/tamp-status-response": { - source: "iana" - }, - "application/tamp-update": { - source: "iana" - }, - "application/tamp-update-confirm": { - source: "iana" - }, - "application/tar": { - compressible: true - }, - "application/taxii+json": { - source: "iana", - compressible: true - }, - "application/td+json": { - source: "iana", - compressible: true - }, - "application/tei+xml": { - source: "iana", - compressible: true, - extensions: ["tei", "teicorpus"] - }, - "application/tetra_isi": { - source: "iana" - }, - "application/thraud+xml": { - source: "iana", - compressible: true, - extensions: ["tfi"] - }, - "application/timestamp-query": { - source: "iana" - }, - "application/timestamp-reply": { - source: "iana" - }, - "application/timestamped-data": { - source: "iana", - extensions: ["tsd"] - }, - "application/tlsrpt+gzip": { - source: "iana" - }, - "application/tlsrpt+json": { - source: "iana", - compressible: true - }, - "application/tnauthlist": { - source: "iana" - }, - "application/token-introspection+jwt": { - source: "iana" - }, - "application/toml": { - compressible: true, - extensions: ["toml"] - }, - "application/trickle-ice-sdpfrag": { - source: "iana" - }, - "application/trig": { - source: "iana", - extensions: ["trig"] - }, - "application/ttml+xml": { - source: "iana", - compressible: true, - extensions: ["ttml"] - }, - "application/tve-trigger": { - source: "iana" - }, - "application/tzif": { - source: "iana" - }, - "application/tzif-leap": { - source: "iana" - }, - "application/ubjson": { - compressible: false, - extensions: ["ubj"] - }, - "application/ulpfec": { - source: "iana" - }, - "application/urc-grpsheet+xml": { - source: "iana", - compressible: true - }, - "application/urc-ressheet+xml": { - source: "iana", - compressible: true, - extensions: ["rsheet"] - }, - "application/urc-targetdesc+xml": { - source: "iana", - compressible: true, - extensions: ["td"] - }, - "application/urc-uisocketdesc+xml": { - source: "iana", - compressible: true - }, - "application/vcard+json": { - source: "iana", - compressible: true - }, - "application/vcard+xml": { - source: "iana", - compressible: true - }, - "application/vemmi": { - source: "iana" - }, - "application/vividence.scriptfile": { - source: "apache" - }, - "application/vnd.1000minds.decision-model+xml": { - source: "iana", - compressible: true, - extensions: ["1km"] - }, - "application/vnd.3gpp-prose+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp-prose-pc3ch+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp-v2x-local-service-information": { - source: "iana" - }, - "application/vnd.3gpp.5gnas": { - source: "iana" - }, - "application/vnd.3gpp.access-transfer-events+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.bsf+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.gmop+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.gtpc": { - source: "iana" - }, - "application/vnd.3gpp.interworking-data": { - source: "iana" - }, - "application/vnd.3gpp.lpp": { - source: "iana" - }, - "application/vnd.3gpp.mc-signalling-ear": { - source: "iana" - }, - "application/vnd.3gpp.mcdata-affiliation-command+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcdata-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcdata-payload": { - source: "iana" - }, - "application/vnd.3gpp.mcdata-service-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcdata-signalling": { - source: "iana" - }, - "application/vnd.3gpp.mcdata-ue-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcdata-user-profile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-affiliation-command+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-floor-request+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-location-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-mbms-usage-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-service-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-signed+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-ue-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-ue-init-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcptt-user-profile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-affiliation-command+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-affiliation-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-location-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-mbms-usage-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-service-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-transmission-request+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-ue-config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mcvideo-user-profile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.mid-call+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.ngap": { - source: "iana" - }, - "application/vnd.3gpp.pfcp": { - source: "iana" - }, - "application/vnd.3gpp.pic-bw-large": { - source: "iana", - extensions: ["plb"] - }, - "application/vnd.3gpp.pic-bw-small": { - source: "iana", - extensions: ["psb"] - }, - "application/vnd.3gpp.pic-bw-var": { - source: "iana", - extensions: ["pvb"] - }, - "application/vnd.3gpp.s1ap": { - source: "iana" - }, - "application/vnd.3gpp.sms": { - source: "iana" - }, - "application/vnd.3gpp.sms+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.srvcc-ext+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.srvcc-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.state-and-event-info+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp.ussd+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp2.bcmcsinfo+xml": { - source: "iana", - compressible: true - }, - "application/vnd.3gpp2.sms": { - source: "iana" - }, - "application/vnd.3gpp2.tcap": { - source: "iana", - extensions: ["tcap"] - }, - "application/vnd.3lightssoftware.imagescal": { - source: "iana" - }, - "application/vnd.3m.post-it-notes": { - source: "iana", - extensions: ["pwn"] - }, - "application/vnd.accpac.simply.aso": { - source: "iana", - extensions: ["aso"] - }, - "application/vnd.accpac.simply.imp": { - source: "iana", - extensions: ["imp"] - }, - "application/vnd.acucobol": { - source: "iana", - extensions: ["acu"] - }, - "application/vnd.acucorp": { - source: "iana", - extensions: ["atc", "acutc"] - }, - "application/vnd.adobe.air-application-installer-package+zip": { - source: "apache", - compressible: false, - extensions: ["air"] - }, - "application/vnd.adobe.flash.movie": { - source: "iana" - }, - "application/vnd.adobe.formscentral.fcdt": { - source: "iana", - extensions: ["fcdt"] - }, - "application/vnd.adobe.fxp": { - source: "iana", - extensions: ["fxp", "fxpl"] - }, - "application/vnd.adobe.partial-upload": { - source: "iana" - }, - "application/vnd.adobe.xdp+xml": { - source: "iana", - compressible: true, - extensions: ["xdp"] - }, - "application/vnd.adobe.xfdf": { - source: "iana", - extensions: ["xfdf"] - }, - "application/vnd.aether.imp": { - source: "iana" - }, - "application/vnd.afpc.afplinedata": { - source: "iana" - }, - "application/vnd.afpc.afplinedata-pagedef": { - source: "iana" - }, - "application/vnd.afpc.cmoca-cmresource": { - source: "iana" - }, - "application/vnd.afpc.foca-charset": { - source: "iana" - }, - "application/vnd.afpc.foca-codedfont": { - source: "iana" - }, - "application/vnd.afpc.foca-codepage": { - source: "iana" - }, - "application/vnd.afpc.modca": { - source: "iana" - }, - "application/vnd.afpc.modca-cmtable": { - source: "iana" - }, - "application/vnd.afpc.modca-formdef": { - source: "iana" - }, - "application/vnd.afpc.modca-mediummap": { - source: "iana" - }, - "application/vnd.afpc.modca-objectcontainer": { - source: "iana" - }, - "application/vnd.afpc.modca-overlay": { - source: "iana" - }, - "application/vnd.afpc.modca-pagesegment": { - source: "iana" - }, - "application/vnd.age": { - source: "iana", - extensions: ["age"] - }, - "application/vnd.ah-barcode": { - source: "iana" - }, - "application/vnd.ahead.space": { - source: "iana", - extensions: ["ahead"] - }, - "application/vnd.airzip.filesecure.azf": { - source: "iana", - extensions: ["azf"] - }, - "application/vnd.airzip.filesecure.azs": { - source: "iana", - extensions: ["azs"] - }, - "application/vnd.amadeus+json": { - source: "iana", - compressible: true - }, - "application/vnd.amazon.ebook": { - source: "apache", - extensions: ["azw"] - }, - "application/vnd.amazon.mobi8-ebook": { - source: "iana" - }, - "application/vnd.americandynamics.acc": { - source: "iana", - extensions: ["acc"] - }, - "application/vnd.amiga.ami": { - source: "iana", - extensions: ["ami"] - }, - "application/vnd.amundsen.maze+xml": { - source: "iana", - compressible: true - }, - "application/vnd.android.ota": { - source: "iana" - }, - "application/vnd.android.package-archive": { - source: "apache", - compressible: false, - extensions: ["apk"] - }, - "application/vnd.anki": { - source: "iana" - }, - "application/vnd.anser-web-certificate-issue-initiation": { - source: "iana", - extensions: ["cii"] - }, - "application/vnd.anser-web-funds-transfer-initiation": { - source: "apache", - extensions: ["fti"] - }, - "application/vnd.antix.game-component": { - source: "iana", - extensions: ["atx"] - }, - "application/vnd.apache.arrow.file": { - source: "iana" - }, - "application/vnd.apache.arrow.stream": { - source: "iana" - }, - "application/vnd.apache.thrift.binary": { - source: "iana" - }, - "application/vnd.apache.thrift.compact": { - source: "iana" - }, - "application/vnd.apache.thrift.json": { - source: "iana" - }, - "application/vnd.api+json": { - source: "iana", - compressible: true - }, - "application/vnd.aplextor.warrp+json": { - source: "iana", - compressible: true - }, - "application/vnd.apothekende.reservation+json": { - source: "iana", - compressible: true - }, - "application/vnd.apple.installer+xml": { - source: "iana", - compressible: true, - extensions: ["mpkg"] - }, - "application/vnd.apple.keynote": { - source: "iana", - extensions: ["key"] - }, - "application/vnd.apple.mpegurl": { - source: "iana", - extensions: ["m3u8"] - }, - "application/vnd.apple.numbers": { - source: "iana", - extensions: ["numbers"] - }, - "application/vnd.apple.pages": { - source: "iana", - extensions: ["pages"] - }, - "application/vnd.apple.pkpass": { - compressible: false, - extensions: ["pkpass"] - }, - "application/vnd.arastra.swi": { - source: "iana" - }, - "application/vnd.aristanetworks.swi": { - source: "iana", - extensions: ["swi"] - }, - "application/vnd.artisan+json": { - source: "iana", - compressible: true - }, - "application/vnd.artsquare": { - source: "iana" - }, - "application/vnd.astraea-software.iota": { - source: "iana", - extensions: ["iota"] - }, - "application/vnd.audiograph": { - source: "iana", - extensions: ["aep"] - }, - "application/vnd.autopackage": { - source: "iana" - }, - "application/vnd.avalon+json": { - source: "iana", - compressible: true - }, - "application/vnd.avistar+xml": { - source: "iana", - compressible: true - }, - "application/vnd.balsamiq.bmml+xml": { - source: "iana", - compressible: true, - extensions: ["bmml"] - }, - "application/vnd.balsamiq.bmpr": { - source: "iana" - }, - "application/vnd.banana-accounting": { - source: "iana" - }, - "application/vnd.bbf.usp.error": { - source: "iana" - }, - "application/vnd.bbf.usp.msg": { - source: "iana" - }, - "application/vnd.bbf.usp.msg+json": { - source: "iana", - compressible: true - }, - "application/vnd.bekitzur-stech+json": { - source: "iana", - compressible: true - }, - "application/vnd.bint.med-content": { - source: "iana" - }, - "application/vnd.biopax.rdf+xml": { - source: "iana", - compressible: true - }, - "application/vnd.blink-idb-value-wrapper": { - source: "iana" - }, - "application/vnd.blueice.multipass": { - source: "iana", - extensions: ["mpm"] - }, - "application/vnd.bluetooth.ep.oob": { - source: "iana" - }, - "application/vnd.bluetooth.le.oob": { - source: "iana" - }, - "application/vnd.bmi": { - source: "iana", - extensions: ["bmi"] - }, - "application/vnd.bpf": { - source: "iana" - }, - "application/vnd.bpf3": { - source: "iana" - }, - "application/vnd.businessobjects": { - source: "iana", - extensions: ["rep"] - }, - "application/vnd.byu.uapi+json": { - source: "iana", - compressible: true - }, - "application/vnd.cab-jscript": { - source: "iana" - }, - "application/vnd.canon-cpdl": { - source: "iana" - }, - "application/vnd.canon-lips": { - source: "iana" - }, - "application/vnd.capasystems-pg+json": { - source: "iana", - compressible: true - }, - "application/vnd.cendio.thinlinc.clientconf": { - source: "iana" - }, - "application/vnd.century-systems.tcp_stream": { - source: "iana" - }, - "application/vnd.chemdraw+xml": { - source: "iana", - compressible: true, - extensions: ["cdxml"] - }, - "application/vnd.chess-pgn": { - source: "iana" - }, - "application/vnd.chipnuts.karaoke-mmd": { - source: "iana", - extensions: ["mmd"] - }, - "application/vnd.ciedi": { - source: "iana" - }, - "application/vnd.cinderella": { - source: "iana", - extensions: ["cdy"] - }, - "application/vnd.cirpack.isdn-ext": { - source: "iana" - }, - "application/vnd.citationstyles.style+xml": { - source: "iana", - compressible: true, - extensions: ["csl"] - }, - "application/vnd.claymore": { - source: "iana", - extensions: ["cla"] - }, - "application/vnd.cloanto.rp9": { - source: "iana", - extensions: ["rp9"] - }, - "application/vnd.clonk.c4group": { - source: "iana", - extensions: ["c4g", "c4d", "c4f", "c4p", "c4u"] - }, - "application/vnd.cluetrust.cartomobile-config": { - source: "iana", - extensions: ["c11amc"] - }, - "application/vnd.cluetrust.cartomobile-config-pkg": { - source: "iana", - extensions: ["c11amz"] - }, - "application/vnd.coffeescript": { - source: "iana" - }, - "application/vnd.collabio.xodocuments.document": { - source: "iana" - }, - "application/vnd.collabio.xodocuments.document-template": { - source: "iana" - }, - "application/vnd.collabio.xodocuments.presentation": { - source: "iana" - }, - "application/vnd.collabio.xodocuments.presentation-template": { - source: "iana" - }, - "application/vnd.collabio.xodocuments.spreadsheet": { - source: "iana" - }, - "application/vnd.collabio.xodocuments.spreadsheet-template": { - source: "iana" - }, - "application/vnd.collection+json": { - source: "iana", - compressible: true - }, - "application/vnd.collection.doc+json": { - source: "iana", - compressible: true - }, - "application/vnd.collection.next+json": { - source: "iana", - compressible: true - }, - "application/vnd.comicbook+zip": { - source: "iana", - compressible: false - }, - "application/vnd.comicbook-rar": { - source: "iana" - }, - "application/vnd.commerce-battelle": { - source: "iana" - }, - "application/vnd.commonspace": { - source: "iana", - extensions: ["csp"] - }, - "application/vnd.contact.cmsg": { - source: "iana", - extensions: ["cdbcmsg"] - }, - "application/vnd.coreos.ignition+json": { - source: "iana", - compressible: true - }, - "application/vnd.cosmocaller": { - source: "iana", - extensions: ["cmc"] - }, - "application/vnd.crick.clicker": { - source: "iana", - extensions: ["clkx"] - }, - "application/vnd.crick.clicker.keyboard": { - source: "iana", - extensions: ["clkk"] - }, - "application/vnd.crick.clicker.palette": { - source: "iana", - extensions: ["clkp"] - }, - "application/vnd.crick.clicker.template": { - source: "iana", - extensions: ["clkt"] - }, - "application/vnd.crick.clicker.wordbank": { - source: "iana", - extensions: ["clkw"] - }, - "application/vnd.criticaltools.wbs+xml": { - source: "iana", - compressible: true, - extensions: ["wbs"] - }, - "application/vnd.cryptii.pipe+json": { - source: "iana", - compressible: true - }, - "application/vnd.crypto-shade-file": { - source: "iana" - }, - "application/vnd.cryptomator.encrypted": { - source: "iana" - }, - "application/vnd.cryptomator.vault": { - source: "iana" - }, - "application/vnd.ctc-posml": { - source: "iana", - extensions: ["pml"] - }, - "application/vnd.ctct.ws+xml": { - source: "iana", - compressible: true - }, - "application/vnd.cups-pdf": { - source: "iana" - }, - "application/vnd.cups-postscript": { - source: "iana" - }, - "application/vnd.cups-ppd": { - source: "iana", - extensions: ["ppd"] - }, - "application/vnd.cups-raster": { - source: "iana" - }, - "application/vnd.cups-raw": { - source: "iana" - }, - "application/vnd.curl": { - source: "iana" - }, - "application/vnd.curl.car": { - source: "apache", - extensions: ["car"] - }, - "application/vnd.curl.pcurl": { - source: "apache", - extensions: ["pcurl"] - }, - "application/vnd.cyan.dean.root+xml": { - source: "iana", - compressible: true - }, - "application/vnd.cybank": { - source: "iana" - }, - "application/vnd.cyclonedx+json": { - source: "iana", - compressible: true - }, - "application/vnd.cyclonedx+xml": { - source: "iana", - compressible: true - }, - "application/vnd.d2l.coursepackage1p0+zip": { - source: "iana", - compressible: false - }, - "application/vnd.d3m-dataset": { - source: "iana" - }, - "application/vnd.d3m-problem": { - source: "iana" - }, - "application/vnd.dart": { - source: "iana", - compressible: true, - extensions: ["dart"] - }, - "application/vnd.data-vision.rdz": { - source: "iana", - extensions: ["rdz"] - }, - "application/vnd.datapackage+json": { - source: "iana", - compressible: true - }, - "application/vnd.dataresource+json": { - source: "iana", - compressible: true - }, - "application/vnd.dbf": { - source: "iana", - extensions: ["dbf"] - }, - "application/vnd.debian.binary-package": { - source: "iana" - }, - "application/vnd.dece.data": { - source: "iana", - extensions: ["uvf", "uvvf", "uvd", "uvvd"] - }, - "application/vnd.dece.ttml+xml": { - source: "iana", - compressible: true, - extensions: ["uvt", "uvvt"] - }, - "application/vnd.dece.unspecified": { - source: "iana", - extensions: ["uvx", "uvvx"] - }, - "application/vnd.dece.zip": { - source: "iana", - extensions: ["uvz", "uvvz"] - }, - "application/vnd.denovo.fcselayout-link": { - source: "iana", - extensions: ["fe_launch"] - }, - "application/vnd.desmume.movie": { - source: "iana" - }, - "application/vnd.dir-bi.plate-dl-nosuffix": { - source: "iana" - }, - "application/vnd.dm.delegation+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dna": { - source: "iana", - extensions: ["dna"] - }, - "application/vnd.document+json": { - source: "iana", - compressible: true - }, - "application/vnd.dolby.mlp": { - source: "apache", - extensions: ["mlp"] - }, - "application/vnd.dolby.mobile.1": { - source: "iana" - }, - "application/vnd.dolby.mobile.2": { - source: "iana" - }, - "application/vnd.doremir.scorecloud-binary-document": { - source: "iana" - }, - "application/vnd.dpgraph": { - source: "iana", - extensions: ["dpg"] - }, - "application/vnd.dreamfactory": { - source: "iana", - extensions: ["dfac"] - }, - "application/vnd.drive+json": { - source: "iana", - compressible: true - }, - "application/vnd.ds-keypoint": { - source: "apache", - extensions: ["kpxx"] - }, - "application/vnd.dtg.local": { - source: "iana" - }, - "application/vnd.dtg.local.flash": { - source: "iana" - }, - "application/vnd.dtg.local.html": { - source: "iana" - }, - "application/vnd.dvb.ait": { - source: "iana", - extensions: ["ait"] - }, - "application/vnd.dvb.dvbisl+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.dvbj": { - source: "iana" - }, - "application/vnd.dvb.esgcontainer": { - source: "iana" - }, - "application/vnd.dvb.ipdcdftnotifaccess": { - source: "iana" - }, - "application/vnd.dvb.ipdcesgaccess": { - source: "iana" - }, - "application/vnd.dvb.ipdcesgaccess2": { - source: "iana" - }, - "application/vnd.dvb.ipdcesgpdd": { - source: "iana" - }, - "application/vnd.dvb.ipdcroaming": { - source: "iana" - }, - "application/vnd.dvb.iptv.alfec-base": { - source: "iana" - }, - "application/vnd.dvb.iptv.alfec-enhancement": { - source: "iana" - }, - "application/vnd.dvb.notif-aggregate-root+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.notif-container+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.notif-generic+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.notif-ia-msglist+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.notif-ia-registration-request+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.notif-ia-registration-response+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.notif-init+xml": { - source: "iana", - compressible: true - }, - "application/vnd.dvb.pfr": { - source: "iana" - }, - "application/vnd.dvb.service": { - source: "iana", - extensions: ["svc"] - }, - "application/vnd.dxr": { - source: "iana" - }, - "application/vnd.dynageo": { - source: "iana", - extensions: ["geo"] - }, - "application/vnd.dzr": { - source: "iana" - }, - "application/vnd.easykaraoke.cdgdownload": { - source: "iana" - }, - "application/vnd.ecdis-update": { - source: "iana" - }, - "application/vnd.ecip.rlp": { - source: "iana" - }, - "application/vnd.eclipse.ditto+json": { - source: "iana", - compressible: true - }, - "application/vnd.ecowin.chart": { - source: "iana", - extensions: ["mag"] - }, - "application/vnd.ecowin.filerequest": { - source: "iana" - }, - "application/vnd.ecowin.fileupdate": { - source: "iana" - }, - "application/vnd.ecowin.series": { - source: "iana" - }, - "application/vnd.ecowin.seriesrequest": { - source: "iana" - }, - "application/vnd.ecowin.seriesupdate": { - source: "iana" - }, - "application/vnd.efi.img": { - source: "iana" - }, - "application/vnd.efi.iso": { - source: "iana" - }, - "application/vnd.emclient.accessrequest+xml": { - source: "iana", - compressible: true - }, - "application/vnd.enliven": { - source: "iana", - extensions: ["nml"] - }, - "application/vnd.enphase.envoy": { - source: "iana" - }, - "application/vnd.eprints.data+xml": { - source: "iana", - compressible: true - }, - "application/vnd.epson.esf": { - source: "iana", - extensions: ["esf"] - }, - "application/vnd.epson.msf": { - source: "iana", - extensions: ["msf"] - }, - "application/vnd.epson.quickanime": { - source: "iana", - extensions: ["qam"] - }, - "application/vnd.epson.salt": { - source: "iana", - extensions: ["slt"] - }, - "application/vnd.epson.ssf": { - source: "iana", - extensions: ["ssf"] - }, - "application/vnd.ericsson.quickcall": { - source: "iana" - }, - "application/vnd.espass-espass+zip": { - source: "iana", - compressible: false - }, - "application/vnd.eszigno3+xml": { - source: "iana", - compressible: true, - extensions: ["es3", "et3"] - }, - "application/vnd.etsi.aoc+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.asic-e+zip": { - source: "iana", - compressible: false - }, - "application/vnd.etsi.asic-s+zip": { - source: "iana", - compressible: false - }, - "application/vnd.etsi.cug+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvcommand+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvdiscovery+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvprofile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvsad-bc+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvsad-cod+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvsad-npvr+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvservice+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvsync+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.iptvueprofile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.mcid+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.mheg5": { - source: "iana" - }, - "application/vnd.etsi.overload-control-policy-dataset+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.pstn+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.sci+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.simservs+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.timestamp-token": { - source: "iana" - }, - "application/vnd.etsi.tsl+xml": { - source: "iana", - compressible: true - }, - "application/vnd.etsi.tsl.der": { - source: "iana" - }, - "application/vnd.eu.kasparian.car+json": { - source: "iana", - compressible: true - }, - "application/vnd.eudora.data": { - source: "iana" - }, - "application/vnd.evolv.ecig.profile": { - source: "iana" - }, - "application/vnd.evolv.ecig.settings": { - source: "iana" - }, - "application/vnd.evolv.ecig.theme": { - source: "iana" - }, - "application/vnd.exstream-empower+zip": { - source: "iana", - compressible: false - }, - "application/vnd.exstream-package": { - source: "iana" - }, - "application/vnd.ezpix-album": { - source: "iana", - extensions: ["ez2"] - }, - "application/vnd.ezpix-package": { - source: "iana", - extensions: ["ez3"] - }, - "application/vnd.f-secure.mobile": { - source: "iana" - }, - "application/vnd.familysearch.gedcom+zip": { - source: "iana", - compressible: false - }, - "application/vnd.fastcopy-disk-image": { - source: "iana" - }, - "application/vnd.fdf": { - source: "iana", - extensions: ["fdf"] - }, - "application/vnd.fdsn.mseed": { - source: "iana", - extensions: ["mseed"] - }, - "application/vnd.fdsn.seed": { - source: "iana", - extensions: ["seed", "dataless"] - }, - "application/vnd.ffsns": { - source: "iana" - }, - "application/vnd.ficlab.flb+zip": { - source: "iana", - compressible: false - }, - "application/vnd.filmit.zfc": { - source: "iana" - }, - "application/vnd.fints": { - source: "iana" - }, - "application/vnd.firemonkeys.cloudcell": { - source: "iana" - }, - "application/vnd.flographit": { - source: "iana", - extensions: ["gph"] - }, - "application/vnd.fluxtime.clip": { - source: "iana", - extensions: ["ftc"] - }, - "application/vnd.font-fontforge-sfd": { - source: "iana" - }, - "application/vnd.framemaker": { - source: "iana", - extensions: ["fm", "frame", "maker", "book"] - }, - "application/vnd.frogans.fnc": { - source: "iana", - extensions: ["fnc"] - }, - "application/vnd.frogans.ltf": { - source: "iana", - extensions: ["ltf"] - }, - "application/vnd.fsc.weblaunch": { - source: "iana", - extensions: ["fsc"] - }, - "application/vnd.fujifilm.fb.docuworks": { - source: "iana" - }, - "application/vnd.fujifilm.fb.docuworks.binder": { - source: "iana" - }, - "application/vnd.fujifilm.fb.docuworks.container": { - source: "iana" - }, - "application/vnd.fujifilm.fb.jfi+xml": { - source: "iana", - compressible: true - }, - "application/vnd.fujitsu.oasys": { - source: "iana", - extensions: ["oas"] - }, - "application/vnd.fujitsu.oasys2": { - source: "iana", - extensions: ["oa2"] - }, - "application/vnd.fujitsu.oasys3": { - source: "iana", - extensions: ["oa3"] - }, - "application/vnd.fujitsu.oasysgp": { - source: "iana", - extensions: ["fg5"] - }, - "application/vnd.fujitsu.oasysprs": { - source: "iana", - extensions: ["bh2"] - }, - "application/vnd.fujixerox.art-ex": { - source: "iana" - }, - "application/vnd.fujixerox.art4": { - source: "iana" - }, - "application/vnd.fujixerox.ddd": { - source: "iana", - extensions: ["ddd"] - }, - "application/vnd.fujixerox.docuworks": { - source: "iana", - extensions: ["xdw"] - }, - "application/vnd.fujixerox.docuworks.binder": { - source: "iana", - extensions: ["xbd"] - }, - "application/vnd.fujixerox.docuworks.container": { - source: "iana" - }, - "application/vnd.fujixerox.hbpl": { - source: "iana" - }, - "application/vnd.fut-misnet": { - source: "iana" - }, - "application/vnd.futoin+cbor": { - source: "iana" - }, - "application/vnd.futoin+json": { - source: "iana", - compressible: true - }, - "application/vnd.fuzzysheet": { - source: "iana", - extensions: ["fzs"] - }, - "application/vnd.genomatix.tuxedo": { - source: "iana", - extensions: ["txd"] - }, - "application/vnd.gentics.grd+json": { - source: "iana", - compressible: true - }, - "application/vnd.geo+json": { - source: "iana", - compressible: true - }, - "application/vnd.geocube+xml": { - source: "iana", - compressible: true - }, - "application/vnd.geogebra.file": { - source: "iana", - extensions: ["ggb"] - }, - "application/vnd.geogebra.slides": { - source: "iana" - }, - "application/vnd.geogebra.tool": { - source: "iana", - extensions: ["ggt"] - }, - "application/vnd.geometry-explorer": { - source: "iana", - extensions: ["gex", "gre"] - }, - "application/vnd.geonext": { - source: "iana", - extensions: ["gxt"] - }, - "application/vnd.geoplan": { - source: "iana", - extensions: ["g2w"] - }, - "application/vnd.geospace": { - source: "iana", - extensions: ["g3w"] - }, - "application/vnd.gerber": { - source: "iana" - }, - "application/vnd.globalplatform.card-content-mgt": { - source: "iana" - }, - "application/vnd.globalplatform.card-content-mgt-response": { - source: "iana" - }, - "application/vnd.gmx": { - source: "iana", - extensions: ["gmx"] - }, - "application/vnd.google-apps.document": { - compressible: false, - extensions: ["gdoc"] - }, - "application/vnd.google-apps.presentation": { - compressible: false, - extensions: ["gslides"] - }, - "application/vnd.google-apps.spreadsheet": { - compressible: false, - extensions: ["gsheet"] - }, - "application/vnd.google-earth.kml+xml": { - source: "iana", - compressible: true, - extensions: ["kml"] - }, - "application/vnd.google-earth.kmz": { - source: "iana", - compressible: false, - extensions: ["kmz"] - }, - "application/vnd.gov.sk.e-form+xml": { - source: "iana", - compressible: true - }, - "application/vnd.gov.sk.e-form+zip": { - source: "iana", - compressible: false - }, - "application/vnd.gov.sk.xmldatacontainer+xml": { - source: "iana", - compressible: true - }, - "application/vnd.grafeq": { - source: "iana", - extensions: ["gqf", "gqs"] - }, - "application/vnd.gridmp": { - source: "iana" - }, - "application/vnd.groove-account": { - source: "iana", - extensions: ["gac"] - }, - "application/vnd.groove-help": { - source: "iana", - extensions: ["ghf"] - }, - "application/vnd.groove-identity-message": { - source: "iana", - extensions: ["gim"] - }, - "application/vnd.groove-injector": { - source: "iana", - extensions: ["grv"] - }, - "application/vnd.groove-tool-message": { - source: "iana", - extensions: ["gtm"] - }, - "application/vnd.groove-tool-template": { - source: "iana", - extensions: ["tpl"] - }, - "application/vnd.groove-vcard": { - source: "iana", - extensions: ["vcg"] - }, - "application/vnd.hal+json": { - source: "iana", - compressible: true - }, - "application/vnd.hal+xml": { - source: "iana", - compressible: true, - extensions: ["hal"] - }, - "application/vnd.handheld-entertainment+xml": { - source: "iana", - compressible: true, - extensions: ["zmm"] - }, - "application/vnd.hbci": { - source: "iana", - extensions: ["hbci"] - }, - "application/vnd.hc+json": { - source: "iana", - compressible: true - }, - "application/vnd.hcl-bireports": { - source: "iana" - }, - "application/vnd.hdt": { - source: "iana" - }, - "application/vnd.heroku+json": { - source: "iana", - compressible: true - }, - "application/vnd.hhe.lesson-player": { - source: "iana", - extensions: ["les"] - }, - "application/vnd.hl7cda+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/vnd.hl7v2+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/vnd.hp-hpgl": { - source: "iana", - extensions: ["hpgl"] - }, - "application/vnd.hp-hpid": { - source: "iana", - extensions: ["hpid"] - }, - "application/vnd.hp-hps": { - source: "iana", - extensions: ["hps"] - }, - "application/vnd.hp-jlyt": { - source: "iana", - extensions: ["jlt"] - }, - "application/vnd.hp-pcl": { - source: "iana", - extensions: ["pcl"] - }, - "application/vnd.hp-pclxl": { - source: "iana", - extensions: ["pclxl"] - }, - "application/vnd.httphone": { - source: "iana" - }, - "application/vnd.hydrostatix.sof-data": { - source: "iana", - extensions: ["sfd-hdstx"] - }, - "application/vnd.hyper+json": { - source: "iana", - compressible: true - }, - "application/vnd.hyper-item+json": { - source: "iana", - compressible: true - }, - "application/vnd.hyperdrive+json": { - source: "iana", - compressible: true - }, - "application/vnd.hzn-3d-crossword": { - source: "iana" - }, - "application/vnd.ibm.afplinedata": { - source: "iana" - }, - "application/vnd.ibm.electronic-media": { - source: "iana" - }, - "application/vnd.ibm.minipay": { - source: "iana", - extensions: ["mpy"] - }, - "application/vnd.ibm.modcap": { - source: "iana", - extensions: ["afp", "listafp", "list3820"] - }, - "application/vnd.ibm.rights-management": { - source: "iana", - extensions: ["irm"] - }, - "application/vnd.ibm.secure-container": { - source: "iana", - extensions: ["sc"] - }, - "application/vnd.iccprofile": { - source: "iana", - extensions: ["icc", "icm"] - }, - "application/vnd.ieee.1905": { - source: "iana" - }, - "application/vnd.igloader": { - source: "iana", - extensions: ["igl"] - }, - "application/vnd.imagemeter.folder+zip": { - source: "iana", - compressible: false - }, - "application/vnd.imagemeter.image+zip": { - source: "iana", - compressible: false - }, - "application/vnd.immervision-ivp": { - source: "iana", - extensions: ["ivp"] - }, - "application/vnd.immervision-ivu": { - source: "iana", - extensions: ["ivu"] - }, - "application/vnd.ims.imsccv1p1": { - source: "iana" - }, - "application/vnd.ims.imsccv1p2": { - source: "iana" - }, - "application/vnd.ims.imsccv1p3": { - source: "iana" - }, - "application/vnd.ims.lis.v2.result+json": { - source: "iana", - compressible: true - }, - "application/vnd.ims.lti.v2.toolconsumerprofile+json": { - source: "iana", - compressible: true - }, - "application/vnd.ims.lti.v2.toolproxy+json": { - source: "iana", - compressible: true - }, - "application/vnd.ims.lti.v2.toolproxy.id+json": { - source: "iana", - compressible: true - }, - "application/vnd.ims.lti.v2.toolsettings+json": { - source: "iana", - compressible: true - }, - "application/vnd.ims.lti.v2.toolsettings.simple+json": { - source: "iana", - compressible: true - }, - "application/vnd.informedcontrol.rms+xml": { - source: "iana", - compressible: true - }, - "application/vnd.informix-visionary": { - source: "iana" - }, - "application/vnd.infotech.project": { - source: "iana" - }, - "application/vnd.infotech.project+xml": { - source: "iana", - compressible: true - }, - "application/vnd.innopath.wamp.notification": { - source: "iana" - }, - "application/vnd.insors.igm": { - source: "iana", - extensions: ["igm"] - }, - "application/vnd.intercon.formnet": { - source: "iana", - extensions: ["xpw", "xpx"] - }, - "application/vnd.intergeo": { - source: "iana", - extensions: ["i2g"] - }, - "application/vnd.intertrust.digibox": { - source: "iana" - }, - "application/vnd.intertrust.nncp": { - source: "iana" - }, - "application/vnd.intu.qbo": { - source: "iana", - extensions: ["qbo"] - }, - "application/vnd.intu.qfx": { - source: "iana", - extensions: ["qfx"] - }, - "application/vnd.iptc.g2.catalogitem+xml": { - source: "iana", - compressible: true - }, - "application/vnd.iptc.g2.conceptitem+xml": { - source: "iana", - compressible: true - }, - "application/vnd.iptc.g2.knowledgeitem+xml": { - source: "iana", - compressible: true - }, - "application/vnd.iptc.g2.newsitem+xml": { - source: "iana", - compressible: true - }, - "application/vnd.iptc.g2.newsmessage+xml": { - source: "iana", - compressible: true - }, - "application/vnd.iptc.g2.packageitem+xml": { - source: "iana", - compressible: true - }, - "application/vnd.iptc.g2.planningitem+xml": { - source: "iana", - compressible: true - }, - "application/vnd.ipunplugged.rcprofile": { - source: "iana", - extensions: ["rcprofile"] - }, - "application/vnd.irepository.package+xml": { - source: "iana", - compressible: true, - extensions: ["irp"] - }, - "application/vnd.is-xpr": { - source: "iana", - extensions: ["xpr"] - }, - "application/vnd.isac.fcs": { - source: "iana", - extensions: ["fcs"] - }, - "application/vnd.iso11783-10+zip": { - source: "iana", - compressible: false - }, - "application/vnd.jam": { - source: "iana", - extensions: ["jam"] - }, - "application/vnd.japannet-directory-service": { - source: "iana" - }, - "application/vnd.japannet-jpnstore-wakeup": { - source: "iana" - }, - "application/vnd.japannet-payment-wakeup": { - source: "iana" - }, - "application/vnd.japannet-registration": { - source: "iana" - }, - "application/vnd.japannet-registration-wakeup": { - source: "iana" - }, - "application/vnd.japannet-setstore-wakeup": { - source: "iana" - }, - "application/vnd.japannet-verification": { - source: "iana" - }, - "application/vnd.japannet-verification-wakeup": { - source: "iana" - }, - "application/vnd.jcp.javame.midlet-rms": { - source: "iana", - extensions: ["rms"] - }, - "application/vnd.jisp": { - source: "iana", - extensions: ["jisp"] - }, - "application/vnd.joost.joda-archive": { - source: "iana", - extensions: ["joda"] - }, - "application/vnd.jsk.isdn-ngn": { - source: "iana" - }, - "application/vnd.kahootz": { - source: "iana", - extensions: ["ktz", "ktr"] - }, - "application/vnd.kde.karbon": { - source: "iana", - extensions: ["karbon"] - }, - "application/vnd.kde.kchart": { - source: "iana", - extensions: ["chrt"] - }, - "application/vnd.kde.kformula": { - source: "iana", - extensions: ["kfo"] - }, - "application/vnd.kde.kivio": { - source: "iana", - extensions: ["flw"] - }, - "application/vnd.kde.kontour": { - source: "iana", - extensions: ["kon"] - }, - "application/vnd.kde.kpresenter": { - source: "iana", - extensions: ["kpr", "kpt"] - }, - "application/vnd.kde.kspread": { - source: "iana", - extensions: ["ksp"] - }, - "application/vnd.kde.kword": { - source: "iana", - extensions: ["kwd", "kwt"] - }, - "application/vnd.kenameaapp": { - source: "iana", - extensions: ["htke"] - }, - "application/vnd.kidspiration": { - source: "iana", - extensions: ["kia"] - }, - "application/vnd.kinar": { - source: "iana", - extensions: ["kne", "knp"] - }, - "application/vnd.koan": { - source: "iana", - extensions: ["skp", "skd", "skt", "skm"] - }, - "application/vnd.kodak-descriptor": { - source: "iana", - extensions: ["sse"] - }, - "application/vnd.las": { - source: "iana" - }, - "application/vnd.las.las+json": { - source: "iana", - compressible: true - }, - "application/vnd.las.las+xml": { - source: "iana", - compressible: true, - extensions: ["lasxml"] - }, - "application/vnd.laszip": { - source: "iana" - }, - "application/vnd.leap+json": { - source: "iana", - compressible: true - }, - "application/vnd.liberty-request+xml": { - source: "iana", - compressible: true - }, - "application/vnd.llamagraphics.life-balance.desktop": { - source: "iana", - extensions: ["lbd"] - }, - "application/vnd.llamagraphics.life-balance.exchange+xml": { - source: "iana", - compressible: true, - extensions: ["lbe"] - }, - "application/vnd.logipipe.circuit+zip": { - source: "iana", - compressible: false - }, - "application/vnd.loom": { - source: "iana" - }, - "application/vnd.lotus-1-2-3": { - source: "iana", - extensions: ["123"] - }, - "application/vnd.lotus-approach": { - source: "iana", - extensions: ["apr"] - }, - "application/vnd.lotus-freelance": { - source: "iana", - extensions: ["pre"] - }, - "application/vnd.lotus-notes": { - source: "iana", - extensions: ["nsf"] - }, - "application/vnd.lotus-organizer": { - source: "iana", - extensions: ["org"] - }, - "application/vnd.lotus-screencam": { - source: "iana", - extensions: ["scm"] - }, - "application/vnd.lotus-wordpro": { - source: "iana", - extensions: ["lwp"] - }, - "application/vnd.macports.portpkg": { - source: "iana", - extensions: ["portpkg"] - }, - "application/vnd.mapbox-vector-tile": { - source: "iana", - extensions: ["mvt"] - }, - "application/vnd.marlin.drm.actiontoken+xml": { - source: "iana", - compressible: true - }, - "application/vnd.marlin.drm.conftoken+xml": { - source: "iana", - compressible: true - }, - "application/vnd.marlin.drm.license+xml": { - source: "iana", - compressible: true - }, - "application/vnd.marlin.drm.mdcf": { - source: "iana" - }, - "application/vnd.mason+json": { - source: "iana", - compressible: true - }, - "application/vnd.maxar.archive.3tz+zip": { - source: "iana", - compressible: false - }, - "application/vnd.maxmind.maxmind-db": { - source: "iana" - }, - "application/vnd.mcd": { - source: "iana", - extensions: ["mcd"] - }, - "application/vnd.medcalcdata": { - source: "iana", - extensions: ["mc1"] - }, - "application/vnd.mediastation.cdkey": { - source: "iana", - extensions: ["cdkey"] - }, - "application/vnd.meridian-slingshot": { - source: "iana" - }, - "application/vnd.mfer": { - source: "iana", - extensions: ["mwf"] - }, - "application/vnd.mfmp": { - source: "iana", - extensions: ["mfm"] - }, - "application/vnd.micro+json": { - source: "iana", - compressible: true - }, - "application/vnd.micrografx.flo": { - source: "iana", - extensions: ["flo"] - }, - "application/vnd.micrografx.igx": { - source: "iana", - extensions: ["igx"] - }, - "application/vnd.microsoft.portable-executable": { - source: "iana" - }, - "application/vnd.microsoft.windows.thumbnail-cache": { - source: "iana" - }, - "application/vnd.miele+json": { - source: "iana", - compressible: true - }, - "application/vnd.mif": { - source: "iana", - extensions: ["mif"] - }, - "application/vnd.minisoft-hp3000-save": { - source: "iana" - }, - "application/vnd.mitsubishi.misty-guard.trustweb": { - source: "iana" - }, - "application/vnd.mobius.daf": { - source: "iana", - extensions: ["daf"] - }, - "application/vnd.mobius.dis": { - source: "iana", - extensions: ["dis"] - }, - "application/vnd.mobius.mbk": { - source: "iana", - extensions: ["mbk"] - }, - "application/vnd.mobius.mqy": { - source: "iana", - extensions: ["mqy"] - }, - "application/vnd.mobius.msl": { - source: "iana", - extensions: ["msl"] - }, - "application/vnd.mobius.plc": { - source: "iana", - extensions: ["plc"] - }, - "application/vnd.mobius.txf": { - source: "iana", - extensions: ["txf"] - }, - "application/vnd.mophun.application": { - source: "iana", - extensions: ["mpn"] - }, - "application/vnd.mophun.certificate": { - source: "iana", - extensions: ["mpc"] - }, - "application/vnd.motorola.flexsuite": { - source: "iana" - }, - "application/vnd.motorola.flexsuite.adsi": { - source: "iana" - }, - "application/vnd.motorola.flexsuite.fis": { - source: "iana" - }, - "application/vnd.motorola.flexsuite.gotap": { - source: "iana" - }, - "application/vnd.motorola.flexsuite.kmr": { - source: "iana" - }, - "application/vnd.motorola.flexsuite.ttc": { - source: "iana" - }, - "application/vnd.motorola.flexsuite.wem": { - source: "iana" - }, - "application/vnd.motorola.iprm": { - source: "iana" - }, - "application/vnd.mozilla.xul+xml": { - source: "iana", - compressible: true, - extensions: ["xul"] - }, - "application/vnd.ms-3mfdocument": { - source: "iana" - }, - "application/vnd.ms-artgalry": { - source: "iana", - extensions: ["cil"] - }, - "application/vnd.ms-asf": { - source: "iana" - }, - "application/vnd.ms-cab-compressed": { - source: "iana", - extensions: ["cab"] - }, - "application/vnd.ms-color.iccprofile": { - source: "apache" - }, - "application/vnd.ms-excel": { - source: "iana", - compressible: false, - extensions: ["xls", "xlm", "xla", "xlc", "xlt", "xlw"] - }, - "application/vnd.ms-excel.addin.macroenabled.12": { - source: "iana", - extensions: ["xlam"] - }, - "application/vnd.ms-excel.sheet.binary.macroenabled.12": { - source: "iana", - extensions: ["xlsb"] - }, - "application/vnd.ms-excel.sheet.macroenabled.12": { - source: "iana", - extensions: ["xlsm"] - }, - "application/vnd.ms-excel.template.macroenabled.12": { - source: "iana", - extensions: ["xltm"] - }, - "application/vnd.ms-fontobject": { - source: "iana", - compressible: true, - extensions: ["eot"] - }, - "application/vnd.ms-htmlhelp": { - source: "iana", - extensions: ["chm"] - }, - "application/vnd.ms-ims": { - source: "iana", - extensions: ["ims"] - }, - "application/vnd.ms-lrm": { - source: "iana", - extensions: ["lrm"] - }, - "application/vnd.ms-office.activex+xml": { - source: "iana", - compressible: true - }, - "application/vnd.ms-officetheme": { - source: "iana", - extensions: ["thmx"] - }, - "application/vnd.ms-opentype": { - source: "apache", - compressible: true - }, - "application/vnd.ms-outlook": { - compressible: false, - extensions: ["msg"] - }, - "application/vnd.ms-package.obfuscated-opentype": { - source: "apache" - }, - "application/vnd.ms-pki.seccat": { - source: "apache", - extensions: ["cat"] - }, - "application/vnd.ms-pki.stl": { - source: "apache", - extensions: ["stl"] - }, - "application/vnd.ms-playready.initiator+xml": { - source: "iana", - compressible: true - }, - "application/vnd.ms-powerpoint": { - source: "iana", - compressible: false, - extensions: ["ppt", "pps", "pot"] - }, - "application/vnd.ms-powerpoint.addin.macroenabled.12": { - source: "iana", - extensions: ["ppam"] - }, - "application/vnd.ms-powerpoint.presentation.macroenabled.12": { - source: "iana", - extensions: ["pptm"] - }, - "application/vnd.ms-powerpoint.slide.macroenabled.12": { - source: "iana", - extensions: ["sldm"] - }, - "application/vnd.ms-powerpoint.slideshow.macroenabled.12": { - source: "iana", - extensions: ["ppsm"] - }, - "application/vnd.ms-powerpoint.template.macroenabled.12": { - source: "iana", - extensions: ["potm"] - }, - "application/vnd.ms-printdevicecapabilities+xml": { - source: "iana", - compressible: true - }, - "application/vnd.ms-printing.printticket+xml": { - source: "apache", - compressible: true - }, - "application/vnd.ms-printschematicket+xml": { - source: "iana", - compressible: true - }, - "application/vnd.ms-project": { - source: "iana", - extensions: ["mpp", "mpt"] - }, - "application/vnd.ms-tnef": { - source: "iana" - }, - "application/vnd.ms-windows.devicepairing": { - source: "iana" - }, - "application/vnd.ms-windows.nwprinting.oob": { - source: "iana" - }, - "application/vnd.ms-windows.printerpairing": { - source: "iana" - }, - "application/vnd.ms-windows.wsd.oob": { - source: "iana" - }, - "application/vnd.ms-wmdrm.lic-chlg-req": { - source: "iana" - }, - "application/vnd.ms-wmdrm.lic-resp": { - source: "iana" - }, - "application/vnd.ms-wmdrm.meter-chlg-req": { - source: "iana" - }, - "application/vnd.ms-wmdrm.meter-resp": { - source: "iana" - }, - "application/vnd.ms-word.document.macroenabled.12": { - source: "iana", - extensions: ["docm"] - }, - "application/vnd.ms-word.template.macroenabled.12": { - source: "iana", - extensions: ["dotm"] - }, - "application/vnd.ms-works": { - source: "iana", - extensions: ["wps", "wks", "wcm", "wdb"] - }, - "application/vnd.ms-wpl": { - source: "iana", - extensions: ["wpl"] - }, - "application/vnd.ms-xpsdocument": { - source: "iana", - compressible: false, - extensions: ["xps"] - }, - "application/vnd.msa-disk-image": { - source: "iana" - }, - "application/vnd.mseq": { - source: "iana", - extensions: ["mseq"] - }, - "application/vnd.msign": { - source: "iana" - }, - "application/vnd.multiad.creator": { - source: "iana" - }, - "application/vnd.multiad.creator.cif": { - source: "iana" - }, - "application/vnd.music-niff": { - source: "iana" - }, - "application/vnd.musician": { - source: "iana", - extensions: ["mus"] - }, - "application/vnd.muvee.style": { - source: "iana", - extensions: ["msty"] - }, - "application/vnd.mynfc": { - source: "iana", - extensions: ["taglet"] - }, - "application/vnd.nacamar.ybrid+json": { - source: "iana", - compressible: true - }, - "application/vnd.ncd.control": { - source: "iana" - }, - "application/vnd.ncd.reference": { - source: "iana" - }, - "application/vnd.nearst.inv+json": { - source: "iana", - compressible: true - }, - "application/vnd.nebumind.line": { - source: "iana" - }, - "application/vnd.nervana": { - source: "iana" - }, - "application/vnd.netfpx": { - source: "iana" - }, - "application/vnd.neurolanguage.nlu": { - source: "iana", - extensions: ["nlu"] - }, - "application/vnd.nimn": { - source: "iana" - }, - "application/vnd.nintendo.nitro.rom": { - source: "iana" - }, - "application/vnd.nintendo.snes.rom": { - source: "iana" - }, - "application/vnd.nitf": { - source: "iana", - extensions: ["ntf", "nitf"] - }, - "application/vnd.noblenet-directory": { - source: "iana", - extensions: ["nnd"] - }, - "application/vnd.noblenet-sealer": { - source: "iana", - extensions: ["nns"] - }, - "application/vnd.noblenet-web": { - source: "iana", - extensions: ["nnw"] - }, - "application/vnd.nokia.catalogs": { - source: "iana" - }, - "application/vnd.nokia.conml+wbxml": { - source: "iana" - }, - "application/vnd.nokia.conml+xml": { - source: "iana", - compressible: true - }, - "application/vnd.nokia.iptv.config+xml": { - source: "iana", - compressible: true - }, - "application/vnd.nokia.isds-radio-presets": { - source: "iana" - }, - "application/vnd.nokia.landmark+wbxml": { - source: "iana" - }, - "application/vnd.nokia.landmark+xml": { - source: "iana", - compressible: true - }, - "application/vnd.nokia.landmarkcollection+xml": { - source: "iana", - compressible: true - }, - "application/vnd.nokia.n-gage.ac+xml": { - source: "iana", - compressible: true, - extensions: ["ac"] - }, - "application/vnd.nokia.n-gage.data": { - source: "iana", - extensions: ["ngdat"] - }, - "application/vnd.nokia.n-gage.symbian.install": { - source: "iana", - extensions: ["n-gage"] - }, - "application/vnd.nokia.ncd": { - source: "iana" - }, - "application/vnd.nokia.pcd+wbxml": { - source: "iana" - }, - "application/vnd.nokia.pcd+xml": { - source: "iana", - compressible: true - }, - "application/vnd.nokia.radio-preset": { - source: "iana", - extensions: ["rpst"] - }, - "application/vnd.nokia.radio-presets": { - source: "iana", - extensions: ["rpss"] - }, - "application/vnd.novadigm.edm": { - source: "iana", - extensions: ["edm"] - }, - "application/vnd.novadigm.edx": { - source: "iana", - extensions: ["edx"] - }, - "application/vnd.novadigm.ext": { - source: "iana", - extensions: ["ext"] - }, - "application/vnd.ntt-local.content-share": { - source: "iana" - }, - "application/vnd.ntt-local.file-transfer": { - source: "iana" - }, - "application/vnd.ntt-local.ogw_remote-access": { - source: "iana" - }, - "application/vnd.ntt-local.sip-ta_remote": { - source: "iana" - }, - "application/vnd.ntt-local.sip-ta_tcp_stream": { - source: "iana" - }, - "application/vnd.oasis.opendocument.chart": { - source: "iana", - extensions: ["odc"] - }, - "application/vnd.oasis.opendocument.chart-template": { - source: "iana", - extensions: ["otc"] - }, - "application/vnd.oasis.opendocument.database": { - source: "iana", - extensions: ["odb"] - }, - "application/vnd.oasis.opendocument.formula": { - source: "iana", - extensions: ["odf"] - }, - "application/vnd.oasis.opendocument.formula-template": { - source: "iana", - extensions: ["odft"] - }, - "application/vnd.oasis.opendocument.graphics": { - source: "iana", - compressible: false, - extensions: ["odg"] - }, - "application/vnd.oasis.opendocument.graphics-template": { - source: "iana", - extensions: ["otg"] - }, - "application/vnd.oasis.opendocument.image": { - source: "iana", - extensions: ["odi"] - }, - "application/vnd.oasis.opendocument.image-template": { - source: "iana", - extensions: ["oti"] - }, - "application/vnd.oasis.opendocument.presentation": { - source: "iana", - compressible: false, - extensions: ["odp"] - }, - "application/vnd.oasis.opendocument.presentation-template": { - source: "iana", - extensions: ["otp"] - }, - "application/vnd.oasis.opendocument.spreadsheet": { - source: "iana", - compressible: false, - extensions: ["ods"] - }, - "application/vnd.oasis.opendocument.spreadsheet-template": { - source: "iana", - extensions: ["ots"] - }, - "application/vnd.oasis.opendocument.text": { - source: "iana", - compressible: false, - extensions: ["odt"] - }, - "application/vnd.oasis.opendocument.text-master": { - source: "iana", - extensions: ["odm"] - }, - "application/vnd.oasis.opendocument.text-template": { - source: "iana", - extensions: ["ott"] - }, - "application/vnd.oasis.opendocument.text-web": { - source: "iana", - extensions: ["oth"] - }, - "application/vnd.obn": { - source: "iana" - }, - "application/vnd.ocf+cbor": { - source: "iana" - }, - "application/vnd.oci.image.manifest.v1+json": { - source: "iana", - compressible: true - }, - "application/vnd.oftn.l10n+json": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.contentaccessdownload+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.contentaccessstreaming+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.cspg-hexbinary": { - source: "iana" - }, - "application/vnd.oipf.dae.svg+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.dae.xhtml+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.mippvcontrolmessage+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.pae.gem": { - source: "iana" - }, - "application/vnd.oipf.spdiscovery+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.spdlist+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.ueprofile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oipf.userprofile+xml": { - source: "iana", - compressible: true - }, - "application/vnd.olpc-sugar": { - source: "iana", - extensions: ["xo"] - }, - "application/vnd.oma-scws-config": { - source: "iana" - }, - "application/vnd.oma-scws-http-request": { - source: "iana" - }, - "application/vnd.oma-scws-http-response": { - source: "iana" - }, - "application/vnd.oma.bcast.associated-procedure-parameter+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.drm-trigger+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.imd+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.ltkm": { - source: "iana" - }, - "application/vnd.oma.bcast.notification+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.provisioningtrigger": { - source: "iana" - }, - "application/vnd.oma.bcast.sgboot": { - source: "iana" - }, - "application/vnd.oma.bcast.sgdd+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.sgdu": { - source: "iana" - }, - "application/vnd.oma.bcast.simple-symbol-container": { - source: "iana" - }, - "application/vnd.oma.bcast.smartcard-trigger+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.sprov+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.bcast.stkm": { - source: "iana" - }, - "application/vnd.oma.cab-address-book+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.cab-feature-handler+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.cab-pcc+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.cab-subs-invite+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.cab-user-prefs+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.dcd": { - source: "iana" - }, - "application/vnd.oma.dcdc": { - source: "iana" - }, - "application/vnd.oma.dd2+xml": { - source: "iana", - compressible: true, - extensions: ["dd2"] - }, - "application/vnd.oma.drm.risd+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.group-usage-list+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.lwm2m+cbor": { - source: "iana" - }, - "application/vnd.oma.lwm2m+json": { - source: "iana", - compressible: true - }, - "application/vnd.oma.lwm2m+tlv": { - source: "iana" - }, - "application/vnd.oma.pal+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.poc.detailed-progress-report+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.poc.final-report+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.poc.groups+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.poc.invocation-descriptor+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.poc.optimized-progress-report+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.push": { - source: "iana" - }, - "application/vnd.oma.scidm.messages+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oma.xcap-directory+xml": { - source: "iana", - compressible: true - }, - "application/vnd.omads-email+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/vnd.omads-file+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/vnd.omads-folder+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/vnd.omaloc-supl-init": { - source: "iana" - }, - "application/vnd.onepager": { - source: "iana" - }, - "application/vnd.onepagertamp": { - source: "iana" - }, - "application/vnd.onepagertamx": { - source: "iana" - }, - "application/vnd.onepagertat": { - source: "iana" - }, - "application/vnd.onepagertatp": { - source: "iana" - }, - "application/vnd.onepagertatx": { - source: "iana" - }, - "application/vnd.openblox.game+xml": { - source: "iana", - compressible: true, - extensions: ["obgx"] - }, - "application/vnd.openblox.game-binary": { - source: "iana" - }, - "application/vnd.openeye.oeb": { - source: "iana" - }, - "application/vnd.openofficeorg.extension": { - source: "apache", - extensions: ["oxt"] - }, - "application/vnd.openstreetmap.data+xml": { - source: "iana", - compressible: true, - extensions: ["osm"] - }, - "application/vnd.opentimestamps.ots": { - source: "iana" - }, - "application/vnd.openxmlformats-officedocument.custom-properties+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.customxmlproperties+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawing+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawingml.chart+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.extended-properties+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.comments+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.presentation": { - source: "iana", - compressible: false, - extensions: ["pptx"] - }, - "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.presprops+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.slide": { - source: "iana", - extensions: ["sldx"] - }, - "application/vnd.openxmlformats-officedocument.presentationml.slide+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.slideshow": { - source: "iana", - extensions: ["ppsx"] - }, - "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.tags+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.template": { - source: "iana", - extensions: ["potx"] - }, - "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { - source: "iana", - compressible: false, - extensions: ["xlsx"] - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.template": { - source: "iana", - extensions: ["xltx"] - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.theme+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.themeoverride+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.vmldrawing": { - source: "iana" - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - source: "iana", - compressible: false, - extensions: ["docx"] - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.template": { - source: "iana", - extensions: ["dotx"] - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-package.core-properties+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml": { - source: "iana", - compressible: true - }, - "application/vnd.openxmlformats-package.relationships+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oracle.resource+json": { - source: "iana", - compressible: true - }, - "application/vnd.orange.indata": { - source: "iana" - }, - "application/vnd.osa.netdeploy": { - source: "iana" - }, - "application/vnd.osgeo.mapguide.package": { - source: "iana", - extensions: ["mgp"] - }, - "application/vnd.osgi.bundle": { - source: "iana" - }, - "application/vnd.osgi.dp": { - source: "iana", - extensions: ["dp"] - }, - "application/vnd.osgi.subsystem": { - source: "iana", - extensions: ["esa"] - }, - "application/vnd.otps.ct-kip+xml": { - source: "iana", - compressible: true - }, - "application/vnd.oxli.countgraph": { - source: "iana" - }, - "application/vnd.pagerduty+json": { - source: "iana", - compressible: true - }, - "application/vnd.palm": { - source: "iana", - extensions: ["pdb", "pqa", "oprc"] - }, - "application/vnd.panoply": { - source: "iana" - }, - "application/vnd.paos.xml": { - source: "iana" - }, - "application/vnd.patentdive": { - source: "iana" - }, - "application/vnd.patientecommsdoc": { - source: "iana" - }, - "application/vnd.pawaafile": { - source: "iana", - extensions: ["paw"] - }, - "application/vnd.pcos": { - source: "iana" - }, - "application/vnd.pg.format": { - source: "iana", - extensions: ["str"] - }, - "application/vnd.pg.osasli": { - source: "iana", - extensions: ["ei6"] - }, - "application/vnd.piaccess.application-licence": { - source: "iana" - }, - "application/vnd.picsel": { - source: "iana", - extensions: ["efif"] - }, - "application/vnd.pmi.widget": { - source: "iana", - extensions: ["wg"] - }, - "application/vnd.poc.group-advertisement+xml": { - source: "iana", - compressible: true - }, - "application/vnd.pocketlearn": { - source: "iana", - extensions: ["plf"] - }, - "application/vnd.powerbuilder6": { - source: "iana", - extensions: ["pbd"] - }, - "application/vnd.powerbuilder6-s": { - source: "iana" - }, - "application/vnd.powerbuilder7": { - source: "iana" - }, - "application/vnd.powerbuilder7-s": { - source: "iana" - }, - "application/vnd.powerbuilder75": { - source: "iana" - }, - "application/vnd.powerbuilder75-s": { - source: "iana" - }, - "application/vnd.preminet": { - source: "iana" - }, - "application/vnd.previewsystems.box": { - source: "iana", - extensions: ["box"] - }, - "application/vnd.proteus.magazine": { - source: "iana", - extensions: ["mgz"] - }, - "application/vnd.psfs": { - source: "iana" - }, - "application/vnd.publishare-delta-tree": { - source: "iana", - extensions: ["qps"] - }, - "application/vnd.pvi.ptid1": { - source: "iana", - extensions: ["ptid"] - }, - "application/vnd.pwg-multiplexed": { - source: "iana" - }, - "application/vnd.pwg-xhtml-print+xml": { - source: "iana", - compressible: true - }, - "application/vnd.qualcomm.brew-app-res": { - source: "iana" - }, - "application/vnd.quarantainenet": { - source: "iana" - }, - "application/vnd.quark.quarkxpress": { - source: "iana", - extensions: ["qxd", "qxt", "qwd", "qwt", "qxl", "qxb"] - }, - "application/vnd.quobject-quoxdocument": { - source: "iana" - }, - "application/vnd.radisys.moml+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-audit+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-audit-conf+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-audit-conn+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-audit-dialog+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-audit-stream+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-conf+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog-base+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog-fax-detect+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog-fax-sendrecv+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog-group+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog-speech+xml": { - source: "iana", - compressible: true - }, - "application/vnd.radisys.msml-dialog-transform+xml": { - source: "iana", - compressible: true - }, - "application/vnd.rainstor.data": { - source: "iana" - }, - "application/vnd.rapid": { - source: "iana" - }, - "application/vnd.rar": { - source: "iana", - extensions: ["rar"] - }, - "application/vnd.realvnc.bed": { - source: "iana", - extensions: ["bed"] - }, - "application/vnd.recordare.musicxml": { - source: "iana", - extensions: ["mxl"] - }, - "application/vnd.recordare.musicxml+xml": { - source: "iana", - compressible: true, - extensions: ["musicxml"] - }, - "application/vnd.renlearn.rlprint": { - source: "iana" - }, - "application/vnd.resilient.logic": { - source: "iana" - }, - "application/vnd.restful+json": { - source: "iana", - compressible: true - }, - "application/vnd.rig.cryptonote": { - source: "iana", - extensions: ["cryptonote"] - }, - "application/vnd.rim.cod": { - source: "apache", - extensions: ["cod"] - }, - "application/vnd.rn-realmedia": { - source: "apache", - extensions: ["rm"] - }, - "application/vnd.rn-realmedia-vbr": { - source: "apache", - extensions: ["rmvb"] - }, - "application/vnd.route66.link66+xml": { - source: "iana", - compressible: true, - extensions: ["link66"] - }, - "application/vnd.rs-274x": { - source: "iana" - }, - "application/vnd.ruckus.download": { - source: "iana" - }, - "application/vnd.s3sms": { - source: "iana" - }, - "application/vnd.sailingtracker.track": { - source: "iana", - extensions: ["st"] - }, - "application/vnd.sar": { - source: "iana" - }, - "application/vnd.sbm.cid": { - source: "iana" - }, - "application/vnd.sbm.mid2": { - source: "iana" - }, - "application/vnd.scribus": { - source: "iana" - }, - "application/vnd.sealed.3df": { - source: "iana" - }, - "application/vnd.sealed.csf": { - source: "iana" - }, - "application/vnd.sealed.doc": { - source: "iana" - }, - "application/vnd.sealed.eml": { - source: "iana" - }, - "application/vnd.sealed.mht": { - source: "iana" - }, - "application/vnd.sealed.net": { - source: "iana" - }, - "application/vnd.sealed.ppt": { - source: "iana" - }, - "application/vnd.sealed.tiff": { - source: "iana" - }, - "application/vnd.sealed.xls": { - source: "iana" - }, - "application/vnd.sealedmedia.softseal.html": { - source: "iana" - }, - "application/vnd.sealedmedia.softseal.pdf": { - source: "iana" - }, - "application/vnd.seemail": { - source: "iana", - extensions: ["see"] - }, - "application/vnd.seis+json": { - source: "iana", - compressible: true - }, - "application/vnd.sema": { - source: "iana", - extensions: ["sema"] - }, - "application/vnd.semd": { - source: "iana", - extensions: ["semd"] - }, - "application/vnd.semf": { - source: "iana", - extensions: ["semf"] - }, - "application/vnd.shade-save-file": { - source: "iana" - }, - "application/vnd.shana.informed.formdata": { - source: "iana", - extensions: ["ifm"] - }, - "application/vnd.shana.informed.formtemplate": { - source: "iana", - extensions: ["itp"] - }, - "application/vnd.shana.informed.interchange": { - source: "iana", - extensions: ["iif"] - }, - "application/vnd.shana.informed.package": { - source: "iana", - extensions: ["ipk"] - }, - "application/vnd.shootproof+json": { - source: "iana", - compressible: true - }, - "application/vnd.shopkick+json": { - source: "iana", - compressible: true - }, - "application/vnd.shp": { - source: "iana" - }, - "application/vnd.shx": { - source: "iana" - }, - "application/vnd.sigrok.session": { - source: "iana" - }, - "application/vnd.simtech-mindmapper": { - source: "iana", - extensions: ["twd", "twds"] - }, - "application/vnd.siren+json": { - source: "iana", - compressible: true - }, - "application/vnd.smaf": { - source: "iana", - extensions: ["mmf"] - }, - "application/vnd.smart.notebook": { - source: "iana" - }, - "application/vnd.smart.teacher": { - source: "iana", - extensions: ["teacher"] - }, - "application/vnd.snesdev-page-table": { - source: "iana" - }, - "application/vnd.software602.filler.form+xml": { - source: "iana", - compressible: true, - extensions: ["fo"] - }, - "application/vnd.software602.filler.form-xml-zip": { - source: "iana" - }, - "application/vnd.solent.sdkm+xml": { - source: "iana", - compressible: true, - extensions: ["sdkm", "sdkd"] - }, - "application/vnd.spotfire.dxp": { - source: "iana", - extensions: ["dxp"] - }, - "application/vnd.spotfire.sfs": { - source: "iana", - extensions: ["sfs"] - }, - "application/vnd.sqlite3": { - source: "iana" - }, - "application/vnd.sss-cod": { - source: "iana" - }, - "application/vnd.sss-dtf": { - source: "iana" - }, - "application/vnd.sss-ntf": { - source: "iana" - }, - "application/vnd.stardivision.calc": { - source: "apache", - extensions: ["sdc"] - }, - "application/vnd.stardivision.draw": { - source: "apache", - extensions: ["sda"] - }, - "application/vnd.stardivision.impress": { - source: "apache", - extensions: ["sdd"] - }, - "application/vnd.stardivision.math": { - source: "apache", - extensions: ["smf"] - }, - "application/vnd.stardivision.writer": { - source: "apache", - extensions: ["sdw", "vor"] - }, - "application/vnd.stardivision.writer-global": { - source: "apache", - extensions: ["sgl"] - }, - "application/vnd.stepmania.package": { - source: "iana", - extensions: ["smzip"] - }, - "application/vnd.stepmania.stepchart": { - source: "iana", - extensions: ["sm"] - }, - "application/vnd.street-stream": { - source: "iana" - }, - "application/vnd.sun.wadl+xml": { - source: "iana", - compressible: true, - extensions: ["wadl"] - }, - "application/vnd.sun.xml.calc": { - source: "apache", - extensions: ["sxc"] - }, - "application/vnd.sun.xml.calc.template": { - source: "apache", - extensions: ["stc"] - }, - "application/vnd.sun.xml.draw": { - source: "apache", - extensions: ["sxd"] - }, - "application/vnd.sun.xml.draw.template": { - source: "apache", - extensions: ["std"] - }, - "application/vnd.sun.xml.impress": { - source: "apache", - extensions: ["sxi"] - }, - "application/vnd.sun.xml.impress.template": { - source: "apache", - extensions: ["sti"] - }, - "application/vnd.sun.xml.math": { - source: "apache", - extensions: ["sxm"] - }, - "application/vnd.sun.xml.writer": { - source: "apache", - extensions: ["sxw"] - }, - "application/vnd.sun.xml.writer.global": { - source: "apache", - extensions: ["sxg"] - }, - "application/vnd.sun.xml.writer.template": { - source: "apache", - extensions: ["stw"] - }, - "application/vnd.sus-calendar": { - source: "iana", - extensions: ["sus", "susp"] - }, - "application/vnd.svd": { - source: "iana", - extensions: ["svd"] - }, - "application/vnd.swiftview-ics": { - source: "iana" - }, - "application/vnd.sycle+xml": { - source: "iana", - compressible: true - }, - "application/vnd.syft+json": { - source: "iana", - compressible: true - }, - "application/vnd.symbian.install": { - source: "apache", - extensions: ["sis", "sisx"] - }, - "application/vnd.syncml+xml": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["xsm"] - }, - "application/vnd.syncml.dm+wbxml": { - source: "iana", - charset: "UTF-8", - extensions: ["bdm"] - }, - "application/vnd.syncml.dm+xml": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["xdm"] - }, - "application/vnd.syncml.dm.notification": { - source: "iana" - }, - "application/vnd.syncml.dmddf+wbxml": { - source: "iana" - }, - "application/vnd.syncml.dmddf+xml": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["ddf"] - }, - "application/vnd.syncml.dmtnds+wbxml": { - source: "iana" - }, - "application/vnd.syncml.dmtnds+xml": { - source: "iana", - charset: "UTF-8", - compressible: true - }, - "application/vnd.syncml.ds.notification": { - source: "iana" - }, - "application/vnd.tableschema+json": { - source: "iana", - compressible: true - }, - "application/vnd.tao.intent-module-archive": { - source: "iana", - extensions: ["tao"] - }, - "application/vnd.tcpdump.pcap": { - source: "iana", - extensions: ["pcap", "cap", "dmp"] - }, - "application/vnd.think-cell.ppttc+json": { - source: "iana", - compressible: true - }, - "application/vnd.tmd.mediaflex.api+xml": { - source: "iana", - compressible: true - }, - "application/vnd.tml": { - source: "iana" - }, - "application/vnd.tmobile-livetv": { - source: "iana", - extensions: ["tmo"] - }, - "application/vnd.tri.onesource": { - source: "iana" - }, - "application/vnd.trid.tpt": { - source: "iana", - extensions: ["tpt"] - }, - "application/vnd.triscape.mxs": { - source: "iana", - extensions: ["mxs"] - }, - "application/vnd.trueapp": { - source: "iana", - extensions: ["tra"] - }, - "application/vnd.truedoc": { - source: "iana" - }, - "application/vnd.ubisoft.webplayer": { - source: "iana" - }, - "application/vnd.ufdl": { - source: "iana", - extensions: ["ufd", "ufdl"] - }, - "application/vnd.uiq.theme": { - source: "iana", - extensions: ["utz"] - }, - "application/vnd.umajin": { - source: "iana", - extensions: ["umj"] - }, - "application/vnd.unity": { - source: "iana", - extensions: ["unityweb"] - }, - "application/vnd.uoml+xml": { - source: "iana", - compressible: true, - extensions: ["uoml"] - }, - "application/vnd.uplanet.alert": { - source: "iana" - }, - "application/vnd.uplanet.alert-wbxml": { - source: "iana" - }, - "application/vnd.uplanet.bearer-choice": { - source: "iana" - }, - "application/vnd.uplanet.bearer-choice-wbxml": { - source: "iana" - }, - "application/vnd.uplanet.cacheop": { - source: "iana" - }, - "application/vnd.uplanet.cacheop-wbxml": { - source: "iana" - }, - "application/vnd.uplanet.channel": { - source: "iana" - }, - "application/vnd.uplanet.channel-wbxml": { - source: "iana" - }, - "application/vnd.uplanet.list": { - source: "iana" - }, - "application/vnd.uplanet.list-wbxml": { - source: "iana" - }, - "application/vnd.uplanet.listcmd": { - source: "iana" - }, - "application/vnd.uplanet.listcmd-wbxml": { - source: "iana" - }, - "application/vnd.uplanet.signal": { - source: "iana" - }, - "application/vnd.uri-map": { - source: "iana" - }, - "application/vnd.valve.source.material": { - source: "iana" - }, - "application/vnd.vcx": { - source: "iana", - extensions: ["vcx"] - }, - "application/vnd.vd-study": { - source: "iana" - }, - "application/vnd.vectorworks": { - source: "iana" - }, - "application/vnd.vel+json": { - source: "iana", - compressible: true - }, - "application/vnd.verimatrix.vcas": { - source: "iana" - }, - "application/vnd.veritone.aion+json": { - source: "iana", - compressible: true - }, - "application/vnd.veryant.thin": { - source: "iana" - }, - "application/vnd.ves.encrypted": { - source: "iana" - }, - "application/vnd.vidsoft.vidconference": { - source: "iana" - }, - "application/vnd.visio": { - source: "iana", - extensions: ["vsd", "vst", "vss", "vsw"] - }, - "application/vnd.visionary": { - source: "iana", - extensions: ["vis"] - }, - "application/vnd.vividence.scriptfile": { - source: "iana" - }, - "application/vnd.vsf": { - source: "iana", - extensions: ["vsf"] - }, - "application/vnd.wap.sic": { - source: "iana" - }, - "application/vnd.wap.slc": { - source: "iana" - }, - "application/vnd.wap.wbxml": { - source: "iana", - charset: "UTF-8", - extensions: ["wbxml"] - }, - "application/vnd.wap.wmlc": { - source: "iana", - extensions: ["wmlc"] - }, - "application/vnd.wap.wmlscriptc": { - source: "iana", - extensions: ["wmlsc"] - }, - "application/vnd.webturbo": { - source: "iana", - extensions: ["wtb"] - }, - "application/vnd.wfa.dpp": { - source: "iana" - }, - "application/vnd.wfa.p2p": { - source: "iana" - }, - "application/vnd.wfa.wsc": { - source: "iana" - }, - "application/vnd.windows.devicepairing": { - source: "iana" - }, - "application/vnd.wmc": { - source: "iana" - }, - "application/vnd.wmf.bootstrap": { - source: "iana" - }, - "application/vnd.wolfram.mathematica": { - source: "iana" - }, - "application/vnd.wolfram.mathematica.package": { - source: "iana" - }, - "application/vnd.wolfram.player": { - source: "iana", - extensions: ["nbp"] - }, - "application/vnd.wordperfect": { - source: "iana", - extensions: ["wpd"] - }, - "application/vnd.wqd": { - source: "iana", - extensions: ["wqd"] - }, - "application/vnd.wrq-hp3000-labelled": { - source: "iana" - }, - "application/vnd.wt.stf": { - source: "iana", - extensions: ["stf"] - }, - "application/vnd.wv.csp+wbxml": { - source: "iana" - }, - "application/vnd.wv.csp+xml": { - source: "iana", - compressible: true - }, - "application/vnd.wv.ssp+xml": { - source: "iana", - compressible: true - }, - "application/vnd.xacml+json": { - source: "iana", - compressible: true - }, - "application/vnd.xara": { - source: "iana", - extensions: ["xar"] - }, - "application/vnd.xfdl": { - source: "iana", - extensions: ["xfdl"] - }, - "application/vnd.xfdl.webform": { - source: "iana" - }, - "application/vnd.xmi+xml": { - source: "iana", - compressible: true - }, - "application/vnd.xmpie.cpkg": { - source: "iana" - }, - "application/vnd.xmpie.dpkg": { - source: "iana" - }, - "application/vnd.xmpie.plan": { - source: "iana" - }, - "application/vnd.xmpie.ppkg": { - source: "iana" - }, - "application/vnd.xmpie.xlim": { - source: "iana" - }, - "application/vnd.yamaha.hv-dic": { - source: "iana", - extensions: ["hvd"] - }, - "application/vnd.yamaha.hv-script": { - source: "iana", - extensions: ["hvs"] - }, - "application/vnd.yamaha.hv-voice": { - source: "iana", - extensions: ["hvp"] - }, - "application/vnd.yamaha.openscoreformat": { - source: "iana", - extensions: ["osf"] - }, - "application/vnd.yamaha.openscoreformat.osfpvg+xml": { - source: "iana", - compressible: true, - extensions: ["osfpvg"] - }, - "application/vnd.yamaha.remote-setup": { - source: "iana" - }, - "application/vnd.yamaha.smaf-audio": { - source: "iana", - extensions: ["saf"] - }, - "application/vnd.yamaha.smaf-phrase": { - source: "iana", - extensions: ["spf"] - }, - "application/vnd.yamaha.through-ngn": { - source: "iana" - }, - "application/vnd.yamaha.tunnel-udpencap": { - source: "iana" - }, - "application/vnd.yaoweme": { - source: "iana" - }, - "application/vnd.yellowriver-custom-menu": { - source: "iana", - extensions: ["cmp"] - }, - "application/vnd.youtube.yt": { - source: "iana" - }, - "application/vnd.zul": { - source: "iana", - extensions: ["zir", "zirz"] - }, - "application/vnd.zzazz.deck+xml": { - source: "iana", - compressible: true, - extensions: ["zaz"] - }, - "application/voicexml+xml": { - source: "iana", - compressible: true, - extensions: ["vxml"] - }, - "application/voucher-cms+json": { - source: "iana", - compressible: true - }, - "application/vq-rtcpxr": { - source: "iana" - }, - "application/wasm": { - source: "iana", - compressible: true, - extensions: ["wasm"] - }, - "application/watcherinfo+xml": { - source: "iana", - compressible: true, - extensions: ["wif"] - }, - "application/webpush-options+json": { - source: "iana", - compressible: true - }, - "application/whoispp-query": { - source: "iana" - }, - "application/whoispp-response": { - source: "iana" - }, - "application/widget": { - source: "iana", - extensions: ["wgt"] - }, - "application/winhlp": { - source: "apache", - extensions: ["hlp"] - }, - "application/wita": { - source: "iana" - }, - "application/wordperfect5.1": { - source: "iana" - }, - "application/wsdl+xml": { - source: "iana", - compressible: true, - extensions: ["wsdl"] - }, - "application/wspolicy+xml": { - source: "iana", - compressible: true, - extensions: ["wspolicy"] - }, - "application/x-7z-compressed": { - source: "apache", - compressible: false, - extensions: ["7z"] - }, - "application/x-abiword": { - source: "apache", - extensions: ["abw"] - }, - "application/x-ace-compressed": { - source: "apache", - extensions: ["ace"] - }, - "application/x-amf": { - source: "apache" - }, - "application/x-apple-diskimage": { - source: "apache", - extensions: ["dmg"] - }, - "application/x-arj": { - compressible: false, - extensions: ["arj"] - }, - "application/x-authorware-bin": { - source: "apache", - extensions: ["aab", "x32", "u32", "vox"] - }, - "application/x-authorware-map": { - source: "apache", - extensions: ["aam"] - }, - "application/x-authorware-seg": { - source: "apache", - extensions: ["aas"] - }, - "application/x-bcpio": { - source: "apache", - extensions: ["bcpio"] - }, - "application/x-bdoc": { - compressible: false, - extensions: ["bdoc"] - }, - "application/x-bittorrent": { - source: "apache", - extensions: ["torrent"] - }, - "application/x-blorb": { - source: "apache", - extensions: ["blb", "blorb"] - }, - "application/x-bzip": { - source: "apache", - compressible: false, - extensions: ["bz"] - }, - "application/x-bzip2": { - source: "apache", - compressible: false, - extensions: ["bz2", "boz"] - }, - "application/x-cbr": { - source: "apache", - extensions: ["cbr", "cba", "cbt", "cbz", "cb7"] - }, - "application/x-cdlink": { - source: "apache", - extensions: ["vcd"] - }, - "application/x-cfs-compressed": { - source: "apache", - extensions: ["cfs"] - }, - "application/x-chat": { - source: "apache", - extensions: ["chat"] - }, - "application/x-chess-pgn": { - source: "apache", - extensions: ["pgn"] - }, - "application/x-chrome-extension": { - extensions: ["crx"] - }, - "application/x-cocoa": { - source: "nginx", - extensions: ["cco"] - }, - "application/x-compress": { - source: "apache" - }, - "application/x-conference": { - source: "apache", - extensions: ["nsc"] - }, - "application/x-cpio": { - source: "apache", - extensions: ["cpio"] - }, - "application/x-csh": { - source: "apache", - extensions: ["csh"] - }, - "application/x-deb": { - compressible: false - }, - "application/x-debian-package": { - source: "apache", - extensions: ["deb", "udeb"] - }, - "application/x-dgc-compressed": { - source: "apache", - extensions: ["dgc"] - }, - "application/x-director": { - source: "apache", - extensions: ["dir", "dcr", "dxr", "cst", "cct", "cxt", "w3d", "fgd", "swa"] - }, - "application/x-doom": { - source: "apache", - extensions: ["wad"] - }, - "application/x-dtbncx+xml": { - source: "apache", - compressible: true, - extensions: ["ncx"] - }, - "application/x-dtbook+xml": { - source: "apache", - compressible: true, - extensions: ["dtb"] - }, - "application/x-dtbresource+xml": { - source: "apache", - compressible: true, - extensions: ["res"] - }, - "application/x-dvi": { - source: "apache", - compressible: false, - extensions: ["dvi"] - }, - "application/x-envoy": { - source: "apache", - extensions: ["evy"] - }, - "application/x-eva": { - source: "apache", - extensions: ["eva"] - }, - "application/x-font-bdf": { - source: "apache", - extensions: ["bdf"] - }, - "application/x-font-dos": { - source: "apache" - }, - "application/x-font-framemaker": { - source: "apache" - }, - "application/x-font-ghostscript": { - source: "apache", - extensions: ["gsf"] - }, - "application/x-font-libgrx": { - source: "apache" - }, - "application/x-font-linux-psf": { - source: "apache", - extensions: ["psf"] - }, - "application/x-font-pcf": { - source: "apache", - extensions: ["pcf"] - }, - "application/x-font-snf": { - source: "apache", - extensions: ["snf"] - }, - "application/x-font-speedo": { - source: "apache" - }, - "application/x-font-sunos-news": { - source: "apache" - }, - "application/x-font-type1": { - source: "apache", - extensions: ["pfa", "pfb", "pfm", "afm"] - }, - "application/x-font-vfont": { - source: "apache" - }, - "application/x-freearc": { - source: "apache", - extensions: ["arc"] - }, - "application/x-futuresplash": { - source: "apache", - extensions: ["spl"] - }, - "application/x-gca-compressed": { - source: "apache", - extensions: ["gca"] - }, - "application/x-glulx": { - source: "apache", - extensions: ["ulx"] - }, - "application/x-gnumeric": { - source: "apache", - extensions: ["gnumeric"] - }, - "application/x-gramps-xml": { - source: "apache", - extensions: ["gramps"] - }, - "application/x-gtar": { - source: "apache", - extensions: ["gtar"] - }, - "application/x-gzip": { - source: "apache" - }, - "application/x-hdf": { - source: "apache", - extensions: ["hdf"] - }, - "application/x-httpd-php": { - compressible: true, - extensions: ["php"] - }, - "application/x-install-instructions": { - source: "apache", - extensions: ["install"] - }, - "application/x-iso9660-image": { - source: "apache", - extensions: ["iso"] - }, - "application/x-iwork-keynote-sffkey": { - extensions: ["key"] - }, - "application/x-iwork-numbers-sffnumbers": { - extensions: ["numbers"] - }, - "application/x-iwork-pages-sffpages": { - extensions: ["pages"] - }, - "application/x-java-archive-diff": { - source: "nginx", - extensions: ["jardiff"] - }, - "application/x-java-jnlp-file": { - source: "apache", - compressible: false, - extensions: ["jnlp"] - }, - "application/x-javascript": { - compressible: true - }, - "application/x-keepass2": { - extensions: ["kdbx"] - }, - "application/x-latex": { - source: "apache", - compressible: false, - extensions: ["latex"] - }, - "application/x-lua-bytecode": { - extensions: ["luac"] - }, - "application/x-lzh-compressed": { - source: "apache", - extensions: ["lzh", "lha"] - }, - "application/x-makeself": { - source: "nginx", - extensions: ["run"] - }, - "application/x-mie": { - source: "apache", - extensions: ["mie"] - }, - "application/x-mobipocket-ebook": { - source: "apache", - extensions: ["prc", "mobi"] - }, - "application/x-mpegurl": { - compressible: false - }, - "application/x-ms-application": { - source: "apache", - extensions: ["application"] - }, - "application/x-ms-shortcut": { - source: "apache", - extensions: ["lnk"] - }, - "application/x-ms-wmd": { - source: "apache", - extensions: ["wmd"] - }, - "application/x-ms-wmz": { - source: "apache", - extensions: ["wmz"] - }, - "application/x-ms-xbap": { - source: "apache", - extensions: ["xbap"] - }, - "application/x-msaccess": { - source: "apache", - extensions: ["mdb"] - }, - "application/x-msbinder": { - source: "apache", - extensions: ["obd"] - }, - "application/x-mscardfile": { - source: "apache", - extensions: ["crd"] - }, - "application/x-msclip": { - source: "apache", - extensions: ["clp"] - }, - "application/x-msdos-program": { - extensions: ["exe"] - }, - "application/x-msdownload": { - source: "apache", - extensions: ["exe", "dll", "com", "bat", "msi"] - }, - "application/x-msmediaview": { - source: "apache", - extensions: ["mvb", "m13", "m14"] - }, - "application/x-msmetafile": { - source: "apache", - extensions: ["wmf", "wmz", "emf", "emz"] - }, - "application/x-msmoney": { - source: "apache", - extensions: ["mny"] - }, - "application/x-mspublisher": { - source: "apache", - extensions: ["pub"] - }, - "application/x-msschedule": { - source: "apache", - extensions: ["scd"] - }, - "application/x-msterminal": { - source: "apache", - extensions: ["trm"] - }, - "application/x-mswrite": { - source: "apache", - extensions: ["wri"] - }, - "application/x-netcdf": { - source: "apache", - extensions: ["nc", "cdf"] - }, - "application/x-ns-proxy-autoconfig": { - compressible: true, - extensions: ["pac"] - }, - "application/x-nzb": { - source: "apache", - extensions: ["nzb"] - }, - "application/x-perl": { - source: "nginx", - extensions: ["pl", "pm"] - }, - "application/x-pilot": { - source: "nginx", - extensions: ["prc", "pdb"] - }, - "application/x-pkcs12": { - source: "apache", - compressible: false, - extensions: ["p12", "pfx"] - }, - "application/x-pkcs7-certificates": { - source: "apache", - extensions: ["p7b", "spc"] - }, - "application/x-pkcs7-certreqresp": { - source: "apache", - extensions: ["p7r"] - }, - "application/x-pki-message": { - source: "iana" - }, - "application/x-rar-compressed": { - source: "apache", - compressible: false, - extensions: ["rar"] - }, - "application/x-redhat-package-manager": { - source: "nginx", - extensions: ["rpm"] - }, - "application/x-research-info-systems": { - source: "apache", - extensions: ["ris"] - }, - "application/x-sea": { - source: "nginx", - extensions: ["sea"] - }, - "application/x-sh": { - source: "apache", - compressible: true, - extensions: ["sh"] - }, - "application/x-shar": { - source: "apache", - extensions: ["shar"] - }, - "application/x-shockwave-flash": { - source: "apache", - compressible: false, - extensions: ["swf"] - }, - "application/x-silverlight-app": { - source: "apache", - extensions: ["xap"] - }, - "application/x-sql": { - source: "apache", - extensions: ["sql"] - }, - "application/x-stuffit": { - source: "apache", - compressible: false, - extensions: ["sit"] - }, - "application/x-stuffitx": { - source: "apache", - extensions: ["sitx"] - }, - "application/x-subrip": { - source: "apache", - extensions: ["srt"] - }, - "application/x-sv4cpio": { - source: "apache", - extensions: ["sv4cpio"] - }, - "application/x-sv4crc": { - source: "apache", - extensions: ["sv4crc"] - }, - "application/x-t3vm-image": { - source: "apache", - extensions: ["t3"] - }, - "application/x-tads": { - source: "apache", - extensions: ["gam"] - }, - "application/x-tar": { - source: "apache", - compressible: true, - extensions: ["tar"] - }, - "application/x-tcl": { - source: "apache", - extensions: ["tcl", "tk"] - }, - "application/x-tex": { - source: "apache", - extensions: ["tex"] - }, - "application/x-tex-tfm": { - source: "apache", - extensions: ["tfm"] - }, - "application/x-texinfo": { - source: "apache", - extensions: ["texinfo", "texi"] - }, - "application/x-tgif": { - source: "apache", - extensions: ["obj"] - }, - "application/x-ustar": { - source: "apache", - extensions: ["ustar"] - }, - "application/x-virtualbox-hdd": { - compressible: true, - extensions: ["hdd"] - }, - "application/x-virtualbox-ova": { - compressible: true, - extensions: ["ova"] - }, - "application/x-virtualbox-ovf": { - compressible: true, - extensions: ["ovf"] - }, - "application/x-virtualbox-vbox": { - compressible: true, - extensions: ["vbox"] - }, - "application/x-virtualbox-vbox-extpack": { - compressible: false, - extensions: ["vbox-extpack"] - }, - "application/x-virtualbox-vdi": { - compressible: true, - extensions: ["vdi"] - }, - "application/x-virtualbox-vhd": { - compressible: true, - extensions: ["vhd"] - }, - "application/x-virtualbox-vmdk": { - compressible: true, - extensions: ["vmdk"] - }, - "application/x-wais-source": { - source: "apache", - extensions: ["src"] - }, - "application/x-web-app-manifest+json": { - compressible: true, - extensions: ["webapp"] - }, - "application/x-www-form-urlencoded": { - source: "iana", - compressible: true - }, - "application/x-x509-ca-cert": { - source: "iana", - extensions: ["der", "crt", "pem"] - }, - "application/x-x509-ca-ra-cert": { - source: "iana" - }, - "application/x-x509-next-ca-cert": { - source: "iana" - }, - "application/x-xfig": { - source: "apache", - extensions: ["fig"] - }, - "application/x-xliff+xml": { - source: "apache", - compressible: true, - extensions: ["xlf"] - }, - "application/x-xpinstall": { - source: "apache", - compressible: false, - extensions: ["xpi"] - }, - "application/x-xz": { - source: "apache", - extensions: ["xz"] - }, - "application/x-zmachine": { - source: "apache", - extensions: ["z1", "z2", "z3", "z4", "z5", "z6", "z7", "z8"] - }, - "application/x400-bp": { - source: "iana" - }, - "application/xacml+xml": { - source: "iana", - compressible: true - }, - "application/xaml+xml": { - source: "apache", - compressible: true, - extensions: ["xaml"] - }, - "application/xcap-att+xml": { - source: "iana", - compressible: true, - extensions: ["xav"] - }, - "application/xcap-caps+xml": { - source: "iana", - compressible: true, - extensions: ["xca"] - }, - "application/xcap-diff+xml": { - source: "iana", - compressible: true, - extensions: ["xdf"] - }, - "application/xcap-el+xml": { - source: "iana", - compressible: true, - extensions: ["xel"] - }, - "application/xcap-error+xml": { - source: "iana", - compressible: true - }, - "application/xcap-ns+xml": { - source: "iana", - compressible: true, - extensions: ["xns"] - }, - "application/xcon-conference-info+xml": { - source: "iana", - compressible: true - }, - "application/xcon-conference-info-diff+xml": { - source: "iana", - compressible: true - }, - "application/xenc+xml": { - source: "iana", - compressible: true, - extensions: ["xenc"] - }, - "application/xhtml+xml": { - source: "iana", - compressible: true, - extensions: ["xhtml", "xht"] - }, - "application/xhtml-voice+xml": { - source: "apache", - compressible: true - }, - "application/xliff+xml": { - source: "iana", - compressible: true, - extensions: ["xlf"] - }, - "application/xml": { - source: "iana", - compressible: true, - extensions: ["xml", "xsl", "xsd", "rng"] - }, - "application/xml-dtd": { - source: "iana", - compressible: true, - extensions: ["dtd"] - }, - "application/xml-external-parsed-entity": { - source: "iana" - }, - "application/xml-patch+xml": { - source: "iana", - compressible: true - }, - "application/xmpp+xml": { - source: "iana", - compressible: true - }, - "application/xop+xml": { - source: "iana", - compressible: true, - extensions: ["xop"] - }, - "application/xproc+xml": { - source: "apache", - compressible: true, - extensions: ["xpl"] - }, - "application/xslt+xml": { - source: "iana", - compressible: true, - extensions: ["xsl", "xslt"] - }, - "application/xspf+xml": { - source: "apache", - compressible: true, - extensions: ["xspf"] - }, - "application/xv+xml": { - source: "iana", - compressible: true, - extensions: ["mxml", "xhvml", "xvml", "xvm"] - }, - "application/yang": { - source: "iana", - extensions: ["yang"] - }, - "application/yang-data+json": { - source: "iana", - compressible: true - }, - "application/yang-data+xml": { - source: "iana", - compressible: true - }, - "application/yang-patch+json": { - source: "iana", - compressible: true - }, - "application/yang-patch+xml": { - source: "iana", - compressible: true - }, - "application/yin+xml": { - source: "iana", - compressible: true, - extensions: ["yin"] - }, - "application/zip": { - source: "iana", - compressible: false, - extensions: ["zip"] - }, - "application/zlib": { - source: "iana" - }, - "application/zstd": { - source: "iana" - }, - "audio/1d-interleaved-parityfec": { - source: "iana" - }, - "audio/32kadpcm": { - source: "iana" - }, - "audio/3gpp": { - source: "iana", - compressible: false, - extensions: ["3gpp"] - }, - "audio/3gpp2": { - source: "iana" - }, - "audio/aac": { - source: "iana" - }, - "audio/ac3": { - source: "iana" - }, - "audio/adpcm": { - source: "apache", - extensions: ["adp"] - }, - "audio/amr": { - source: "iana", - extensions: ["amr"] - }, - "audio/amr-wb": { - source: "iana" - }, - "audio/amr-wb+": { - source: "iana" - }, - "audio/aptx": { - source: "iana" - }, - "audio/asc": { - source: "iana" - }, - "audio/atrac-advanced-lossless": { - source: "iana" - }, - "audio/atrac-x": { - source: "iana" - }, - "audio/atrac3": { - source: "iana" - }, - "audio/basic": { - source: "iana", - compressible: false, - extensions: ["au", "snd"] - }, - "audio/bv16": { - source: "iana" - }, - "audio/bv32": { - source: "iana" - }, - "audio/clearmode": { - source: "iana" - }, - "audio/cn": { - source: "iana" - }, - "audio/dat12": { - source: "iana" - }, - "audio/dls": { - source: "iana" - }, - "audio/dsr-es201108": { - source: "iana" - }, - "audio/dsr-es202050": { - source: "iana" - }, - "audio/dsr-es202211": { - source: "iana" - }, - "audio/dsr-es202212": { - source: "iana" - }, - "audio/dv": { - source: "iana" - }, - "audio/dvi4": { - source: "iana" - }, - "audio/eac3": { - source: "iana" - }, - "audio/encaprtp": { - source: "iana" - }, - "audio/evrc": { - source: "iana" - }, - "audio/evrc-qcp": { - source: "iana" - }, - "audio/evrc0": { - source: "iana" - }, - "audio/evrc1": { - source: "iana" - }, - "audio/evrcb": { - source: "iana" - }, - "audio/evrcb0": { - source: "iana" - }, - "audio/evrcb1": { - source: "iana" - }, - "audio/evrcnw": { - source: "iana" - }, - "audio/evrcnw0": { - source: "iana" - }, - "audio/evrcnw1": { - source: "iana" - }, - "audio/evrcwb": { - source: "iana" - }, - "audio/evrcwb0": { - source: "iana" - }, - "audio/evrcwb1": { - source: "iana" - }, - "audio/evs": { - source: "iana" - }, - "audio/flexfec": { - source: "iana" - }, - "audio/fwdred": { - source: "iana" - }, - "audio/g711-0": { - source: "iana" - }, - "audio/g719": { - source: "iana" - }, - "audio/g722": { - source: "iana" - }, - "audio/g7221": { - source: "iana" - }, - "audio/g723": { - source: "iana" - }, - "audio/g726-16": { - source: "iana" - }, - "audio/g726-24": { - source: "iana" - }, - "audio/g726-32": { - source: "iana" - }, - "audio/g726-40": { - source: "iana" - }, - "audio/g728": { - source: "iana" - }, - "audio/g729": { - source: "iana" - }, - "audio/g7291": { - source: "iana" - }, - "audio/g729d": { - source: "iana" - }, - "audio/g729e": { - source: "iana" - }, - "audio/gsm": { - source: "iana" - }, - "audio/gsm-efr": { - source: "iana" - }, - "audio/gsm-hr-08": { - source: "iana" - }, - "audio/ilbc": { - source: "iana" - }, - "audio/ip-mr_v2.5": { - source: "iana" - }, - "audio/isac": { - source: "apache" - }, - "audio/l16": { - source: "iana" - }, - "audio/l20": { - source: "iana" - }, - "audio/l24": { - source: "iana", - compressible: false - }, - "audio/l8": { - source: "iana" - }, - "audio/lpc": { - source: "iana" - }, - "audio/melp": { - source: "iana" - }, - "audio/melp1200": { - source: "iana" - }, - "audio/melp2400": { - source: "iana" - }, - "audio/melp600": { - source: "iana" - }, - "audio/mhas": { - source: "iana" - }, - "audio/midi": { - source: "apache", - extensions: ["mid", "midi", "kar", "rmi"] - }, - "audio/mobile-xmf": { - source: "iana", - extensions: ["mxmf"] - }, - "audio/mp3": { - compressible: false, - extensions: ["mp3"] - }, - "audio/mp4": { - source: "iana", - compressible: false, - extensions: ["m4a", "mp4a"] - }, - "audio/mp4a-latm": { - source: "iana" - }, - "audio/mpa": { - source: "iana" - }, - "audio/mpa-robust": { - source: "iana" - }, - "audio/mpeg": { - source: "iana", - compressible: false, - extensions: ["mpga", "mp2", "mp2a", "mp3", "m2a", "m3a"] - }, - "audio/mpeg4-generic": { - source: "iana" - }, - "audio/musepack": { - source: "apache" - }, - "audio/ogg": { - source: "iana", - compressible: false, - extensions: ["oga", "ogg", "spx", "opus"] - }, - "audio/opus": { - source: "iana" - }, - "audio/parityfec": { - source: "iana" - }, - "audio/pcma": { - source: "iana" - }, - "audio/pcma-wb": { - source: "iana" - }, - "audio/pcmu": { - source: "iana" - }, - "audio/pcmu-wb": { - source: "iana" - }, - "audio/prs.sid": { - source: "iana" - }, - "audio/qcelp": { - source: "iana" - }, - "audio/raptorfec": { - source: "iana" - }, - "audio/red": { - source: "iana" - }, - "audio/rtp-enc-aescm128": { - source: "iana" - }, - "audio/rtp-midi": { - source: "iana" - }, - "audio/rtploopback": { - source: "iana" - }, - "audio/rtx": { - source: "iana" - }, - "audio/s3m": { - source: "apache", - extensions: ["s3m"] - }, - "audio/scip": { - source: "iana" - }, - "audio/silk": { - source: "apache", - extensions: ["sil"] - }, - "audio/smv": { - source: "iana" - }, - "audio/smv-qcp": { - source: "iana" - }, - "audio/smv0": { - source: "iana" - }, - "audio/sofa": { - source: "iana" - }, - "audio/sp-midi": { - source: "iana" - }, - "audio/speex": { - source: "iana" - }, - "audio/t140c": { - source: "iana" - }, - "audio/t38": { - source: "iana" - }, - "audio/telephone-event": { - source: "iana" - }, - "audio/tetra_acelp": { - source: "iana" - }, - "audio/tetra_acelp_bb": { - source: "iana" - }, - "audio/tone": { - source: "iana" - }, - "audio/tsvcis": { - source: "iana" - }, - "audio/uemclip": { - source: "iana" - }, - "audio/ulpfec": { - source: "iana" - }, - "audio/usac": { - source: "iana" - }, - "audio/vdvi": { - source: "iana" - }, - "audio/vmr-wb": { - source: "iana" - }, - "audio/vnd.3gpp.iufp": { - source: "iana" - }, - "audio/vnd.4sb": { - source: "iana" - }, - "audio/vnd.audiokoz": { - source: "iana" - }, - "audio/vnd.celp": { - source: "iana" - }, - "audio/vnd.cisco.nse": { - source: "iana" - }, - "audio/vnd.cmles.radio-events": { - source: "iana" - }, - "audio/vnd.cns.anp1": { - source: "iana" - }, - "audio/vnd.cns.inf1": { - source: "iana" - }, - "audio/vnd.dece.audio": { - source: "iana", - extensions: ["uva", "uvva"] - }, - "audio/vnd.digital-winds": { - source: "iana", - extensions: ["eol"] - }, - "audio/vnd.dlna.adts": { - source: "iana" - }, - "audio/vnd.dolby.heaac.1": { - source: "iana" - }, - "audio/vnd.dolby.heaac.2": { - source: "iana" - }, - "audio/vnd.dolby.mlp": { - source: "iana" - }, - "audio/vnd.dolby.mps": { - source: "iana" - }, - "audio/vnd.dolby.pl2": { - source: "iana" - }, - "audio/vnd.dolby.pl2x": { - source: "iana" - }, - "audio/vnd.dolby.pl2z": { - source: "iana" - }, - "audio/vnd.dolby.pulse.1": { - source: "iana" - }, - "audio/vnd.dra": { - source: "iana", - extensions: ["dra"] - }, - "audio/vnd.dts": { - source: "iana", - extensions: ["dts"] - }, - "audio/vnd.dts.hd": { - source: "iana", - extensions: ["dtshd"] - }, - "audio/vnd.dts.uhd": { - source: "iana" - }, - "audio/vnd.dvb.file": { - source: "iana" - }, - "audio/vnd.everad.plj": { - source: "iana" - }, - "audio/vnd.hns.audio": { - source: "iana" - }, - "audio/vnd.lucent.voice": { - source: "iana", - extensions: ["lvp"] - }, - "audio/vnd.ms-playready.media.pya": { - source: "iana", - extensions: ["pya"] - }, - "audio/vnd.nokia.mobile-xmf": { - source: "iana" - }, - "audio/vnd.nortel.vbk": { - source: "iana" - }, - "audio/vnd.nuera.ecelp4800": { - source: "iana", - extensions: ["ecelp4800"] - }, - "audio/vnd.nuera.ecelp7470": { - source: "iana", - extensions: ["ecelp7470"] - }, - "audio/vnd.nuera.ecelp9600": { - source: "iana", - extensions: ["ecelp9600"] - }, - "audio/vnd.octel.sbc": { - source: "iana" - }, - "audio/vnd.presonus.multitrack": { - source: "iana" - }, - "audio/vnd.qcelp": { - source: "iana" - }, - "audio/vnd.rhetorex.32kadpcm": { - source: "iana" - }, - "audio/vnd.rip": { - source: "iana", - extensions: ["rip"] - }, - "audio/vnd.rn-realaudio": { - compressible: false - }, - "audio/vnd.sealedmedia.softseal.mpeg": { - source: "iana" - }, - "audio/vnd.vmx.cvsd": { - source: "iana" - }, - "audio/vnd.wave": { - compressible: false - }, - "audio/vorbis": { - source: "iana", - compressible: false - }, - "audio/vorbis-config": { - source: "iana" - }, - "audio/wav": { - compressible: false, - extensions: ["wav"] - }, - "audio/wave": { - compressible: false, - extensions: ["wav"] - }, - "audio/webm": { - source: "apache", - compressible: false, - extensions: ["weba"] - }, - "audio/x-aac": { - source: "apache", - compressible: false, - extensions: ["aac"] - }, - "audio/x-aiff": { - source: "apache", - extensions: ["aif", "aiff", "aifc"] - }, - "audio/x-caf": { - source: "apache", - compressible: false, - extensions: ["caf"] - }, - "audio/x-flac": { - source: "apache", - extensions: ["flac"] - }, - "audio/x-m4a": { - source: "nginx", - extensions: ["m4a"] - }, - "audio/x-matroska": { - source: "apache", - extensions: ["mka"] - }, - "audio/x-mpegurl": { - source: "apache", - extensions: ["m3u"] - }, - "audio/x-ms-wax": { - source: "apache", - extensions: ["wax"] - }, - "audio/x-ms-wma": { - source: "apache", - extensions: ["wma"] - }, - "audio/x-pn-realaudio": { - source: "apache", - extensions: ["ram", "ra"] - }, - "audio/x-pn-realaudio-plugin": { - source: "apache", - extensions: ["rmp"] - }, - "audio/x-realaudio": { - source: "nginx", - extensions: ["ra"] - }, - "audio/x-tta": { - source: "apache" - }, - "audio/x-wav": { - source: "apache", - extensions: ["wav"] - }, - "audio/xm": { - source: "apache", - extensions: ["xm"] - }, - "chemical/x-cdx": { - source: "apache", - extensions: ["cdx"] - }, - "chemical/x-cif": { - source: "apache", - extensions: ["cif"] - }, - "chemical/x-cmdf": { - source: "apache", - extensions: ["cmdf"] - }, - "chemical/x-cml": { - source: "apache", - extensions: ["cml"] - }, - "chemical/x-csml": { - source: "apache", - extensions: ["csml"] - }, - "chemical/x-pdb": { - source: "apache" - }, - "chemical/x-xyz": { - source: "apache", - extensions: ["xyz"] - }, - "font/collection": { - source: "iana", - extensions: ["ttc"] - }, - "font/otf": { - source: "iana", - compressible: true, - extensions: ["otf"] - }, - "font/sfnt": { - source: "iana" - }, - "font/ttf": { - source: "iana", - compressible: true, - extensions: ["ttf"] - }, - "font/woff": { - source: "iana", - extensions: ["woff"] - }, - "font/woff2": { - source: "iana", - extensions: ["woff2"] - }, - "image/aces": { - source: "iana", - extensions: ["exr"] - }, - "image/apng": { - compressible: false, - extensions: ["apng"] - }, - "image/avci": { - source: "iana", - extensions: ["avci"] - }, - "image/avcs": { - source: "iana", - extensions: ["avcs"] - }, - "image/avif": { - source: "iana", - compressible: false, - extensions: ["avif"] - }, - "image/bmp": { - source: "iana", - compressible: true, - extensions: ["bmp"] - }, - "image/cgm": { - source: "iana", - extensions: ["cgm"] - }, - "image/dicom-rle": { - source: "iana", - extensions: ["drle"] - }, - "image/emf": { - source: "iana", - extensions: ["emf"] - }, - "image/fits": { - source: "iana", - extensions: ["fits"] - }, - "image/g3fax": { - source: "iana", - extensions: ["g3"] - }, - "image/gif": { - source: "iana", - compressible: false, - extensions: ["gif"] - }, - "image/heic": { - source: "iana", - extensions: ["heic"] - }, - "image/heic-sequence": { - source: "iana", - extensions: ["heics"] - }, - "image/heif": { - source: "iana", - extensions: ["heif"] - }, - "image/heif-sequence": { - source: "iana", - extensions: ["heifs"] - }, - "image/hej2k": { - source: "iana", - extensions: ["hej2"] - }, - "image/hsj2": { - source: "iana", - extensions: ["hsj2"] - }, - "image/ief": { - source: "iana", - extensions: ["ief"] - }, - "image/jls": { - source: "iana", - extensions: ["jls"] - }, - "image/jp2": { - source: "iana", - compressible: false, - extensions: ["jp2", "jpg2"] - }, - "image/jpeg": { - source: "iana", - compressible: false, - extensions: ["jpeg", "jpg", "jpe"] - }, - "image/jph": { - source: "iana", - extensions: ["jph"] - }, - "image/jphc": { - source: "iana", - extensions: ["jhc"] - }, - "image/jpm": { - source: "iana", - compressible: false, - extensions: ["jpm"] - }, - "image/jpx": { - source: "iana", - compressible: false, - extensions: ["jpx", "jpf"] - }, - "image/jxr": { - source: "iana", - extensions: ["jxr"] - }, - "image/jxra": { - source: "iana", - extensions: ["jxra"] - }, - "image/jxrs": { - source: "iana", - extensions: ["jxrs"] - }, - "image/jxs": { - source: "iana", - extensions: ["jxs"] - }, - "image/jxsc": { - source: "iana", - extensions: ["jxsc"] - }, - "image/jxsi": { - source: "iana", - extensions: ["jxsi"] - }, - "image/jxss": { - source: "iana", - extensions: ["jxss"] - }, - "image/ktx": { - source: "iana", - extensions: ["ktx"] - }, - "image/ktx2": { - source: "iana", - extensions: ["ktx2"] - }, - "image/naplps": { - source: "iana" - }, - "image/pjpeg": { - compressible: false - }, - "image/png": { - source: "iana", - compressible: false, - extensions: ["png"] - }, - "image/prs.btif": { - source: "iana", - extensions: ["btif"] - }, - "image/prs.pti": { - source: "iana", - extensions: ["pti"] - }, - "image/pwg-raster": { - source: "iana" - }, - "image/sgi": { - source: "apache", - extensions: ["sgi"] - }, - "image/svg+xml": { - source: "iana", - compressible: true, - extensions: ["svg", "svgz"] - }, - "image/t38": { - source: "iana", - extensions: ["t38"] - }, - "image/tiff": { - source: "iana", - compressible: false, - extensions: ["tif", "tiff"] - }, - "image/tiff-fx": { - source: "iana", - extensions: ["tfx"] - }, - "image/vnd.adobe.photoshop": { - source: "iana", - compressible: true, - extensions: ["psd"] - }, - "image/vnd.airzip.accelerator.azv": { - source: "iana", - extensions: ["azv"] - }, - "image/vnd.cns.inf2": { - source: "iana" - }, - "image/vnd.dece.graphic": { - source: "iana", - extensions: ["uvi", "uvvi", "uvg", "uvvg"] - }, - "image/vnd.djvu": { - source: "iana", - extensions: ["djvu", "djv"] - }, - "image/vnd.dvb.subtitle": { - source: "iana", - extensions: ["sub"] - }, - "image/vnd.dwg": { - source: "iana", - extensions: ["dwg"] - }, - "image/vnd.dxf": { - source: "iana", - extensions: ["dxf"] - }, - "image/vnd.fastbidsheet": { - source: "iana", - extensions: ["fbs"] - }, - "image/vnd.fpx": { - source: "iana", - extensions: ["fpx"] - }, - "image/vnd.fst": { - source: "iana", - extensions: ["fst"] - }, - "image/vnd.fujixerox.edmics-mmr": { - source: "iana", - extensions: ["mmr"] - }, - "image/vnd.fujixerox.edmics-rlc": { - source: "iana", - extensions: ["rlc"] - }, - "image/vnd.globalgraphics.pgb": { - source: "iana" - }, - "image/vnd.microsoft.icon": { - source: "iana", - compressible: true, - extensions: ["ico"] - }, - "image/vnd.mix": { - source: "iana" - }, - "image/vnd.mozilla.apng": { - source: "iana" - }, - "image/vnd.ms-dds": { - compressible: true, - extensions: ["dds"] - }, - "image/vnd.ms-modi": { - source: "iana", - extensions: ["mdi"] - }, - "image/vnd.ms-photo": { - source: "apache", - extensions: ["wdp"] - }, - "image/vnd.net-fpx": { - source: "iana", - extensions: ["npx"] - }, - "image/vnd.pco.b16": { - source: "iana", - extensions: ["b16"] - }, - "image/vnd.radiance": { - source: "iana" - }, - "image/vnd.sealed.png": { - source: "iana" - }, - "image/vnd.sealedmedia.softseal.gif": { - source: "iana" - }, - "image/vnd.sealedmedia.softseal.jpg": { - source: "iana" - }, - "image/vnd.svf": { - source: "iana" - }, - "image/vnd.tencent.tap": { - source: "iana", - extensions: ["tap"] - }, - "image/vnd.valve.source.texture": { - source: "iana", - extensions: ["vtf"] - }, - "image/vnd.wap.wbmp": { - source: "iana", - extensions: ["wbmp"] - }, - "image/vnd.xiff": { - source: "iana", - extensions: ["xif"] - }, - "image/vnd.zbrush.pcx": { - source: "iana", - extensions: ["pcx"] - }, - "image/webp": { - source: "apache", - extensions: ["webp"] - }, - "image/wmf": { - source: "iana", - extensions: ["wmf"] - }, - "image/x-3ds": { - source: "apache", - extensions: ["3ds"] - }, - "image/x-cmu-raster": { - source: "apache", - extensions: ["ras"] - }, - "image/x-cmx": { - source: "apache", - extensions: ["cmx"] - }, - "image/x-freehand": { - source: "apache", - extensions: ["fh", "fhc", "fh4", "fh5", "fh7"] - }, - "image/x-icon": { - source: "apache", - compressible: true, - extensions: ["ico"] - }, - "image/x-jng": { - source: "nginx", - extensions: ["jng"] - }, - "image/x-mrsid-image": { - source: "apache", - extensions: ["sid"] - }, - "image/x-ms-bmp": { - source: "nginx", - compressible: true, - extensions: ["bmp"] - }, - "image/x-pcx": { - source: "apache", - extensions: ["pcx"] - }, - "image/x-pict": { - source: "apache", - extensions: ["pic", "pct"] - }, - "image/x-portable-anymap": { - source: "apache", - extensions: ["pnm"] - }, - "image/x-portable-bitmap": { - source: "apache", - extensions: ["pbm"] - }, - "image/x-portable-graymap": { - source: "apache", - extensions: ["pgm"] - }, - "image/x-portable-pixmap": { - source: "apache", - extensions: ["ppm"] - }, - "image/x-rgb": { - source: "apache", - extensions: ["rgb"] - }, - "image/x-tga": { - source: "apache", - extensions: ["tga"] - }, - "image/x-xbitmap": { - source: "apache", - extensions: ["xbm"] - }, - "image/x-xcf": { - compressible: false - }, - "image/x-xpixmap": { - source: "apache", - extensions: ["xpm"] - }, - "image/x-xwindowdump": { - source: "apache", - extensions: ["xwd"] - }, - "message/cpim": { - source: "iana" - }, - "message/delivery-status": { - source: "iana" - }, - "message/disposition-notification": { - source: "iana", - extensions: [ - "disposition-notification" - ] - }, - "message/external-body": { - source: "iana" - }, - "message/feedback-report": { - source: "iana" - }, - "message/global": { - source: "iana", - extensions: ["u8msg"] - }, - "message/global-delivery-status": { - source: "iana", - extensions: ["u8dsn"] - }, - "message/global-disposition-notification": { - source: "iana", - extensions: ["u8mdn"] - }, - "message/global-headers": { - source: "iana", - extensions: ["u8hdr"] - }, - "message/http": { - source: "iana", - compressible: false - }, - "message/imdn+xml": { - source: "iana", - compressible: true - }, - "message/news": { - source: "iana" - }, - "message/partial": { - source: "iana", - compressible: false - }, - "message/rfc822": { - source: "iana", - compressible: true, - extensions: ["eml", "mime"] - }, - "message/s-http": { - source: "iana" - }, - "message/sip": { - source: "iana" - }, - "message/sipfrag": { - source: "iana" - }, - "message/tracking-status": { - source: "iana" - }, - "message/vnd.si.simp": { - source: "iana" - }, - "message/vnd.wfa.wsc": { - source: "iana", - extensions: ["wsc"] - }, - "model/3mf": { - source: "iana", - extensions: ["3mf"] - }, - "model/e57": { - source: "iana" - }, - "model/gltf+json": { - source: "iana", - compressible: true, - extensions: ["gltf"] - }, - "model/gltf-binary": { - source: "iana", - compressible: true, - extensions: ["glb"] - }, - "model/iges": { - source: "iana", - compressible: false, - extensions: ["igs", "iges"] - }, - "model/mesh": { - source: "iana", - compressible: false, - extensions: ["msh", "mesh", "silo"] - }, - "model/mtl": { - source: "iana", - extensions: ["mtl"] - }, - "model/obj": { - source: "iana", - extensions: ["obj"] - }, - "model/step": { - source: "iana" - }, - "model/step+xml": { - source: "iana", - compressible: true, - extensions: ["stpx"] - }, - "model/step+zip": { - source: "iana", - compressible: false, - extensions: ["stpz"] - }, - "model/step-xml+zip": { - source: "iana", - compressible: false, - extensions: ["stpxz"] - }, - "model/stl": { - source: "iana", - extensions: ["stl"] - }, - "model/vnd.collada+xml": { - source: "iana", - compressible: true, - extensions: ["dae"] - }, - "model/vnd.dwf": { - source: "iana", - extensions: ["dwf"] - }, - "model/vnd.flatland.3dml": { - source: "iana" - }, - "model/vnd.gdl": { - source: "iana", - extensions: ["gdl"] - }, - "model/vnd.gs-gdl": { - source: "apache" - }, - "model/vnd.gs.gdl": { - source: "iana" - }, - "model/vnd.gtw": { - source: "iana", - extensions: ["gtw"] - }, - "model/vnd.moml+xml": { - source: "iana", - compressible: true - }, - "model/vnd.mts": { - source: "iana", - extensions: ["mts"] - }, - "model/vnd.opengex": { - source: "iana", - extensions: ["ogex"] - }, - "model/vnd.parasolid.transmit.binary": { - source: "iana", - extensions: ["x_b"] - }, - "model/vnd.parasolid.transmit.text": { - source: "iana", - extensions: ["x_t"] - }, - "model/vnd.pytha.pyox": { - source: "iana" - }, - "model/vnd.rosette.annotated-data-model": { - source: "iana" - }, - "model/vnd.sap.vds": { - source: "iana", - extensions: ["vds"] - }, - "model/vnd.usdz+zip": { - source: "iana", - compressible: false, - extensions: ["usdz"] - }, - "model/vnd.valve.source.compiled-map": { - source: "iana", - extensions: ["bsp"] - }, - "model/vnd.vtu": { - source: "iana", - extensions: ["vtu"] - }, - "model/vrml": { - source: "iana", - compressible: false, - extensions: ["wrl", "vrml"] - }, - "model/x3d+binary": { - source: "apache", - compressible: false, - extensions: ["x3db", "x3dbz"] - }, - "model/x3d+fastinfoset": { - source: "iana", - extensions: ["x3db"] - }, - "model/x3d+vrml": { - source: "apache", - compressible: false, - extensions: ["x3dv", "x3dvz"] - }, - "model/x3d+xml": { - source: "iana", - compressible: true, - extensions: ["x3d", "x3dz"] - }, - "model/x3d-vrml": { - source: "iana", - extensions: ["x3dv"] - }, - "multipart/alternative": { - source: "iana", - compressible: false - }, - "multipart/appledouble": { - source: "iana" - }, - "multipart/byteranges": { - source: "iana" - }, - "multipart/digest": { - source: "iana" - }, - "multipart/encrypted": { - source: "iana", - compressible: false - }, - "multipart/form-data": { - source: "iana", - compressible: false - }, - "multipart/header-set": { - source: "iana" - }, - "multipart/mixed": { - source: "iana" - }, - "multipart/multilingual": { - source: "iana" - }, - "multipart/parallel": { - source: "iana" - }, - "multipart/related": { - source: "iana", - compressible: false - }, - "multipart/report": { - source: "iana" - }, - "multipart/signed": { - source: "iana", - compressible: false - }, - "multipart/vnd.bint.med-plus": { - source: "iana" - }, - "multipart/voice-message": { - source: "iana" - }, - "multipart/x-mixed-replace": { - source: "iana" - }, - "text/1d-interleaved-parityfec": { - source: "iana" - }, - "text/cache-manifest": { - source: "iana", - compressible: true, - extensions: ["appcache", "manifest"] - }, - "text/calendar": { - source: "iana", - extensions: ["ics", "ifb"] - }, - "text/calender": { - compressible: true - }, - "text/cmd": { - compressible: true - }, - "text/coffeescript": { - extensions: ["coffee", "litcoffee"] - }, - "text/cql": { - source: "iana" - }, - "text/cql-expression": { - source: "iana" - }, - "text/cql-identifier": { - source: "iana" - }, - "text/css": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["css"] - }, - "text/csv": { - source: "iana", - compressible: true, - extensions: ["csv"] - }, - "text/csv-schema": { - source: "iana" - }, - "text/directory": { - source: "iana" - }, - "text/dns": { - source: "iana" - }, - "text/ecmascript": { - source: "iana" - }, - "text/encaprtp": { - source: "iana" - }, - "text/enriched": { - source: "iana" - }, - "text/fhirpath": { - source: "iana" - }, - "text/flexfec": { - source: "iana" - }, - "text/fwdred": { - source: "iana" - }, - "text/gff3": { - source: "iana" - }, - "text/grammar-ref-list": { - source: "iana" - }, - "text/html": { - source: "iana", - compressible: true, - extensions: ["html", "htm", "shtml"] - }, - "text/jade": { - extensions: ["jade"] - }, - "text/javascript": { - source: "iana", - compressible: true - }, - "text/jcr-cnd": { - source: "iana" - }, - "text/jsx": { - compressible: true, - extensions: ["jsx"] - }, - "text/less": { - compressible: true, - extensions: ["less"] - }, - "text/markdown": { - source: "iana", - compressible: true, - extensions: ["markdown", "md"] - }, - "text/mathml": { - source: "nginx", - extensions: ["mml"] - }, - "text/mdx": { - compressible: true, - extensions: ["mdx"] - }, - "text/mizar": { - source: "iana" - }, - "text/n3": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["n3"] - }, - "text/parameters": { - source: "iana", - charset: "UTF-8" - }, - "text/parityfec": { - source: "iana" - }, - "text/plain": { - source: "iana", - compressible: true, - extensions: ["txt", "text", "conf", "def", "list", "log", "in", "ini"] - }, - "text/provenance-notation": { - source: "iana", - charset: "UTF-8" - }, - "text/prs.fallenstein.rst": { - source: "iana" - }, - "text/prs.lines.tag": { - source: "iana", - extensions: ["dsc"] - }, - "text/prs.prop.logic": { - source: "iana" - }, - "text/raptorfec": { - source: "iana" - }, - "text/red": { - source: "iana" - }, - "text/rfc822-headers": { - source: "iana" - }, - "text/richtext": { - source: "iana", - compressible: true, - extensions: ["rtx"] - }, - "text/rtf": { - source: "iana", - compressible: true, - extensions: ["rtf"] - }, - "text/rtp-enc-aescm128": { - source: "iana" - }, - "text/rtploopback": { - source: "iana" - }, - "text/rtx": { - source: "iana" - }, - "text/sgml": { - source: "iana", - extensions: ["sgml", "sgm"] - }, - "text/shaclc": { - source: "iana" - }, - "text/shex": { - source: "iana", - extensions: ["shex"] - }, - "text/slim": { - extensions: ["slim", "slm"] - }, - "text/spdx": { - source: "iana", - extensions: ["spdx"] - }, - "text/strings": { - source: "iana" - }, - "text/stylus": { - extensions: ["stylus", "styl"] - }, - "text/t140": { - source: "iana" - }, - "text/tab-separated-values": { - source: "iana", - compressible: true, - extensions: ["tsv"] - }, - "text/troff": { - source: "iana", - extensions: ["t", "tr", "roff", "man", "me", "ms"] - }, - "text/turtle": { - source: "iana", - charset: "UTF-8", - extensions: ["ttl"] - }, - "text/ulpfec": { - source: "iana" - }, - "text/uri-list": { - source: "iana", - compressible: true, - extensions: ["uri", "uris", "urls"] - }, - "text/vcard": { - source: "iana", - compressible: true, - extensions: ["vcard"] - }, - "text/vnd.a": { - source: "iana" - }, - "text/vnd.abc": { - source: "iana" - }, - "text/vnd.ascii-art": { - source: "iana" - }, - "text/vnd.curl": { - source: "iana", - extensions: ["curl"] - }, - "text/vnd.curl.dcurl": { - source: "apache", - extensions: ["dcurl"] - }, - "text/vnd.curl.mcurl": { - source: "apache", - extensions: ["mcurl"] - }, - "text/vnd.curl.scurl": { - source: "apache", - extensions: ["scurl"] - }, - "text/vnd.debian.copyright": { - source: "iana", - charset: "UTF-8" - }, - "text/vnd.dmclientscript": { - source: "iana" - }, - "text/vnd.dvb.subtitle": { - source: "iana", - extensions: ["sub"] - }, - "text/vnd.esmertec.theme-descriptor": { - source: "iana", - charset: "UTF-8" - }, - "text/vnd.familysearch.gedcom": { - source: "iana", - extensions: ["ged"] - }, - "text/vnd.ficlab.flt": { - source: "iana" - }, - "text/vnd.fly": { - source: "iana", - extensions: ["fly"] - }, - "text/vnd.fmi.flexstor": { - source: "iana", - extensions: ["flx"] - }, - "text/vnd.gml": { - source: "iana" - }, - "text/vnd.graphviz": { - source: "iana", - extensions: ["gv"] - }, - "text/vnd.hans": { - source: "iana" - }, - "text/vnd.hgl": { - source: "iana" - }, - "text/vnd.in3d.3dml": { - source: "iana", - extensions: ["3dml"] - }, - "text/vnd.in3d.spot": { - source: "iana", - extensions: ["spot"] - }, - "text/vnd.iptc.newsml": { - source: "iana" - }, - "text/vnd.iptc.nitf": { - source: "iana" - }, - "text/vnd.latex-z": { - source: "iana" - }, - "text/vnd.motorola.reflex": { - source: "iana" - }, - "text/vnd.ms-mediapackage": { - source: "iana" - }, - "text/vnd.net2phone.commcenter.command": { - source: "iana" - }, - "text/vnd.radisys.msml-basic-layout": { - source: "iana" - }, - "text/vnd.senx.warpscript": { - source: "iana" - }, - "text/vnd.si.uricatalogue": { - source: "iana" - }, - "text/vnd.sosi": { - source: "iana" - }, - "text/vnd.sun.j2me.app-descriptor": { - source: "iana", - charset: "UTF-8", - extensions: ["jad"] - }, - "text/vnd.trolltech.linguist": { - source: "iana", - charset: "UTF-8" - }, - "text/vnd.wap.si": { - source: "iana" - }, - "text/vnd.wap.sl": { - source: "iana" - }, - "text/vnd.wap.wml": { - source: "iana", - extensions: ["wml"] - }, - "text/vnd.wap.wmlscript": { - source: "iana", - extensions: ["wmls"] - }, - "text/vtt": { - source: "iana", - charset: "UTF-8", - compressible: true, - extensions: ["vtt"] - }, - "text/x-asm": { - source: "apache", - extensions: ["s", "asm"] - }, - "text/x-c": { - source: "apache", - extensions: ["c", "cc", "cxx", "cpp", "h", "hh", "dic"] - }, - "text/x-component": { - source: "nginx", - extensions: ["htc"] - }, - "text/x-fortran": { - source: "apache", - extensions: ["f", "for", "f77", "f90"] - }, - "text/x-gwt-rpc": { - compressible: true - }, - "text/x-handlebars-template": { - extensions: ["hbs"] - }, - "text/x-java-source": { - source: "apache", - extensions: ["java"] - }, - "text/x-jquery-tmpl": { - compressible: true - }, - "text/x-lua": { - extensions: ["lua"] - }, - "text/x-markdown": { - compressible: true, - extensions: ["mkd"] - }, - "text/x-nfo": { - source: "apache", - extensions: ["nfo"] - }, - "text/x-opml": { - source: "apache", - extensions: ["opml"] - }, - "text/x-org": { - compressible: true, - extensions: ["org"] - }, - "text/x-pascal": { - source: "apache", - extensions: ["p", "pas"] - }, - "text/x-processing": { - compressible: true, - extensions: ["pde"] - }, - "text/x-sass": { - extensions: ["sass"] - }, - "text/x-scss": { - extensions: ["scss"] - }, - "text/x-setext": { - source: "apache", - extensions: ["etx"] - }, - "text/x-sfv": { - source: "apache", - extensions: ["sfv"] - }, - "text/x-suse-ymp": { - compressible: true, - extensions: ["ymp"] - }, - "text/x-uuencode": { - source: "apache", - extensions: ["uu"] - }, - "text/x-vcalendar": { - source: "apache", - extensions: ["vcs"] - }, - "text/x-vcard": { - source: "apache", - extensions: ["vcf"] - }, - "text/xml": { - source: "iana", - compressible: true, - extensions: ["xml"] - }, - "text/xml-external-parsed-entity": { - source: "iana" - }, - "text/yaml": { - compressible: true, - extensions: ["yaml", "yml"] - }, - "video/1d-interleaved-parityfec": { - source: "iana" - }, - "video/3gpp": { - source: "iana", - extensions: ["3gp", "3gpp"] - }, - "video/3gpp-tt": { - source: "iana" - }, - "video/3gpp2": { - source: "iana", - extensions: ["3g2"] - }, - "video/av1": { - source: "iana" - }, - "video/bmpeg": { - source: "iana" - }, - "video/bt656": { - source: "iana" - }, - "video/celb": { - source: "iana" - }, - "video/dv": { - source: "iana" - }, - "video/encaprtp": { - source: "iana" - }, - "video/ffv1": { - source: "iana" - }, - "video/flexfec": { - source: "iana" - }, - "video/h261": { - source: "iana", - extensions: ["h261"] - }, - "video/h263": { - source: "iana", - extensions: ["h263"] - }, - "video/h263-1998": { - source: "iana" - }, - "video/h263-2000": { - source: "iana" - }, - "video/h264": { - source: "iana", - extensions: ["h264"] - }, - "video/h264-rcdo": { - source: "iana" - }, - "video/h264-svc": { - source: "iana" - }, - "video/h265": { - source: "iana" - }, - "video/iso.segment": { - source: "iana", - extensions: ["m4s"] - }, - "video/jpeg": { - source: "iana", - extensions: ["jpgv"] - }, - "video/jpeg2000": { - source: "iana" - }, - "video/jpm": { - source: "apache", - extensions: ["jpm", "jpgm"] - }, - "video/jxsv": { - source: "iana" - }, - "video/mj2": { - source: "iana", - extensions: ["mj2", "mjp2"] - }, - "video/mp1s": { - source: "iana" - }, - "video/mp2p": { - source: "iana" - }, - "video/mp2t": { - source: "iana", - extensions: ["ts"] - }, - "video/mp4": { - source: "iana", - compressible: false, - extensions: ["mp4", "mp4v", "mpg4"] - }, - "video/mp4v-es": { - source: "iana" - }, - "video/mpeg": { - source: "iana", - compressible: false, - extensions: ["mpeg", "mpg", "mpe", "m1v", "m2v"] - }, - "video/mpeg4-generic": { - source: "iana" - }, - "video/mpv": { - source: "iana" - }, - "video/nv": { - source: "iana" - }, - "video/ogg": { - source: "iana", - compressible: false, - extensions: ["ogv"] - }, - "video/parityfec": { - source: "iana" - }, - "video/pointer": { - source: "iana" - }, - "video/quicktime": { - source: "iana", - compressible: false, - extensions: ["qt", "mov"] - }, - "video/raptorfec": { - source: "iana" - }, - "video/raw": { - source: "iana" - }, - "video/rtp-enc-aescm128": { - source: "iana" - }, - "video/rtploopback": { - source: "iana" - }, - "video/rtx": { - source: "iana" - }, - "video/scip": { - source: "iana" - }, - "video/smpte291": { - source: "iana" - }, - "video/smpte292m": { - source: "iana" - }, - "video/ulpfec": { - source: "iana" - }, - "video/vc1": { - source: "iana" - }, - "video/vc2": { - source: "iana" - }, - "video/vnd.cctv": { - source: "iana" - }, - "video/vnd.dece.hd": { - source: "iana", - extensions: ["uvh", "uvvh"] - }, - "video/vnd.dece.mobile": { - source: "iana", - extensions: ["uvm", "uvvm"] - }, - "video/vnd.dece.mp4": { - source: "iana" - }, - "video/vnd.dece.pd": { - source: "iana", - extensions: ["uvp", "uvvp"] - }, - "video/vnd.dece.sd": { - source: "iana", - extensions: ["uvs", "uvvs"] - }, - "video/vnd.dece.video": { - source: "iana", - extensions: ["uvv", "uvvv"] - }, - "video/vnd.directv.mpeg": { - source: "iana" - }, - "video/vnd.directv.mpeg-tts": { - source: "iana" - }, - "video/vnd.dlna.mpeg-tts": { - source: "iana" - }, - "video/vnd.dvb.file": { - source: "iana", - extensions: ["dvb"] - }, - "video/vnd.fvt": { - source: "iana", - extensions: ["fvt"] - }, - "video/vnd.hns.video": { - source: "iana" - }, - "video/vnd.iptvforum.1dparityfec-1010": { - source: "iana" - }, - "video/vnd.iptvforum.1dparityfec-2005": { - source: "iana" - }, - "video/vnd.iptvforum.2dparityfec-1010": { - source: "iana" - }, - "video/vnd.iptvforum.2dparityfec-2005": { - source: "iana" - }, - "video/vnd.iptvforum.ttsavc": { - source: "iana" - }, - "video/vnd.iptvforum.ttsmpeg2": { - source: "iana" - }, - "video/vnd.motorola.video": { - source: "iana" - }, - "video/vnd.motorola.videop": { - source: "iana" - }, - "video/vnd.mpegurl": { - source: "iana", - extensions: ["mxu", "m4u"] - }, - "video/vnd.ms-playready.media.pyv": { - source: "iana", - extensions: ["pyv"] - }, - "video/vnd.nokia.interleaved-multimedia": { - source: "iana" - }, - "video/vnd.nokia.mp4vr": { - source: "iana" - }, - "video/vnd.nokia.videovoip": { - source: "iana" - }, - "video/vnd.objectvideo": { - source: "iana" - }, - "video/vnd.radgamettools.bink": { - source: "iana" - }, - "video/vnd.radgamettools.smacker": { - source: "iana" - }, - "video/vnd.sealed.mpeg1": { - source: "iana" - }, - "video/vnd.sealed.mpeg4": { - source: "iana" - }, - "video/vnd.sealed.swf": { - source: "iana" - }, - "video/vnd.sealedmedia.softseal.mov": { - source: "iana" - }, - "video/vnd.uvvu.mp4": { - source: "iana", - extensions: ["uvu", "uvvu"] - }, - "video/vnd.vivo": { - source: "iana", - extensions: ["viv"] - }, - "video/vnd.youtube.yt": { - source: "iana" - }, - "video/vp8": { - source: "iana" - }, - "video/vp9": { - source: "iana" - }, - "video/webm": { - source: "apache", - compressible: false, - extensions: ["webm"] - }, - "video/x-f4v": { - source: "apache", - extensions: ["f4v"] - }, - "video/x-fli": { - source: "apache", - extensions: ["fli"] - }, - "video/x-flv": { - source: "apache", - compressible: false, - extensions: ["flv"] - }, - "video/x-m4v": { - source: "apache", - extensions: ["m4v"] - }, - "video/x-matroska": { - source: "apache", - compressible: false, - extensions: ["mkv", "mk3d", "mks"] - }, - "video/x-mng": { - source: "apache", - extensions: ["mng"] - }, - "video/x-ms-asf": { - source: "apache", - extensions: ["asf", "asx"] - }, - "video/x-ms-vob": { - source: "apache", - extensions: ["vob"] - }, - "video/x-ms-wm": { - source: "apache", - extensions: ["wm"] - }, - "video/x-ms-wmv": { - source: "apache", - compressible: false, - extensions: ["wmv"] - }, - "video/x-ms-wmx": { - source: "apache", - extensions: ["wmx"] - }, - "video/x-ms-wvx": { - source: "apache", - extensions: ["wvx"] - }, - "video/x-msvideo": { - source: "apache", - extensions: ["avi"] - }, - "video/x-sgi-movie": { - source: "apache", - extensions: ["movie"] - }, - "video/x-smv": { - source: "apache", - extensions: ["smv"] - }, - "x-conference/x-cooltalk": { - source: "apache", - extensions: ["ice"] - }, - "x-shader/x-fragment": { - compressible: true - }, - "x-shader/x-vertex": { - compressible: true - } - }; -}); - -// ../../node_modules/.pnpm/mime-db@1.52.0/node_modules/mime-db/index.js -var require_mime_db = __commonJS((exports2, module2) => { - /*! - * mime-db - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015-2022 Douglas Christopher Wilson - * MIT Licensed - */ - module2.exports = require_db(); -}); - -// ../../node_modules/.pnpm/mime-types@2.1.35/node_modules/mime-types/index.js -var require_mime_types = __commonJS((exports2) => { - /*! - * mime-types - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - var db = require_mime_db(); - var extname2 = require("path").extname; - var EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/; - var TEXT_TYPE_REGEXP = /^text\//i; - exports2.charset = charset; - exports2.charsets = { lookup: charset }; - exports2.contentType = contentType; - exports2.extension = extension; - exports2.extensions = Object.create(null); - exports2.lookup = lookup; - exports2.types = Object.create(null); - populateMaps(exports2.extensions, exports2.types); - function charset(type) { - if (!type || typeof type !== "string") { - return false; - } - var match = EXTRACT_TYPE_REGEXP.exec(type); - var mime = match && db[match[1].toLowerCase()]; - if (mime && mime.charset) { - return mime.charset; - } - if (match && TEXT_TYPE_REGEXP.test(match[1])) { - return "UTF-8"; - } - return false; - } - function contentType(str) { - if (!str || typeof str !== "string") { - return false; - } - var mime = str.indexOf("/") === -1 ? exports2.lookup(str) : str; - if (!mime) { - return false; - } - if (mime.indexOf("charset") === -1) { - var charset2 = exports2.charset(mime); - if (charset2) - mime += "; charset=" + charset2.toLowerCase(); - } - return mime; - } - function extension(type) { - if (!type || typeof type !== "string") { - return false; - } - var match = EXTRACT_TYPE_REGEXP.exec(type); - var exts = match && exports2.extensions[match[1].toLowerCase()]; - if (!exts || !exts.length) { - return false; - } - return exts[0]; - } - function lookup(path13) { - if (!path13 || typeof path13 !== "string") { - return false; - } - var extension2 = extname2("x." + path13).toLowerCase().substr(1); - if (!extension2) { - return false; - } - return exports2.types[extension2] || false; - } - function populateMaps(extensions2, types2) { - var preference = ["nginx", "apache", undefined, "iana"]; - Object.keys(db).forEach(function forEachMimeType(type) { - var mime = db[type]; - var exts = mime.extensions; - if (!exts || !exts.length) { - return; - } - extensions2[type] = exts; - for (var i = 0;i < exts.length; i++) { - var extension2 = exts[i]; - if (types2[extension2]) { - var from = preference.indexOf(db[types2[extension2]].source); - var to = preference.indexOf(mime.source); - if (types2[extension2] !== "application/octet-stream" && (from > to || from === to && types2[extension2].substr(0, 12) === "application/")) { - continue; - } - } - types2[extension2] = type; - } - }); - } -}); - -// ../../node_modules/.pnpm/accepts@1.3.8/node_modules/accepts/index.js -var require_accepts = __commonJS((exports2, module2) => { - /*! - * accepts - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - var Negotiator = require_negotiator(); - var mime = require_mime_types(); - module2.exports = Accepts; - function Accepts(req) { - if (!(this instanceof Accepts)) { - return new Accepts(req); - } - this.headers = req.headers; - this.negotiator = new Negotiator(req); - } - Accepts.prototype.type = Accepts.prototype.types = function(types_) { - var types2 = types_; - if (types2 && !Array.isArray(types2)) { - types2 = new Array(arguments.length); - for (var i = 0;i < types2.length; i++) { - types2[i] = arguments[i]; - } - } - if (!types2 || types2.length === 0) { - return this.negotiator.mediaTypes(); - } - if (!this.headers.accept) { - return types2[0]; - } - var mimes = types2.map(extToMime); - var accepts = this.negotiator.mediaTypes(mimes.filter(validMime)); - var first = accepts[0]; - return first ? types2[mimes.indexOf(first)] : false; - }; - Accepts.prototype.encoding = Accepts.prototype.encodings = function(encodings_) { - var encodings = encodings_; - if (encodings && !Array.isArray(encodings)) { - encodings = new Array(arguments.length); - for (var i = 0;i < encodings.length; i++) { - encodings[i] = arguments[i]; - } - } - if (!encodings || encodings.length === 0) { - return this.negotiator.encodings(); - } - return this.negotiator.encodings(encodings)[0] || false; - }; - Accepts.prototype.charset = Accepts.prototype.charsets = function(charsets_) { - var charsets = charsets_; - if (charsets && !Array.isArray(charsets)) { - charsets = new Array(arguments.length); - for (var i = 0;i < charsets.length; i++) { - charsets[i] = arguments[i]; - } - } - if (!charsets || charsets.length === 0) { - return this.negotiator.charsets(); - } - return this.negotiator.charsets(charsets)[0] || false; - }; - Accepts.prototype.lang = Accepts.prototype.langs = Accepts.prototype.language = Accepts.prototype.languages = function(languages_) { - var languages = languages_; - if (languages && !Array.isArray(languages)) { - languages = new Array(arguments.length); - for (var i = 0;i < languages.length; i++) { - languages[i] = arguments[i]; - } - } - if (!languages || languages.length === 0) { - return this.negotiator.languages(); - } - return this.negotiator.languages(languages)[0] || false; - }; - function extToMime(type) { - return type.indexOf("/") === -1 ? mime.lookup(type) : type; - } - function validMime(type) { - return typeof type === "string"; - } -}); - -// ../../node_modules/.pnpm/base64id@2.0.0/node_modules/base64id/lib/base64id.js -var require_base64id = __commonJS((exports2, module2) => { - /*! - * base64id v0.1.0 - */ - var crypto2 = require("crypto"); - var Base64Id = function() {}; - Base64Id.prototype.getRandomBytes = function(bytes) { - var BUFFER_SIZE = 4096; - var self = this; - bytes = bytes || 12; - if (bytes > BUFFER_SIZE) { - return crypto2.randomBytes(bytes); - } - var bytesInBuffer = parseInt(BUFFER_SIZE / bytes); - var threshold = parseInt(bytesInBuffer * 0.85); - if (!threshold) { - return crypto2.randomBytes(bytes); - } - if (this.bytesBufferIndex == null) { - this.bytesBufferIndex = -1; - } - if (this.bytesBufferIndex == bytesInBuffer) { - this.bytesBuffer = null; - this.bytesBufferIndex = -1; - } - if (this.bytesBufferIndex == -1 || this.bytesBufferIndex > threshold) { - if (!this.isGeneratingBytes) { - this.isGeneratingBytes = true; - crypto2.randomBytes(BUFFER_SIZE, function(err, bytes2) { - self.bytesBuffer = bytes2; - self.bytesBufferIndex = 0; - self.isGeneratingBytes = false; - }); - } - if (this.bytesBufferIndex == -1) { - return crypto2.randomBytes(bytes); - } - } - var result = this.bytesBuffer.slice(bytes * this.bytesBufferIndex, bytes * (this.bytesBufferIndex + 1)); - this.bytesBufferIndex++; - return result; - }; - Base64Id.prototype.generateId = function() { - var rand = Buffer.alloc(15); - if (!rand.writeInt32BE) { - return Math.abs(Math.random() * Math.random() * Date.now() | 0).toString() + Math.abs(Math.random() * Math.random() * Date.now() | 0).toString(); - } - this.sequenceNumber = this.sequenceNumber + 1 | 0; - rand.writeInt32BE(this.sequenceNumber, 11); - if (crypto2.randomBytes) { - this.getRandomBytes(12).copy(rand); - } else { - [0, 4, 8].forEach(function(i) { - rand.writeInt32BE(Math.random() * Math.pow(2, 32) | 0, i); - }); - } - return rand.toString("base64").replace(/\//g, "_").replace(/\+/g, "-"); - }; - exports2 = module2.exports = new Base64Id; -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/commons.js -var require_commons = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ERROR_PACKET = exports2.PACKET_TYPES_REVERSE = exports2.PACKET_TYPES = undefined; - var PACKET_TYPES = Object.create(null); - exports2.PACKET_TYPES = PACKET_TYPES; - PACKET_TYPES["open"] = "0"; - PACKET_TYPES["close"] = "1"; - PACKET_TYPES["ping"] = "2"; - PACKET_TYPES["pong"] = "3"; - PACKET_TYPES["message"] = "4"; - PACKET_TYPES["upgrade"] = "5"; - PACKET_TYPES["noop"] = "6"; - var PACKET_TYPES_REVERSE = Object.create(null); - exports2.PACKET_TYPES_REVERSE = PACKET_TYPES_REVERSE; - Object.keys(PACKET_TYPES).forEach((key) => { - PACKET_TYPES_REVERSE[PACKET_TYPES[key]] = key; - }); - var ERROR_PACKET = { type: "error", data: "parser error" }; - exports2.ERROR_PACKET = ERROR_PACKET; -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/encodePacket.js -var require_encodePacket = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.encodePacket = undefined; - exports2.encodePacketToBinary = encodePacketToBinary; - var commons_js_1 = require_commons(); - var encodePacket = ({ type, data }, supportsBinary, callback) => { - if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { - return callback(supportsBinary ? data : "b" + toBuffer(data, true).toString("base64")); - } - return callback(commons_js_1.PACKET_TYPES[type] + (data || "")); - }; - exports2.encodePacket = encodePacket; - var toBuffer = (data, forceBufferConversion) => { - if (Buffer.isBuffer(data) || data instanceof Uint8Array && !forceBufferConversion) { - return data; - } else if (data instanceof ArrayBuffer) { - return Buffer.from(data); - } else { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength); - } - }; - var TEXT_ENCODER; - function encodePacketToBinary(packet, callback) { - if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { - return callback(toBuffer(packet.data, false)); - } - (0, exports2.encodePacket)(packet, true, (encoded) => { - if (!TEXT_ENCODER) { - TEXT_ENCODER = new TextEncoder; - } - callback(TEXT_ENCODER.encode(encoded)); - }); - } -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/decodePacket.js -var require_decodePacket = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.decodePacket = undefined; - var commons_js_1 = require_commons(); - var decodePacket = (encodedPacket, binaryType) => { - if (typeof encodedPacket !== "string") { - return { - type: "message", - data: mapBinary(encodedPacket, binaryType) - }; - } - const type = encodedPacket.charAt(0); - if (type === "b") { - const buffer = Buffer.from(encodedPacket.substring(1), "base64"); - return { - type: "message", - data: mapBinary(buffer, binaryType) - }; - } - if (!commons_js_1.PACKET_TYPES_REVERSE[type]) { - return commons_js_1.ERROR_PACKET; - } - return encodedPacket.length > 1 ? { - type: commons_js_1.PACKET_TYPES_REVERSE[type], - data: encodedPacket.substring(1) - } : { - type: commons_js_1.PACKET_TYPES_REVERSE[type] - }; - }; - exports2.decodePacket = decodePacket; - var mapBinary = (data, binaryType) => { - switch (binaryType) { - case "arraybuffer": - if (data instanceof ArrayBuffer) { - return data; - } else if (Buffer.isBuffer(data)) { - return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); - } else { - return data.buffer; - } - case "nodebuffer": - default: - if (Buffer.isBuffer(data)) { - return data; - } else { - return Buffer.from(data); - } - } - }; -}); - -// ../../node_modules/.pnpm/engine.io-parser@5.2.3/node_modules/engine.io-parser/build/cjs/index.js -var require_cjs = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.decodePayload = exports2.decodePacket = exports2.encodePayload = exports2.encodePacket = exports2.protocol = undefined; - exports2.createPacketEncoderStream = createPacketEncoderStream; - exports2.createPacketDecoderStream = createPacketDecoderStream; - var encodePacket_js_1 = require_encodePacket(); - Object.defineProperty(exports2, "encodePacket", { enumerable: true, get: function() { - return encodePacket_js_1.encodePacket; - } }); - var decodePacket_js_1 = require_decodePacket(); - Object.defineProperty(exports2, "decodePacket", { enumerable: true, get: function() { - return decodePacket_js_1.decodePacket; - } }); - var commons_js_1 = require_commons(); - var SEPARATOR = String.fromCharCode(30); - var encodePayload = (packets, callback) => { - const length = packets.length; - const encodedPackets = new Array(length); - let count = 0; - packets.forEach((packet, i) => { - (0, encodePacket_js_1.encodePacket)(packet, false, (encodedPacket) => { - encodedPackets[i] = encodedPacket; - if (++count === length) { - callback(encodedPackets.join(SEPARATOR)); - } - }); - }); - }; - exports2.encodePayload = encodePayload; - var decodePayload = (encodedPayload, binaryType) => { - const encodedPackets = encodedPayload.split(SEPARATOR); - const packets = []; - for (let i = 0;i < encodedPackets.length; i++) { - const decodedPacket = (0, decodePacket_js_1.decodePacket)(encodedPackets[i], binaryType); - packets.push(decodedPacket); - if (decodedPacket.type === "error") { - break; - } - } - return packets; - }; - exports2.decodePayload = decodePayload; - function createPacketEncoderStream() { - return new TransformStream({ - transform(packet, controller) { - (0, encodePacket_js_1.encodePacketToBinary)(packet, (encodedPacket) => { - const payloadLength = encodedPacket.length; - let header; - if (payloadLength < 126) { - header = new Uint8Array(1); - new DataView(header.buffer).setUint8(0, payloadLength); - } else if (payloadLength < 65536) { - header = new Uint8Array(3); - const view = new DataView(header.buffer); - view.setUint8(0, 126); - view.setUint16(1, payloadLength); - } else { - header = new Uint8Array(9); - const view = new DataView(header.buffer); - view.setUint8(0, 127); - view.setBigUint64(1, BigInt(payloadLength)); - } - if (packet.data && typeof packet.data !== "string") { - header[0] |= 128; - } - controller.enqueue(header); - controller.enqueue(encodedPacket); - }); - } - }); - } - var TEXT_DECODER; - function totalLength(chunks) { - return chunks.reduce((acc, chunk2) => acc + chunk2.length, 0); - } - function concatChunks(chunks, size) { - if (chunks[0].length === size) { - return chunks.shift(); - } - const buffer = new Uint8Array(size); - let j = 0; - for (let i = 0;i < size; i++) { - buffer[i] = chunks[0][j++]; - if (j === chunks[0].length) { - chunks.shift(); - j = 0; - } - } - if (chunks.length && j < chunks[0].length) { - chunks[0] = chunks[0].slice(j); - } - return buffer; - } - function createPacketDecoderStream(maxPayload, binaryType) { - if (!TEXT_DECODER) { - TEXT_DECODER = new TextDecoder; - } - const chunks = []; - let state = 0; - let expectedLength = -1; - let isBinary = false; - return new TransformStream({ - transform(chunk2, controller) { - chunks.push(chunk2); - while (true) { - if (state === 0) { - if (totalLength(chunks) < 1) { - break; - } - const header = concatChunks(chunks, 1); - isBinary = (header[0] & 128) === 128; - expectedLength = header[0] & 127; - if (expectedLength < 126) { - state = 3; - } else if (expectedLength === 126) { - state = 1; - } else { - state = 2; - } - } else if (state === 1) { - if (totalLength(chunks) < 2) { - break; - } - const headerArray = concatChunks(chunks, 2); - expectedLength = new DataView(headerArray.buffer, headerArray.byteOffset, headerArray.length).getUint16(0); - state = 3; - } else if (state === 2) { - if (totalLength(chunks) < 8) { - break; - } - const headerArray = concatChunks(chunks, 8); - const view = new DataView(headerArray.buffer, headerArray.byteOffset, headerArray.length); - const n = view.getUint32(0); - if (n > Math.pow(2, 53 - 32) - 1) { - controller.enqueue(commons_js_1.ERROR_PACKET); - break; - } - expectedLength = n * Math.pow(2, 32) + view.getUint32(4); - state = 3; - } else { - if (totalLength(chunks) < expectedLength) { - break; - } - const data = concatChunks(chunks, expectedLength); - controller.enqueue((0, decodePacket_js_1.decodePacket)(isBinary ? data : TEXT_DECODER.decode(data), binaryType)); - state = 0; - } - if (expectedLength === 0 || expectedLength > maxPayload) { - controller.enqueue(commons_js_1.ERROR_PACKET); - break; - } - } - } - }); - } - exports2.protocol = 4; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/parser-v3/utf8.js -var require_utf8 = __commonJS((exports2, module2) => { - /*! https://mths.be/utf8js v2.1.2 by @mathias */ - var stringFromCharCode = String.fromCharCode; - function ucs2decode(string4) { - var output = []; - var counter = 0; - var length = string4.length; - var value; - var extra; - while (counter < length) { - value = string4.charCodeAt(counter++); - if (value >= 55296 && value <= 56319 && counter < length) { - extra = string4.charCodeAt(counter++); - if ((extra & 64512) == 56320) { - output.push(((value & 1023) << 10) + (extra & 1023) + 65536); - } else { - output.push(value); - counter--; - } - } else { - output.push(value); - } - } - return output; - } - function ucs2encode(array3) { - var length = array3.length; - var index = -1; - var value; - var output = ""; - while (++index < length) { - value = array3[index]; - if (value > 65535) { - value -= 65536; - output += stringFromCharCode(value >>> 10 & 1023 | 55296); - value = 56320 | value & 1023; - } - output += stringFromCharCode(value); - } - return output; - } - function checkScalarValue(codePoint, strict) { - if (codePoint >= 55296 && codePoint <= 57343) { - if (strict) { - throw Error("Lone surrogate U+" + codePoint.toString(16).toUpperCase() + " is not a scalar value"); - } - return false; - } - return true; - } - function createByte(codePoint, shift) { - return stringFromCharCode(codePoint >> shift & 63 | 128); - } - function encodeCodePoint(codePoint, strict) { - if ((codePoint & 4294967168) == 0) { - return stringFromCharCode(codePoint); - } - var symbol21 = ""; - if ((codePoint & 4294965248) == 0) { - symbol21 = stringFromCharCode(codePoint >> 6 & 31 | 192); - } else if ((codePoint & 4294901760) == 0) { - if (!checkScalarValue(codePoint, strict)) { - codePoint = 65533; - } - symbol21 = stringFromCharCode(codePoint >> 12 & 15 | 224); - symbol21 += createByte(codePoint, 6); - } else if ((codePoint & 4292870144) == 0) { - symbol21 = stringFromCharCode(codePoint >> 18 & 7 | 240); - symbol21 += createByte(codePoint, 12); - symbol21 += createByte(codePoint, 6); - } - symbol21 += stringFromCharCode(codePoint & 63 | 128); - return symbol21; - } - function utf8encode(string4, opts) { - opts = opts || {}; - var strict = opts.strict !== false; - var codePoints = ucs2decode(string4); - var length = codePoints.length; - var index = -1; - var codePoint; - var byteString = ""; - while (++index < length) { - codePoint = codePoints[index]; - byteString += encodeCodePoint(codePoint, strict); - } - return byteString; - } - function readContinuationByte() { - if (byteIndex >= byteCount) { - throw Error("Invalid byte index"); - } - var continuationByte = byteArray[byteIndex] & 255; - byteIndex++; - if ((continuationByte & 192) == 128) { - return continuationByte & 63; - } - throw Error("Invalid continuation byte"); - } - function decodeSymbol(strict) { - var byte1; - var byte2; - var byte3; - var byte4; - var codePoint; - if (byteIndex > byteCount) { - throw Error("Invalid byte index"); - } - if (byteIndex == byteCount) { - return false; - } - byte1 = byteArray[byteIndex] & 255; - byteIndex++; - if ((byte1 & 128) == 0) { - return byte1; - } - if ((byte1 & 224) == 192) { - byte2 = readContinuationByte(); - codePoint = (byte1 & 31) << 6 | byte2; - if (codePoint >= 128) { - return codePoint; - } else { - throw Error("Invalid continuation byte"); - } - } - if ((byte1 & 240) == 224) { - byte2 = readContinuationByte(); - byte3 = readContinuationByte(); - codePoint = (byte1 & 15) << 12 | byte2 << 6 | byte3; - if (codePoint >= 2048) { - return checkScalarValue(codePoint, strict) ? codePoint : 65533; - } else { - throw Error("Invalid continuation byte"); - } - } - if ((byte1 & 248) == 240) { - byte2 = readContinuationByte(); - byte3 = readContinuationByte(); - byte4 = readContinuationByte(); - codePoint = (byte1 & 7) << 18 | byte2 << 12 | byte3 << 6 | byte4; - if (codePoint >= 65536 && codePoint <= 1114111) { - return codePoint; - } - } - throw Error("Invalid UTF-8 detected"); - } - var byteArray; - var byteCount; - var byteIndex; - function utf8decode(byteString, opts) { - opts = opts || {}; - var strict = opts.strict !== false; - byteArray = ucs2decode(byteString); - byteCount = byteArray.length; - byteIndex = 0; - var codePoints = []; - var tmp; - while ((tmp = decodeSymbol(strict)) !== false) { - codePoints.push(tmp); - } - return ucs2encode(codePoints); - } - module2.exports = { - version: "2.1.2", - encode: utf8encode, - decode: utf8decode - }; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/parser-v3/index.js -var require_parser_v3 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.packets = exports2.protocol = undefined; - exports2.encodePacket = encodePacket; - exports2.encodeBase64Packet = encodeBase64Packet; - exports2.decodePacket = decodePacket; - exports2.decodeBase64Packet = decodeBase64Packet; - exports2.encodePayload = encodePayload; - exports2.decodePayload = decodePayload; - exports2.encodePayloadAsBinary = encodePayloadAsBinary; - exports2.decodePayloadAsBinary = decodePayloadAsBinary; - var utf8 = require_utf8(); - exports2.protocol = 3; - var hasBinary = (packets) => { - for (const packet of packets) { - if (packet.data instanceof ArrayBuffer || ArrayBuffer.isView(packet.data)) { - return true; - } - } - return false; - }; - exports2.packets = { - open: 0, - close: 1, - ping: 2, - pong: 3, - message: 4, - upgrade: 5, - noop: 6 - }; - var packetslist = Object.keys(exports2.packets); - var err = { type: "error", data: "parser error" }; - var EMPTY_BUFFER = Buffer.concat([]); - function encodePacket(packet, supportsBinary, utf8encode, callback) { - if (typeof supportsBinary === "function") { - callback = supportsBinary; - supportsBinary = null; - } - if (typeof utf8encode === "function") { - callback = utf8encode; - utf8encode = null; - } - if (Buffer.isBuffer(packet.data)) { - return encodeBuffer(packet, supportsBinary, callback); - } else if (packet.data && (packet.data.buffer || packet.data) instanceof ArrayBuffer) { - return encodeBuffer({ type: packet.type, data: arrayBufferToBuffer(packet.data) }, supportsBinary, callback); - } - var encoded = exports2.packets[packet.type]; - if (packet.data !== undefined) { - encoded += utf8encode ? utf8.encode(String(packet.data), { strict: false }) : String(packet.data); - } - return callback("" + encoded); - } - function encodeBuffer(packet, supportsBinary, callback) { - if (!supportsBinary) { - return encodeBase64Packet(packet, callback); - } - var data = packet.data; - var typeBuffer = Buffer.allocUnsafe(1); - typeBuffer[0] = exports2.packets[packet.type]; - return callback(Buffer.concat([typeBuffer, data])); - } - function encodeBase64Packet(packet, callback) { - var data = Buffer.isBuffer(packet.data) ? packet.data : arrayBufferToBuffer(packet.data); - var message = "b" + exports2.packets[packet.type]; - message += data.toString("base64"); - return callback(message); - } - function decodePacket(data, binaryType, utf8decode) { - if (data === undefined) { - return err; - } - let type; - if (typeof data === "string") { - type = data.charAt(0); - if (type === "b") { - return decodeBase64Packet(data.slice(1), binaryType); - } - if (utf8decode) { - data = tryDecode(data); - if (data === false) { - return err; - } - } - if (Number(type) != type || !packetslist[type]) { - return err; - } - if (data.length > 1) { - return { type: packetslist[type], data: data.slice(1) }; - } else { - return { type: packetslist[type] }; - } - } - if (binaryType === "arraybuffer") { - var intArray = new Uint8Array(data); - type = intArray[0]; - return { type: packetslist[type], data: intArray.buffer.slice(1) }; - } - if (data instanceof ArrayBuffer) { - data = arrayBufferToBuffer(data); - } - type = data[0]; - return { type: packetslist[type], data: data.slice(1) }; - } - function tryDecode(data) { - try { - data = utf8.decode(data, { strict: false }); - } catch (e) { - return false; - } - return data; - } - function decodeBase64Packet(msg, binaryType) { - var type = packetslist[msg.charAt(0)]; - var data = Buffer.from(msg.slice(1), "base64"); - if (binaryType === "arraybuffer") { - var abv = new Uint8Array(data.length); - for (var i = 0;i < abv.length; i++) { - abv[i] = data[i]; - } - data = abv.buffer; - } - return { type, data }; - } - function encodePayload(packets, supportsBinary, callback) { - if (typeof supportsBinary === "function") { - callback = supportsBinary; - supportsBinary = null; - } - if (supportsBinary && hasBinary(packets)) { - return encodePayloadAsBinary(packets, callback); - } - if (!packets.length) { - return callback("0:"); - } - function encodeOne(packet, doneCallback) { - encodePacket(packet, supportsBinary, false, function(message) { - doneCallback(null, setLengthHeader(message)); - }); - } - map2(packets, encodeOne, function(err2, results) { - return callback(results.join("")); - }); - } - function setLengthHeader(message) { - return message.length + ":" + message; - } - function map2(ary2, each, done) { - const results = new Array(ary2.length); - let count = 0; - for (let i = 0;i < ary2.length; i++) { - each(ary2[i], (error48, msg) => { - results[i] = msg; - if (++count === ary2.length) { - done(null, results); - } - }); - } - } - function decodePayload(data, binaryType, callback) { - if (typeof data !== "string") { - return decodePayloadAsBinary(data, binaryType, callback); - } - if (typeof binaryType === "function") { - callback = binaryType; - binaryType = null; - } - if (data === "") { - return callback(err, 0, 1); - } - var length = "", n, msg, packet; - for (var i = 0, l = data.length;i < l; i++) { - var chr = data.charAt(i); - if (chr !== ":") { - length += chr; - continue; - } - if (length === "" || length != (n = Number(length))) { - return callback(err, 0, 1); - } - msg = data.slice(i + 1, i + 1 + n); - if (length != msg.length) { - return callback(err, 0, 1); - } - if (msg.length) { - packet = decodePacket(msg, binaryType, false); - if (err.type === packet.type && err.data === packet.data) { - return callback(err, 0, 1); - } - var more = callback(packet, i + n, l); - if (more === false) - return; - } - i += n; - length = ""; - } - if (length !== "") { - return callback(err, 0, 1); - } - } - function bufferToString(buffer) { - var str = ""; - for (var i = 0, l = buffer.length;i < l; i++) { - str += String.fromCharCode(buffer[i]); - } - return str; - } - function stringToBuffer(string4) { - var buf = Buffer.allocUnsafe(string4.length); - for (var i = 0, l = string4.length;i < l; i++) { - buf.writeUInt8(string4.charCodeAt(i), i); - } - return buf; - } - function arrayBufferToBuffer(data) { - var length = data.byteLength || data.length; - var offset = data.byteOffset || 0; - return Buffer.from(data.buffer || data, offset, length); - } - function encodePayloadAsBinary(packets, callback) { - if (!packets.length) { - return callback(EMPTY_BUFFER); - } - map2(packets, encodeOneBinaryPacket, function(err2, results) { - return callback(Buffer.concat(results)); - }); - } - function encodeOneBinaryPacket(p, doneCallback) { - function onBinaryPacketEncode(packet) { - var encodingLength = "" + packet.length; - var sizeBuffer; - if (typeof packet === "string") { - sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); - sizeBuffer[0] = 0; - for (var i = 0;i < encodingLength.length; i++) { - sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); - } - sizeBuffer[sizeBuffer.length - 1] = 255; - return doneCallback(null, Buffer.concat([sizeBuffer, stringToBuffer(packet)])); - } - sizeBuffer = Buffer.allocUnsafe(encodingLength.length + 2); - sizeBuffer[0] = 1; - for (var i = 0;i < encodingLength.length; i++) { - sizeBuffer[i + 1] = parseInt(encodingLength[i], 10); - } - sizeBuffer[sizeBuffer.length - 1] = 255; - doneCallback(null, Buffer.concat([sizeBuffer, packet])); - } - encodePacket(p, true, true, onBinaryPacketEncode); - } - function decodePayloadAsBinary(data, binaryType, callback) { - if (typeof binaryType === "function") { - callback = binaryType; - binaryType = null; - } - var bufferTail = data; - var buffers = []; - var i; - while (bufferTail.length > 0) { - var strLen = ""; - var isString2 = bufferTail[0] === 0; - for (i = 1;; i++) { - if (bufferTail[i] === 255) - break; - if (strLen.length > 310) { - return callback(err, 0, 1); - } - strLen += "" + bufferTail[i]; - } - bufferTail = bufferTail.slice(strLen.length + 1); - var msgLength = parseInt(strLen, 10); - var msg = bufferTail.slice(1, msgLength + 1); - if (isString2) - msg = bufferToString(msg); - buffers.push(msg); - bufferTail = bufferTail.slice(msgLength + 1); - } - var total = buffers.length; - for (i = 0;i < total; i++) { - var buffer = buffers[i]; - callback(decodePacket(buffer, binaryType, true), i, total); - } - } -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transport.js -var require_transport = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Transport = undefined; - var events_1 = require("events"); - var parser_v4 = require_cjs(); - var parser_v3 = require_parser_v3(); - var debug_1 = require_src2(); - var debug = (0, debug_1.default)("engine:transport"); - function noop3() {} - - class Transport extends events_1.EventEmitter { - get readyState() { - return this._readyState; - } - set readyState(state) { - debug("readyState updated from %s to %s (%s)", this._readyState, state, this.name); - this._readyState = state; - } - constructor(req) { - super(); - this.writable = false; - this._readyState = "open"; - this.discarded = false; - this.protocol = req._query.EIO === "4" ? 4 : 3; - this.parser = this.protocol === 4 ? parser_v4 : parser_v3; - this.supportsBinary = !(req._query && req._query.b64); - } - discard() { - this.discarded = true; - } - onRequest(req) {} - close(fn) { - if (this.readyState === "closed" || this.readyState === "closing") - return; - this.readyState = "closing"; - this.doClose(fn || noop3); - } - onError(msg, desc) { - if (this.listeners("error").length) { - const err = new Error(msg); - err.type = "TransportError"; - err.description = desc; - this.emit("error", err); - } else { - debug("ignored transport error %s (%s)", msg, desc); - } - } - onPacket(packet) { - this.emit("packet", packet); - } - onData(data) { - this.onPacket(this.parser.decodePacket(data)); - } - onClose() { - this.readyState = "closed"; - this.emit("close"); - } - } - exports2.Transport = Transport; - Transport.upgradesTo = []; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports/polling.js -var require_polling = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Polling = undefined; - var transport_1 = require_transport(); - var zlib_1 = require("zlib"); - var accepts = require_accepts(); - var debug_1 = require_src2(); - var debug = (0, debug_1.default)("engine:polling"); - var compressionMethods = { - gzip: zlib_1.createGzip, - deflate: zlib_1.createDeflate - }; - - class Polling extends transport_1.Transport { - constructor(req) { - super(req); - this.closeTimeout = 30 * 1000; - } - get name() { - return "polling"; - } - onRequest(req) { - const res = req.res; - req.res = null; - if (req.method === "GET") { - this.onPollRequest(req, res); - } else if (req.method === "POST") { - this.onDataRequest(req, res); - } else { - res.writeHead(500); - res.end(); - } - } - onPollRequest(req, res) { - if (this.req) { - debug("request overlap"); - this.onError("overlap from client"); - res.writeHead(400); - res.end(); - return; - } - debug("setting request"); - this.req = req; - this.res = res; - const onClose = () => { - this.onError("poll connection closed prematurely"); - }; - const cleanup = () => { - req.removeListener("close", onClose); - this.req = this.res = null; - }; - req.cleanup = cleanup; - req.on("close", onClose); - this.writable = true; - this.emit("ready"); - if (this.writable && this.shouldClose) { - debug("triggering empty send to append close packet"); - this.send([{ type: "noop" }]); - } - } - onDataRequest(req, res) { - if (this.dataReq) { - this.onError("data request overlap from client"); - res.writeHead(400); - res.end(); - return; - } - const isBinary = req.headers["content-type"] === "application/octet-stream"; - if (isBinary && this.protocol === 4) { - return this.onError("invalid content"); - } - this.dataReq = req; - this.dataRes = res; - let chunks = isBinary ? Buffer.concat([]) : ""; - const cleanup = () => { - req.removeListener("data", onData); - req.removeListener("end", onEnd); - req.removeListener("close", onClose); - this.dataReq = this.dataRes = chunks = null; - }; - const onClose = () => { - cleanup(); - this.onError("data request connection closed prematurely"); - }; - const onData = (data) => { - let contentLength; - if (isBinary) { - chunks = Buffer.concat([chunks, data]); - contentLength = chunks.length; - } else { - chunks += data; - contentLength = Buffer.byteLength(chunks); - } - if (contentLength > this.maxHttpBufferSize) { - res.writeHead(413).end(); - cleanup(); - } - }; - const onEnd = () => { - this.onData(chunks); - const headers = { - "Content-Type": "text/html", - "Content-Length": "2" - }; - res.writeHead(200, this.headers(req, headers)); - res.end("ok"); - cleanup(); - }; - req.on("close", onClose); - if (!isBinary) - req.setEncoding("utf8"); - req.on("data", onData); - req.on("end", onEnd); - } - onData(data) { - debug('received "%s"', data); - const callback = (packet) => { - if (packet.type === "close") { - debug("got xhr close packet"); - this.onClose(); - return false; - } - this.onPacket(packet); - }; - if (this.protocol === 3) { - this.parser.decodePayload(data, callback); - } else { - this.parser.decodePayload(data).forEach(callback); - } - } - onClose() { - if (this.writable) { - this.send([{ type: "noop" }]); - } - super.onClose(); - } - send(packets) { - this.writable = false; - if (this.shouldClose) { - debug("appending close packet to payload"); - packets.push({ type: "close" }); - this.shouldClose(); - this.shouldClose = null; - } - const doWrite = (data) => { - const compress = packets.some((packet) => { - return packet.options && packet.options.compress; - }); - this.write(data, { compress }); - }; - if (this.protocol === 3) { - this.parser.encodePayload(packets, this.supportsBinary, doWrite); - } else { - this.parser.encodePayload(packets, doWrite); - } - } - write(data, options) { - debug('writing "%s"', data); - this.doWrite(data, options, () => { - this.req.cleanup(); - this.emit("drain"); - }); - } - doWrite(data, options, callback) { - const isString2 = typeof data === "string"; - const contentType = isString2 ? "text/plain; charset=UTF-8" : "application/octet-stream"; - const headers = { - "Content-Type": contentType - }; - const respond = (data2) => { - headers["Content-Length"] = typeof data2 === "string" ? Buffer.byteLength(data2) : data2.length; - this.res.writeHead(200, this.headers(this.req, headers)); - this.res.end(data2); - callback(); - }; - if (!this.httpCompression || !options.compress) { - respond(data); - return; - } - const len = isString2 ? Buffer.byteLength(data) : data.length; - if (len < this.httpCompression.threshold) { - respond(data); - return; - } - const encoding = accepts(this.req).encodings(["gzip", "deflate"]); - if (!encoding) { - respond(data); - return; - } - this.compress(data, encoding, (err, data2) => { - if (err) { - this.res.writeHead(500); - this.res.end(); - callback(err); - return; - } - headers["Content-Encoding"] = encoding; - respond(data2); - }); - } - compress(data, encoding, callback) { - debug("compressing"); - const buffers = []; - let nread = 0; - compressionMethods[encoding](this.httpCompression).on("error", callback).on("data", function(chunk2) { - buffers.push(chunk2); - nread += chunk2.length; - }).on("end", function() { - callback(null, Buffer.concat(buffers, nread)); - }).end(data); - } - doClose(fn) { - debug("closing"); - let closeTimeoutTimer; - if (this.dataReq) { - debug("aborting ongoing data request"); - this.dataReq.destroy(); - } - const onClose = () => { - clearTimeout(closeTimeoutTimer); - fn(); - this.onClose(); - }; - if (this.writable) { - debug("transport writable - closing right away"); - this.send([{ type: "close" }]); - onClose(); - } else if (this.discarded) { - debug("transport discarded - closing right away"); - onClose(); - } else { - debug("transport not writable - buffering orderly close"); - this.shouldClose = onClose; - closeTimeoutTimer = setTimeout(onClose, this.closeTimeout); - } - } - headers(req, headers = {}) { - const ua = req.headers["user-agent"]; - if (ua && (~ua.indexOf(";MSIE") || ~ua.indexOf("Trident/"))) { - headers["X-XSS-Protection"] = "0"; - } - headers["cache-control"] = "no-store"; - this.emit("headers", headers, req); - return headers; - } - } - exports2.Polling = Polling; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports/polling-jsonp.js -var require_polling_jsonp = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.JSONP = undefined; - var polling_1 = require_polling(); - var qs = require("querystring"); - var rDoubleSlashes = /\\\\n/g; - var rSlashes = /(\\)?\\n/g; - - class JSONP extends polling_1.Polling { - constructor(req) { - super(req); - this.head = "___eio[" + (req._query.j || "").replace(/[^0-9]/g, "") + "]("; - this.foot = ");"; - } - onData(data) { - data = qs.parse(data).d; - if (typeof data === "string") { - data = data.replace(rSlashes, function(match, slashes) { - return slashes ? match : ` -`; - }); - super.onData(data.replace(rDoubleSlashes, "\\n")); - } - } - doWrite(data, options, callback) { - const js = JSON.stringify(data).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); - data = this.head + js + this.foot; - super.doWrite(data, options, callback); - } - } - exports2.JSONP = JSONP; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports/websocket.js -var require_websocket = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.WebSocket = undefined; - var transport_1 = require_transport(); - var debug_1 = require_src2(); - var debug = (0, debug_1.default)("engine:ws"); - - class WebSocket extends transport_1.Transport { - constructor(req) { - super(req); - this._doSend = (data) => { - this.socket.send(data, this._onSent); - }; - this._doSendLast = (data) => { - this.socket.send(data, this._onSentLast); - }; - this._onSent = (err) => { - if (err) { - this.onError("write error", err.stack); - } - }; - this._onSentLast = (err) => { - if (err) { - this.onError("write error", err.stack); - } else { - this.emit("drain"); - this.writable = true; - this.emit("ready"); - } - }; - this.socket = req.websocket; - this.socket.on("message", (data, isBinary) => { - const message = isBinary ? data : data.toString(); - debug('received "%s"', message); - super.onData(message); - }); - this.socket.once("close", this.onClose.bind(this)); - this.socket.on("error", this.onError.bind(this)); - this.writable = true; - this.perMessageDeflate = null; - } - get name() { - return "websocket"; - } - get handlesUpgrades() { - return true; - } - send(packets) { - this.writable = false; - for (let i = 0;i < packets.length; i++) { - const packet = packets[i]; - const isLast = i + 1 === packets.length; - if (this._canSendPreEncodedFrame(packet)) { - this.socket._sender.sendFrame(packet.options.wsPreEncodedFrame, isLast ? this._onSentLast : this._onSent); - } else { - this.parser.encodePacket(packet, this.supportsBinary, isLast ? this._doSendLast : this._doSend); - } - } - } - _canSendPreEncodedFrame(packet) { - var _a21, _b16, _c; - return !this.perMessageDeflate && typeof ((_b16 = (_a21 = this.socket) === null || _a21 === undefined ? undefined : _a21._sender) === null || _b16 === undefined ? undefined : _b16.sendFrame) === "function" && ((_c = packet.options) === null || _c === undefined ? undefined : _c.wsPreEncodedFrame) !== undefined; - } - doClose(fn) { - debug("closing"); - this.socket.close(); - fn && fn(); - } - } - exports2.WebSocket = WebSocket; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports/webtransport.js -var require_webtransport = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.WebTransport = undefined; - var transport_1 = require_transport(); - var debug_1 = require_src2(); - var engine_io_parser_1 = require_cjs(); - var debug = (0, debug_1.default)("engine:webtransport"); - - class WebTransport extends transport_1.Transport { - constructor(session, stream, reader) { - super({ _query: { EIO: "4" } }); - this.session = session; - const transformStream = (0, engine_io_parser_1.createPacketEncoderStream)(); - transformStream.readable.pipeTo(stream.writable).catch(() => { - debug("the stream was closed"); - }); - this.writer = transformStream.writable.getWriter(); - (async () => { - try { - while (true) { - const { value, done } = await reader.read(); - if (done) { - debug("session is closed"); - break; - } - debug("received chunk: %o", value); - this.onPacket(value); - } - } catch (e) { - debug("error while reading: %s", e.message); - } - })(); - session.closed.then(() => this.onClose()); - this.writable = true; - } - get name() { - return "webtransport"; - } - async send(packets) { - this.writable = false; - try { - for (let i = 0;i < packets.length; i++) { - const packet = packets[i]; - await this.writer.write(packet); - } - } catch (e) { - debug("error while writing: %s", e.message); - } - this.emit("drain"); - this.writable = true; - this.emit("ready"); - } - doClose(fn) { - debug("closing WebTransport session"); - this.session.close(); - fn && fn(); - } - } - exports2.WebTransport = WebTransport; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports/index.js -var require_transports = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var polling_1 = require_polling(); - var polling_jsonp_1 = require_polling_jsonp(); - var websocket_1 = require_websocket(); - var webtransport_1 = require_webtransport(); - exports2.default = { - polling, - websocket: websocket_1.WebSocket, - webtransport: webtransport_1.WebTransport - }; - function polling(req) { - if (typeof req._query.j === "string") { - return new polling_jsonp_1.JSONP(req); - } else { - return new polling_1.Polling(req); - } - } - polling.upgradesTo = ["websocket", "webtransport"]; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/socket.js -var require_socket = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Socket = undefined; - var events_1 = require("events"); - var debug_1 = require_src2(); - var timers_1 = require("timers"); - var debug = (0, debug_1.default)("engine:socket"); - - class Socket extends events_1.EventEmitter { - get readyState() { - return this._readyState; - } - set readyState(state) { - debug("readyState updated from %s to %s", this._readyState, state); - this._readyState = state; - } - constructor(id, server, transport, req, protocol) { - super(); - this._readyState = "opening"; - this.upgrading = false; - this.upgraded = false; - this.writeBuffer = []; - this.packetsFn = []; - this.sentCallbackFn = []; - this.cleanupFn = []; - this.id = id; - this.server = server; - this.request = req; - this.protocol = protocol; - if (req) { - if (req.websocket && req.websocket._socket) { - this.remoteAddress = req.websocket._socket.remoteAddress; - } else { - this.remoteAddress = req.connection.remoteAddress; - } - } - this.pingTimeoutTimer = null; - this.pingIntervalTimer = null; - this.setTransport(transport); - this.onOpen(); - } - onOpen() { - this.readyState = "open"; - this.transport.sid = this.id; - this.sendPacket("open", JSON.stringify({ - sid: this.id, - upgrades: this.getAvailableUpgrades(), - pingInterval: this.server.opts.pingInterval, - pingTimeout: this.server.opts.pingTimeout, - maxPayload: this.server.opts.maxHttpBufferSize - })); - if (this.server.opts.initialPacket) { - this.sendPacket("message", this.server.opts.initialPacket); - } - this.emit("open"); - if (this.protocol === 3) { - this.resetPingTimeout(); - } else { - this.schedulePing(); - } - } - onPacket(packet) { - if (this.readyState !== "open") { - return debug("packet received with closed socket"); - } - debug(`received packet ${packet.type}`); - this.emit("packet", packet); - switch (packet.type) { - case "ping": - if (this.transport.protocol !== 3) { - this.onError(new Error("invalid heartbeat direction")); - return; - } - debug("got ping"); - this.pingTimeoutTimer.refresh(); - this.sendPacket("pong"); - this.emit("heartbeat"); - break; - case "pong": - if (this.transport.protocol === 3) { - this.onError(new Error("invalid heartbeat direction")); - return; - } - debug("got pong"); - (0, timers_1.clearTimeout)(this.pingTimeoutTimer); - this.pingIntervalTimer.refresh(); - this.emit("heartbeat"); - break; - case "error": - this.onClose("parse error"); - break; - case "message": - this.emit("data", packet.data); - this.emit("message", packet.data); - break; - } - } - onError(err) { - debug("transport error"); - this.onClose("transport error", err); - } - schedulePing() { - this.pingIntervalTimer = (0, timers_1.setTimeout)(() => { - debug("writing ping packet - expecting pong within %sms", this.server.opts.pingTimeout); - this.sendPacket("ping"); - this.resetPingTimeout(); - }, this.server.opts.pingInterval); - } - resetPingTimeout() { - (0, timers_1.clearTimeout)(this.pingTimeoutTimer); - this.pingTimeoutTimer = (0, timers_1.setTimeout)(() => { - if (this.readyState === "closed") - return; - this.onClose("ping timeout"); - }, this.protocol === 3 ? this.server.opts.pingInterval + this.server.opts.pingTimeout : this.server.opts.pingTimeout); - } - setTransport(transport) { - const onError = this.onError.bind(this); - const onReady = () => this.flush(); - const onPacket = this.onPacket.bind(this); - const onDrain = this.onDrain.bind(this); - const onClose = this.onClose.bind(this, "transport close"); - this.transport = transport; - this.transport.once("error", onError); - this.transport.on("ready", onReady); - this.transport.on("packet", onPacket); - this.transport.on("drain", onDrain); - this.transport.once("close", onClose); - this.cleanupFn.push(function() { - transport.removeListener("error", onError); - transport.removeListener("ready", onReady); - transport.removeListener("packet", onPacket); - transport.removeListener("drain", onDrain); - transport.removeListener("close", onClose); - }); - } - onDrain() { - if (this.sentCallbackFn.length > 0) { - debug("executing batch send callback"); - const seqFn = this.sentCallbackFn.shift(); - if (seqFn) { - for (let i = 0;i < seqFn.length; i++) { - seqFn[i](this.transport); - } - } - } - } - _maybeUpgrade(transport) { - debug('might upgrade socket transport from "%s" to "%s"', this.transport.name, transport.name); - this.upgrading = true; - const upgradeTimeoutTimer = (0, timers_1.setTimeout)(() => { - debug("client did not complete upgrade - closing transport"); - cleanup(); - if (transport.readyState === "open") { - transport.close(); - } - }, this.server.opts.upgradeTimeout); - let checkIntervalTimer; - const onPacket = (packet) => { - if (packet.type === "ping" && packet.data === "probe") { - debug("got probe ping packet, sending pong"); - transport.send([{ type: "pong", data: "probe" }]); - this.emit("upgrading", transport); - clearInterval(checkIntervalTimer); - checkIntervalTimer = setInterval(check2, 100); - } else if (packet.type === "upgrade" && this.readyState !== "closed") { - debug("got upgrade packet - upgrading"); - cleanup(); - this.transport.discard(); - this.upgraded = true; - this.clearTransport(); - this.setTransport(transport); - this.emit("upgrade", transport); - this.flush(); - if (this.readyState === "closing") { - transport.close(() => { - this.onClose("forced close"); - }); - } - } else { - cleanup(); - transport.close(); - } - }; - const check2 = () => { - if (this.transport.name === "polling" && this.transport.writable) { - debug("writing a noop packet to polling for fast upgrade"); - this.transport.send([{ type: "noop" }]); - } - }; - const cleanup = () => { - this.upgrading = false; - clearInterval(checkIntervalTimer); - (0, timers_1.clearTimeout)(upgradeTimeoutTimer); - transport.removeListener("packet", onPacket); - transport.removeListener("close", onTransportClose); - transport.removeListener("error", onError); - this.removeListener("close", onClose); - }; - const onError = (err) => { - debug("client did not complete upgrade - %s", err); - cleanup(); - transport.close(); - transport = null; - }; - const onTransportClose = () => { - onError("transport closed"); - }; - const onClose = () => { - onError("socket closed"); - }; - transport.on("packet", onPacket); - transport.once("close", onTransportClose); - transport.once("error", onError); - this.once("close", onClose); - } - clearTransport() { - let cleanup; - const toCleanUp = this.cleanupFn.length; - for (let i = 0;i < toCleanUp; i++) { - cleanup = this.cleanupFn.shift(); - cleanup(); - } - this.transport.on("error", function() { - debug("error triggered by discarded transport"); - }); - this.transport.close(); - (0, timers_1.clearTimeout)(this.pingTimeoutTimer); - } - onClose(reason, description) { - if (this.readyState !== "closed") { - this.readyState = "closed"; - (0, timers_1.clearTimeout)(this.pingIntervalTimer); - (0, timers_1.clearTimeout)(this.pingTimeoutTimer); - process.nextTick(() => { - this.writeBuffer = []; - }); - this.packetsFn = []; - this.sentCallbackFn = []; - this.clearTransport(); - this.emit("close", reason, description); - } - } - send(data, options, callback) { - this.sendPacket("message", data, options, callback); - return this; - } - write(data, options, callback) { - this.sendPacket("message", data, options, callback); - return this; - } - sendPacket(type, data, options = {}, callback) { - if (typeof options === "function") { - callback = options; - options = {}; - } - if (this.readyState !== "closing" && this.readyState !== "closed") { - debug('sending packet "%s" (%s)', type, data); - options.compress = options.compress !== false; - const packet = { - type, - options - }; - if (data) - packet.data = data; - this.emit("packetCreate", packet); - this.writeBuffer.push(packet); - if (typeof callback === "function") - this.packetsFn.push(callback); - this.flush(); - } - } - flush() { - if (this.readyState !== "closed" && this.transport.writable && this.writeBuffer.length) { - debug("flushing buffer to transport"); - this.emit("flush", this.writeBuffer); - this.server.emit("flush", this, this.writeBuffer); - const wbuf = this.writeBuffer; - this.writeBuffer = []; - if (this.packetsFn.length) { - this.sentCallbackFn.push(this.packetsFn); - this.packetsFn = []; - } else { - this.sentCallbackFn.push(null); - } - this.transport.send(wbuf); - this.emit("drain"); - this.server.emit("drain", this); - } - } - getAvailableUpgrades() { - const availableUpgrades = []; - const allUpgrades = this.server.upgrades(this.transport.name); - for (let i = 0;i < allUpgrades.length; ++i) { - const upg = allUpgrades[i]; - if (this.server.opts.transports.indexOf(upg) !== -1) { - availableUpgrades.push(upg); - } - } - return availableUpgrades; - } - close(discard) { - if (discard && (this.readyState === "open" || this.readyState === "closing")) { - return this.closeTransport(discard); - } - if (this.readyState !== "open") - return; - this.readyState = "closing"; - if (this.writeBuffer.length) { - debug("there are %d remaining packets in the buffer, waiting for the 'drain' event", this.writeBuffer.length); - this.once("drain", () => { - debug("all packets have been sent, closing the transport"); - this.closeTransport(discard); - }); - return; - } - debug("the buffer is empty, closing the transport right away"); - this.closeTransport(discard); - } - closeTransport(discard) { - debug("closing the transport (discard? %s)", !!discard); - if (discard) - this.transport.discard(); - this.transport.close(this.onClose.bind(this, "forced close")); - } - } - exports2.Socket = Socket; -}); - -// ../../node_modules/.pnpm/cookie@0.7.2/node_modules/cookie/index.js -var require_cookie = __commonJS((exports2) => { - /*! - * cookie - * Copyright(c) 2012-2014 Roman Shtylman - * Copyright(c) 2015 Douglas Christopher Wilson - * MIT Licensed - */ - exports2.parse = parse5; - exports2.serialize = serialize; - var __toString = Object.prototype.toString; - var __hasOwnProperty = Object.prototype.hasOwnProperty; - var cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - var cookieValueRegExp = /^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/; - var domainValueRegExp = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; - var pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; - function parse5(str, opt) { - if (typeof str !== "string") { - throw new TypeError("argument str must be a string"); - } - var obj = {}; - var len = str.length; - if (len < 2) - return obj; - var dec = opt && opt.decode || decode3; - var index = 0; - var eqIdx = 0; - var endIdx = 0; - do { - eqIdx = str.indexOf("=", index); - if (eqIdx === -1) - break; - endIdx = str.indexOf(";", index); - if (endIdx === -1) { - endIdx = len; - } else if (eqIdx > endIdx) { - index = str.lastIndexOf(";", eqIdx - 1) + 1; - continue; - } - var keyStartIdx = startIndex(str, index, eqIdx); - var keyEndIdx = endIndex(str, eqIdx, keyStartIdx); - var key = str.slice(keyStartIdx, keyEndIdx); - if (!__hasOwnProperty.call(obj, key)) { - var valStartIdx = startIndex(str, eqIdx + 1, endIdx); - var valEndIdx = endIndex(str, endIdx, valStartIdx); - if (str.charCodeAt(valStartIdx) === 34 && str.charCodeAt(valEndIdx - 1) === 34) { - valStartIdx++; - valEndIdx--; - } - var val = str.slice(valStartIdx, valEndIdx); - obj[key] = tryDecode(val, dec); - } - index = endIdx + 1; - } while (index < len); - return obj; - } - function startIndex(str, index, max) { - do { - var code = str.charCodeAt(index); - if (code !== 32 && code !== 9) - return index; - } while (++index < max); - return max; - } - function endIndex(str, index, min) { - while (index > min) { - var code = str.charCodeAt(--index); - if (code !== 32 && code !== 9) - return index + 1; - } - return min; - } - function serialize(name21, val, opt) { - var enc = opt && opt.encode || encodeURIComponent; - if (typeof enc !== "function") { - throw new TypeError("option encode is invalid"); - } - if (!cookieNameRegExp.test(name21)) { - throw new TypeError("argument name is invalid"); - } - var value = enc(val); - if (!cookieValueRegExp.test(value)) { - throw new TypeError("argument val is invalid"); - } - var str = name21 + "=" + value; - if (!opt) - return str; - if (opt.maxAge != null) { - var maxAge = Math.floor(opt.maxAge); - if (!isFinite(maxAge)) { - throw new TypeError("option maxAge is invalid"); - } - str += "; Max-Age=" + maxAge; - } - if (opt.domain) { - if (!domainValueRegExp.test(opt.domain)) { - throw new TypeError("option domain is invalid"); - } - str += "; Domain=" + opt.domain; - } - if (opt.path) { - if (!pathValueRegExp.test(opt.path)) { - throw new TypeError("option path is invalid"); - } - str += "; Path=" + opt.path; - } - if (opt.expires) { - var expires = opt.expires; - if (!isDate2(expires) || isNaN(expires.valueOf())) { - throw new TypeError("option expires is invalid"); - } - str += "; Expires=" + expires.toUTCString(); - } - if (opt.httpOnly) { - str += "; HttpOnly"; - } - if (opt.secure) { - str += "; Secure"; - } - if (opt.partitioned) { - str += "; Partitioned"; - } - if (opt.priority) { - var priority = typeof opt.priority === "string" ? opt.priority.toLowerCase() : opt.priority; - switch (priority) { - case "low": - str += "; Priority=Low"; - break; - case "medium": - str += "; Priority=Medium"; - break; - case "high": - str += "; Priority=High"; - break; - default: - throw new TypeError("option priority is invalid"); - } - } - if (opt.sameSite) { - var sameSite = typeof opt.sameSite === "string" ? opt.sameSite.toLowerCase() : opt.sameSite; - switch (sameSite) { - case true: - str += "; SameSite=Strict"; - break; - case "lax": - str += "; SameSite=Lax"; - break; - case "strict": - str += "; SameSite=Strict"; - break; - case "none": - str += "; SameSite=None"; - break; - default: - throw new TypeError("option sameSite is invalid"); - } - } - return str; - } - function decode3(str) { - return str.indexOf("%") !== -1 ? decodeURIComponent(str) : str; - } - function isDate2(val) { - return __toString.call(val) === "[object Date]"; - } - function tryDecode(str, decode4) { - try { - return decode4(str); - } catch (e) { - return str; - } - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/constants.js -var require_constants4 = __commonJS((exports2, module2) => { - var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"]; - var hasBlob = typeof Blob !== "undefined"; - if (hasBlob) - BINARY_TYPES.push("blob"); - module2.exports = { - BINARY_TYPES, - EMPTY_BUFFER: Buffer.alloc(0), - GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", - hasBlob, - kForOnEventAttribute: Symbol("kIsForOnEventAttribute"), - kListener: Symbol("kListener"), - kStatusCode: Symbol("status-code"), - kWebSocket: Symbol("websocket"), - NOOP: () => {} - }; -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/buffer-util.js -var require_buffer_util = __commonJS((exports2, module2) => { - var { EMPTY_BUFFER } = require_constants4(); - var FastBuffer = Buffer[Symbol.species]; - function concat(list, totalLength) { - if (list.length === 0) - return EMPTY_BUFFER; - if (list.length === 1) - return list[0]; - const target = Buffer.allocUnsafe(totalLength); - let offset = 0; - for (let i = 0;i < list.length; i++) { - const buf = list[i]; - target.set(buf, offset); - offset += buf.length; - } - if (offset < totalLength) { - return new FastBuffer(target.buffer, target.byteOffset, offset); - } - return target; - } - function _mask(source, mask, output, offset, length) { - for (let i = 0;i < length; i++) { - output[offset + i] = source[i] ^ mask[i & 3]; - } - } - function _unmask(buffer, mask) { - for (let i = 0;i < buffer.length; i++) { - buffer[i] ^= mask[i & 3]; - } - } - function toArrayBuffer(buf) { - if (buf.length === buf.buffer.byteLength) { - return buf.buffer; - } - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); - } - function toBuffer(data) { - toBuffer.readOnly = true; - if (Buffer.isBuffer(data)) - return data; - let buf; - if (data instanceof ArrayBuffer) { - buf = new FastBuffer(data); - } else if (ArrayBuffer.isView(data)) { - buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); - } else { - buf = Buffer.from(data); - toBuffer.readOnly = false; - } - return buf; - } - module2.exports = { - concat, - mask: _mask, - toArrayBuffer, - toBuffer, - unmask: _unmask - }; - if (!process.env.WS_NO_BUFFER_UTIL) { - try { - const bufferUtil = (()=>{throw new Error("Cannot require module "+"bufferutil");})(); - module2.exports.mask = function(source, mask, output, offset, length) { - if (length < 48) - _mask(source, mask, output, offset, length); - else - bufferUtil.mask(source, mask, output, offset, length); - }; - module2.exports.unmask = function(buffer, mask) { - if (buffer.length < 32) - _unmask(buffer, mask); - else - bufferUtil.unmask(buffer, mask); - }; - } catch (e) {} - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/limiter.js -var require_limiter = __commonJS((exports2, module2) => { - var kDone = Symbol("kDone"); - var kRun = Symbol("kRun"); - - class Limiter { - constructor(concurrency) { - this[kDone] = () => { - this.pending--; - this[kRun](); - }; - this.concurrency = concurrency || Infinity; - this.jobs = []; - this.pending = 0; - } - add(job) { - this.jobs.push(job); - this[kRun](); - } - [kRun]() { - if (this.pending === this.concurrency) - return; - if (this.jobs.length) { - const job = this.jobs.shift(); - this.pending++; - job(this[kDone]); - } - } - } - module2.exports = Limiter; -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/permessage-deflate.js -var require_permessage_deflate = __commonJS((exports2, module2) => { - var zlib3 = require("zlib"); - var bufferUtil = require_buffer_util(); - var Limiter = require_limiter(); - var { kStatusCode } = require_constants4(); - var FastBuffer = Buffer[Symbol.species]; - var TRAILER = Buffer.from([0, 0, 255, 255]); - var kPerMessageDeflate = Symbol("permessage-deflate"); - var kTotalLength = Symbol("total-length"); - var kCallback = Symbol("callback"); - var kBuffers = Symbol("buffers"); - var kError = Symbol("error"); - var zlibLimiter; - - class PerMessageDeflate { - constructor(options, isServer, maxPayload) { - this._maxPayload = maxPayload | 0; - this._options = options || {}; - this._threshold = this._options.threshold !== undefined ? this._options.threshold : 1024; - this._isServer = !!isServer; - this._deflate = null; - this._inflate = null; - this.params = null; - if (!zlibLimiter) { - const concurrency = this._options.concurrencyLimit !== undefined ? this._options.concurrencyLimit : 10; - zlibLimiter = new Limiter(concurrency); - } - } - static get extensionName() { - return "permessage-deflate"; - } - offer() { - const params = {}; - if (this._options.serverNoContextTakeover) { - params.server_no_context_takeover = true; - } - if (this._options.clientNoContextTakeover) { - params.client_no_context_takeover = true; - } - if (this._options.serverMaxWindowBits) { - params.server_max_window_bits = this._options.serverMaxWindowBits; - } - if (this._options.clientMaxWindowBits) { - params.client_max_window_bits = this._options.clientMaxWindowBits; - } else if (this._options.clientMaxWindowBits == null) { - params.client_max_window_bits = true; - } - return params; - } - accept(configurations) { - configurations = this.normalizeParams(configurations); - this.params = this._isServer ? this.acceptAsServer(configurations) : this.acceptAsClient(configurations); - return this.params; - } - cleanup() { - if (this._inflate) { - this._inflate.close(); - this._inflate = null; - } - if (this._deflate) { - const callback = this._deflate[kCallback]; - this._deflate.close(); - this._deflate = null; - if (callback) { - callback(new Error("The deflate stream was closed while data was being processed")); - } - } - } - acceptAsServer(offers) { - const opts = this._options; - const accepted = offers.find((params) => { - if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && !params.client_max_window_bits) { - return false; - } - return true; - }); - if (!accepted) { - throw new Error("None of the extension offers can be accepted"); - } - if (opts.serverNoContextTakeover) { - accepted.server_no_context_takeover = true; - } - if (opts.clientNoContextTakeover) { - accepted.client_no_context_takeover = true; - } - if (typeof opts.serverMaxWindowBits === "number") { - accepted.server_max_window_bits = opts.serverMaxWindowBits; - } - if (typeof opts.clientMaxWindowBits === "number") { - accepted.client_max_window_bits = opts.clientMaxWindowBits; - } else if (accepted.client_max_window_bits === true || opts.clientMaxWindowBits === false) { - delete accepted.client_max_window_bits; - } - return accepted; - } - acceptAsClient(response) { - const params = response[0]; - if (this._options.clientNoContextTakeover === false && params.client_no_context_takeover) { - throw new Error('Unexpected parameter "client_no_context_takeover"'); - } - if (!params.client_max_window_bits) { - if (typeof this._options.clientMaxWindowBits === "number") { - params.client_max_window_bits = this._options.clientMaxWindowBits; - } - } else if (this._options.clientMaxWindowBits === false || typeof this._options.clientMaxWindowBits === "number" && params.client_max_window_bits > this._options.clientMaxWindowBits) { - throw new Error('Unexpected or invalid parameter "client_max_window_bits"'); - } - return params; - } - normalizeParams(configurations) { - configurations.forEach((params) => { - Object.keys(params).forEach((key) => { - let value = params[key]; - if (value.length > 1) { - throw new Error(`Parameter "${key}" must have only a single value`); - } - value = value[0]; - if (key === "client_max_window_bits") { - if (value !== true) { - const num = +value; - if (!Number.isInteger(num) || num < 8 || num > 15) { - throw new TypeError(`Invalid value for parameter "${key}": ${value}`); - } - value = num; - } else if (!this._isServer) { - throw new TypeError(`Invalid value for parameter "${key}": ${value}`); - } - } else if (key === "server_max_window_bits") { - const num = +value; - if (!Number.isInteger(num) || num < 8 || num > 15) { - throw new TypeError(`Invalid value for parameter "${key}": ${value}`); - } - value = num; - } else if (key === "client_no_context_takeover" || key === "server_no_context_takeover") { - if (value !== true) { - throw new TypeError(`Invalid value for parameter "${key}": ${value}`); - } - } else { - throw new Error(`Unknown parameter "${key}"`); - } - params[key] = value; - }); - }); - return configurations; - } - decompress(data, fin, callback) { - zlibLimiter.add((done) => { - this._decompress(data, fin, (err, result) => { - done(); - callback(err, result); - }); - }); - } - compress(data, fin, callback) { - zlibLimiter.add((done) => { - this._compress(data, fin, (err, result) => { - done(); - callback(err, result); - }); - }); - } - _decompress(data, fin, callback) { - const endpoint = this._isServer ? "client" : "server"; - if (!this._inflate) { - const key = `${endpoint}_max_window_bits`; - const windowBits = typeof this.params[key] !== "number" ? zlib3.Z_DEFAULT_WINDOWBITS : this.params[key]; - this._inflate = zlib3.createInflateRaw({ - ...this._options.zlibInflateOptions, - windowBits - }); - this._inflate[kPerMessageDeflate] = this; - this._inflate[kTotalLength] = 0; - this._inflate[kBuffers] = []; - this._inflate.on("error", inflateOnError); - this._inflate.on("data", inflateOnData); - } - this._inflate[kCallback] = callback; - this._inflate.write(data); - if (fin) - this._inflate.write(TRAILER); - this._inflate.flush(() => { - const err = this._inflate[kError]; - if (err) { - this._inflate.close(); - this._inflate = null; - callback(err); - return; - } - const data2 = bufferUtil.concat(this._inflate[kBuffers], this._inflate[kTotalLength]); - if (this._inflate._readableState.endEmitted) { - this._inflate.close(); - this._inflate = null; - } else { - this._inflate[kTotalLength] = 0; - this._inflate[kBuffers] = []; - if (fin && this.params[`${endpoint}_no_context_takeover`]) { - this._inflate.reset(); - } - } - callback(null, data2); - }); - } - _compress(data, fin, callback) { - const endpoint = this._isServer ? "server" : "client"; - if (!this._deflate) { - const key = `${endpoint}_max_window_bits`; - const windowBits = typeof this.params[key] !== "number" ? zlib3.Z_DEFAULT_WINDOWBITS : this.params[key]; - this._deflate = zlib3.createDeflateRaw({ - ...this._options.zlibDeflateOptions, - windowBits - }); - this._deflate[kTotalLength] = 0; - this._deflate[kBuffers] = []; - this._deflate.on("data", deflateOnData); - } - this._deflate[kCallback] = callback; - this._deflate.write(data); - this._deflate.flush(zlib3.Z_SYNC_FLUSH, () => { - if (!this._deflate) { - return; - } - let data2 = bufferUtil.concat(this._deflate[kBuffers], this._deflate[kTotalLength]); - if (fin) { - data2 = new FastBuffer(data2.buffer, data2.byteOffset, data2.length - 4); - } - this._deflate[kCallback] = null; - this._deflate[kTotalLength] = 0; - this._deflate[kBuffers] = []; - if (fin && this.params[`${endpoint}_no_context_takeover`]) { - this._deflate.reset(); - } - callback(null, data2); - }); - } - } - module2.exports = PerMessageDeflate; - function deflateOnData(chunk2) { - this[kBuffers].push(chunk2); - this[kTotalLength] += chunk2.length; - } - function inflateOnData(chunk2) { - this[kTotalLength] += chunk2.length; - if (this[kPerMessageDeflate]._maxPayload < 1 || this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload) { - this[kBuffers].push(chunk2); - return; - } - this[kError] = new RangeError("Max payload size exceeded"); - this[kError].code = "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"; - this[kError][kStatusCode] = 1009; - this.removeListener("data", inflateOnData); - this.reset(); - } - function inflateOnError(err) { - this[kPerMessageDeflate]._inflate = null; - if (this[kError]) { - this[kCallback](this[kError]); - return; - } - err[kStatusCode] = 1007; - this[kCallback](err); - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/validation.js -var require_validation = __commonJS((exports2, module2) => { - var { isUtf8 } = require("buffer"); - var { hasBlob } = require_constants4(); - var tokenChars = [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 1, - 1, - 0, - 1, - 1, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 1, - 0, - 1, - 0 - ]; - function isValidStatusCode(code) { - return code >= 1000 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3000 && code <= 4999; - } - function _isValidUTF8(buf) { - const len = buf.length; - let i = 0; - while (i < len) { - if ((buf[i] & 128) === 0) { - i++; - } else if ((buf[i] & 224) === 192) { - if (i + 1 === len || (buf[i + 1] & 192) !== 128 || (buf[i] & 254) === 192) { - return false; - } - i += 2; - } else if ((buf[i] & 240) === 224) { - if (i + 2 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || buf[i] === 224 && (buf[i + 1] & 224) === 128 || buf[i] === 237 && (buf[i + 1] & 224) === 160) { - return false; - } - i += 3; - } else if ((buf[i] & 248) === 240) { - if (i + 3 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || (buf[i + 3] & 192) !== 128 || buf[i] === 240 && (buf[i + 1] & 240) === 128 || buf[i] === 244 && buf[i + 1] > 143 || buf[i] > 244) { - return false; - } - i += 4; - } else { - return false; - } - } - return true; - } - function isBlob2(value) { - return hasBlob && typeof value === "object" && typeof value.arrayBuffer === "function" && typeof value.type === "string" && typeof value.stream === "function" && (value[Symbol.toStringTag] === "Blob" || value[Symbol.toStringTag] === "File"); - } - module2.exports = { - isBlob: isBlob2, - isValidStatusCode, - isValidUTF8: _isValidUTF8, - tokenChars - }; - if (isUtf8) { - module2.exports.isValidUTF8 = function(buf) { - return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); - }; - } else if (!process.env.WS_NO_UTF_8_VALIDATE) { - try { - const isValidUTF8 = (()=>{throw new Error("Cannot require module "+"utf-8-validate");})(); - module2.exports.isValidUTF8 = function(buf) { - return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); - }; - } catch (e) {} - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/receiver.js -var require_receiver = __commonJS((exports2, module2) => { - var { Writable } = require("stream"); - var PerMessageDeflate = require_permessage_deflate(); - var { - BINARY_TYPES, - EMPTY_BUFFER, - kStatusCode, - kWebSocket - } = require_constants4(); - var { concat, toArrayBuffer, unmask } = require_buffer_util(); - var { isValidStatusCode, isValidUTF8 } = require_validation(); - var FastBuffer = Buffer[Symbol.species]; - var GET_INFO = 0; - var GET_PAYLOAD_LENGTH_16 = 1; - var GET_PAYLOAD_LENGTH_64 = 2; - var GET_MASK = 3; - var GET_DATA = 4; - var INFLATING = 5; - var DEFER_EVENT = 6; - - class Receiver extends Writable { - constructor(options = {}) { - super(); - this._allowSynchronousEvents = options.allowSynchronousEvents !== undefined ? options.allowSynchronousEvents : true; - this._binaryType = options.binaryType || BINARY_TYPES[0]; - this._extensions = options.extensions || {}; - this._isServer = !!options.isServer; - this._maxPayload = options.maxPayload | 0; - this._skipUTF8Validation = !!options.skipUTF8Validation; - this[kWebSocket] = undefined; - this._bufferedBytes = 0; - this._buffers = []; - this._compressed = false; - this._payloadLength = 0; - this._mask = undefined; - this._fragmented = 0; - this._masked = false; - this._fin = false; - this._opcode = 0; - this._totalPayloadLength = 0; - this._messageLength = 0; - this._fragments = []; - this._errored = false; - this._loop = false; - this._state = GET_INFO; - } - _write(chunk2, encoding, cb) { - if (this._opcode === 8 && this._state == GET_INFO) - return cb(); - this._bufferedBytes += chunk2.length; - this._buffers.push(chunk2); - this.startLoop(cb); - } - consume(n) { - this._bufferedBytes -= n; - if (n === this._buffers[0].length) - return this._buffers.shift(); - if (n < this._buffers[0].length) { - const buf = this._buffers[0]; - this._buffers[0] = new FastBuffer(buf.buffer, buf.byteOffset + n, buf.length - n); - return new FastBuffer(buf.buffer, buf.byteOffset, n); - } - const dst = Buffer.allocUnsafe(n); - do { - const buf = this._buffers[0]; - const offset = dst.length - n; - if (n >= buf.length) { - dst.set(this._buffers.shift(), offset); - } else { - dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); - this._buffers[0] = new FastBuffer(buf.buffer, buf.byteOffset + n, buf.length - n); - } - n -= buf.length; - } while (n > 0); - return dst; - } - startLoop(cb) { - this._loop = true; - do { - switch (this._state) { - case GET_INFO: - this.getInfo(cb); - break; - case GET_PAYLOAD_LENGTH_16: - this.getPayloadLength16(cb); - break; - case GET_PAYLOAD_LENGTH_64: - this.getPayloadLength64(cb); - break; - case GET_MASK: - this.getMask(); - break; - case GET_DATA: - this.getData(cb); - break; - case INFLATING: - case DEFER_EVENT: - this._loop = false; - return; - } - } while (this._loop); - if (!this._errored) - cb(); - } - getInfo(cb) { - if (this._bufferedBytes < 2) { - this._loop = false; - return; - } - const buf = this.consume(2); - if ((buf[0] & 48) !== 0) { - const error48 = this.createError(RangeError, "RSV2 and RSV3 must be clear", true, 1002, "WS_ERR_UNEXPECTED_RSV_2_3"); - cb(error48); - return; - } - const compressed = (buf[0] & 64) === 64; - if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { - const error48 = this.createError(RangeError, "RSV1 must be clear", true, 1002, "WS_ERR_UNEXPECTED_RSV_1"); - cb(error48); - return; - } - this._fin = (buf[0] & 128) === 128; - this._opcode = buf[0] & 15; - this._payloadLength = buf[1] & 127; - if (this._opcode === 0) { - if (compressed) { - const error48 = this.createError(RangeError, "RSV1 must be clear", true, 1002, "WS_ERR_UNEXPECTED_RSV_1"); - cb(error48); - return; - } - if (!this._fragmented) { - const error48 = this.createError(RangeError, "invalid opcode 0", true, 1002, "WS_ERR_INVALID_OPCODE"); - cb(error48); - return; - } - this._opcode = this._fragmented; - } else if (this._opcode === 1 || this._opcode === 2) { - if (this._fragmented) { - const error48 = this.createError(RangeError, `invalid opcode ${this._opcode}`, true, 1002, "WS_ERR_INVALID_OPCODE"); - cb(error48); - return; - } - this._compressed = compressed; - } else if (this._opcode > 7 && this._opcode < 11) { - if (!this._fin) { - const error48 = this.createError(RangeError, "FIN must be set", true, 1002, "WS_ERR_EXPECTED_FIN"); - cb(error48); - return; - } - if (compressed) { - const error48 = this.createError(RangeError, "RSV1 must be clear", true, 1002, "WS_ERR_UNEXPECTED_RSV_1"); - cb(error48); - return; - } - if (this._payloadLength > 125 || this._opcode === 8 && this._payloadLength === 1) { - const error48 = this.createError(RangeError, `invalid payload length ${this._payloadLength}`, true, 1002, "WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH"); - cb(error48); - return; - } - } else { - const error48 = this.createError(RangeError, `invalid opcode ${this._opcode}`, true, 1002, "WS_ERR_INVALID_OPCODE"); - cb(error48); - return; - } - if (!this._fin && !this._fragmented) - this._fragmented = this._opcode; - this._masked = (buf[1] & 128) === 128; - if (this._isServer) { - if (!this._masked) { - const error48 = this.createError(RangeError, "MASK must be set", true, 1002, "WS_ERR_EXPECTED_MASK"); - cb(error48); - return; - } - } else if (this._masked) { - const error48 = this.createError(RangeError, "MASK must be clear", true, 1002, "WS_ERR_UNEXPECTED_MASK"); - cb(error48); - return; - } - if (this._payloadLength === 126) - this._state = GET_PAYLOAD_LENGTH_16; - else if (this._payloadLength === 127) - this._state = GET_PAYLOAD_LENGTH_64; - else - this.haveLength(cb); - } - getPayloadLength16(cb) { - if (this._bufferedBytes < 2) { - this._loop = false; - return; - } - this._payloadLength = this.consume(2).readUInt16BE(0); - this.haveLength(cb); - } - getPayloadLength64(cb) { - if (this._bufferedBytes < 8) { - this._loop = false; - return; - } - const buf = this.consume(8); - const num = buf.readUInt32BE(0); - if (num > Math.pow(2, 53 - 32) - 1) { - const error48 = this.createError(RangeError, "Unsupported WebSocket frame: payload length > 2^53 - 1", false, 1009, "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH"); - cb(error48); - return; - } - this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); - this.haveLength(cb); - } - haveLength(cb) { - if (this._payloadLength && this._opcode < 8) { - this._totalPayloadLength += this._payloadLength; - if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { - const error48 = this.createError(RangeError, "Max payload size exceeded", false, 1009, "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"); - cb(error48); - return; - } - } - if (this._masked) - this._state = GET_MASK; - else - this._state = GET_DATA; - } - getMask() { - if (this._bufferedBytes < 4) { - this._loop = false; - return; - } - this._mask = this.consume(4); - this._state = GET_DATA; - } - getData(cb) { - let data = EMPTY_BUFFER; - if (this._payloadLength) { - if (this._bufferedBytes < this._payloadLength) { - this._loop = false; - return; - } - data = this.consume(this._payloadLength); - if (this._masked && (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0) { - unmask(data, this._mask); - } - } - if (this._opcode > 7) { - this.controlMessage(data, cb); - return; - } - if (this._compressed) { - this._state = INFLATING; - this.decompress(data, cb); - return; - } - if (data.length) { - this._messageLength = this._totalPayloadLength; - this._fragments.push(data); - } - this.dataMessage(cb); - } - decompress(data, cb) { - const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; - perMessageDeflate.decompress(data, this._fin, (err, buf) => { - if (err) - return cb(err); - if (buf.length) { - this._messageLength += buf.length; - if (this._messageLength > this._maxPayload && this._maxPayload > 0) { - const error48 = this.createError(RangeError, "Max payload size exceeded", false, 1009, "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"); - cb(error48); - return; - } - this._fragments.push(buf); - } - this.dataMessage(cb); - if (this._state === GET_INFO) - this.startLoop(cb); - }); - } - dataMessage(cb) { - if (!this._fin) { - this._state = GET_INFO; - return; - } - const messageLength = this._messageLength; - const fragments = this._fragments; - this._totalPayloadLength = 0; - this._messageLength = 0; - this._fragmented = 0; - this._fragments = []; - if (this._opcode === 2) { - let data; - if (this._binaryType === "nodebuffer") { - data = concat(fragments, messageLength); - } else if (this._binaryType === "arraybuffer") { - data = toArrayBuffer(concat(fragments, messageLength)); - } else if (this._binaryType === "blob") { - data = new Blob(fragments); - } else { - data = fragments; - } - if (this._allowSynchronousEvents) { - this.emit("message", data, true); - this._state = GET_INFO; - } else { - this._state = DEFER_EVENT; - setImmediate(() => { - this.emit("message", data, true); - this._state = GET_INFO; - this.startLoop(cb); - }); - } - } else { - const buf = concat(fragments, messageLength); - if (!this._skipUTF8Validation && !isValidUTF8(buf)) { - const error48 = this.createError(Error, "invalid UTF-8 sequence", true, 1007, "WS_ERR_INVALID_UTF8"); - cb(error48); - return; - } - if (this._state === INFLATING || this._allowSynchronousEvents) { - this.emit("message", buf, false); - this._state = GET_INFO; - } else { - this._state = DEFER_EVENT; - setImmediate(() => { - this.emit("message", buf, false); - this._state = GET_INFO; - this.startLoop(cb); - }); - } - } - } - controlMessage(data, cb) { - if (this._opcode === 8) { - if (data.length === 0) { - this._loop = false; - this.emit("conclude", 1005, EMPTY_BUFFER); - this.end(); - } else { - const code = data.readUInt16BE(0); - if (!isValidStatusCode(code)) { - const error48 = this.createError(RangeError, `invalid status code ${code}`, true, 1002, "WS_ERR_INVALID_CLOSE_CODE"); - cb(error48); - return; - } - const buf = new FastBuffer(data.buffer, data.byteOffset + 2, data.length - 2); - if (!this._skipUTF8Validation && !isValidUTF8(buf)) { - const error48 = this.createError(Error, "invalid UTF-8 sequence", true, 1007, "WS_ERR_INVALID_UTF8"); - cb(error48); - return; - } - this._loop = false; - this.emit("conclude", code, buf); - this.end(); - } - this._state = GET_INFO; - return; - } - if (this._allowSynchronousEvents) { - this.emit(this._opcode === 9 ? "ping" : "pong", data); - this._state = GET_INFO; - } else { - this._state = DEFER_EVENT; - setImmediate(() => { - this.emit(this._opcode === 9 ? "ping" : "pong", data); - this._state = GET_INFO; - this.startLoop(cb); - }); - } - } - createError(ErrorCtor, message, prefix, statusCode, errorCode) { - this._loop = false; - this._errored = true; - const err = new ErrorCtor(prefix ? `Invalid WebSocket frame: ${message}` : message); - Error.captureStackTrace(err, this.createError); - err.code = errorCode; - err[kStatusCode] = statusCode; - return err; - } - } - module2.exports = Receiver; -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/sender.js -var require_sender = __commonJS((exports2, module2) => { - var { Duplex } = require("stream"); - var { randomFillSync } = require("crypto"); - var PerMessageDeflate = require_permessage_deflate(); - var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants4(); - var { isBlob: isBlob2, isValidStatusCode } = require_validation(); - var { mask: applyMask, toBuffer } = require_buffer_util(); - var kByteLength = Symbol("kByteLength"); - var maskBuffer = Buffer.alloc(4); - var RANDOM_POOL_SIZE = 8 * 1024; - var randomPool; - var randomPoolPointer = RANDOM_POOL_SIZE; - var DEFAULT = 0; - var DEFLATING = 1; - var GET_BLOB_DATA = 2; - - class Sender { - constructor(socket, extensions2, generateMask) { - this._extensions = extensions2 || {}; - if (generateMask) { - this._generateMask = generateMask; - this._maskBuffer = Buffer.alloc(4); - } - this._socket = socket; - this._firstFragment = true; - this._compress = false; - this._bufferedBytes = 0; - this._queue = []; - this._state = DEFAULT; - this.onerror = NOOP; - this[kWebSocket] = undefined; - } - static frame(data, options) { - let mask; - let merge3 = false; - let offset = 2; - let skipMasking = false; - if (options.mask) { - mask = options.maskBuffer || maskBuffer; - if (options.generateMask) { - options.generateMask(mask); - } else { - if (randomPoolPointer === RANDOM_POOL_SIZE) { - if (randomPool === undefined) { - randomPool = Buffer.alloc(RANDOM_POOL_SIZE); - } - randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); - randomPoolPointer = 0; - } - mask[0] = randomPool[randomPoolPointer++]; - mask[1] = randomPool[randomPoolPointer++]; - mask[2] = randomPool[randomPoolPointer++]; - mask[3] = randomPool[randomPoolPointer++]; - } - skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; - offset = 6; - } - let dataLength; - if (typeof data === "string") { - if ((!options.mask || skipMasking) && options[kByteLength] !== undefined) { - dataLength = options[kByteLength]; - } else { - data = Buffer.from(data); - dataLength = data.length; - } - } else { - dataLength = data.length; - merge3 = options.mask && options.readOnly && !skipMasking; - } - let payloadLength = dataLength; - if (dataLength >= 65536) { - offset += 8; - payloadLength = 127; - } else if (dataLength > 125) { - offset += 2; - payloadLength = 126; - } - const target = Buffer.allocUnsafe(merge3 ? dataLength + offset : offset); - target[0] = options.fin ? options.opcode | 128 : options.opcode; - if (options.rsv1) - target[0] |= 64; - target[1] = payloadLength; - if (payloadLength === 126) { - target.writeUInt16BE(dataLength, 2); - } else if (payloadLength === 127) { - target[2] = target[3] = 0; - target.writeUIntBE(dataLength, 4, 6); - } - if (!options.mask) - return [target, data]; - target[1] |= 128; - target[offset - 4] = mask[0]; - target[offset - 3] = mask[1]; - target[offset - 2] = mask[2]; - target[offset - 1] = mask[3]; - if (skipMasking) - return [target, data]; - if (merge3) { - applyMask(data, mask, target, offset, dataLength); - return [target]; - } - applyMask(data, mask, data, 0, dataLength); - return [target, data]; - } - close(code, data, mask, cb) { - let buf; - if (code === undefined) { - buf = EMPTY_BUFFER; - } else if (typeof code !== "number" || !isValidStatusCode(code)) { - throw new TypeError("First argument must be a valid error code number"); - } else if (data === undefined || !data.length) { - buf = Buffer.allocUnsafe(2); - buf.writeUInt16BE(code, 0); - } else { - const length = Buffer.byteLength(data); - if (length > 123) { - throw new RangeError("The message must not be greater than 123 bytes"); - } - buf = Buffer.allocUnsafe(2 + length); - buf.writeUInt16BE(code, 0); - if (typeof data === "string") { - buf.write(data, 2); - } else { - buf.set(data, 2); - } - } - const options = { - [kByteLength]: buf.length, - fin: true, - generateMask: this._generateMask, - mask, - maskBuffer: this._maskBuffer, - opcode: 8, - readOnly: false, - rsv1: false - }; - if (this._state !== DEFAULT) { - this.enqueue([this.dispatch, buf, false, options, cb]); - } else { - this.sendFrame(Sender.frame(buf, options), cb); - } - } - ping(data, mask, cb) { - let byteLength; - let readOnly; - if (typeof data === "string") { - byteLength = Buffer.byteLength(data); - readOnly = false; - } else if (isBlob2(data)) { - byteLength = data.size; - readOnly = false; - } else { - data = toBuffer(data); - byteLength = data.length; - readOnly = toBuffer.readOnly; - } - if (byteLength > 125) { - throw new RangeError("The data size must not be greater than 125 bytes"); - } - const options = { - [kByteLength]: byteLength, - fin: true, - generateMask: this._generateMask, - mask, - maskBuffer: this._maskBuffer, - opcode: 9, - readOnly, - rsv1: false - }; - if (isBlob2(data)) { - if (this._state !== DEFAULT) { - this.enqueue([this.getBlobData, data, false, options, cb]); - } else { - this.getBlobData(data, false, options, cb); - } - } else if (this._state !== DEFAULT) { - this.enqueue([this.dispatch, data, false, options, cb]); - } else { - this.sendFrame(Sender.frame(data, options), cb); - } - } - pong(data, mask, cb) { - let byteLength; - let readOnly; - if (typeof data === "string") { - byteLength = Buffer.byteLength(data); - readOnly = false; - } else if (isBlob2(data)) { - byteLength = data.size; - readOnly = false; - } else { - data = toBuffer(data); - byteLength = data.length; - readOnly = toBuffer.readOnly; - } - if (byteLength > 125) { - throw new RangeError("The data size must not be greater than 125 bytes"); - } - const options = { - [kByteLength]: byteLength, - fin: true, - generateMask: this._generateMask, - mask, - maskBuffer: this._maskBuffer, - opcode: 10, - readOnly, - rsv1: false - }; - if (isBlob2(data)) { - if (this._state !== DEFAULT) { - this.enqueue([this.getBlobData, data, false, options, cb]); - } else { - this.getBlobData(data, false, options, cb); - } - } else if (this._state !== DEFAULT) { - this.enqueue([this.dispatch, data, false, options, cb]); - } else { - this.sendFrame(Sender.frame(data, options), cb); - } - } - send(data, options, cb) { - const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; - let opcode = options.binary ? 2 : 1; - let rsv1 = options.compress; - let byteLength; - let readOnly; - if (typeof data === "string") { - byteLength = Buffer.byteLength(data); - readOnly = false; - } else if (isBlob2(data)) { - byteLength = data.size; - readOnly = false; - } else { - data = toBuffer(data); - byteLength = data.length; - readOnly = toBuffer.readOnly; - } - if (this._firstFragment) { - this._firstFragment = false; - if (rsv1 && perMessageDeflate && perMessageDeflate.params[perMessageDeflate._isServer ? "server_no_context_takeover" : "client_no_context_takeover"]) { - rsv1 = byteLength >= perMessageDeflate._threshold; - } - this._compress = rsv1; - } else { - rsv1 = false; - opcode = 0; - } - if (options.fin) - this._firstFragment = true; - const opts = { - [kByteLength]: byteLength, - fin: options.fin, - generateMask: this._generateMask, - mask: options.mask, - maskBuffer: this._maskBuffer, - opcode, - readOnly, - rsv1 - }; - if (isBlob2(data)) { - if (this._state !== DEFAULT) { - this.enqueue([this.getBlobData, data, this._compress, opts, cb]); - } else { - this.getBlobData(data, this._compress, opts, cb); - } - } else if (this._state !== DEFAULT) { - this.enqueue([this.dispatch, data, this._compress, opts, cb]); - } else { - this.dispatch(data, this._compress, opts, cb); - } - } - getBlobData(blob, compress, options, cb) { - this._bufferedBytes += options[kByteLength]; - this._state = GET_BLOB_DATA; - blob.arrayBuffer().then((arrayBuffer) => { - if (this._socket.destroyed) { - const err = new Error("The socket was closed while the blob was being read"); - process.nextTick(callCallbacks, this, err, cb); - return; - } - this._bufferedBytes -= options[kByteLength]; - const data = toBuffer(arrayBuffer); - if (!compress) { - this._state = DEFAULT; - this.sendFrame(Sender.frame(data, options), cb); - this.dequeue(); - } else { - this.dispatch(data, compress, options, cb); - } - }).catch((err) => { - process.nextTick(onError, this, err, cb); - }); - } - dispatch(data, compress, options, cb) { - if (!compress) { - this.sendFrame(Sender.frame(data, options), cb); - return; - } - const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; - this._bufferedBytes += options[kByteLength]; - this._state = DEFLATING; - perMessageDeflate.compress(data, options.fin, (_, buf) => { - if (this._socket.destroyed) { - const err = new Error("The socket was closed while data was being compressed"); - callCallbacks(this, err, cb); - return; - } - this._bufferedBytes -= options[kByteLength]; - this._state = DEFAULT; - options.readOnly = false; - this.sendFrame(Sender.frame(buf, options), cb); - this.dequeue(); - }); - } - dequeue() { - while (this._state === DEFAULT && this._queue.length) { - const params = this._queue.shift(); - this._bufferedBytes -= params[3][kByteLength]; - Reflect.apply(params[0], this, params.slice(1)); - } - } - enqueue(params) { - this._bufferedBytes += params[3][kByteLength]; - this._queue.push(params); - } - sendFrame(list, cb) { - if (list.length === 2) { - this._socket.cork(); - this._socket.write(list[0]); - this._socket.write(list[1], cb); - this._socket.uncork(); - } else { - this._socket.write(list[0], cb); - } - } - } - module2.exports = Sender; - function callCallbacks(sender, err, cb) { - if (typeof cb === "function") - cb(err); - for (let i = 0;i < sender._queue.length; i++) { - const params = sender._queue[i]; - const callback = params[params.length - 1]; - if (typeof callback === "function") - callback(err); - } - } - function onError(sender, err, cb) { - callCallbacks(sender, err, cb); - sender.onerror(err); - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/event-target.js -var require_event_target = __commonJS((exports2, module2) => { - var { kForOnEventAttribute, kListener } = require_constants4(); - var kCode = Symbol("kCode"); - var kData = Symbol("kData"); - var kError = Symbol("kError"); - var kMessage = Symbol("kMessage"); - var kReason = Symbol("kReason"); - var kTarget = Symbol("kTarget"); - var kType = Symbol("kType"); - var kWasClean = Symbol("kWasClean"); - - class Event { - constructor(type) { - this[kTarget] = null; - this[kType] = type; - } - get target() { - return this[kTarget]; - } - get type() { - return this[kType]; - } - } - Object.defineProperty(Event.prototype, "target", { enumerable: true }); - Object.defineProperty(Event.prototype, "type", { enumerable: true }); - - class CloseEvent extends Event { - constructor(type, options = {}) { - super(type); - this[kCode] = options.code === undefined ? 0 : options.code; - this[kReason] = options.reason === undefined ? "" : options.reason; - this[kWasClean] = options.wasClean === undefined ? false : options.wasClean; - } - get code() { - return this[kCode]; - } - get reason() { - return this[kReason]; - } - get wasClean() { - return this[kWasClean]; - } - } - Object.defineProperty(CloseEvent.prototype, "code", { enumerable: true }); - Object.defineProperty(CloseEvent.prototype, "reason", { enumerable: true }); - Object.defineProperty(CloseEvent.prototype, "wasClean", { enumerable: true }); - - class ErrorEvent extends Event { - constructor(type, options = {}) { - super(type); - this[kError] = options.error === undefined ? null : options.error; - this[kMessage] = options.message === undefined ? "" : options.message; - } - get error() { - return this[kError]; - } - get message() { - return this[kMessage]; - } - } - Object.defineProperty(ErrorEvent.prototype, "error", { enumerable: true }); - Object.defineProperty(ErrorEvent.prototype, "message", { enumerable: true }); - - class MessageEvent extends Event { - constructor(type, options = {}) { - super(type); - this[kData] = options.data === undefined ? null : options.data; - } - get data() { - return this[kData]; - } - } - Object.defineProperty(MessageEvent.prototype, "data", { enumerable: true }); - var EventTarget = { - addEventListener(type, handler, options = {}) { - for (const listener of this.listeners(type)) { - if (!options[kForOnEventAttribute] && listener[kListener] === handler && !listener[kForOnEventAttribute]) { - return; - } - } - let wrapper; - if (type === "message") { - wrapper = function onMessage(data, isBinary) { - const event = new MessageEvent("message", { - data: isBinary ? data : data.toString() - }); - event[kTarget] = this; - callListener(handler, this, event); - }; - } else if (type === "close") { - wrapper = function onClose(code, message) { - const event = new CloseEvent("close", { - code, - reason: message.toString(), - wasClean: this._closeFrameReceived && this._closeFrameSent - }); - event[kTarget] = this; - callListener(handler, this, event); - }; - } else if (type === "error") { - wrapper = function onError(error48) { - const event = new ErrorEvent("error", { - error: error48, - message: error48.message - }); - event[kTarget] = this; - callListener(handler, this, event); - }; - } else if (type === "open") { - wrapper = function onOpen() { - const event = new Event("open"); - event[kTarget] = this; - callListener(handler, this, event); - }; - } else { - return; - } - wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; - wrapper[kListener] = handler; - if (options.once) { - this.once(type, wrapper); - } else { - this.on(type, wrapper); - } - }, - removeEventListener(type, handler) { - for (const listener of this.listeners(type)) { - if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { - this.removeListener(type, listener); - break; - } - } - } - }; - module2.exports = { - CloseEvent, - ErrorEvent, - Event, - EventTarget, - MessageEvent - }; - function callListener(listener, thisArg, event) { - if (typeof listener === "object" && listener.handleEvent) { - listener.handleEvent.call(listener, event); - } else { - listener.call(thisArg, event); - } - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/extension.js -var require_extension = __commonJS((exports2, module2) => { - var { tokenChars } = require_validation(); - function push(dest, name21, elem) { - if (dest[name21] === undefined) - dest[name21] = [elem]; - else - dest[name21].push(elem); - } - function parse5(header) { - const offers = Object.create(null); - let params = Object.create(null); - let mustUnescape = false; - let isEscaping = false; - let inQuotes = false; - let extensionName; - let paramName; - let start = -1; - let code = -1; - let end = -1; - let i = 0; - for (;i < header.length; i++) { - code = header.charCodeAt(i); - if (extensionName === undefined) { - if (end === -1 && tokenChars[code] === 1) { - if (start === -1) - start = i; - } else if (i !== 0 && (code === 32 || code === 9)) { - if (end === -1 && start !== -1) - end = i; - } else if (code === 59 || code === 44) { - if (start === -1) { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - if (end === -1) - end = i; - const name21 = header.slice(start, end); - if (code === 44) { - push(offers, name21, params); - params = Object.create(null); - } else { - extensionName = name21; - } - start = end = -1; - } else { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - } else if (paramName === undefined) { - if (end === -1 && tokenChars[code] === 1) { - if (start === -1) - start = i; - } else if (code === 32 || code === 9) { - if (end === -1 && start !== -1) - end = i; - } else if (code === 59 || code === 44) { - if (start === -1) { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - if (end === -1) - end = i; - push(params, header.slice(start, end), true); - if (code === 44) { - push(offers, extensionName, params); - params = Object.create(null); - extensionName = undefined; - } - start = end = -1; - } else if (code === 61 && start !== -1 && end === -1) { - paramName = header.slice(start, i); - start = end = -1; - } else { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - } else { - if (isEscaping) { - if (tokenChars[code] !== 1) { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - if (start === -1) - start = i; - else if (!mustUnescape) - mustUnescape = true; - isEscaping = false; - } else if (inQuotes) { - if (tokenChars[code] === 1) { - if (start === -1) - start = i; - } else if (code === 34 && start !== -1) { - inQuotes = false; - end = i; - } else if (code === 92) { - isEscaping = true; - } else { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - } else if (code === 34 && header.charCodeAt(i - 1) === 61) { - inQuotes = true; - } else if (end === -1 && tokenChars[code] === 1) { - if (start === -1) - start = i; - } else if (start !== -1 && (code === 32 || code === 9)) { - if (end === -1) - end = i; - } else if (code === 59 || code === 44) { - if (start === -1) { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - if (end === -1) - end = i; - let value = header.slice(start, end); - if (mustUnescape) { - value = value.replace(/\\/g, ""); - mustUnescape = false; - } - push(params, paramName, value); - if (code === 44) { - push(offers, extensionName, params); - params = Object.create(null); - extensionName = undefined; - } - paramName = undefined; - start = end = -1; - } else { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - } - } - if (start === -1 || inQuotes || code === 32 || code === 9) { - throw new SyntaxError("Unexpected end of input"); - } - if (end === -1) - end = i; - const token = header.slice(start, end); - if (extensionName === undefined) { - push(offers, token, params); - } else { - if (paramName === undefined) { - push(params, token, true); - } else if (mustUnescape) { - push(params, paramName, token.replace(/\\/g, "")); - } else { - push(params, paramName, token); - } - push(offers, extensionName, params); - } - return offers; - } - function format(extensions2) { - return Object.keys(extensions2).map((extension) => { - let configurations = extensions2[extension]; - if (!Array.isArray(configurations)) - configurations = [configurations]; - return configurations.map((params) => { - return [extension].concat(Object.keys(params).map((k) => { - let values = params[k]; - if (!Array.isArray(values)) - values = [values]; - return values.map((v) => v === true ? k : `${k}=${v}`).join("; "); - })).join("; "); - }).join(", "); - }).join(", "); - } - module2.exports = { format, parse: parse5 }; -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/websocket.js -var require_websocket2 = __commonJS((exports2, module2) => { - var EventEmitter = require("events"); - var https3 = require("https"); - var http4 = require("http"); - var net2 = require("net"); - var tls = require("tls"); - var { randomBytes, createHash } = require("crypto"); - var { Duplex, Readable: Readable3 } = require("stream"); - var { URL: URL2 } = require("url"); - var PerMessageDeflate = require_permessage_deflate(); - var Receiver = require_receiver(); - var Sender = require_sender(); - var { isBlob: isBlob2 } = require_validation(); - var { - BINARY_TYPES, - EMPTY_BUFFER, - GUID, - kForOnEventAttribute, - kListener, - kStatusCode, - kWebSocket, - NOOP - } = require_constants4(); - var { - EventTarget: { addEventListener, removeEventListener } - } = require_event_target(); - var { format, parse: parse5 } = require_extension(); - var { toBuffer } = require_buffer_util(); - var closeTimeout = 30 * 1000; - var kAborted = Symbol("kAborted"); - var protocolVersions = [8, 13]; - var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; - var subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; - - class WebSocket extends EventEmitter { - constructor(address, protocols, options) { - super(); - this._binaryType = BINARY_TYPES[0]; - this._closeCode = 1006; - this._closeFrameReceived = false; - this._closeFrameSent = false; - this._closeMessage = EMPTY_BUFFER; - this._closeTimer = null; - this._errorEmitted = false; - this._extensions = {}; - this._paused = false; - this._protocol = ""; - this._readyState = WebSocket.CONNECTING; - this._receiver = null; - this._sender = null; - this._socket = null; - if (address !== null) { - this._bufferedAmount = 0; - this._isServer = false; - this._redirects = 0; - if (protocols === undefined) { - protocols = []; - } else if (!Array.isArray(protocols)) { - if (typeof protocols === "object" && protocols !== null) { - options = protocols; - protocols = []; - } else { - protocols = [protocols]; - } - } - initAsClient(this, address, protocols, options); - } else { - this._autoPong = options.autoPong; - this._isServer = true; - } - } - get binaryType() { - return this._binaryType; - } - set binaryType(type) { - if (!BINARY_TYPES.includes(type)) - return; - this._binaryType = type; - if (this._receiver) - this._receiver._binaryType = type; - } - get bufferedAmount() { - if (!this._socket) - return this._bufferedAmount; - return this._socket._writableState.length + this._sender._bufferedBytes; - } - get extensions() { - return Object.keys(this._extensions).join(); - } - get isPaused() { - return this._paused; - } - get onclose() { - return null; - } - get onerror() { - return null; - } - get onopen() { - return null; - } - get onmessage() { - return null; - } - get protocol() { - return this._protocol; - } - get readyState() { - return this._readyState; - } - get url() { - return this._url; - } - setSocket(socket, head2, options) { - const receiver = new Receiver({ - allowSynchronousEvents: options.allowSynchronousEvents, - binaryType: this.binaryType, - extensions: this._extensions, - isServer: this._isServer, - maxPayload: options.maxPayload, - skipUTF8Validation: options.skipUTF8Validation - }); - const sender = new Sender(socket, this._extensions, options.generateMask); - this._receiver = receiver; - this._sender = sender; - this._socket = socket; - receiver[kWebSocket] = this; - sender[kWebSocket] = this; - socket[kWebSocket] = this; - receiver.on("conclude", receiverOnConclude); - receiver.on("drain", receiverOnDrain); - receiver.on("error", receiverOnError); - receiver.on("message", receiverOnMessage); - receiver.on("ping", receiverOnPing); - receiver.on("pong", receiverOnPong); - sender.onerror = senderOnError; - if (socket.setTimeout) - socket.setTimeout(0); - if (socket.setNoDelay) - socket.setNoDelay(); - if (head2.length > 0) - socket.unshift(head2); - socket.on("close", socketOnClose); - socket.on("data", socketOnData); - socket.on("end", socketOnEnd); - socket.on("error", socketOnError); - this._readyState = WebSocket.OPEN; - this.emit("open"); - } - emitClose() { - if (!this._socket) { - this._readyState = WebSocket.CLOSED; - this.emit("close", this._closeCode, this._closeMessage); - return; - } - if (this._extensions[PerMessageDeflate.extensionName]) { - this._extensions[PerMessageDeflate.extensionName].cleanup(); - } - this._receiver.removeAllListeners(); - this._readyState = WebSocket.CLOSED; - this.emit("close", this._closeCode, this._closeMessage); - } - close(code, data) { - if (this.readyState === WebSocket.CLOSED) - return; - if (this.readyState === WebSocket.CONNECTING) { - const msg = "WebSocket was closed before the connection was established"; - abortHandshake(this, this._req, msg); - return; - } - if (this.readyState === WebSocket.CLOSING) { - if (this._closeFrameSent && (this._closeFrameReceived || this._receiver._writableState.errorEmitted)) { - this._socket.end(); - } - return; - } - this._readyState = WebSocket.CLOSING; - this._sender.close(code, data, !this._isServer, (err) => { - if (err) - return; - this._closeFrameSent = true; - if (this._closeFrameReceived || this._receiver._writableState.errorEmitted) { - this._socket.end(); - } - }); - setCloseTimer(this); - } - pause() { - if (this.readyState === WebSocket.CONNECTING || this.readyState === WebSocket.CLOSED) { - return; - } - this._paused = true; - this._socket.pause(); - } - ping(data, mask, cb) { - if (this.readyState === WebSocket.CONNECTING) { - throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); - } - if (typeof data === "function") { - cb = data; - data = mask = undefined; - } else if (typeof mask === "function") { - cb = mask; - mask = undefined; - } - if (typeof data === "number") - data = data.toString(); - if (this.readyState !== WebSocket.OPEN) { - sendAfterClose(this, data, cb); - return; - } - if (mask === undefined) - mask = !this._isServer; - this._sender.ping(data || EMPTY_BUFFER, mask, cb); - } - pong(data, mask, cb) { - if (this.readyState === WebSocket.CONNECTING) { - throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); - } - if (typeof data === "function") { - cb = data; - data = mask = undefined; - } else if (typeof mask === "function") { - cb = mask; - mask = undefined; - } - if (typeof data === "number") - data = data.toString(); - if (this.readyState !== WebSocket.OPEN) { - sendAfterClose(this, data, cb); - return; - } - if (mask === undefined) - mask = !this._isServer; - this._sender.pong(data || EMPTY_BUFFER, mask, cb); - } - resume() { - if (this.readyState === WebSocket.CONNECTING || this.readyState === WebSocket.CLOSED) { - return; - } - this._paused = false; - if (!this._receiver._writableState.needDrain) - this._socket.resume(); - } - send(data, options, cb) { - if (this.readyState === WebSocket.CONNECTING) { - throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); - } - if (typeof options === "function") { - cb = options; - options = {}; - } - if (typeof data === "number") - data = data.toString(); - if (this.readyState !== WebSocket.OPEN) { - sendAfterClose(this, data, cb); - return; - } - const opts = { - binary: typeof data !== "string", - mask: !this._isServer, - compress: true, - fin: true, - ...options - }; - if (!this._extensions[PerMessageDeflate.extensionName]) { - opts.compress = false; - } - this._sender.send(data || EMPTY_BUFFER, opts, cb); - } - terminate() { - if (this.readyState === WebSocket.CLOSED) - return; - if (this.readyState === WebSocket.CONNECTING) { - const msg = "WebSocket was closed before the connection was established"; - abortHandshake(this, this._req, msg); - return; - } - if (this._socket) { - this._readyState = WebSocket.CLOSING; - this._socket.destroy(); - } - } - } - Object.defineProperty(WebSocket, "CONNECTING", { - enumerable: true, - value: readyStates.indexOf("CONNECTING") - }); - Object.defineProperty(WebSocket.prototype, "CONNECTING", { - enumerable: true, - value: readyStates.indexOf("CONNECTING") - }); - Object.defineProperty(WebSocket, "OPEN", { - enumerable: true, - value: readyStates.indexOf("OPEN") - }); - Object.defineProperty(WebSocket.prototype, "OPEN", { - enumerable: true, - value: readyStates.indexOf("OPEN") - }); - Object.defineProperty(WebSocket, "CLOSING", { - enumerable: true, - value: readyStates.indexOf("CLOSING") - }); - Object.defineProperty(WebSocket.prototype, "CLOSING", { - enumerable: true, - value: readyStates.indexOf("CLOSING") - }); - Object.defineProperty(WebSocket, "CLOSED", { - enumerable: true, - value: readyStates.indexOf("CLOSED") - }); - Object.defineProperty(WebSocket.prototype, "CLOSED", { - enumerable: true, - value: readyStates.indexOf("CLOSED") - }); - [ - "binaryType", - "bufferedAmount", - "extensions", - "isPaused", - "protocol", - "readyState", - "url" - ].forEach((property) => { - Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); - }); - ["open", "error", "close", "message"].forEach((method) => { - Object.defineProperty(WebSocket.prototype, `on${method}`, { - enumerable: true, - get() { - for (const listener of this.listeners(method)) { - if (listener[kForOnEventAttribute]) - return listener[kListener]; - } - return null; - }, - set(handler) { - for (const listener of this.listeners(method)) { - if (listener[kForOnEventAttribute]) { - this.removeListener(method, listener); - break; - } - } - if (typeof handler !== "function") - return; - this.addEventListener(method, handler, { - [kForOnEventAttribute]: true - }); - } - }); - }); - WebSocket.prototype.addEventListener = addEventListener; - WebSocket.prototype.removeEventListener = removeEventListener; - module2.exports = WebSocket; - function initAsClient(websocket, address, protocols, options) { - const opts = { - allowSynchronousEvents: true, - autoPong: true, - protocolVersion: protocolVersions[1], - maxPayload: 100 * 1024 * 1024, - skipUTF8Validation: false, - perMessageDeflate: true, - followRedirects: false, - maxRedirects: 10, - ...options, - socketPath: undefined, - hostname: undefined, - protocol: undefined, - timeout: undefined, - method: "GET", - host: undefined, - path: undefined, - port: undefined - }; - websocket._autoPong = opts.autoPong; - if (!protocolVersions.includes(opts.protocolVersion)) { - throw new RangeError(`Unsupported protocol version: ${opts.protocolVersion} ` + `(supported versions: ${protocolVersions.join(", ")})`); - } - let parsedUrl; - if (address instanceof URL2) { - parsedUrl = address; - } else { - try { - parsedUrl = new URL2(address); - } catch (e) { - throw new SyntaxError(`Invalid URL: ${address}`); - } - } - if (parsedUrl.protocol === "http:") { - parsedUrl.protocol = "ws:"; - } else if (parsedUrl.protocol === "https:") { - parsedUrl.protocol = "wss:"; - } - websocket._url = parsedUrl.href; - const isSecure = parsedUrl.protocol === "wss:"; - const isIpcUrl = parsedUrl.protocol === "ws+unix:"; - let invalidUrlMessage; - if (parsedUrl.protocol !== "ws:" && !isSecure && !isIpcUrl) { - invalidUrlMessage = `The URL's protocol must be one of "ws:", "wss:", ` + '"http:", "https:", or "ws+unix:"'; - } else if (isIpcUrl && !parsedUrl.pathname) { - invalidUrlMessage = "The URL's pathname is empty"; - } else if (parsedUrl.hash) { - invalidUrlMessage = "The URL contains a fragment identifier"; - } - if (invalidUrlMessage) { - const err = new SyntaxError(invalidUrlMessage); - if (websocket._redirects === 0) { - throw err; - } else { - emitErrorAndClose(websocket, err); - return; - } - } - const defaultPort = isSecure ? 443 : 80; - const key = randomBytes(16).toString("base64"); - const request = isSecure ? https3.request : http4.request; - const protocolSet = new Set; - let perMessageDeflate; - opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect); - opts.defaultPort = opts.defaultPort || defaultPort; - opts.port = parsedUrl.port || defaultPort; - opts.host = parsedUrl.hostname.startsWith("[") ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; - opts.headers = { - ...opts.headers, - "Sec-WebSocket-Version": opts.protocolVersion, - "Sec-WebSocket-Key": key, - Connection: "Upgrade", - Upgrade: "websocket" - }; - opts.path = parsedUrl.pathname + parsedUrl.search; - opts.timeout = opts.handshakeTimeout; - if (opts.perMessageDeflate) { - perMessageDeflate = new PerMessageDeflate(opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, false, opts.maxPayload); - opts.headers["Sec-WebSocket-Extensions"] = format({ - [PerMessageDeflate.extensionName]: perMessageDeflate.offer() - }); - } - if (protocols.length) { - for (const protocol of protocols) { - if (typeof protocol !== "string" || !subprotocolRegex.test(protocol) || protocolSet.has(protocol)) { - throw new SyntaxError("An invalid or duplicated subprotocol was specified"); - } - protocolSet.add(protocol); - } - opts.headers["Sec-WebSocket-Protocol"] = protocols.join(","); - } - if (opts.origin) { - if (opts.protocolVersion < 13) { - opts.headers["Sec-WebSocket-Origin"] = opts.origin; - } else { - opts.headers.Origin = opts.origin; - } - } - if (parsedUrl.username || parsedUrl.password) { - opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; - } - if (isIpcUrl) { - const parts = opts.path.split(":"); - opts.socketPath = parts[0]; - opts.path = parts[1]; - } - let req; - if (opts.followRedirects) { - if (websocket._redirects === 0) { - websocket._originalIpc = isIpcUrl; - websocket._originalSecure = isSecure; - websocket._originalHostOrSocketPath = isIpcUrl ? opts.socketPath : parsedUrl.host; - const headers = options && options.headers; - options = { ...options, headers: {} }; - if (headers) { - for (const [key2, value] of Object.entries(headers)) { - options.headers[key2.toLowerCase()] = value; - } - } - } else if (websocket.listenerCount("redirect") === 0) { - const isSameHost = isIpcUrl ? websocket._originalIpc ? opts.socketPath === websocket._originalHostOrSocketPath : false : websocket._originalIpc ? false : parsedUrl.host === websocket._originalHostOrSocketPath; - if (!isSameHost || websocket._originalSecure && !isSecure) { - delete opts.headers.authorization; - delete opts.headers.cookie; - if (!isSameHost) - delete opts.headers.host; - opts.auth = undefined; - } - } - if (opts.auth && !options.headers.authorization) { - options.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64"); - } - req = websocket._req = request(opts); - if (websocket._redirects) { - websocket.emit("redirect", websocket.url, req); - } - } else { - req = websocket._req = request(opts); - } - if (opts.timeout) { - req.on("timeout", () => { - abortHandshake(websocket, req, "Opening handshake has timed out"); - }); - } - req.on("error", (err) => { - if (req === null || req[kAborted]) - return; - req = websocket._req = null; - emitErrorAndClose(websocket, err); - }); - req.on("response", (res) => { - const location = res.headers.location; - const statusCode = res.statusCode; - if (location && opts.followRedirects && statusCode >= 300 && statusCode < 400) { - if (++websocket._redirects > opts.maxRedirects) { - abortHandshake(websocket, req, "Maximum redirects exceeded"); - return; - } - req.abort(); - let addr; - try { - addr = new URL2(location, address); - } catch (e) { - const err = new SyntaxError(`Invalid URL: ${location}`); - emitErrorAndClose(websocket, err); - return; - } - initAsClient(websocket, addr, protocols, options); - } else if (!websocket.emit("unexpected-response", req, res)) { - abortHandshake(websocket, req, `Unexpected server response: ${res.statusCode}`); - } - }); - req.on("upgrade", (res, socket, head2) => { - websocket.emit("upgrade", res); - if (websocket.readyState !== WebSocket.CONNECTING) - return; - req = websocket._req = null; - const upgrade = res.headers.upgrade; - if (upgrade === undefined || upgrade.toLowerCase() !== "websocket") { - abortHandshake(websocket, socket, "Invalid Upgrade header"); - return; - } - const digest = createHash("sha1").update(key + GUID).digest("base64"); - if (res.headers["sec-websocket-accept"] !== digest) { - abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); - return; - } - const serverProt = res.headers["sec-websocket-protocol"]; - let protError; - if (serverProt !== undefined) { - if (!protocolSet.size) { - protError = "Server sent a subprotocol but none was requested"; - } else if (!protocolSet.has(serverProt)) { - protError = "Server sent an invalid subprotocol"; - } - } else if (protocolSet.size) { - protError = "Server sent no subprotocol"; - } - if (protError) { - abortHandshake(websocket, socket, protError); - return; - } - if (serverProt) - websocket._protocol = serverProt; - const secWebSocketExtensions = res.headers["sec-websocket-extensions"]; - if (secWebSocketExtensions !== undefined) { - if (!perMessageDeflate) { - const message = "Server sent a Sec-WebSocket-Extensions header but no extension " + "was requested"; - abortHandshake(websocket, socket, message); - return; - } - let extensions2; - try { - extensions2 = parse5(secWebSocketExtensions); - } catch (err) { - const message = "Invalid Sec-WebSocket-Extensions header"; - abortHandshake(websocket, socket, message); - return; - } - const extensionNames = Object.keys(extensions2); - if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName) { - const message = "Server indicated an extension that was not requested"; - abortHandshake(websocket, socket, message); - return; - } - try { - perMessageDeflate.accept(extensions2[PerMessageDeflate.extensionName]); - } catch (err) { - const message = "Invalid Sec-WebSocket-Extensions header"; - abortHandshake(websocket, socket, message); - return; - } - websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; - } - websocket.setSocket(socket, head2, { - allowSynchronousEvents: opts.allowSynchronousEvents, - generateMask: opts.generateMask, - maxPayload: opts.maxPayload, - skipUTF8Validation: opts.skipUTF8Validation - }); - }); - if (opts.finishRequest) { - opts.finishRequest(req, websocket); - } else { - req.end(); - } - } - function emitErrorAndClose(websocket, err) { - websocket._readyState = WebSocket.CLOSING; - websocket._errorEmitted = true; - websocket.emit("error", err); - websocket.emitClose(); - } - function netConnect(options) { - options.path = options.socketPath; - return net2.connect(options); - } - function tlsConnect(options) { - options.path = undefined; - if (!options.servername && options.servername !== "") { - options.servername = net2.isIP(options.host) ? "" : options.host; - } - return tls.connect(options); - } - function abortHandshake(websocket, stream, message) { - websocket._readyState = WebSocket.CLOSING; - const err = new Error(message); - Error.captureStackTrace(err, abortHandshake); - if (stream.setHeader) { - stream[kAborted] = true; - stream.abort(); - if (stream.socket && !stream.socket.destroyed) { - stream.socket.destroy(); - } - process.nextTick(emitErrorAndClose, websocket, err); - } else { - stream.destroy(err); - stream.once("error", websocket.emit.bind(websocket, "error")); - stream.once("close", websocket.emitClose.bind(websocket)); - } - } - function sendAfterClose(websocket, data, cb) { - if (data) { - const length = isBlob2(data) ? data.size : toBuffer(data).length; - if (websocket._socket) - websocket._sender._bufferedBytes += length; - else - websocket._bufferedAmount += length; - } - if (cb) { - const err = new Error(`WebSocket is not open: readyState ${websocket.readyState} ` + `(${readyStates[websocket.readyState]})`); - process.nextTick(cb, err); - } - } - function receiverOnConclude(code, reason) { - const websocket = this[kWebSocket]; - websocket._closeFrameReceived = true; - websocket._closeMessage = reason; - websocket._closeCode = code; - if (websocket._socket[kWebSocket] === undefined) - return; - websocket._socket.removeListener("data", socketOnData); - process.nextTick(resume, websocket._socket); - if (code === 1005) - websocket.close(); - else - websocket.close(code, reason); - } - function receiverOnDrain() { - const websocket = this[kWebSocket]; - if (!websocket.isPaused) - websocket._socket.resume(); - } - function receiverOnError(err) { - const websocket = this[kWebSocket]; - if (websocket._socket[kWebSocket] !== undefined) { - websocket._socket.removeListener("data", socketOnData); - process.nextTick(resume, websocket._socket); - websocket.close(err[kStatusCode]); - } - if (!websocket._errorEmitted) { - websocket._errorEmitted = true; - websocket.emit("error", err); - } - } - function receiverOnFinish() { - this[kWebSocket].emitClose(); - } - function receiverOnMessage(data, isBinary) { - this[kWebSocket].emit("message", data, isBinary); - } - function receiverOnPing(data) { - const websocket = this[kWebSocket]; - if (websocket._autoPong) - websocket.pong(data, !this._isServer, NOOP); - websocket.emit("ping", data); - } - function receiverOnPong(data) { - this[kWebSocket].emit("pong", data); - } - function resume(stream) { - stream.resume(); - } - function senderOnError(err) { - const websocket = this[kWebSocket]; - if (websocket.readyState === WebSocket.CLOSED) - return; - if (websocket.readyState === WebSocket.OPEN) { - websocket._readyState = WebSocket.CLOSING; - setCloseTimer(websocket); - } - this._socket.end(); - if (!websocket._errorEmitted) { - websocket._errorEmitted = true; - websocket.emit("error", err); - } - } - function setCloseTimer(websocket) { - websocket._closeTimer = setTimeout(websocket._socket.destroy.bind(websocket._socket), closeTimeout); - } - function socketOnClose() { - const websocket = this[kWebSocket]; - this.removeListener("close", socketOnClose); - this.removeListener("data", socketOnData); - this.removeListener("end", socketOnEnd); - websocket._readyState = WebSocket.CLOSING; - let chunk2; - if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && (chunk2 = websocket._socket.read()) !== null) { - websocket._receiver.write(chunk2); - } - websocket._receiver.end(); - this[kWebSocket] = undefined; - clearTimeout(websocket._closeTimer); - if (websocket._receiver._writableState.finished || websocket._receiver._writableState.errorEmitted) { - websocket.emitClose(); - } else { - websocket._receiver.on("error", receiverOnFinish); - websocket._receiver.on("finish", receiverOnFinish); - } - } - function socketOnData(chunk2) { - if (!this[kWebSocket]._receiver.write(chunk2)) { - this.pause(); - } - } - function socketOnEnd() { - const websocket = this[kWebSocket]; - websocket._readyState = WebSocket.CLOSING; - websocket._receiver.end(); - this.end(); - } - function socketOnError() { - const websocket = this[kWebSocket]; - this.removeListener("error", socketOnError); - this.on("error", NOOP); - if (websocket) { - websocket._readyState = WebSocket.CLOSING; - this.destroy(); - } - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/stream.js -var require_stream = __commonJS((exports2, module2) => { - var WebSocket = require_websocket2(); - var { Duplex } = require("stream"); - function emitClose(stream) { - stream.emit("close"); - } - function duplexOnEnd() { - if (!this.destroyed && this._writableState.finished) { - this.destroy(); - } - } - function duplexOnError(err) { - this.removeListener("error", duplexOnError); - this.destroy(); - if (this.listenerCount("error") === 0) { - this.emit("error", err); - } - } - function createWebSocketStream(ws, options) { - let terminateOnDestroy = true; - const duplex = new Duplex({ - ...options, - autoDestroy: false, - emitClose: false, - objectMode: false, - writableObjectMode: false - }); - ws.on("message", function message(msg, isBinary) { - const data = !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; - if (!duplex.push(data)) - ws.pause(); - }); - ws.once("error", function error48(err) { - if (duplex.destroyed) - return; - terminateOnDestroy = false; - duplex.destroy(err); - }); - ws.once("close", function close() { - if (duplex.destroyed) - return; - duplex.push(null); - }); - duplex._destroy = function(err, callback) { - if (ws.readyState === ws.CLOSED) { - callback(err); - process.nextTick(emitClose, duplex); - return; - } - let called = false; - ws.once("error", function error48(err2) { - called = true; - callback(err2); - }); - ws.once("close", function close() { - if (!called) - callback(err); - process.nextTick(emitClose, duplex); - }); - if (terminateOnDestroy) - ws.terminate(); - }; - duplex._final = function(callback) { - if (ws.readyState === ws.CONNECTING) { - ws.once("open", function open() { - duplex._final(callback); - }); - return; - } - if (ws._socket === null) - return; - if (ws._socket._writableState.finished) { - callback(); - if (duplex._readableState.endEmitted) - duplex.destroy(); - } else { - ws._socket.once("finish", function finish() { - callback(); - }); - ws.close(); - } - }; - duplex._read = function() { - if (ws.isPaused) - ws.resume(); - }; - duplex._write = function(chunk2, encoding, callback) { - if (ws.readyState === ws.CONNECTING) { - ws.once("open", function open() { - duplex._write(chunk2, encoding, callback); - }); - return; - } - ws.send(chunk2, callback); - }; - duplex.on("end", duplexOnEnd); - duplex.on("error", duplexOnError); - return duplex; - } - module2.exports = createWebSocketStream; -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/subprotocol.js -var require_subprotocol = __commonJS((exports2, module2) => { - var { tokenChars } = require_validation(); - function parse5(header) { - const protocols = new Set; - let start = -1; - let end = -1; - let i = 0; - for (i;i < header.length; i++) { - const code = header.charCodeAt(i); - if (end === -1 && tokenChars[code] === 1) { - if (start === -1) - start = i; - } else if (i !== 0 && (code === 32 || code === 9)) { - if (end === -1 && start !== -1) - end = i; - } else if (code === 44) { - if (start === -1) { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - if (end === -1) - end = i; - const protocol2 = header.slice(start, end); - if (protocols.has(protocol2)) { - throw new SyntaxError(`The "${protocol2}" subprotocol is duplicated`); - } - protocols.add(protocol2); - start = end = -1; - } else { - throw new SyntaxError(`Unexpected character at index ${i}`); - } - } - if (start === -1 || end !== -1) { - throw new SyntaxError("Unexpected end of input"); - } - const protocol = header.slice(start, i); - if (protocols.has(protocol)) { - throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); - } - protocols.add(protocol); - return protocols; - } - module2.exports = { parse: parse5 }; -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/lib/websocket-server.js -var require_websocket_server = __commonJS((exports2, module2) => { - var EventEmitter = require("events"); - var http4 = require("http"); - var { Duplex } = require("stream"); - var { createHash } = require("crypto"); - var extension = require_extension(); - var PerMessageDeflate = require_permessage_deflate(); - var subprotocol = require_subprotocol(); - var WebSocket = require_websocket2(); - var { GUID, kWebSocket } = require_constants4(); - var keyRegex = /^[+/0-9A-Za-z]{22}==$/; - var RUNNING = 0; - var CLOSING = 1; - var CLOSED = 2; - - class WebSocketServer extends EventEmitter { - constructor(options, callback) { - super(); - options = { - allowSynchronousEvents: true, - autoPong: true, - maxPayload: 100 * 1024 * 1024, - skipUTF8Validation: false, - perMessageDeflate: false, - handleProtocols: null, - clientTracking: true, - verifyClient: null, - noServer: false, - backlog: null, - server: null, - host: null, - path: null, - port: null, - WebSocket, - ...options - }; - if (options.port == null && !options.server && !options.noServer || options.port != null && (options.server || options.noServer) || options.server && options.noServer) { - throw new TypeError('One and only one of the "port", "server", or "noServer" options ' + "must be specified"); - } - if (options.port != null) { - this._server = http4.createServer((req, res) => { - const body = http4.STATUS_CODES[426]; - res.writeHead(426, { - "Content-Length": body.length, - "Content-Type": "text/plain" - }); - res.end(body); - }); - this._server.listen(options.port, options.host, options.backlog, callback); - } else if (options.server) { - this._server = options.server; - } - if (this._server) { - const emitConnection = this.emit.bind(this, "connection"); - this._removeListeners = addListeners(this._server, { - listening: this.emit.bind(this, "listening"), - error: this.emit.bind(this, "error"), - upgrade: (req, socket, head2) => { - this.handleUpgrade(req, socket, head2, emitConnection); - } - }); - } - if (options.perMessageDeflate === true) - options.perMessageDeflate = {}; - if (options.clientTracking) { - this.clients = new Set; - this._shouldEmitClose = false; - } - this.options = options; - this._state = RUNNING; - } - address() { - if (this.options.noServer) { - throw new Error('The server is operating in "noServer" mode'); - } - if (!this._server) - return null; - return this._server.address(); - } - close(cb) { - if (this._state === CLOSED) { - if (cb) { - this.once("close", () => { - cb(new Error("The server is not running")); - }); - } - process.nextTick(emitClose, this); - return; - } - if (cb) - this.once("close", cb); - if (this._state === CLOSING) - return; - this._state = CLOSING; - if (this.options.noServer || this.options.server) { - if (this._server) { - this._removeListeners(); - this._removeListeners = this._server = null; - } - if (this.clients) { - if (!this.clients.size) { - process.nextTick(emitClose, this); - } else { - this._shouldEmitClose = true; - } - } else { - process.nextTick(emitClose, this); - } - } else { - const server = this._server; - this._removeListeners(); - this._removeListeners = this._server = null; - server.close(() => { - emitClose(this); - }); - } - } - shouldHandle(req) { - if (this.options.path) { - const index = req.url.indexOf("?"); - const pathname = index !== -1 ? req.url.slice(0, index) : req.url; - if (pathname !== this.options.path) - return false; - } - return true; - } - handleUpgrade(req, socket, head2, cb) { - socket.on("error", socketOnError); - const key = req.headers["sec-websocket-key"]; - const upgrade = req.headers.upgrade; - const version2 = +req.headers["sec-websocket-version"]; - if (req.method !== "GET") { - const message = "Invalid HTTP method"; - abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); - return; - } - if (upgrade === undefined || upgrade.toLowerCase() !== "websocket") { - const message = "Invalid Upgrade header"; - abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); - return; - } - if (key === undefined || !keyRegex.test(key)) { - const message = "Missing or invalid Sec-WebSocket-Key header"; - abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); - return; - } - if (version2 !== 13 && version2 !== 8) { - const message = "Missing or invalid Sec-WebSocket-Version header"; - abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { - "Sec-WebSocket-Version": "13, 8" - }); - return; - } - if (!this.shouldHandle(req)) { - abortHandshake(socket, 400); - return; - } - const secWebSocketProtocol = req.headers["sec-websocket-protocol"]; - let protocols = new Set; - if (secWebSocketProtocol !== undefined) { - try { - protocols = subprotocol.parse(secWebSocketProtocol); - } catch (err) { - const message = "Invalid Sec-WebSocket-Protocol header"; - abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); - return; - } - } - const secWebSocketExtensions = req.headers["sec-websocket-extensions"]; - const extensions2 = {}; - if (this.options.perMessageDeflate && secWebSocketExtensions !== undefined) { - const perMessageDeflate = new PerMessageDeflate(this.options.perMessageDeflate, true, this.options.maxPayload); - try { - const offers = extension.parse(secWebSocketExtensions); - if (offers[PerMessageDeflate.extensionName]) { - perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); - extensions2[PerMessageDeflate.extensionName] = perMessageDeflate; - } - } catch (err) { - const message = "Invalid or unacceptable Sec-WebSocket-Extensions header"; - abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); - return; - } - } - if (this.options.verifyClient) { - const info = { - origin: req.headers[`${version2 === 8 ? "sec-websocket-origin" : "origin"}`], - secure: !!(req.socket.authorized || req.socket.encrypted), - req - }; - if (this.options.verifyClient.length === 2) { - this.options.verifyClient(info, (verified, code, message, headers) => { - if (!verified) { - return abortHandshake(socket, code || 401, message, headers); - } - this.completeUpgrade(extensions2, key, protocols, req, socket, head2, cb); - }); - return; - } - if (!this.options.verifyClient(info)) - return abortHandshake(socket, 401); - } - this.completeUpgrade(extensions2, key, protocols, req, socket, head2, cb); - } - completeUpgrade(extensions2, key, protocols, req, socket, head2, cb) { - if (!socket.readable || !socket.writable) - return socket.destroy(); - if (socket[kWebSocket]) { - throw new Error("server.handleUpgrade() was called more than once with the same " + "socket, possibly due to a misconfiguration"); - } - if (this._state > RUNNING) - return abortHandshake(socket, 503); - const digest = createHash("sha1").update(key + GUID).digest("base64"); - const headers = [ - "HTTP/1.1 101 Switching Protocols", - "Upgrade: websocket", - "Connection: Upgrade", - `Sec-WebSocket-Accept: ${digest}` - ]; - const ws = new this.options.WebSocket(null, undefined, this.options); - if (protocols.size) { - const protocol = this.options.handleProtocols ? this.options.handleProtocols(protocols, req) : protocols.values().next().value; - if (protocol) { - headers.push(`Sec-WebSocket-Protocol: ${protocol}`); - ws._protocol = protocol; - } - } - if (extensions2[PerMessageDeflate.extensionName]) { - const params = extensions2[PerMessageDeflate.extensionName].params; - const value = extension.format({ - [PerMessageDeflate.extensionName]: [params] - }); - headers.push(`Sec-WebSocket-Extensions: ${value}`); - ws._extensions = extensions2; - } - this.emit("headers", headers, req); - socket.write(headers.concat(`\r -`).join(`\r -`)); - socket.removeListener("error", socketOnError); - ws.setSocket(socket, head2, { - allowSynchronousEvents: this.options.allowSynchronousEvents, - maxPayload: this.options.maxPayload, - skipUTF8Validation: this.options.skipUTF8Validation - }); - if (this.clients) { - this.clients.add(ws); - ws.on("close", () => { - this.clients.delete(ws); - if (this._shouldEmitClose && !this.clients.size) { - process.nextTick(emitClose, this); - } - }); - } - cb(ws, req); - } - } - module2.exports = WebSocketServer; - function addListeners(server, map2) { - for (const event of Object.keys(map2)) - server.on(event, map2[event]); - return function removeListeners() { - for (const event of Object.keys(map2)) { - server.removeListener(event, map2[event]); - } - }; - } - function emitClose(server) { - server._state = CLOSED; - server.emit("close"); - } - function socketOnError() { - this.destroy(); - } - function abortHandshake(socket, code, message, headers) { - message = message || http4.STATUS_CODES[code]; - headers = { - Connection: "close", - "Content-Type": "text/html", - "Content-Length": Buffer.byteLength(message), - ...headers - }; - socket.once("finish", socket.destroy); - socket.end(`HTTP/1.1 ${code} ${http4.STATUS_CODES[code]}\r -` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join(`\r -`) + `\r -\r -` + message); - } - function abortHandshakeOrEmitwsClientError(server, req, socket, code, message, headers) { - if (server.listenerCount("wsClientError")) { - const err = new Error(message); - Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); - server.emit("wsClientError", err, socket, req); - } else { - abortHandshake(socket, code, message, headers); - } - } -}); - -// ../../node_modules/.pnpm/ws@8.18.3/node_modules/ws/index.js -var require_ws = __commonJS((exports2, module2) => { - var WebSocket = require_websocket2(); - WebSocket.createWebSocketStream = require_stream(); - WebSocket.Server = require_websocket_server(); - WebSocket.Receiver = require_receiver(); - WebSocket.Sender = require_sender(); - WebSocket.WebSocket = WebSocket; - WebSocket.WebSocketServer = WebSocket.Server; - module2.exports = WebSocket; -}); - -// ../../node_modules/.pnpm/object-assign@4.1.1/node_modules/object-assign/index.js -var require_object_assign = __commonJS((exports2, module2) => { - var getOwnPropertySymbols = Object.getOwnPropertySymbols; - var hasOwnProperty = Object.prototype.hasOwnProperty; - var propIsEnumerable = Object.prototype.propertyIsEnumerable; - function toObject(val) { - if (val === null || val === undefined) { - throw new TypeError("Object.assign cannot be called with null or undefined"); - } - return Object(val); - } - function shouldUseNative() { - try { - if (!Object.assign) { - return false; - } - var test1 = new String("abc"); - test1[5] = "de"; - if (Object.getOwnPropertyNames(test1)[0] === "5") { - return false; - } - var test2 = {}; - for (var i = 0;i < 10; i++) { - test2["_" + String.fromCharCode(i)] = i; - } - var order2 = Object.getOwnPropertyNames(test2).map(function(n) { - return test2[n]; - }); - if (order2.join("") !== "0123456789") { - return false; - } - var test3 = {}; - "abcdefghijklmnopqrst".split("").forEach(function(letter) { - test3[letter] = letter; - }); - if (Object.keys(Object.assign({}, test3)).join("") !== "abcdefghijklmnopqrst") { - return false; - } - return true; - } catch (err) { - return false; - } - } - module2.exports = shouldUseNative() ? Object.assign : function(target, source) { - var from; - var to = toObject(target); - var symbols; - for (var s = 1;s < arguments.length; s++) { - from = Object(arguments[s]); - for (var key in from) { - if (hasOwnProperty.call(from, key)) { - to[key] = from[key]; - } - } - if (getOwnPropertySymbols) { - symbols = getOwnPropertySymbols(from); - for (var i = 0;i < symbols.length; i++) { - if (propIsEnumerable.call(from, symbols[i])) { - to[symbols[i]] = from[symbols[i]]; - } - } - } - } - return to; - }; -}); - -// ../../node_modules/.pnpm/vary@1.1.2/node_modules/vary/index.js -var require_vary = __commonJS((exports2, module2) => { - /*! - * vary - * Copyright(c) 2014-2017 Douglas Christopher Wilson - * MIT Licensed - */ - module2.exports = vary; - module2.exports.append = append; - var FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; - function append(header, field) { - if (typeof header !== "string") { - throw new TypeError("header argument is required"); - } - if (!field) { - throw new TypeError("field argument is required"); - } - var fields = !Array.isArray(field) ? parse5(String(field)) : field; - for (var j = 0;j < fields.length; j++) { - if (!FIELD_NAME_REGEXP.test(fields[j])) { - throw new TypeError("field argument contains an invalid header name"); - } - } - if (header === "*") { - return header; - } - var val = header; - var vals = parse5(header.toLowerCase()); - if (fields.indexOf("*") !== -1 || vals.indexOf("*") !== -1) { - return "*"; - } - for (var i = 0;i < fields.length; i++) { - var fld = fields[i].toLowerCase(); - if (vals.indexOf(fld) === -1) { - vals.push(fld); - val = val ? val + ", " + fields[i] : fields[i]; - } - } - return val; - } - function parse5(header) { - var end = 0; - var list = []; - var start = 0; - for (var i = 0, len = header.length;i < len; i++) { - switch (header.charCodeAt(i)) { - case 32: - if (start === end) { - start = end = i + 1; - } - break; - case 44: - list.push(header.substring(start, end)); - start = end = i + 1; - break; - default: - end = i + 1; - break; - } - } - list.push(header.substring(start, end)); - return list; - } - function vary(res, field) { - if (!res || !res.getHeader || !res.setHeader) { - throw new TypeError("res argument is required"); - } - var val = res.getHeader("Vary") || ""; - var header = Array.isArray(val) ? val.join(", ") : String(val); - if (val = append(header, field)) { - res.setHeader("Vary", val); - } - } -}); - -// ../../node_modules/.pnpm/cors@2.8.6/node_modules/cors/lib/index.js -var require_lib = __commonJS((exports2, module2) => { - (function() { - var assign = require_object_assign(); - var vary = require_vary(); - var defaults = { - origin: "*", - methods: "GET,HEAD,PUT,PATCH,POST,DELETE", - preflightContinue: false, - optionsSuccessStatus: 204 - }; - function isString2(s) { - return typeof s === "string" || s instanceof String; - } - function isOriginAllowed(origin, allowedOrigin) { - if (Array.isArray(allowedOrigin)) { - for (var i = 0;i < allowedOrigin.length; ++i) { - if (isOriginAllowed(origin, allowedOrigin[i])) { - return true; - } - } - return false; - } else if (isString2(allowedOrigin)) { - return origin === allowedOrigin; - } else if (allowedOrigin instanceof RegExp) { - return allowedOrigin.test(origin); - } else { - return !!allowedOrigin; - } - } - function configureOrigin(options, req) { - var requestOrigin = req.headers.origin, headers = [], isAllowed; - if (!options.origin || options.origin === "*") { - headers.push([{ - key: "Access-Control-Allow-Origin", - value: "*" - }]); - } else if (isString2(options.origin)) { - headers.push([{ - key: "Access-Control-Allow-Origin", - value: options.origin - }]); - headers.push([{ - key: "Vary", - value: "Origin" - }]); - } else { - isAllowed = isOriginAllowed(requestOrigin, options.origin); - headers.push([{ - key: "Access-Control-Allow-Origin", - value: isAllowed ? requestOrigin : false - }]); - headers.push([{ - key: "Vary", - value: "Origin" - }]); - } - return headers; - } - function configureMethods(options) { - var methods = options.methods; - if (methods.join) { - methods = options.methods.join(","); - } - return { - key: "Access-Control-Allow-Methods", - value: methods - }; - } - function configureCredentials(options) { - if (options.credentials === true) { - return { - key: "Access-Control-Allow-Credentials", - value: "true" - }; - } - return null; - } - function configureAllowedHeaders(options, req) { - var allowedHeaders = options.allowedHeaders || options.headers; - var headers = []; - if (!allowedHeaders) { - allowedHeaders = req.headers["access-control-request-headers"]; - headers.push([{ - key: "Vary", - value: "Access-Control-Request-Headers" - }]); - } else if (allowedHeaders.join) { - allowedHeaders = allowedHeaders.join(","); - } - if (allowedHeaders && allowedHeaders.length) { - headers.push([{ - key: "Access-Control-Allow-Headers", - value: allowedHeaders - }]); - } - return headers; - } - function configureExposedHeaders(options) { - var headers = options.exposedHeaders; - if (!headers) { - return null; - } else if (headers.join) { - headers = headers.join(","); - } - if (headers && headers.length) { - return { - key: "Access-Control-Expose-Headers", - value: headers - }; - } - return null; - } - function configureMaxAge(options) { - var maxAge = (typeof options.maxAge === "number" || options.maxAge) && options.maxAge.toString(); - if (maxAge && maxAge.length) { - return { - key: "Access-Control-Max-Age", - value: maxAge - }; - } - return null; - } - function applyHeaders(headers, res) { - for (var i = 0, n = headers.length;i < n; i++) { - var header = headers[i]; - if (header) { - if (Array.isArray(header)) { - applyHeaders(header, res); - } else if (header.key === "Vary" && header.value) { - vary(res, header.value); - } else if (header.value) { - res.setHeader(header.key, header.value); - } - } - } - } - function cors(options, req, res, next) { - var headers = [], method = req.method && req.method.toUpperCase && req.method.toUpperCase(); - if (method === "OPTIONS") { - headers.push(configureOrigin(options, req)); - headers.push(configureCredentials(options)); - headers.push(configureMethods(options)); - headers.push(configureAllowedHeaders(options, req)); - headers.push(configureMaxAge(options)); - headers.push(configureExposedHeaders(options)); - applyHeaders(headers, res); - if (options.preflightContinue) { - next(); - } else { - res.statusCode = options.optionsSuccessStatus; - res.setHeader("Content-Length", "0"); - res.end(); - } - } else { - headers.push(configureOrigin(options, req)); - headers.push(configureCredentials(options)); - headers.push(configureExposedHeaders(options)); - applyHeaders(headers, res); - next(); - } - } - function middlewareWrapper(o) { - var optionsCallback = null; - if (typeof o === "function") { - optionsCallback = o; - } else { - optionsCallback = function(req, cb) { - cb(null, o); - }; - } - return function corsMiddleware(req, res, next) { - optionsCallback(req, function(err, options) { - if (err) { - next(err); - } else { - var corsOptions = assign({}, defaults, options); - var originCallback = null; - if (corsOptions.origin && typeof corsOptions.origin === "function") { - originCallback = corsOptions.origin; - } else if (corsOptions.origin) { - originCallback = function(origin, cb) { - cb(null, corsOptions.origin); - }; - } - if (originCallback) { - originCallback(req.headers.origin, function(err2, origin) { - if (err2 || !origin) { - next(err2); - } else { - corsOptions.origin = origin; - cors(corsOptions, req, res, next); - } - }); - } else { - next(); - } - } - }); - }; - } - module2.exports = middlewareWrapper; - })(); -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/server.js -var require_server = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Server = exports2.BaseServer = undefined; - var base64id = require_base64id(); - var transports_1 = require_transports(); - var events_1 = require("events"); - var socket_1 = require_socket(); - var debug_1 = require_src2(); - var cookie_1 = require_cookie(); - var ws_1 = require_ws(); - var webtransport_1 = require_webtransport(); - var engine_io_parser_1 = require_cjs(); - var debug = (0, debug_1.default)("engine"); - var kResponseHeaders = Symbol("responseHeaders"); - function parseSessionId(data) { - try { - const parsed = JSON.parse(data); - if (typeof parsed.sid === "string") { - return parsed.sid; - } - } catch (e) {} - } - - class BaseServer extends events_1.EventEmitter { - constructor(opts = {}) { - super(); - this.middlewares = []; - this.clients = {}; - this.clientsCount = 0; - this.opts = Object.assign({ - wsEngine: ws_1.Server, - pingTimeout: 20000, - pingInterval: 25000, - upgradeTimeout: 1e4, - maxHttpBufferSize: 1e6, - transports: ["polling", "websocket"], - allowUpgrades: true, - httpCompression: { - threshold: 1024 - }, - cors: false, - allowEIO3: false - }, opts); - if (opts.cookie) { - this.opts.cookie = Object.assign({ - name: "io", - path: "/", - httpOnly: opts.cookie.path !== false, - sameSite: "lax" - }, opts.cookie); - } - if (this.opts.cors) { - this.use(require_lib()(this.opts.cors)); - } - if (opts.perMessageDeflate) { - this.opts.perMessageDeflate = Object.assign({ - threshold: 1024 - }, opts.perMessageDeflate); - } - this.init(); - } - _computePath(options) { - let path13 = (options.path || "/engine.io").replace(/\/$/, ""); - if (options.addTrailingSlash !== false) { - path13 += "/"; - } - return path13; - } - upgrades(transport) { - if (!this.opts.allowUpgrades) - return []; - return transports_1.default[transport].upgradesTo || []; - } - verify(req, upgrade, fn) { - const transport = req._query.transport; - if (!~this.opts.transports.indexOf(transport) || transport === "webtransport") { - debug('unknown transport "%s"', transport); - return fn(Server.errors.UNKNOWN_TRANSPORT, { transport }); - } - const isOriginInvalid = checkInvalidHeaderChar(req.headers.origin); - if (isOriginInvalid) { - const origin = req.headers.origin; - req.headers.origin = null; - debug("origin header invalid"); - return fn(Server.errors.BAD_REQUEST, { - name: "INVALID_ORIGIN", - origin - }); - } - const sid = req._query.sid; - if (sid) { - if (!this.clients.hasOwnProperty(sid)) { - debug('unknown sid "%s"', sid); - return fn(Server.errors.UNKNOWN_SID, { - sid - }); - } - const previousTransport = this.clients[sid].transport.name; - if (!upgrade && previousTransport !== transport) { - debug("bad request: unexpected transport without upgrade"); - return fn(Server.errors.BAD_REQUEST, { - name: "TRANSPORT_MISMATCH", - transport, - previousTransport - }); - } - } else { - if (req.method !== "GET") { - return fn(Server.errors.BAD_HANDSHAKE_METHOD, { - method: req.method - }); - } - if (transport === "websocket" && !upgrade) { - debug("invalid transport upgrade"); - return fn(Server.errors.BAD_REQUEST, { - name: "TRANSPORT_HANDSHAKE_ERROR" - }); - } - if (!this.opts.allowRequest) - return fn(); - return this.opts.allowRequest(req, (message, success2) => { - if (!success2) { - return fn(Server.errors.FORBIDDEN, { - message - }); - } - fn(); - }); - } - fn(); - } - use(fn) { - this.middlewares.push(fn); - } - _applyMiddlewares(req, res, callback) { - if (this.middlewares.length === 0) { - debug("no middleware to apply, skipping"); - return callback(); - } - const apply = (i) => { - debug("applying middleware n°%d", i + 1); - this.middlewares[i](req, res, (err) => { - if (err) { - return callback(err); - } - if (i + 1 < this.middlewares.length) { - apply(i + 1); - } else { - callback(); - } - }); - }; - apply(0); - } - close() { - debug("closing all open clients"); - for (let i in this.clients) { - if (this.clients.hasOwnProperty(i)) { - this.clients[i].close(true); - } - } - this.cleanup(); - return this; - } - generateId(req) { - return base64id.generateId(); - } - async handshake(transportName, req, closeConnection) { - const protocol = req._query.EIO === "4" ? 4 : 3; - if (protocol === 3 && !this.opts.allowEIO3) { - debug("unsupported protocol version"); - this.emit("connection_error", { - req, - code: Server.errors.UNSUPPORTED_PROTOCOL_VERSION, - message: Server.errorMessages[Server.errors.UNSUPPORTED_PROTOCOL_VERSION], - context: { - protocol - } - }); - closeConnection(Server.errors.UNSUPPORTED_PROTOCOL_VERSION); - return; - } - let id; - try { - id = await this.generateId(req); - } catch (e) { - debug("error while generating an id"); - this.emit("connection_error", { - req, - code: Server.errors.BAD_REQUEST, - message: Server.errorMessages[Server.errors.BAD_REQUEST], - context: { - name: "ID_GENERATION_ERROR", - error: e - } - }); - closeConnection(Server.errors.BAD_REQUEST); - return; - } - debug('handshaking client "%s"', id); - try { - var transport = this.createTransport(transportName, req); - if (transportName === "polling") { - transport.maxHttpBufferSize = this.opts.maxHttpBufferSize; - transport.httpCompression = this.opts.httpCompression; - } else if (transportName === "websocket") { - transport.perMessageDeflate = this.opts.perMessageDeflate; - } - } catch (e) { - debug('error handshaking to transport "%s"', transportName); - this.emit("connection_error", { - req, - code: Server.errors.BAD_REQUEST, - message: Server.errorMessages[Server.errors.BAD_REQUEST], - context: { - name: "TRANSPORT_HANDSHAKE_ERROR", - error: e - } - }); - closeConnection(Server.errors.BAD_REQUEST); - return; - } - const socket = new socket_1.Socket(id, this, transport, req, protocol); - transport.on("headers", (headers, req2) => { - const isInitialRequest = !req2._query.sid; - if (isInitialRequest) { - if (this.opts.cookie) { - headers["Set-Cookie"] = [ - (0, cookie_1.serialize)(this.opts.cookie.name, id, this.opts.cookie) - ]; - } - this.emit("initial_headers", headers, req2); - } - this.emit("headers", headers, req2); - }); - transport.onRequest(req); - this.clients[id] = socket; - this.clientsCount++; - socket.once("close", () => { - delete this.clients[id]; - this.clientsCount--; - }); - this.emit("connection", socket); - return transport; - } - async onWebTransportSession(session) { - const timeout2 = setTimeout(() => { - debug("the client failed to establish a bidirectional stream in the given period"); - session.close(); - }, this.opts.upgradeTimeout); - const streamReader = session.incomingBidirectionalStreams.getReader(); - const result = await streamReader.read(); - if (result.done) { - debug("session is closed"); - return; - } - const stream = result.value; - const transformStream = (0, engine_io_parser_1.createPacketDecoderStream)(this.opts.maxHttpBufferSize, "nodebuffer"); - const reader = stream.readable.pipeThrough(transformStream).getReader(); - const { value, done } = await reader.read(); - if (done) { - debug("stream is closed"); - return; - } - clearTimeout(timeout2); - if (value.type !== "open") { - debug("invalid WebTransport handshake"); - return session.close(); - } - if (value.data === undefined) { - const transport = new webtransport_1.WebTransport(session, stream, reader); - const id = base64id.generateId(); - debug('handshaking client "%s" (WebTransport)', id); - const socket = new socket_1.Socket(id, this, transport, null, 4); - this.clients[id] = socket; - this.clientsCount++; - socket.once("close", () => { - delete this.clients[id]; - this.clientsCount--; - }); - this.emit("connection", socket); - return; - } - const sid = parseSessionId(value.data); - if (!sid) { - debug("invalid WebTransport handshake"); - return session.close(); - } - const client = this.clients[sid]; - if (!client) { - debug("upgrade attempt for closed client"); - session.close(); - } else if (client.upgrading) { - debug("transport has already been trying to upgrade"); - session.close(); - } else if (client.upgraded) { - debug("transport had already been upgraded"); - session.close(); - } else { - debug("upgrading existing transport"); - const transport = new webtransport_1.WebTransport(session, stream, reader); - client._maybeUpgrade(transport); - } - } - } - exports2.BaseServer = BaseServer; - BaseServer.errors = { - UNKNOWN_TRANSPORT: 0, - UNKNOWN_SID: 1, - BAD_HANDSHAKE_METHOD: 2, - BAD_REQUEST: 3, - FORBIDDEN: 4, - UNSUPPORTED_PROTOCOL_VERSION: 5 - }; - BaseServer.errorMessages = { - 0: "Transport unknown", - 1: "Session ID unknown", - 2: "Bad handshake method", - 3: "Bad request", - 4: "Forbidden", - 5: "Unsupported protocol version" - }; - - class WebSocketResponse { - constructor(req, socket) { - this.req = req; - this.socket = socket; - req[kResponseHeaders] = {}; - } - setHeader(name21, value) { - this.req[kResponseHeaders][name21] = value; - } - getHeader(name21) { - return this.req[kResponseHeaders][name21]; - } - removeHeader(name21) { - delete this.req[kResponseHeaders][name21]; - } - write() {} - writeHead() {} - end() { - this.socket.destroy(); - } - } - - class Server extends BaseServer { - init() { - if (!~this.opts.transports.indexOf("websocket")) - return; - if (this.ws) - this.ws.close(); - this.ws = new this.opts.wsEngine({ - noServer: true, - clientTracking: false, - perMessageDeflate: this.opts.perMessageDeflate, - maxPayload: this.opts.maxHttpBufferSize - }); - if (typeof this.ws.on === "function") { - this.ws.on("headers", (headersArray, req) => { - const additionalHeaders = req[kResponseHeaders] || {}; - delete req[kResponseHeaders]; - const isInitialRequest = !req._query.sid; - if (isInitialRequest) { - this.emit("initial_headers", additionalHeaders, req); - } - this.emit("headers", additionalHeaders, req); - debug("writing headers: %j", additionalHeaders); - Object.keys(additionalHeaders).forEach((key) => { - headersArray.push(`${key}: ${additionalHeaders[key]}`); - }); - }); - } - } - cleanup() { - if (this.ws) { - debug("closing webSocketServer"); - this.ws.close(); - } - } - prepare(req) { - if (!req._query) { - const url2 = new URL(req.url, "https://socket.io"); - req._query = Object.fromEntries(url2.searchParams.entries()); - } - } - createTransport(transportName, req) { - return new transports_1.default[transportName](req); - } - handleRequest(req, res) { - debug('handling "%s" http request "%s"', req.method, req.url); - this.prepare(req); - req.res = res; - const callback = (errorCode, errorContext) => { - if (errorCode !== undefined) { - this.emit("connection_error", { - req, - code: errorCode, - message: Server.errorMessages[errorCode], - context: errorContext - }); - abortRequest(res, errorCode, errorContext); - return; - } - if (req._query.sid) { - debug("setting new request for existing client"); - this.clients[req._query.sid].transport.onRequest(req); - } else { - const closeConnection = (errorCode2, errorContext2) => abortRequest(res, errorCode2, errorContext2); - this.handshake(req._query.transport, req, closeConnection); - } - }; - this._applyMiddlewares(req, res, (err) => { - if (err) { - callback(Server.errors.BAD_REQUEST, { name: "MIDDLEWARE_FAILURE" }); - } else { - this.verify(req, false, callback); - } - }); - } - handleUpgrade(req, socket, upgradeHead) { - this.prepare(req); - const res = new WebSocketResponse(req, socket); - const callback = (errorCode, errorContext) => { - if (errorCode !== undefined) { - this.emit("connection_error", { - req, - code: errorCode, - message: Server.errorMessages[errorCode], - context: errorContext - }); - abortUpgrade(socket, errorCode, errorContext); - return; - } - const head2 = Buffer.from(upgradeHead); - upgradeHead = null; - res.writeHead(); - this.ws.handleUpgrade(req, socket, head2, (websocket) => { - this.onWebSocket(req, socket, websocket); - }); - }; - this._applyMiddlewares(req, res, (err) => { - if (err) { - callback(Server.errors.BAD_REQUEST, { name: "MIDDLEWARE_FAILURE" }); - } else { - this.verify(req, true, callback); - } - }); - } - onWebSocket(req, socket, websocket) { - websocket.on("error", onUpgradeError); - if (transports_1.default[req._query.transport] !== undefined && !transports_1.default[req._query.transport].prototype.handlesUpgrades) { - debug("transport doesnt handle upgraded requests"); - websocket.close(); - return; - } - const id = req._query.sid; - req.websocket = websocket; - if (id) { - const client = this.clients[id]; - if (!client) { - debug("upgrade attempt for closed client"); - websocket.close(); - } else if (client.upgrading) { - debug("transport has already been trying to upgrade"); - websocket.close(); - } else if (client.upgraded) { - debug("transport had already been upgraded"); - websocket.close(); - } else { - debug("upgrading existing transport"); - websocket.removeListener("error", onUpgradeError); - const transport = this.createTransport(req._query.transport, req); - transport.perMessageDeflate = this.opts.perMessageDeflate; - client._maybeUpgrade(transport); - } - } else { - const closeConnection = (errorCode, errorContext) => abortUpgrade(socket, errorCode, errorContext); - this.handshake(req._query.transport, req, closeConnection); - } - function onUpgradeError() { - debug("websocket error before upgrade"); - } - } - attach(server, options = {}) { - const path13 = this._computePath(options); - const destroyUpgradeTimeout = options.destroyUpgradeTimeout || 1000; - function check2(req) { - return path13 === req.url.slice(0, path13.length); - } - const listeners = server.listeners("request").slice(0); - server.removeAllListeners("request"); - server.on("close", this.close.bind(this)); - server.on("listening", this.init.bind(this)); - server.on("request", (req, res) => { - if (check2(req)) { - debug('intercepting request for path "%s"', path13); - this.handleRequest(req, res); - } else { - let i = 0; - const l = listeners.length; - for (;i < l; i++) { - listeners[i].call(server, req, res); - } - } - }); - if (~this.opts.transports.indexOf("websocket")) { - server.on("upgrade", (req, socket, head2) => { - if (check2(req)) { - this.handleUpgrade(req, socket, head2); - } else if (options.destroyUpgrade !== false) { - setTimeout(function() { - if (socket.writable && socket.bytesWritten <= 0) { - socket.on("error", (e) => { - debug("error while destroying upgrade: %s", e.message); - }); - return socket.end(); - } - }, destroyUpgradeTimeout); - } - }); - } - } - } - exports2.Server = Server; - function abortRequest(res, errorCode, errorContext) { - const statusCode = errorCode === Server.errors.FORBIDDEN ? 403 : 400; - const message = errorContext && errorContext.message ? errorContext.message : Server.errorMessages[errorCode]; - res.writeHead(statusCode, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - code: errorCode, - message - })); - } - function abortUpgrade(socket, errorCode, errorContext = {}) { - socket.on("error", () => { - debug("ignoring error from closed connection"); - }); - if (socket.writable) { - const message = errorContext.message || Server.errorMessages[errorCode]; - const length = Buffer.byteLength(message); - socket.write(`HTTP/1.1 400 Bad Request\r -` + `Connection: close\r -` + `Content-type: text/html\r -` + "Content-Length: " + length + `\r -` + `\r -` + message); - } - socket.destroy(); - } - var validHdrChars = [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 0, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ]; - function checkInvalidHeaderChar(val) { - val += ""; - if (val.length < 1) - return false; - if (!validHdrChars[val.charCodeAt(0)]) { - debug('invalid header, index 0, char "%s"', val.charCodeAt(0)); - return true; - } - if (val.length < 2) - return false; - if (!validHdrChars[val.charCodeAt(1)]) { - debug('invalid header, index 1, char "%s"', val.charCodeAt(1)); - return true; - } - if (val.length < 3) - return false; - if (!validHdrChars[val.charCodeAt(2)]) { - debug('invalid header, index 2, char "%s"', val.charCodeAt(2)); - return true; - } - if (val.length < 4) - return false; - if (!validHdrChars[val.charCodeAt(3)]) { - debug('invalid header, index 3, char "%s"', val.charCodeAt(3)); - return true; - } - for (let i = 4;i < val.length; ++i) { - if (!validHdrChars[val.charCodeAt(i)]) { - debug('invalid header, index "%i", char "%s"', i, val.charCodeAt(i)); - return true; - } - } - return false; - } -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports-uws/polling.js -var require_polling2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Polling = undefined; - var transport_1 = require_transport(); - var zlib_1 = require("zlib"); - var accepts = require_accepts(); - var debug_1 = require_src2(); - var debug = (0, debug_1.default)("engine:polling"); - var compressionMethods = { - gzip: zlib_1.createGzip, - deflate: zlib_1.createDeflate - }; - - class Polling extends transport_1.Transport { - constructor(req) { - super(req); - this.closeTimeout = 30 * 1000; - } - get name() { - return "polling"; - } - onRequest(req) { - const res = req.res; - req.res = null; - if (req.getMethod() === "get") { - this.onPollRequest(req, res); - } else if (req.getMethod() === "post") { - this.onDataRequest(req, res); - } else { - res.writeStatus("500 Internal Server Error"); - res.end(); - } - } - onPollRequest(req, res) { - if (this.req) { - debug("request overlap"); - this.onError("overlap from client"); - res.writeStatus("500 Internal Server Error"); - res.end(); - return; - } - debug("setting request"); - this.req = req; - this.res = res; - const onClose = () => { - this.writable = false; - this.onError("poll connection closed prematurely"); - }; - const cleanup = () => { - this.req = this.res = null; - }; - req.cleanup = cleanup; - res.onAborted(onClose); - this.writable = true; - this.emit("ready"); - if (this.writable && this.shouldClose) { - debug("triggering empty send to append close packet"); - this.send([{ type: "noop" }]); - } - } - onDataRequest(req, res) { - if (this.dataReq) { - this.onError("data request overlap from client"); - res.writeStatus("500 Internal Server Error"); - res.end(); - return; - } - const expectedContentLength = Number(req.headers["content-length"]); - if (!expectedContentLength) { - this.onError("content-length header required"); - res.writeStatus("411 Length Required").end(); - return; - } - if (expectedContentLength > this.maxHttpBufferSize) { - this.onError("payload too large"); - res.writeStatus("413 Payload Too Large").end(); - return; - } - const isBinary = req.headers["content-type"] === "application/octet-stream"; - if (isBinary && this.protocol === 4) { - return this.onError("invalid content"); - } - this.dataReq = req; - this.dataRes = res; - let buffer; - let offset = 0; - const headers = { - "Content-Type": "text/html" - }; - this.headers(req, headers); - for (let key in headers) { - res.writeHeader(key, String(headers[key])); - } - const onEnd = (buffer2) => { - this.onData(buffer2.toString()); - this.onDataRequestCleanup(); - res.cork(() => { - res.end("ok"); - }); - }; - res.onAborted(() => { - this.onDataRequestCleanup(); - this.onError("data request connection closed prematurely"); - }); - res.onData((arrayBuffer, isLast) => { - const totalLength = offset + arrayBuffer.byteLength; - if (totalLength > expectedContentLength) { - this.onError("content-length mismatch"); - res.close(); - return; - } - if (!buffer) { - if (isLast) { - onEnd(Buffer.from(arrayBuffer)); - return; - } - buffer = Buffer.allocUnsafe(expectedContentLength); - } - Buffer.from(arrayBuffer).copy(buffer, offset); - if (isLast) { - if (totalLength != expectedContentLength) { - this.onError("content-length mismatch"); - res.writeStatus("400 Content-Length Mismatch").end(); - this.onDataRequestCleanup(); - return; - } - onEnd(buffer); - return; - } - offset = totalLength; - }); - } - onDataRequestCleanup() { - this.dataReq = this.dataRes = null; - } - onData(data) { - debug('received "%s"', data); - const callback = (packet) => { - if (packet.type === "close") { - debug("got xhr close packet"); - this.onClose(); - return false; - } - this.onPacket(packet); - }; - if (this.protocol === 3) { - this.parser.decodePayload(data, callback); - } else { - this.parser.decodePayload(data).forEach(callback); - } - } - onClose() { - if (this.writable) { - this.send([{ type: "noop" }]); - } - super.onClose(); - } - send(packets) { - this.writable = false; - if (this.shouldClose) { - debug("appending close packet to payload"); - packets.push({ type: "close" }); - this.shouldClose(); - this.shouldClose = null; - } - const doWrite = (data) => { - const compress = packets.some((packet) => { - return packet.options && packet.options.compress; - }); - this.write(data, { compress }); - }; - if (this.protocol === 3) { - this.parser.encodePayload(packets, this.supportsBinary, doWrite); - } else { - this.parser.encodePayload(packets, doWrite); - } - } - write(data, options) { - debug('writing "%s"', data); - this.doWrite(data, options, () => { - this.req.cleanup(); - this.emit("drain"); - }); - } - doWrite(data, options, callback) { - const isString2 = typeof data === "string"; - const contentType = isString2 ? "text/plain; charset=UTF-8" : "application/octet-stream"; - const headers = { - "Content-Type": contentType - }; - const respond = (data2) => { - this.headers(this.req, headers); - this.res.cork(() => { - Object.keys(headers).forEach((key) => { - this.res.writeHeader(key, String(headers[key])); - }); - this.res.end(data2); - }); - callback(); - }; - if (!this.httpCompression || !options.compress) { - respond(data); - return; - } - const len = isString2 ? Buffer.byteLength(data) : data.length; - if (len < this.httpCompression.threshold) { - respond(data); - return; - } - const encoding = accepts(this.req).encodings(["gzip", "deflate"]); - if (!encoding) { - respond(data); - return; - } - this.compress(data, encoding, (err, data2) => { - if (err) { - this.res.writeStatus("500 Internal Server Error"); - this.res.end(); - callback(err); - return; - } - headers["Content-Encoding"] = encoding; - respond(data2); - }); - } - compress(data, encoding, callback) { - debug("compressing"); - const buffers = []; - let nread = 0; - compressionMethods[encoding](this.httpCompression).on("error", callback).on("data", function(chunk2) { - buffers.push(chunk2); - nread += chunk2.length; - }).on("end", function() { - callback(null, Buffer.concat(buffers, nread)); - }).end(data); - } - doClose(fn) { - debug("closing"); - let closeTimeoutTimer; - const onClose = () => { - clearTimeout(closeTimeoutTimer); - fn(); - this.onClose(); - }; - if (this.writable) { - debug("transport writable - closing right away"); - this.send([{ type: "close" }]); - onClose(); - } else if (this.discarded) { - debug("transport discarded - closing right away"); - onClose(); - } else { - debug("transport not writable - buffering orderly close"); - this.shouldClose = onClose; - closeTimeoutTimer = setTimeout(onClose, this.closeTimeout); - } - } - headers(req, headers) { - headers = headers || {}; - const ua = req.headers["user-agent"]; - if (ua && (~ua.indexOf(";MSIE") || ~ua.indexOf("Trident/"))) { - headers["X-XSS-Protection"] = "0"; - } - headers["cache-control"] = "no-store"; - this.emit("headers", headers, req); - return headers; - } - } - exports2.Polling = Polling; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports-uws/websocket.js -var require_websocket3 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.WebSocket = undefined; - var transport_1 = require_transport(); - var debug_1 = require_src2(); - var debug = (0, debug_1.default)("engine:ws"); - - class WebSocket extends transport_1.Transport { - constructor(req) { - super(req); - this.writable = false; - this.perMessageDeflate = null; - } - get name() { - return "websocket"; - } - get handlesUpgrades() { - return true; - } - send(packets) { - this.writable = false; - for (let i = 0;i < packets.length; i++) { - const packet = packets[i]; - const isLast = i + 1 === packets.length; - const send = (data) => { - const isBinary = typeof data !== "string"; - const compress = this.perMessageDeflate && Buffer.byteLength(data) > this.perMessageDeflate.threshold; - debug('writing "%s"', data); - this.socket.send(data, isBinary, compress); - if (isLast) { - this.emit("drain"); - this.writable = true; - this.emit("ready"); - } - }; - if (packet.options && typeof packet.options.wsPreEncoded === "string") { - send(packet.options.wsPreEncoded); - } else { - this.parser.encodePacket(packet, this.supportsBinary, send); - } - } - } - doClose(fn) { - debug("closing"); - fn && fn(); - this.socket.end(); - } - } - exports2.WebSocket = WebSocket; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/transports-uws/index.js -var require_transports_uws = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var polling_1 = require_polling2(); - var websocket_1 = require_websocket3(); - exports2.default = { - polling: polling_1.Polling, - websocket: websocket_1.WebSocket - }; -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/userver.js -var require_userver = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.uServer = undefined; - var debug_1 = require_src2(); - var server_1 = require_server(); - var transports_uws_1 = require_transports_uws(); - var debug = (0, debug_1.default)("engine:uws"); - - class uServer extends server_1.BaseServer { - init() {} - cleanup() {} - prepare(req, res) { - req.method = req.getMethod().toUpperCase(); - req.url = req.getUrl(); - const params = new URLSearchParams(req.getQuery()); - req._query = Object.fromEntries(params.entries()); - req.headers = {}; - req.forEach((key, value) => { - req.headers[key] = value; - }); - req.connection = { - remoteAddress: Buffer.from(res.getRemoteAddressAsText()).toString() - }; - res.onAborted(() => { - debug("response has been aborted"); - }); - } - createTransport(transportName, req) { - return new transports_uws_1.default[transportName](req); - } - attach(app, options = {}) { - const path13 = this._computePath(options); - app.any(path13, this.handleRequest.bind(this)).ws(path13, { - compression: options.compression, - idleTimeout: options.idleTimeout, - maxBackpressure: options.maxBackpressure, - maxPayloadLength: this.opts.maxHttpBufferSize, - upgrade: this.handleUpgrade.bind(this), - open: (ws) => { - const transport = ws.getUserData().transport; - transport.socket = ws; - transport.writable = true; - transport.emit("ready"); - }, - message: (ws, message, isBinary) => { - ws.getUserData().transport.onData(isBinary ? message : Buffer.from(message).toString()); - }, - close: (ws, code, message) => { - ws.getUserData().transport.onClose(code, message); - } - }); - } - _applyMiddlewares(req, res, callback) { - if (this.middlewares.length === 0) { - return callback(); - } - req.res = new ResponseWrapper(res); - super._applyMiddlewares(req, req.res, (err) => { - req.res.writeHead(); - callback(err); - }); - } - handleRequest(res, req) { - debug('handling "%s" http request "%s"', req.getMethod(), req.getUrl()); - this.prepare(req, res); - req.res = res; - const callback = (errorCode, errorContext) => { - if (errorCode !== undefined) { - this.emit("connection_error", { - req, - code: errorCode, - message: server_1.Server.errorMessages[errorCode], - context: errorContext - }); - this.abortRequest(req.res, errorCode, errorContext); - return; - } - if (req._query.sid) { - debug("setting new request for existing client"); - this.clients[req._query.sid].transport.onRequest(req); - } else { - const closeConnection = (errorCode2, errorContext2) => this.abortRequest(res, errorCode2, errorContext2); - this.handshake(req._query.transport, req, closeConnection); - } - }; - this._applyMiddlewares(req, res, (err) => { - if (err) { - callback(server_1.Server.errors.BAD_REQUEST, { name: "MIDDLEWARE_FAILURE" }); - } else { - this.verify(req, false, callback); - } - }); - } - handleUpgrade(res, req, context2) { - debug("on upgrade"); - this.prepare(req, res); - req.res = res; - const callback = async (errorCode, errorContext) => { - if (errorCode !== undefined) { - this.emit("connection_error", { - req, - code: errorCode, - message: server_1.Server.errorMessages[errorCode], - context: errorContext - }); - this.abortRequest(res, errorCode, errorContext); - return; - } - const id = req._query.sid; - let transport; - if (id) { - const client = this.clients[id]; - if (!client) { - debug("upgrade attempt for closed client"); - return res.close(); - } else if (client.upgrading) { - debug("transport has already been trying to upgrade"); - return res.close(); - } else if (client.upgraded) { - debug("transport had already been upgraded"); - return res.close(); - } else { - debug("upgrading existing transport"); - transport = this.createTransport(req._query.transport, req); - client._maybeUpgrade(transport); - } - } else { - transport = await this.handshake(req._query.transport, req, (errorCode2, errorContext2) => this.abortRequest(res, errorCode2, errorContext2)); - if (!transport) { - return; - } - } - req.res.writeStatus("101 Switching Protocols"); - res.upgrade({ - transport - }, req.getHeader("sec-websocket-key"), req.getHeader("sec-websocket-protocol"), req.getHeader("sec-websocket-extensions"), context2); - }; - this._applyMiddlewares(req, res, (err) => { - if (err) { - callback(server_1.Server.errors.BAD_REQUEST, { name: "MIDDLEWARE_FAILURE" }); - } else { - this.verify(req, true, callback); - } - }); - } - abortRequest(res, errorCode, errorContext) { - const statusCode = errorCode === server_1.Server.errors.FORBIDDEN ? "403 Forbidden" : "400 Bad Request"; - const message = errorContext && errorContext.message ? errorContext.message : server_1.Server.errorMessages[errorCode]; - res.writeStatus(statusCode); - res.writeHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ - code: errorCode, - message - })); - } - } - exports2.uServer = uServer; - - class ResponseWrapper { - constructor(res) { - this.res = res; - this.statusWritten = false; - this.headers = []; - this.isAborted = false; - } - set statusCode(status) { - if (!status) { - return; - } - this.writeStatus(status === 200 ? "200 OK" : "204 No Content"); - } - writeHead(status) { - this.statusCode = status; - } - setHeader(key, value) { - if (Array.isArray(value)) { - value.forEach((val) => { - this.writeHeader(key, val); - }); - } else { - this.writeHeader(key, value); - } - } - removeHeader() {} - getHeader() {} - writeStatus(status) { - if (this.isAborted) - return; - this.res.writeStatus(status); - this.statusWritten = true; - this.writeBufferedHeaders(); - return this; - } - writeHeader(key, value) { - if (this.isAborted) - return; - if (key === "Content-Length") { - return; - } - if (this.statusWritten) { - this.res.writeHeader(key, value); - } else { - this.headers.push([key, value]); - } - } - writeBufferedHeaders() { - this.headers.forEach(([key, value]) => { - this.res.writeHeader(key, value); - }); - } - end(data) { - if (this.isAborted) - return; - this.res.cork(() => { - if (!this.statusWritten) { - this.writeBufferedHeaders(); - } - this.res.end(data); - }); - } - onData(fn) { - if (this.isAborted) - return; - this.res.onData(fn); - } - onAborted(fn) { - if (this.isAborted) - return; - this.res.onAborted(() => { - this.isAborted = true; - fn(); - }); - } - cork(fn) { - if (this.isAborted) - return; - this.res.cork(fn); - } - } -}); - -// ../../node_modules/.pnpm/engine.io@6.6.5/node_modules/engine.io/build/engine.io.js -var require_engine_io = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.protocol = exports2.Transport = exports2.Socket = exports2.uServer = exports2.parser = exports2.transports = exports2.Server = undefined; - exports2.listen = listen; - exports2.attach = attach; - var http_1 = require("http"); - var server_1 = require_server(); - Object.defineProperty(exports2, "Server", { enumerable: true, get: function() { - return server_1.Server; - } }); - var index_1 = require_transports(); - exports2.transports = index_1.default; - var parser = require_cjs(); - exports2.parser = parser; - var userver_1 = require_userver(); - Object.defineProperty(exports2, "uServer", { enumerable: true, get: function() { - return userver_1.uServer; - } }); - var socket_1 = require_socket(); - Object.defineProperty(exports2, "Socket", { enumerable: true, get: function() { - return socket_1.Socket; - } }); - var transport_1 = require_transport(); - Object.defineProperty(exports2, "Transport", { enumerable: true, get: function() { - return transport_1.Transport; - } }); - exports2.protocol = parser.protocol; - function listen(port, options, listenCallback) { - if (typeof options === "function") { - listenCallback = options; - options = {}; - } - const server = (0, http_1.createServer)(function(req, res) { - res.writeHead(501); - res.end("Not Implemented"); - }); - const engine = attach(server, options); - engine.httpServer = server; - server.listen(port, listenCallback); - return engine; - } - function attach(server, options) { - const engine = new server_1.Server(options); - engine.attach(server, options); - return engine; - } -}); - -// ../../node_modules/.pnpm/@socket.io+component-emitter@3.1.2/node_modules/@socket.io/component-emitter/lib/cjs/index.js -var require_cjs2 = __commonJS((exports2) => { - exports2.Emitter = Emitter; - function Emitter(obj) { - if (obj) - return mixin(obj); - } - function mixin(obj) { - for (var key in Emitter.prototype) { - obj[key] = Emitter.prototype[key]; - } - return obj; - } - Emitter.prototype.on = Emitter.prototype.addEventListener = function(event, fn) { - this._callbacks = this._callbacks || {}; - (this._callbacks["$" + event] = this._callbacks["$" + event] || []).push(fn); - return this; - }; - Emitter.prototype.once = function(event, fn) { - function on() { - this.off(event, on); - fn.apply(this, arguments); - } - on.fn = fn; - this.on(event, on); - return this; - }; - Emitter.prototype.off = Emitter.prototype.removeListener = Emitter.prototype.removeAllListeners = Emitter.prototype.removeEventListener = function(event, fn) { - this._callbacks = this._callbacks || {}; - if (arguments.length == 0) { - this._callbacks = {}; - return this; - } - var callbacks = this._callbacks["$" + event]; - if (!callbacks) - return this; - if (arguments.length == 1) { - delete this._callbacks["$" + event]; - return this; - } - var cb; - for (var i = 0;i < callbacks.length; i++) { - cb = callbacks[i]; - if (cb === fn || cb.fn === fn) { - callbacks.splice(i, 1); - break; - } - } - if (callbacks.length === 0) { - delete this._callbacks["$" + event]; - } - return this; - }; - Emitter.prototype.emit = function(event) { - this._callbacks = this._callbacks || {}; - var args = new Array(arguments.length - 1), callbacks = this._callbacks["$" + event]; - for (var i = 1;i < arguments.length; i++) { - args[i - 1] = arguments[i]; - } - if (callbacks) { - callbacks = callbacks.slice(0); - for (var i = 0, len = callbacks.length;i < len; ++i) { - callbacks[i].apply(this, args); - } - } - return this; - }; - Emitter.prototype.emitReserved = Emitter.prototype.emit; - Emitter.prototype.listeners = function(event) { - this._callbacks = this._callbacks || {}; - return this._callbacks["$" + event] || []; - }; - Emitter.prototype.hasListeners = function(event) { - return !!this.listeners(event).length; - }; -}); - -// ../../node_modules/.pnpm/socket.io-parser@4.2.5/node_modules/socket.io-parser/build/cjs/is-binary.js -var require_is_binary = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.isBinary = isBinary; - exports2.hasBinary = hasBinary; - var withNativeArrayBuffer = typeof ArrayBuffer === "function"; - var isView = (obj) => { - return typeof ArrayBuffer.isView === "function" ? ArrayBuffer.isView(obj) : obj.buffer instanceof ArrayBuffer; - }; - var toString = Object.prototype.toString; - var withNativeBlob = typeof Blob === "function" || typeof Blob !== "undefined" && toString.call(Blob) === "[object BlobConstructor]"; - var withNativeFile = typeof File === "function" || typeof File !== "undefined" && toString.call(File) === "[object FileConstructor]"; - function isBinary(obj) { - return withNativeArrayBuffer && (obj instanceof ArrayBuffer || isView(obj)) || withNativeBlob && obj instanceof Blob || withNativeFile && obj instanceof File; - } - function hasBinary(obj, toJSON) { - if (!obj || typeof obj !== "object") { - return false; - } - if (Array.isArray(obj)) { - for (let i = 0, l = obj.length;i < l; i++) { - if (hasBinary(obj[i])) { - return true; - } - } - return false; - } - if (isBinary(obj)) { - return true; - } - if (obj.toJSON && typeof obj.toJSON === "function" && arguments.length === 1) { - return hasBinary(obj.toJSON(), true); - } - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key) && hasBinary(obj[key])) { - return true; - } - } - return false; - } -}); - -// ../../node_modules/.pnpm/socket.io-parser@4.2.5/node_modules/socket.io-parser/build/cjs/binary.js -var require_binary = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.deconstructPacket = deconstructPacket; - exports2.reconstructPacket = reconstructPacket; - var is_binary_js_1 = require_is_binary(); - function deconstructPacket(packet) { - const buffers = []; - const packetData = packet.data; - const pack = packet; - pack.data = _deconstructPacket(packetData, buffers); - pack.attachments = buffers.length; - return { packet: pack, buffers }; - } - function _deconstructPacket(data, buffers) { - if (!data) - return data; - if ((0, is_binary_js_1.isBinary)(data)) { - const placeholder = { _placeholder: true, num: buffers.length }; - buffers.push(data); - return placeholder; - } else if (Array.isArray(data)) { - const newData = new Array(data.length); - for (let i = 0;i < data.length; i++) { - newData[i] = _deconstructPacket(data[i], buffers); - } - return newData; - } else if (typeof data === "object" && !(data instanceof Date)) { - const newData = {}; - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - newData[key] = _deconstructPacket(data[key], buffers); - } - } - return newData; - } - return data; - } - function reconstructPacket(packet, buffers) { - packet.data = _reconstructPacket(packet.data, buffers); - delete packet.attachments; - return packet; - } - function _reconstructPacket(data, buffers) { - if (!data) - return data; - if (data && data._placeholder === true) { - const isIndexValid = typeof data.num === "number" && data.num >= 0 && data.num < buffers.length; - if (isIndexValid) { - return buffers[data.num]; - } else { - throw new Error("illegal attachments"); - } - } else if (Array.isArray(data)) { - for (let i = 0;i < data.length; i++) { - data[i] = _reconstructPacket(data[i], buffers); - } - } else if (typeof data === "object") { - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - data[key] = _reconstructPacket(data[key], buffers); - } - } - } - return data; - } -}); - -// ../../node_modules/.pnpm/socket.io-parser@4.2.5/node_modules/socket.io-parser/build/cjs/index.js -var require_cjs3 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Decoder = exports2.Encoder = exports2.PacketType = exports2.protocol = undefined; - exports2.isPacketValid = isPacketValid; - var component_emitter_1 = require_cjs2(); - var binary_js_1 = require_binary(); - var is_binary_js_1 = require_is_binary(); - var debug_1 = require_src2(); - var debug = (0, debug_1.default)("socket.io-parser"); - var RESERVED_EVENTS = [ - "connect", - "connect_error", - "disconnect", - "disconnecting", - "newListener", - "removeListener" - ]; - exports2.protocol = 5; - var PacketType; - (function(PacketType2) { - PacketType2[PacketType2["CONNECT"] = 0] = "CONNECT"; - PacketType2[PacketType2["DISCONNECT"] = 1] = "DISCONNECT"; - PacketType2[PacketType2["EVENT"] = 2] = "EVENT"; - PacketType2[PacketType2["ACK"] = 3] = "ACK"; - PacketType2[PacketType2["CONNECT_ERROR"] = 4] = "CONNECT_ERROR"; - PacketType2[PacketType2["BINARY_EVENT"] = 5] = "BINARY_EVENT"; - PacketType2[PacketType2["BINARY_ACK"] = 6] = "BINARY_ACK"; - })(PacketType || (exports2.PacketType = PacketType = {})); - - class Encoder { - constructor(replacer) { - this.replacer = replacer; - } - encode(obj) { - debug("encoding packet %j", obj); - if (obj.type === PacketType.EVENT || obj.type === PacketType.ACK) { - if ((0, is_binary_js_1.hasBinary)(obj)) { - return this.encodeAsBinary({ - type: obj.type === PacketType.EVENT ? PacketType.BINARY_EVENT : PacketType.BINARY_ACK, - nsp: obj.nsp, - data: obj.data, - id: obj.id - }); - } - } - return [this.encodeAsString(obj)]; - } - encodeAsString(obj) { - let str = "" + obj.type; - if (obj.type === PacketType.BINARY_EVENT || obj.type === PacketType.BINARY_ACK) { - str += obj.attachments + "-"; - } - if (obj.nsp && obj.nsp !== "/") { - str += obj.nsp + ","; - } - if (obj.id != null) { - str += obj.id; - } - if (obj.data != null) { - str += JSON.stringify(obj.data, this.replacer); - } - debug("encoded %j as %s", obj, str); - return str; - } - encodeAsBinary(obj) { - const deconstruction = (0, binary_js_1.deconstructPacket)(obj); - const pack = this.encodeAsString(deconstruction.packet); - const buffers = deconstruction.buffers; - buffers.unshift(pack); - return buffers; - } - } - exports2.Encoder = Encoder; - - class Decoder extends component_emitter_1.Emitter { - constructor(reviver) { - super(); - this.reviver = reviver; - } - add(obj) { - let packet; - if (typeof obj === "string") { - if (this.reconstructor) { - throw new Error("got plaintext data when reconstructing a packet"); - } - packet = this.decodeString(obj); - const isBinaryEvent = packet.type === PacketType.BINARY_EVENT; - if (isBinaryEvent || packet.type === PacketType.BINARY_ACK) { - packet.type = isBinaryEvent ? PacketType.EVENT : PacketType.ACK; - this.reconstructor = new BinaryReconstructor(packet); - if (packet.attachments === 0) { - super.emitReserved("decoded", packet); - } - } else { - super.emitReserved("decoded", packet); - } - } else if ((0, is_binary_js_1.isBinary)(obj) || obj.base64) { - if (!this.reconstructor) { - throw new Error("got binary data when not reconstructing a packet"); - } else { - packet = this.reconstructor.takeBinaryData(obj); - if (packet) { - this.reconstructor = null; - super.emitReserved("decoded", packet); - } - } - } else { - throw new Error("Unknown type: " + obj); - } - } - decodeString(str) { - let i = 0; - const p = { - type: Number(str.charAt(0)) - }; - if (PacketType[p.type] === undefined) { - throw new Error("unknown packet type " + p.type); - } - if (p.type === PacketType.BINARY_EVENT || p.type === PacketType.BINARY_ACK) { - const start = i + 1; - while (str.charAt(++i) !== "-" && i != str.length) {} - const buf = str.substring(start, i); - if (buf != Number(buf) || str.charAt(i) !== "-") { - throw new Error("Illegal attachments"); - } - p.attachments = Number(buf); - } - if (str.charAt(i + 1) === "/") { - const start = i + 1; - while (++i) { - const c = str.charAt(i); - if (c === ",") - break; - if (i === str.length) - break; - } - p.nsp = str.substring(start, i); - } else { - p.nsp = "/"; - } - const next = str.charAt(i + 1); - if (next !== "" && Number(next) == next) { - const start = i + 1; - while (++i) { - const c = str.charAt(i); - if (c == null || Number(c) != c) { - --i; - break; - } - if (i === str.length) - break; - } - p.id = Number(str.substring(start, i + 1)); - } - if (str.charAt(++i)) { - const payload = this.tryParse(str.substr(i)); - if (Decoder.isPayloadValid(p.type, payload)) { - p.data = payload; - } else { - throw new Error("invalid payload"); - } - } - debug("decoded %s as %j", str, p); - return p; - } - tryParse(str) { - try { - return JSON.parse(str, this.reviver); - } catch (e) { - return false; - } - } - static isPayloadValid(type, payload) { - switch (type) { - case PacketType.CONNECT: - return isObject2(payload); - case PacketType.DISCONNECT: - return payload === undefined; - case PacketType.CONNECT_ERROR: - return typeof payload === "string" || isObject2(payload); - case PacketType.EVENT: - case PacketType.BINARY_EVENT: - return Array.isArray(payload) && (typeof payload[0] === "number" || typeof payload[0] === "string" && RESERVED_EVENTS.indexOf(payload[0]) === -1); - case PacketType.ACK: - case PacketType.BINARY_ACK: - return Array.isArray(payload); - } - } - destroy() { - if (this.reconstructor) { - this.reconstructor.finishedReconstruction(); - this.reconstructor = null; - } - } - } - exports2.Decoder = Decoder; - - class BinaryReconstructor { - constructor(packet) { - this.packet = packet; - this.buffers = []; - this.reconPack = packet; - } - takeBinaryData(binData) { - this.buffers.push(binData); - if (this.buffers.length === this.reconPack.attachments) { - const packet = (0, binary_js_1.reconstructPacket)(this.reconPack, this.buffers); - this.finishedReconstruction(); - return packet; - } - return null; - } - finishedReconstruction() { - this.reconPack = null; - this.buffers = []; - } - } - function isNamespaceValid(nsp) { - return typeof nsp === "string"; - } - var isInteger = Number.isInteger || function(value) { - return typeof value === "number" && isFinite(value) && Math.floor(value) === value; - }; - function isAckIdValid(id) { - return id === undefined || isInteger(id); - } - function isObject2(value) { - return Object.prototype.toString.call(value) === "[object Object]"; - } - function isDataValid(type, payload) { - switch (type) { - case PacketType.CONNECT: - return payload === undefined || isObject2(payload); - case PacketType.DISCONNECT: - return payload === undefined; - case PacketType.EVENT: - return Array.isArray(payload) && (typeof payload[0] === "number" || typeof payload[0] === "string" && RESERVED_EVENTS.indexOf(payload[0]) === -1); - case PacketType.ACK: - return Array.isArray(payload); - case PacketType.CONNECT_ERROR: - return typeof payload === "string" || isObject2(payload); - default: - return false; - } - } - function isPacketValid(packet) { - return isNamespaceValid(packet.nsp) && isAckIdValid(packet.id) && isDataValid(packet.type, packet.data); - } -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/client.js -var require_client = __commonJS((exports2) => { - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Client = undefined; - var socket_io_parser_1 = require_cjs3(); - var debug_1 = __importDefault(require_src2()); - var debug = (0, debug_1.default)("socket.io:client"); - - class Client { - constructor(server, conn) { - this.sockets = new Map; - this.nsps = new Map; - this.server = server; - this.conn = conn; - this.encoder = server.encoder; - this.decoder = new server._parser.Decoder; - this.id = conn.id; - this.setup(); - } - get request() { - return this.conn.request; - } - setup() { - this.onclose = this.onclose.bind(this); - this.ondata = this.ondata.bind(this); - this.onerror = this.onerror.bind(this); - this.ondecoded = this.ondecoded.bind(this); - this.decoder.on("decoded", this.ondecoded); - this.conn.on("data", this.ondata); - this.conn.on("error", this.onerror); - this.conn.on("close", this.onclose); - this.connectTimeout = setTimeout(() => { - if (this.nsps.size === 0) { - debug("no namespace joined yet, close the client"); - this.close(); - } else { - debug("the client has already joined a namespace, nothing to do"); - } - }, this.server._connectTimeout); - } - connect(name21, auth = {}) { - if (this.server._nsps.has(name21)) { - debug("connecting to namespace %s", name21); - return this.doConnect(name21, auth); - } - this.server._checkNamespace(name21, auth, (dynamicNspName) => { - if (dynamicNspName) { - this.doConnect(name21, auth); - } else { - debug("creation of namespace %s was denied", name21); - this._packet({ - type: socket_io_parser_1.PacketType.CONNECT_ERROR, - nsp: name21, - data: { - message: "Invalid namespace" - } - }); - } - }); - } - doConnect(name21, auth) { - const nsp = this.server.of(name21); - nsp._add(this, auth, (socket) => { - this.sockets.set(socket.id, socket); - this.nsps.set(nsp.name, socket); - if (this.connectTimeout) { - clearTimeout(this.connectTimeout); - this.connectTimeout = undefined; - } - }); - } - _disconnect() { - for (const socket of this.sockets.values()) { - socket.disconnect(); - } - this.sockets.clear(); - this.close(); - } - _remove(socket) { - if (this.sockets.has(socket.id)) { - const nsp = this.sockets.get(socket.id).nsp.name; - this.sockets.delete(socket.id); - this.nsps.delete(nsp); - } else { - debug("ignoring remove for %s", socket.id); - } - } - close() { - if (this.conn.readyState === "open") { - debug("forcing transport close"); - this.conn.close(); - this.onclose("forced server close"); - } - } - _packet(packet, opts = {}) { - if (this.conn.readyState !== "open") { - debug("ignoring packet write %j", packet); - return; - } - const encodedPackets = opts.preEncoded ? packet : this.encoder.encode(packet); - this.writeToEngine(encodedPackets, opts); - } - writeToEngine(encodedPackets, opts) { - if (opts.volatile && !this.conn.transport.writable) { - debug("volatile packet is discarded since the transport is not currently writable"); - return; - } - const packets = Array.isArray(encodedPackets) ? encodedPackets : [encodedPackets]; - for (const encodedPacket of packets) { - this.conn.write(encodedPacket, opts); - } - } - ondata(data) { - try { - this.decoder.add(data); - } catch (e) { - debug("invalid packet format"); - this.onerror(e); - } - } - ondecoded(packet) { - const { namespace, authPayload } = this._parseNamespace(packet); - const socket = this.nsps.get(namespace); - if (!socket && packet.type === socket_io_parser_1.PacketType.CONNECT) { - this.connect(namespace, authPayload); - } else if (socket && packet.type !== socket_io_parser_1.PacketType.CONNECT && packet.type !== socket_io_parser_1.PacketType.CONNECT_ERROR) { - process.nextTick(function() { - socket._onpacket(packet); - }); - } else { - debug("invalid state (packet type: %s)", packet.type); - this.close(); - } - } - _parseNamespace(packet) { - if (this.conn.protocol !== 3) { - return { - namespace: packet.nsp, - authPayload: packet.data - }; - } - const url2 = new URL(packet.nsp, "https://socket.io"); - return { - namespace: url2.pathname, - authPayload: Object.fromEntries(url2.searchParams.entries()) - }; - } - onerror(err) { - for (const socket of this.sockets.values()) { - socket._onerror(err); - } - this.conn.close(); - } - onclose(reason, description) { - debug("client close with reason %s", reason); - this.destroy(); - for (const socket of this.sockets.values()) { - socket._onclose(reason, description); - } - this.sockets.clear(); - this.decoder.destroy(); - } - destroy() { - this.conn.removeListener("data", this.ondata); - this.conn.removeListener("error", this.onerror); - this.conn.removeListener("close", this.onclose); - this.decoder.removeListener("decoded", this.ondecoded); - if (this.connectTimeout) { - clearTimeout(this.connectTimeout); - this.connectTimeout = undefined; - } - } - } - exports2.Client = Client; -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/typed-events.js -var require_typed_events = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.StrictEventEmitter = undefined; - var events_1 = require("events"); - - class StrictEventEmitter extends events_1.EventEmitter { - on(ev, listener) { - return super.on(ev, listener); - } - once(ev, listener) { - return super.once(ev, listener); - } - emit(ev, ...args) { - return super.emit(ev, ...args); - } - emitReserved(ev, ...args) { - return super.emit(ev, ...args); - } - emitUntyped(ev, ...args) { - return super.emit(ev, ...args); - } - listeners(event) { - return super.listeners(event); - } - } - exports2.StrictEventEmitter = StrictEventEmitter; -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/socket-types.js -var require_socket_types = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.RESERVED_EVENTS = undefined; - exports2.RESERVED_EVENTS = new Set([ - "connect", - "connect_error", - "disconnect", - "disconnecting", - "newListener", - "removeListener" - ]); -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/broadcast-operator.js -var require_broadcast_operator = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.RemoteSocket = exports2.BroadcastOperator = undefined; - var socket_types_1 = require_socket_types(); - var socket_io_parser_1 = require_cjs3(); - - class BroadcastOperator { - constructor(adapter, rooms = new Set, exceptRooms = new Set, flags = {}) { - this.adapter = adapter; - this.rooms = rooms; - this.exceptRooms = exceptRooms; - this.flags = flags; - } - to(room) { - const rooms = new Set(this.rooms); - if (Array.isArray(room)) { - room.forEach((r) => rooms.add(r)); - } else { - rooms.add(room); - } - return new BroadcastOperator(this.adapter, rooms, this.exceptRooms, this.flags); - } - in(room) { - return this.to(room); - } - except(room) { - const exceptRooms = new Set(this.exceptRooms); - if (Array.isArray(room)) { - room.forEach((r) => exceptRooms.add(r)); - } else { - exceptRooms.add(room); - } - return new BroadcastOperator(this.adapter, this.rooms, exceptRooms, this.flags); - } - compress(compress) { - const flags = Object.assign({}, this.flags, { compress }); - return new BroadcastOperator(this.adapter, this.rooms, this.exceptRooms, flags); - } - get volatile() { - const flags = Object.assign({}, this.flags, { volatile: true }); - return new BroadcastOperator(this.adapter, this.rooms, this.exceptRooms, flags); - } - get local() { - const flags = Object.assign({}, this.flags, { local: true }); - return new BroadcastOperator(this.adapter, this.rooms, this.exceptRooms, flags); - } - timeout(timeout2) { - const flags = Object.assign({}, this.flags, { timeout: timeout2 }); - return new BroadcastOperator(this.adapter, this.rooms, this.exceptRooms, flags); - } - emit(ev, ...args) { - if (socket_types_1.RESERVED_EVENTS.has(ev)) { - throw new Error(`"${String(ev)}" is a reserved event name`); - } - const data = [ev, ...args]; - const packet = { - type: socket_io_parser_1.PacketType.EVENT, - data - }; - const withAck = typeof data[data.length - 1] === "function"; - if (!withAck) { - this.adapter.broadcast(packet, { - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags - }); - return true; - } - const ack = data.pop(); - let timedOut = false; - let responses = []; - const timer = setTimeout(() => { - timedOut = true; - ack.apply(this, [ - new Error("operation has timed out"), - this.flags.expectSingleResponse ? null : responses - ]); - }, this.flags.timeout); - let expectedServerCount = -1; - let actualServerCount = 0; - let expectedClientCount = 0; - const checkCompleteness = () => { - if (!timedOut && expectedServerCount === actualServerCount && responses.length === expectedClientCount) { - clearTimeout(timer); - ack.apply(this, [ - null, - this.flags.expectSingleResponse ? responses[0] : responses - ]); - } - }; - this.adapter.broadcastWithAck(packet, { - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags - }, (clientCount) => { - expectedClientCount += clientCount; - actualServerCount++; - checkCompleteness(); - }, (clientResponse) => { - responses.push(clientResponse); - checkCompleteness(); - }); - this.adapter.serverCount().then((serverCount) => { - expectedServerCount = serverCount; - checkCompleteness(); - }); - return true; - } - emitWithAck(ev, ...args) { - return new Promise((resolve2, reject) => { - args.push((err, responses) => { - if (err) { - err.responses = responses; - return reject(err); - } else { - return resolve2(responses); - } - }); - this.emit(ev, ...args); - }); - } - allSockets() { - if (!this.adapter) { - throw new Error("No adapter for this namespace, are you trying to get the list of clients of a dynamic namespace?"); - } - return this.adapter.sockets(this.rooms); - } - fetchSockets() { - return this.adapter.fetchSockets({ - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags - }).then((sockets) => { - return sockets.map((socket) => { - if (socket.server) { - return socket; - } else { - return new RemoteSocket(this.adapter, socket); - } - }); - }); - } - socketsJoin(room) { - this.adapter.addSockets({ - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags - }, Array.isArray(room) ? room : [room]); - } - socketsLeave(room) { - this.adapter.delSockets({ - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags - }, Array.isArray(room) ? room : [room]); - } - disconnectSockets(close = false) { - this.adapter.disconnectSockets({ - rooms: this.rooms, - except: this.exceptRooms, - flags: this.flags - }, close); - } - } - exports2.BroadcastOperator = BroadcastOperator; - - class RemoteSocket { - constructor(adapter, details) { - this.id = details.id; - this.handshake = details.handshake; - this.rooms = new Set(details.rooms); - this.data = details.data; - this.operator = new BroadcastOperator(adapter, new Set([this.id]), new Set, { - expectSingleResponse: true - }); - } - timeout(timeout2) { - return this.operator.timeout(timeout2); - } - emit(ev, ...args) { - return this.operator.emit(ev, ...args); - } - join(room) { - return this.operator.socketsJoin(room); - } - leave(room) { - return this.operator.socketsLeave(room); - } - disconnect(close = false) { - this.operator.disconnectSockets(close); - return this; - } - } - exports2.RemoteSocket = RemoteSocket; -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/socket.js -var require_socket2 = __commonJS((exports2) => { - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Socket = undefined; - var socket_io_parser_1 = require_cjs3(); - var debug_1 = __importDefault(require_src2()); - var typed_events_1 = require_typed_events(); - var base64id_1 = __importDefault(require_base64id()); - var broadcast_operator_1 = require_broadcast_operator(); - var socket_types_1 = require_socket_types(); - var debug = (0, debug_1.default)("socket.io:socket"); - var RECOVERABLE_DISCONNECT_REASONS = new Set([ - "transport error", - "transport close", - "forced close", - "ping timeout", - "server shutting down", - "forced server close" - ]); - function noop3() {} - - class Socket extends typed_events_1.StrictEventEmitter { - constructor(nsp, client, auth, previousSession) { - super(); - this.nsp = nsp; - this.client = client; - this.recovered = false; - this.data = {}; - this.connected = false; - this.acks = new Map; - this.fns = []; - this.flags = {}; - this.server = nsp.server; - this.adapter = nsp.adapter; - if (previousSession) { - this.id = previousSession.sid; - this.pid = previousSession.pid; - previousSession.rooms.forEach((room) => this.join(room)); - this.data = previousSession.data; - previousSession.missedPackets.forEach((packet) => { - this.packet({ - type: socket_io_parser_1.PacketType.EVENT, - data: packet - }); - }); - this.recovered = true; - } else { - if (client.conn.protocol === 3) { - this.id = nsp.name !== "/" ? nsp.name + "#" + client.id : client.id; - } else { - this.id = base64id_1.default.generateId(); - } - if (this.server._opts.connectionStateRecovery) { - this.pid = base64id_1.default.generateId(); - } - } - this.handshake = this.buildHandshake(auth); - this.on("error", noop3); - } - buildHandshake(auth) { - var _a21, _b16, _c, _d; - return { - headers: ((_a21 = this.request) === null || _a21 === undefined ? undefined : _a21.headers) || {}, - time: new Date + "", - address: this.conn.remoteAddress, - xdomain: !!((_b16 = this.request) === null || _b16 === undefined ? undefined : _b16.headers.origin), - secure: !this.request || !!this.request.connection.encrypted, - issued: +new Date, - url: (_c = this.request) === null || _c === undefined ? undefined : _c.url, - query: ((_d = this.request) === null || _d === undefined ? undefined : _d._query) || {}, - auth - }; - } - emit(ev, ...args) { - if (socket_types_1.RESERVED_EVENTS.has(ev)) { - throw new Error(`"${String(ev)}" is a reserved event name`); - } - const data = [ev, ...args]; - const packet = { - type: socket_io_parser_1.PacketType.EVENT, - data - }; - if (typeof data[data.length - 1] === "function") { - const id = this.nsp._ids++; - debug("emitting packet with ack id %d", id); - this.registerAckCallback(id, data.pop()); - packet.id = id; - } - const flags = Object.assign({}, this.flags); - this.flags = {}; - if (this.nsp.server.opts.connectionStateRecovery) { - this.adapter.broadcast(packet, { - rooms: new Set([this.id]), - except: new Set, - flags - }); - } else { - this.notifyOutgoingListeners(packet); - this.packet(packet, flags); - } - return true; - } - emitWithAck(ev, ...args) { - const withErr = this.flags.timeout !== undefined; - return new Promise((resolve2, reject) => { - args.push((arg1, arg2) => { - if (withErr) { - return arg1 ? reject(arg1) : resolve2(arg2); - } else { - return resolve2(arg1); - } - }); - this.emit(ev, ...args); - }); - } - registerAckCallback(id, ack) { - const timeout2 = this.flags.timeout; - if (timeout2 === undefined) { - this.acks.set(id, ack); - return; - } - const timer = setTimeout(() => { - debug("event with ack id %d has timed out after %d ms", id, timeout2); - this.acks.delete(id); - ack.call(this, new Error("operation has timed out")); - }, timeout2); - this.acks.set(id, (...args) => { - clearTimeout(timer); - ack.apply(this, [null, ...args]); - }); - } - to(room) { - return this.newBroadcastOperator().to(room); - } - in(room) { - return this.newBroadcastOperator().in(room); - } - except(room) { - return this.newBroadcastOperator().except(room); - } - send(...args) { - this.emit("message", ...args); - return this; - } - write(...args) { - this.emit("message", ...args); - return this; - } - packet(packet, opts = {}) { - packet.nsp = this.nsp.name; - opts.compress = opts.compress !== false; - this.client._packet(packet, opts); - } - join(rooms) { - debug("join room %s", rooms); - return this.adapter.addAll(this.id, new Set(Array.isArray(rooms) ? rooms : [rooms])); - } - leave(room) { - debug("leave room %s", room); - return this.adapter.del(this.id, room); - } - leaveAll() { - this.adapter.delAll(this.id); - } - _onconnect() { - debug("socket connected - writing packet"); - this.connected = true; - this.join(this.id); - if (this.conn.protocol === 3) { - this.packet({ type: socket_io_parser_1.PacketType.CONNECT }); - } else { - this.packet({ - type: socket_io_parser_1.PacketType.CONNECT, - data: { sid: this.id, pid: this.pid } - }); - } - } - _onpacket(packet) { - debug("got packet %j", packet); - switch (packet.type) { - case socket_io_parser_1.PacketType.EVENT: - this.onevent(packet); - break; - case socket_io_parser_1.PacketType.BINARY_EVENT: - this.onevent(packet); - break; - case socket_io_parser_1.PacketType.ACK: - this.onack(packet); - break; - case socket_io_parser_1.PacketType.BINARY_ACK: - this.onack(packet); - break; - case socket_io_parser_1.PacketType.DISCONNECT: - this.ondisconnect(); - break; - } - } - onevent(packet) { - const args = packet.data || []; - debug("emitting event %j", args); - if (packet.id != null) { - debug("attaching ack callback to event"); - args.push(this.ack(packet.id)); - } - if (this._anyListeners && this._anyListeners.length) { - const listeners = this._anyListeners.slice(); - for (const listener of listeners) { - listener.apply(this, args); - } - } - this.dispatch(args); - } - ack(id) { - const self = this; - let sent = false; - return function() { - if (sent) - return; - const args = Array.prototype.slice.call(arguments); - debug("sending ack %j", args); - self.packet({ - id, - type: socket_io_parser_1.PacketType.ACK, - data: args - }); - sent = true; - }; - } - onack(packet) { - const ack = this.acks.get(packet.id); - if (typeof ack == "function") { - debug("calling ack %s with %j", packet.id, packet.data); - ack.apply(this, packet.data); - this.acks.delete(packet.id); - } else { - debug("bad ack %s", packet.id); - } - } - ondisconnect() { - debug("got disconnect packet"); - this._onclose("client namespace disconnect"); - } - _onerror(err) { - this.emitReserved("error", err); - } - _onclose(reason, description) { - if (!this.connected) - return this; - debug("closing socket - reason %s", reason); - this.emitReserved("disconnecting", reason, description); - if (this.server._opts.connectionStateRecovery && RECOVERABLE_DISCONNECT_REASONS.has(reason)) { - debug("connection state recovery is enabled for sid %s", this.id); - this.adapter.persistSession({ - sid: this.id, - pid: this.pid, - rooms: [...this.rooms], - data: this.data - }); - } - this._cleanup(); - this.client._remove(this); - this.connected = false; - this.emitReserved("disconnect", reason, description); - return; - } - _cleanup() { - this.leaveAll(); - this.nsp._remove(this); - this.join = noop3; - } - _error(err) { - this.packet({ type: socket_io_parser_1.PacketType.CONNECT_ERROR, data: err }); - } - disconnect(close = false) { - if (!this.connected) - return this; - if (close) { - this.client._disconnect(); - } else { - this.packet({ type: socket_io_parser_1.PacketType.DISCONNECT }); - this._onclose("server namespace disconnect"); - } - return this; - } - compress(compress) { - this.flags.compress = compress; - return this; - } - get volatile() { - this.flags.volatile = true; - return this; - } - get broadcast() { - return this.newBroadcastOperator(); - } - get local() { - return this.newBroadcastOperator().local; - } - timeout(timeout2) { - this.flags.timeout = timeout2; - return this; - } - dispatch(event) { - debug("dispatching an event %j", event); - this.run(event, (err) => { - process.nextTick(() => { - if (err) { - return this._onerror(err); - } - if (this.connected) { - super.emitUntyped.apply(this, event); - } else { - debug("ignore packet received after disconnection"); - } - }); - }); - } - use(fn) { - this.fns.push(fn); - return this; - } - run(event, fn) { - if (!this.fns.length) - return fn(); - const fns = this.fns.slice(0); - function run(i) { - fns[i](event, (err) => { - if (err) - return fn(err); - if (!fns[i + 1]) - return fn(); - run(i + 1); - }); - } - run(0); - } - get disconnected() { - return !this.connected; - } - get request() { - return this.client.request; - } - get conn() { - return this.client.conn; - } - get rooms() { - return this.adapter.socketRooms(this.id) || new Set; - } - onAny(listener) { - this._anyListeners = this._anyListeners || []; - this._anyListeners.push(listener); - return this; - } - prependAny(listener) { - this._anyListeners = this._anyListeners || []; - this._anyListeners.unshift(listener); - return this; - } - offAny(listener) { - if (!this._anyListeners) { - return this; - } - if (listener) { - const listeners = this._anyListeners; - for (let i = 0;i < listeners.length; i++) { - if (listener === listeners[i]) { - listeners.splice(i, 1); - return this; - } - } - } else { - this._anyListeners = []; - } - return this; - } - listenersAny() { - return this._anyListeners || []; - } - onAnyOutgoing(listener) { - this._anyOutgoingListeners = this._anyOutgoingListeners || []; - this._anyOutgoingListeners.push(listener); - return this; - } - prependAnyOutgoing(listener) { - this._anyOutgoingListeners = this._anyOutgoingListeners || []; - this._anyOutgoingListeners.unshift(listener); - return this; - } - offAnyOutgoing(listener) { - if (!this._anyOutgoingListeners) { - return this; - } - if (listener) { - const listeners = this._anyOutgoingListeners; - for (let i = 0;i < listeners.length; i++) { - if (listener === listeners[i]) { - listeners.splice(i, 1); - return this; - } - } - } else { - this._anyOutgoingListeners = []; - } - return this; - } - listenersAnyOutgoing() { - return this._anyOutgoingListeners || []; - } - notifyOutgoingListeners(packet) { - if (this._anyOutgoingListeners && this._anyOutgoingListeners.length) { - const listeners = this._anyOutgoingListeners.slice(); - for (const listener of listeners) { - listener.apply(this, packet.data); - } - } - } - newBroadcastOperator() { - const flags = Object.assign({}, this.flags); - this.flags = {}; - return new broadcast_operator_1.BroadcastOperator(this.adapter, new Set, new Set([this.id]), flags); - } - } - exports2.Socket = Socket; -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/namespace.js -var require_namespace = __commonJS((exports2) => { - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Namespace = exports2.RESERVED_EVENTS = undefined; - var socket_1 = require_socket2(); - var typed_events_1 = require_typed_events(); - var debug_1 = __importDefault(require_src2()); - var broadcast_operator_1 = require_broadcast_operator(); - var debug = (0, debug_1.default)("socket.io:namespace"); - exports2.RESERVED_EVENTS = new Set(["connect", "connection", "new_namespace"]); - - class Namespace extends typed_events_1.StrictEventEmitter { - constructor(server, name21) { - super(); - this.sockets = new Map; - this._preConnectSockets = new Map; - this._fns = []; - this._ids = 0; - this.server = server; - this.name = name21; - this._initAdapter(); - } - _initAdapter() { - this.adapter = new (this.server.adapter())(this); - Promise.resolve(this.adapter.init()).catch((err) => { - debug("error while initializing adapter: %s", err); - }); - } - use(fn) { - this._fns.push(fn); - return this; - } - run(socket, fn) { - if (!this._fns.length) - return fn(); - const fns = this._fns.slice(0); - function run(i) { - fns[i](socket, (err) => { - if (err) - return fn(err); - if (!fns[i + 1]) - return fn(); - run(i + 1); - }); - } - run(0); - } - to(room) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).to(room); - } - in(room) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).in(room); - } - except(room) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).except(room); - } - async _add(client, auth, fn) { - var _a21; - debug("adding socket to nsp %s", this.name); - const socket = await this._createSocket(client, auth); - this._preConnectSockets.set(socket.id, socket); - if (((_a21 = this.server.opts.connectionStateRecovery) === null || _a21 === undefined ? undefined : _a21.skipMiddlewares) && socket.recovered && client.conn.readyState === "open") { - return this._doConnect(socket, fn); - } - this.run(socket, (err) => { - process.nextTick(() => { - if (client.conn.readyState !== "open") { - debug("next called after client was closed - ignoring socket"); - socket._cleanup(); - return; - } - if (err) { - debug("middleware error, sending CONNECT_ERROR packet to the client"); - socket._cleanup(); - if (client.conn.protocol === 3) { - return socket._error(err.data || err.message); - } else { - return socket._error({ - message: err.message, - data: err.data - }); - } - } - this._doConnect(socket, fn); - }); - }); - } - async _createSocket(client, auth) { - const sessionId = auth.pid; - const offset = auth.offset; - if (this.server.opts.connectionStateRecovery && typeof sessionId === "string" && typeof offset === "string") { - let session; - try { - session = await this.adapter.restoreSession(sessionId, offset); - } catch (e) { - debug("error while restoring session: %s", e); - } - if (session) { - debug("connection state recovered for sid %s", session.sid); - return new socket_1.Socket(this, client, auth, session); - } - } - return new socket_1.Socket(this, client, auth); - } - _doConnect(socket, fn) { - this._preConnectSockets.delete(socket.id); - this.sockets.set(socket.id, socket); - socket._onconnect(); - if (fn) - fn(socket); - this.emitReserved("connect", socket); - this.emitReserved("connection", socket); - } - _remove(socket) { - this.sockets.delete(socket.id) || this._preConnectSockets.delete(socket.id); - } - emit(ev, ...args) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).emit(ev, ...args); - } - send(...args) { - this.emit("message", ...args); - return this; - } - write(...args) { - this.emit("message", ...args); - return this; - } - serverSideEmit(ev, ...args) { - if (exports2.RESERVED_EVENTS.has(ev)) { - throw new Error(`"${String(ev)}" is a reserved event name`); - } - args.unshift(ev); - this.adapter.serverSideEmit(args); - return true; - } - serverSideEmitWithAck(ev, ...args) { - return new Promise((resolve2, reject) => { - args.push((err, responses) => { - if (err) { - err.responses = responses; - return reject(err); - } else { - return resolve2(responses); - } - }); - this.serverSideEmit(ev, ...args); - }); - } - _onServerSideEmit(args) { - super.emitUntyped.apply(this, args); - } - allSockets() { - return new broadcast_operator_1.BroadcastOperator(this.adapter).allSockets(); - } - compress(compress) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).compress(compress); - } - get volatile() { - return new broadcast_operator_1.BroadcastOperator(this.adapter).volatile; - } - get local() { - return new broadcast_operator_1.BroadcastOperator(this.adapter).local; - } - timeout(timeout2) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).timeout(timeout2); - } - fetchSockets() { - return new broadcast_operator_1.BroadcastOperator(this.adapter).fetchSockets(); - } - socketsJoin(room) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).socketsJoin(room); - } - socketsLeave(room) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).socketsLeave(room); - } - disconnectSockets(close = false) { - return new broadcast_operator_1.BroadcastOperator(this.adapter).disconnectSockets(close); - } - } - exports2.Namespace = Namespace; -}); - -// ../../node_modules/.pnpm/socket.io-adapter@2.5.6/node_modules/socket.io-adapter/dist/contrib/yeast.js -var require_yeast = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.encode = encode3; - exports2.decode = decode3; - exports2.yeast = yeast; - var alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""); - var length = 64; - var map2 = {}; - var seed = 0; - var i = 0; - var prev; - function encode3(num) { - let encoded = ""; - do { - encoded = alphabet[num % length] + encoded; - num = Math.floor(num / length); - } while (num > 0); - return encoded; - } - function decode3(str) { - let decoded = 0; - for (i = 0;i < str.length; i++) { - decoded = decoded * length + map2[str.charAt(i)]; - } - return decoded; - } - function yeast() { - const now2 = encode3(+new Date); - if (now2 !== prev) - return seed = 0, prev = now2; - return now2 + "." + encode3(seed++); - } - for (;i < length; i++) - map2[alphabet[i]] = i; -}); - -// ../../node_modules/.pnpm/socket.io-adapter@2.5.6/node_modules/socket.io-adapter/dist/in-memory-adapter.js -var require_in_memory_adapter = __commonJS((exports2) => { - var _a21; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.SessionAwareAdapter = exports2.Adapter = undefined; - var events_1 = require("events"); - var yeast_1 = require_yeast(); - var WebSocket = require_ws(); - var canPreComputeFrame = typeof ((_a21 = WebSocket === null || WebSocket === undefined ? undefined : WebSocket.Sender) === null || _a21 === undefined ? undefined : _a21.frame) === "function"; - - class Adapter extends events_1.EventEmitter { - constructor(nsp) { - super(); - this.nsp = nsp; - this.rooms = new Map; - this.sids = new Map; - this.encoder = nsp.server.encoder; - } - init() {} - close() {} - serverCount() { - return Promise.resolve(1); - } - addAll(id, rooms) { - if (!this.sids.has(id)) { - this.sids.set(id, new Set); - } - for (const room of rooms) { - this.sids.get(id).add(room); - if (!this.rooms.has(room)) { - this.rooms.set(room, new Set); - this.emit("create-room", room); - } - if (!this.rooms.get(room).has(id)) { - this.rooms.get(room).add(id); - this.emit("join-room", room, id); - } - } - } - del(id, room) { - if (this.sids.has(id)) { - this.sids.get(id).delete(room); - } - this._del(room, id); - } - _del(room, id) { - const _room = this.rooms.get(room); - if (_room != null) { - const deleted = _room.delete(id); - if (deleted) { - this.emit("leave-room", room, id); - } - if (_room.size === 0 && this.rooms.delete(room)) { - this.emit("delete-room", room); - } - } - } - delAll(id) { - if (!this.sids.has(id)) { - return; - } - for (const room of this.sids.get(id)) { - this._del(room, id); - } - this.sids.delete(id); - } - broadcast(packet, opts) { - const flags = opts.flags || {}; - const packetOpts = { - preEncoded: true, - volatile: flags.volatile, - compress: flags.compress - }; - packet.nsp = this.nsp.name; - const encodedPackets = this._encode(packet, packetOpts); - this.apply(opts, (socket) => { - if (typeof socket.notifyOutgoingListeners === "function") { - socket.notifyOutgoingListeners(packet); - } - socket.client.writeToEngine(encodedPackets, packetOpts); - }); - } - broadcastWithAck(packet, opts, clientCountCallback, ack) { - const flags = opts.flags || {}; - const packetOpts = { - preEncoded: true, - volatile: flags.volatile, - compress: flags.compress - }; - packet.nsp = this.nsp.name; - packet.id = this.nsp._ids++; - const encodedPackets = this._encode(packet, packetOpts); - let clientCount = 0; - this.apply(opts, (socket) => { - clientCount++; - socket.acks.set(packet.id, ack); - if (typeof socket.notifyOutgoingListeners === "function") { - socket.notifyOutgoingListeners(packet); - } - socket.client.writeToEngine(encodedPackets, packetOpts); - }); - clientCountCallback(clientCount); - } - _encode(packet, packetOpts) { - const encodedPackets = this.encoder.encode(packet); - if (canPreComputeFrame && encodedPackets.length === 1 && typeof encodedPackets[0] === "string") { - const data = Buffer.from("4" + encodedPackets[0]); - packetOpts.wsPreEncodedFrame = WebSocket.Sender.frame(data, { - readOnly: false, - mask: false, - rsv1: false, - opcode: 1, - fin: true - }); - } - return encodedPackets; - } - sockets(rooms) { - const sids = new Set; - this.apply({ rooms }, (socket) => { - sids.add(socket.id); - }); - return Promise.resolve(sids); - } - socketRooms(id) { - return this.sids.get(id); - } - fetchSockets(opts) { - const sockets = []; - this.apply(opts, (socket) => { - sockets.push(socket); - }); - return Promise.resolve(sockets); - } - addSockets(opts, rooms) { - this.apply(opts, (socket) => { - socket.join(rooms); - }); - } - delSockets(opts, rooms) { - this.apply(opts, (socket) => { - rooms.forEach((room) => socket.leave(room)); - }); - } - disconnectSockets(opts, close) { - this.apply(opts, (socket) => { - socket.disconnect(close); - }); - } - apply(opts, callback) { - const rooms = opts.rooms; - const except = this.computeExceptSids(opts.except); - if (rooms.size) { - const ids = new Set; - for (const room of rooms) { - if (!this.rooms.has(room)) - continue; - for (const id of this.rooms.get(room)) { - if (ids.has(id) || except.has(id)) - continue; - const socket = this.nsp.sockets.get(id); - if (socket) { - callback(socket); - ids.add(id); - } - } - } - } else { - for (const [id] of this.sids) { - if (except.has(id)) - continue; - const socket = this.nsp.sockets.get(id); - if (socket) - callback(socket); - } - } - } - computeExceptSids(exceptRooms) { - const exceptSids = new Set; - if (exceptRooms && exceptRooms.size > 0) { - for (const room of exceptRooms) { - if (this.rooms.has(room)) { - this.rooms.get(room).forEach((sid) => exceptSids.add(sid)); - } - } - } - return exceptSids; - } - serverSideEmit(packet) { - console.warn("this adapter does not support the serverSideEmit() functionality"); - } - persistSession(session) {} - restoreSession(pid, offset) { - return null; - } - } - exports2.Adapter = Adapter; - - class SessionAwareAdapter extends Adapter { - constructor(nsp) { - super(nsp); - this.nsp = nsp; - this.sessions = new Map; - this.packets = []; - this.maxDisconnectionDuration = nsp.server.opts.connectionStateRecovery.maxDisconnectionDuration; - const timer = setInterval(() => { - const threshold = Date.now() - this.maxDisconnectionDuration; - this.sessions.forEach((session, sessionId) => { - const hasExpired = session.disconnectedAt < threshold; - if (hasExpired) { - this.sessions.delete(sessionId); - } - }); - for (let i = this.packets.length - 1;i >= 0; i--) { - const hasExpired = this.packets[i].emittedAt < threshold; - if (hasExpired) { - this.packets.splice(0, i + 1); - break; - } - } - }, 60 * 1000); - timer.unref(); - } - persistSession(session) { - session.disconnectedAt = Date.now(); - this.sessions.set(session.pid, session); - } - restoreSession(pid, offset) { - const session = this.sessions.get(pid); - if (!session) { - return null; - } - const hasExpired = session.disconnectedAt + this.maxDisconnectionDuration < Date.now(); - if (hasExpired) { - this.sessions.delete(pid); - return null; - } - const index = this.packets.findIndex((packet) => packet.id === offset); - if (index === -1) { - return null; - } - const missedPackets = []; - for (let i = index + 1;i < this.packets.length; i++) { - const packet = this.packets[i]; - if (shouldIncludePacket(session.rooms, packet.opts)) { - missedPackets.push(packet.data); - } - } - return Promise.resolve(Object.assign(Object.assign({}, session), { missedPackets })); - } - broadcast(packet, opts) { - var _a24; - const isEventPacket = packet.type === 2; - const withoutAcknowledgement = packet.id === undefined; - const notVolatile = ((_a24 = opts.flags) === null || _a24 === undefined ? undefined : _a24.volatile) === undefined; - if (isEventPacket && withoutAcknowledgement && notVolatile) { - const id = (0, yeast_1.yeast)(); - packet.data.push(id); - this.packets.push({ - id, - opts, - data: packet.data, - emittedAt: Date.now() - }); - } - super.broadcast(packet, opts); - } - } - exports2.SessionAwareAdapter = SessionAwareAdapter; - function shouldIncludePacket(sessionRooms, opts) { - const included = opts.rooms.size === 0 || sessionRooms.some((room) => opts.rooms.has(room)); - const notExcluded = sessionRooms.every((room) => !opts.except.has(room)); - return included && notExcluded; - } -}); - -// ../../node_modules/.pnpm/socket.io-adapter@2.5.6/node_modules/socket.io-adapter/dist/cluster-adapter.js -var require_cluster_adapter = __commonJS((exports2) => { - var __rest = exports2 && exports2.__rest || function(s, e) { - var t = {}; - for (var p in s) - if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) - t[p] = s[p]; - if (s != null && typeof Object.getOwnPropertySymbols === "function") - for (var i = 0, p = Object.getOwnPropertySymbols(s);i < p.length; i++) { - if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) - t[p[i]] = s[p[i]]; - } - return t; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ClusterAdapterWithHeartbeat = exports2.ClusterAdapter = exports2.MessageType = undefined; - var in_memory_adapter_1 = require_in_memory_adapter(); - var debug_1 = require_src2(); - var crypto_1 = require("crypto"); - var debug = (0, debug_1.debug)("socket.io-adapter"); - var EMITTER_UID = "emitter"; - var DEFAULT_TIMEOUT = 5000; - function randomId() { - return (0, crypto_1.randomBytes)(8).toString("hex"); - } - var MessageType; - (function(MessageType2) { - MessageType2[MessageType2["INITIAL_HEARTBEAT"] = 1] = "INITIAL_HEARTBEAT"; - MessageType2[MessageType2["HEARTBEAT"] = 2] = "HEARTBEAT"; - MessageType2[MessageType2["BROADCAST"] = 3] = "BROADCAST"; - MessageType2[MessageType2["SOCKETS_JOIN"] = 4] = "SOCKETS_JOIN"; - MessageType2[MessageType2["SOCKETS_LEAVE"] = 5] = "SOCKETS_LEAVE"; - MessageType2[MessageType2["DISCONNECT_SOCKETS"] = 6] = "DISCONNECT_SOCKETS"; - MessageType2[MessageType2["FETCH_SOCKETS"] = 7] = "FETCH_SOCKETS"; - MessageType2[MessageType2["FETCH_SOCKETS_RESPONSE"] = 8] = "FETCH_SOCKETS_RESPONSE"; - MessageType2[MessageType2["SERVER_SIDE_EMIT"] = 9] = "SERVER_SIDE_EMIT"; - MessageType2[MessageType2["SERVER_SIDE_EMIT_RESPONSE"] = 10] = "SERVER_SIDE_EMIT_RESPONSE"; - MessageType2[MessageType2["BROADCAST_CLIENT_COUNT"] = 11] = "BROADCAST_CLIENT_COUNT"; - MessageType2[MessageType2["BROADCAST_ACK"] = 12] = "BROADCAST_ACK"; - MessageType2[MessageType2["ADAPTER_CLOSE"] = 13] = "ADAPTER_CLOSE"; - })(MessageType || (exports2.MessageType = MessageType = {})); - function encodeOptions(opts) { - return { - rooms: [...opts.rooms], - except: [...opts.except], - flags: opts.flags - }; - } - function decodeOptions(opts) { - return { - rooms: new Set(opts.rooms), - except: new Set(opts.except), - flags: opts.flags - }; - } - - class ClusterAdapter extends in_memory_adapter_1.Adapter { - constructor(nsp) { - super(nsp); - this.requests = new Map; - this.ackRequests = new Map; - this.uid = randomId(); - } - onMessage(message, offset) { - if (message.uid === this.uid) { - return debug("[%s] ignore message from self", this.uid); - } - if (message.nsp !== this.nsp.name) { - return debug("[%s] ignore message from another namespace (%s)", this.uid, message.nsp); - } - debug("[%s] new event of type %d from %s", this.uid, message.type, message.uid); - switch (message.type) { - case MessageType.BROADCAST: { - const withAck = message.data.requestId !== undefined; - if (withAck) { - super.broadcastWithAck(message.data.packet, decodeOptions(message.data.opts), (clientCount) => { - debug("[%s] waiting for %d client acknowledgements", this.uid, clientCount); - this.publishResponse(message.uid, { - type: MessageType.BROADCAST_CLIENT_COUNT, - data: { - requestId: message.data.requestId, - clientCount - } - }); - }, (arg) => { - debug("[%s] received acknowledgement with value %j", this.uid, arg); - this.publishResponse(message.uid, { - type: MessageType.BROADCAST_ACK, - data: { - requestId: message.data.requestId, - packet: arg - } - }); - }); - } else { - const packet = message.data.packet; - const opts = decodeOptions(message.data.opts); - this.addOffsetIfNecessary(packet, opts, offset); - super.broadcast(packet, opts); - } - break; - } - case MessageType.SOCKETS_JOIN: - super.addSockets(decodeOptions(message.data.opts), message.data.rooms); - break; - case MessageType.SOCKETS_LEAVE: - super.delSockets(decodeOptions(message.data.opts), message.data.rooms); - break; - case MessageType.DISCONNECT_SOCKETS: - super.disconnectSockets(decodeOptions(message.data.opts), message.data.close); - break; - case MessageType.FETCH_SOCKETS: { - debug("[%s] calling fetchSockets with opts %j", this.uid, message.data.opts); - super.fetchSockets(decodeOptions(message.data.opts)).then((localSockets) => { - this.publishResponse(message.uid, { - type: MessageType.FETCH_SOCKETS_RESPONSE, - data: { - requestId: message.data.requestId, - sockets: localSockets.map((socket) => { - const _a21 = socket.handshake, { sessionStore } = _a21, handshake = __rest(_a21, ["sessionStore"]); - return { - id: socket.id, - handshake, - rooms: [...socket.rooms], - data: socket.data - }; - }) - } - }); - }); - break; - } - case MessageType.SERVER_SIDE_EMIT: { - const packet = message.data.packet; - const withAck = message.data.requestId !== undefined; - if (!withAck) { - this.nsp._onServerSideEmit(packet); - return; - } - let called = false; - const callback = (arg) => { - if (called) { - return; - } - called = true; - debug("[%s] calling acknowledgement with %j", this.uid, arg); - this.publishResponse(message.uid, { - type: MessageType.SERVER_SIDE_EMIT_RESPONSE, - data: { - requestId: message.data.requestId, - packet: arg - } - }); - }; - this.nsp._onServerSideEmit([...packet, callback]); - break; - } - case MessageType.BROADCAST_CLIENT_COUNT: - case MessageType.BROADCAST_ACK: - case MessageType.FETCH_SOCKETS_RESPONSE: - case MessageType.SERVER_SIDE_EMIT_RESPONSE: - this.onResponse(message); - break; - default: - debug("[%s] unknown message type: %s", this.uid, message.type); - } - } - onResponse(response) { - var _a21, _b16; - const requestId = response.data.requestId; - debug("[%s] received response %s to request %s", this.uid, response.type, requestId); - switch (response.type) { - case MessageType.BROADCAST_CLIENT_COUNT: { - (_a21 = this.ackRequests.get(requestId)) === null || _a21 === undefined || _a21.clientCountCallback(response.data.clientCount); - break; - } - case MessageType.BROADCAST_ACK: { - (_b16 = this.ackRequests.get(requestId)) === null || _b16 === undefined || _b16.ack(response.data.packet); - break; - } - case MessageType.FETCH_SOCKETS_RESPONSE: { - const request = this.requests.get(requestId); - if (!request) { - return; - } - request.current++; - response.data.sockets.forEach((socket) => request.responses.push(socket)); - if (request.current === request.expected) { - clearTimeout(request.timeout); - request.resolve(request.responses); - this.requests.delete(requestId); - } - break; - } - case MessageType.SERVER_SIDE_EMIT_RESPONSE: { - const request = this.requests.get(requestId); - if (!request) { - return; - } - request.current++; - request.responses.push(response.data.packet); - if (request.current === request.expected) { - clearTimeout(request.timeout); - request.resolve(null, request.responses); - this.requests.delete(requestId); - } - break; - } - default: - debug("[%s] unknown response type: %s", this.uid, response.type); - } - } - async broadcast(packet, opts) { - var _a21; - const onlyLocal = (_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.local; - if (!onlyLocal) { - try { - const offset = await this.publishAndReturnOffset({ - type: MessageType.BROADCAST, - data: { - packet, - opts: encodeOptions(opts) - } - }); - this.addOffsetIfNecessary(packet, opts, offset); - } catch (e) { - return debug("[%s] error while broadcasting message: %s", this.uid, e.message); - } - } - super.broadcast(packet, opts); - } - addOffsetIfNecessary(packet, opts, offset) { - var _a21; - if (!this.nsp.server.opts.connectionStateRecovery) { - return; - } - const isEventPacket = packet.type === 2; - const withoutAcknowledgement = packet.id === undefined; - const notVolatile = ((_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.volatile) === undefined; - if (isEventPacket && withoutAcknowledgement && notVolatile) { - packet.data.push(offset); - } - } - broadcastWithAck(packet, opts, clientCountCallback, ack) { - var _a21; - const onlyLocal = (_a21 = opts === null || opts === undefined ? undefined : opts.flags) === null || _a21 === undefined ? undefined : _a21.local; - if (!onlyLocal) { - const requestId = randomId(); - this.ackRequests.set(requestId, { - clientCountCallback, - ack - }); - this.publish({ - type: MessageType.BROADCAST, - data: { - packet, - requestId, - opts: encodeOptions(opts) - } - }); - setTimeout(() => { - this.ackRequests.delete(requestId); - }, opts.flags.timeout); - } - super.broadcastWithAck(packet, opts, clientCountCallback, ack); - } - async addSockets(opts, rooms) { - var _a21; - const onlyLocal = (_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.local; - if (!onlyLocal) { - try { - await this.publishAndReturnOffset({ - type: MessageType.SOCKETS_JOIN, - data: { - opts: encodeOptions(opts), - rooms - } - }); - } catch (e) { - debug("[%s] error while publishing message: %s", this.uid, e.message); - } - } - super.addSockets(opts, rooms); - } - async delSockets(opts, rooms) { - var _a21; - const onlyLocal = (_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.local; - if (!onlyLocal) { - try { - await this.publishAndReturnOffset({ - type: MessageType.SOCKETS_LEAVE, - data: { - opts: encodeOptions(opts), - rooms - } - }); - } catch (e) { - debug("[%s] error while publishing message: %s", this.uid, e.message); - } - } - super.delSockets(opts, rooms); - } - async disconnectSockets(opts, close) { - var _a21; - const onlyLocal = (_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.local; - if (!onlyLocal) { - try { - await this.publishAndReturnOffset({ - type: MessageType.DISCONNECT_SOCKETS, - data: { - opts: encodeOptions(opts), - close - } - }); - } catch (e) { - debug("[%s] error while publishing message: %s", this.uid, e.message); - } - } - super.disconnectSockets(opts, close); - } - async fetchSockets(opts) { - var _a21; - const [localSockets, serverCount] = await Promise.all([ - super.fetchSockets(opts), - this.serverCount() - ]); - const expectedResponseCount = serverCount - 1; - if (((_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.local) || expectedResponseCount <= 0) { - return localSockets; - } - const requestId = randomId(); - return new Promise((resolve2, reject) => { - const timeout2 = setTimeout(() => { - const storedRequest2 = this.requests.get(requestId); - if (storedRequest2) { - reject(new Error(`timeout reached: only ${storedRequest2.current} responses received out of ${storedRequest2.expected}`)); - this.requests.delete(requestId); - } - }, opts.flags.timeout || DEFAULT_TIMEOUT); - const storedRequest = { - type: MessageType.FETCH_SOCKETS, - resolve: resolve2, - timeout: timeout2, - current: 0, - expected: expectedResponseCount, - responses: localSockets - }; - this.requests.set(requestId, storedRequest); - this.publish({ - type: MessageType.FETCH_SOCKETS, - data: { - opts: encodeOptions(opts), - requestId - } - }); - }); - } - async serverSideEmit(packet) { - const withAck = typeof packet[packet.length - 1] === "function"; - if (!withAck) { - return this.publish({ - type: MessageType.SERVER_SIDE_EMIT, - data: { - packet - } - }); - } - const ack = packet.pop(); - const expectedResponseCount = await this.serverCount() - 1; - debug('[%s] waiting for %d responses to "serverSideEmit" request', this.uid, expectedResponseCount); - if (expectedResponseCount <= 0) { - return ack(null, []); - } - const requestId = randomId(); - const timeout2 = setTimeout(() => { - const storedRequest2 = this.requests.get(requestId); - if (storedRequest2) { - ack(new Error(`timeout reached: only ${storedRequest2.current} responses received out of ${storedRequest2.expected}`), storedRequest2.responses); - this.requests.delete(requestId); - } - }, DEFAULT_TIMEOUT); - const storedRequest = { - type: MessageType.SERVER_SIDE_EMIT, - resolve: ack, - timeout: timeout2, - current: 0, - expected: expectedResponseCount, - responses: [] - }; - this.requests.set(requestId, storedRequest); - this.publish({ - type: MessageType.SERVER_SIDE_EMIT, - data: { - requestId, - packet - } - }); - } - publish(message) { - debug("[%s] sending message %s", this.uid, message.type); - this.publishAndReturnOffset(message).catch((err) => { - debug("[%s] error while publishing message: %s", this.uid, err); - }); - } - publishAndReturnOffset(message) { - message.uid = this.uid; - message.nsp = this.nsp.name; - return this.doPublish(message); - } - publishResponse(requesterUid, response) { - response.uid = this.uid; - response.nsp = this.nsp.name; - debug("[%s] sending response %s to %s", this.uid, response.type, requesterUid); - this.doPublishResponse(requesterUid, response).catch((err) => { - debug("[%s] error while publishing response: %s", this.uid, err); - }); - } - } - exports2.ClusterAdapter = ClusterAdapter; - - class ClusterAdapterWithHeartbeat extends ClusterAdapter { - constructor(nsp, opts) { - super(nsp); - this.nodesMap = new Map; - this.customRequests = new Map; - this._opts = Object.assign({ - heartbeatInterval: 5000, - heartbeatTimeout: 1e4 - }, opts); - this.cleanupTimer = setInterval(() => { - const now2 = Date.now(); - this.nodesMap.forEach((lastSeen, uid) => { - const nodeSeemsDown = now2 - lastSeen > this._opts.heartbeatTimeout; - if (nodeSeemsDown) { - debug("[%s] node %s seems down", this.uid, uid); - this.removeNode(uid); - } - }); - }, 1000); - } - init() { - this.publish({ - type: MessageType.INITIAL_HEARTBEAT - }); - } - scheduleHeartbeat() { - if (this.heartbeatTimer) { - this.heartbeatTimer.refresh(); - } else { - this.heartbeatTimer = setTimeout(() => { - this.publish({ - type: MessageType.HEARTBEAT - }); - }, this._opts.heartbeatInterval); - } - } - close() { - this.publish({ - type: MessageType.ADAPTER_CLOSE - }); - clearTimeout(this.heartbeatTimer); - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - } - } - onMessage(message, offset) { - if (message.uid === this.uid) { - return debug("[%s] ignore message from self", this.uid); - } - if (message.uid && message.uid !== EMITTER_UID) { - this.nodesMap.set(message.uid, Date.now()); - } - switch (message.type) { - case MessageType.INITIAL_HEARTBEAT: - this.publish({ - type: MessageType.HEARTBEAT - }); - break; - case MessageType.HEARTBEAT: - break; - case MessageType.ADAPTER_CLOSE: - this.removeNode(message.uid); - break; - default: - super.onMessage(message, offset); - } - } - serverCount() { - return Promise.resolve(1 + this.nodesMap.size); - } - publish(message) { - this.scheduleHeartbeat(); - return super.publish(message); - } - async serverSideEmit(packet) { - const withAck = typeof packet[packet.length - 1] === "function"; - if (!withAck) { - return this.publish({ - type: MessageType.SERVER_SIDE_EMIT, - data: { - packet - } - }); - } - const ack = packet.pop(); - const expectedResponseCount = this.nodesMap.size; - debug('[%s] waiting for %d responses to "serverSideEmit" request', this.uid, expectedResponseCount); - if (expectedResponseCount <= 0) { - return ack(null, []); - } - const requestId = randomId(); - const timeout2 = setTimeout(() => { - const storedRequest2 = this.customRequests.get(requestId); - if (storedRequest2) { - ack(new Error(`timeout reached: missing ${storedRequest2.missingUids.size} responses`), storedRequest2.responses); - this.customRequests.delete(requestId); - } - }, DEFAULT_TIMEOUT); - const storedRequest = { - type: MessageType.SERVER_SIDE_EMIT, - resolve: ack, - timeout: timeout2, - missingUids: new Set([...this.nodesMap.keys()]), - responses: [] - }; - this.customRequests.set(requestId, storedRequest); - this.publish({ - type: MessageType.SERVER_SIDE_EMIT, - data: { - requestId, - packet - } - }); - } - async fetchSockets(opts) { - var _a21; - const [localSockets, serverCount] = await Promise.all([ - super.fetchSockets({ - rooms: opts.rooms, - except: opts.except, - flags: { - local: true - } - }), - this.serverCount() - ]); - const expectedResponseCount = serverCount - 1; - if (((_a21 = opts.flags) === null || _a21 === undefined ? undefined : _a21.local) || expectedResponseCount <= 0) { - return localSockets; - } - const requestId = randomId(); - return new Promise((resolve2, reject) => { - const timeout2 = setTimeout(() => { - const storedRequest2 = this.customRequests.get(requestId); - if (storedRequest2) { - reject(new Error(`timeout reached: missing ${storedRequest2.missingUids.size} responses`)); - this.customRequests.delete(requestId); - } - }, opts.flags.timeout || DEFAULT_TIMEOUT); - const storedRequest = { - type: MessageType.FETCH_SOCKETS, - resolve: resolve2, - timeout: timeout2, - missingUids: new Set([...this.nodesMap.keys()]), - responses: localSockets - }; - this.customRequests.set(requestId, storedRequest); - this.publish({ - type: MessageType.FETCH_SOCKETS, - data: { - opts: encodeOptions(opts), - requestId - } - }); - }); - } - onResponse(response) { - const requestId = response.data.requestId; - debug("[%s] received response %s to request %s", this.uid, response.type, requestId); - switch (response.type) { - case MessageType.FETCH_SOCKETS_RESPONSE: { - const request = this.customRequests.get(requestId); - if (!request) { - return; - } - response.data.sockets.forEach((socket) => request.responses.push(socket)); - request.missingUids.delete(response.uid); - if (request.missingUids.size === 0) { - clearTimeout(request.timeout); - request.resolve(request.responses); - this.customRequests.delete(requestId); - } - break; - } - case MessageType.SERVER_SIDE_EMIT_RESPONSE: { - const request = this.customRequests.get(requestId); - if (!request) { - return; - } - request.responses.push(response.data.packet); - request.missingUids.delete(response.uid); - if (request.missingUids.size === 0) { - clearTimeout(request.timeout); - request.resolve(null, request.responses); - this.customRequests.delete(requestId); - } - break; - } - default: - super.onResponse(response); - } - } - removeNode(uid) { - this.customRequests.forEach((request, requestId) => { - request.missingUids.delete(uid); - if (request.missingUids.size === 0) { - clearTimeout(request.timeout); - if (request.type === MessageType.FETCH_SOCKETS) { - request.resolve(request.responses); - } else if (request.type === MessageType.SERVER_SIDE_EMIT) { - request.resolve(null, request.responses); - } - this.customRequests.delete(requestId); - } - }); - this.nodesMap.delete(uid); - } - } - exports2.ClusterAdapterWithHeartbeat = ClusterAdapterWithHeartbeat; -}); - -// ../../node_modules/.pnpm/socket.io-adapter@2.5.6/node_modules/socket.io-adapter/dist/index.js -var require_dist6 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.MessageType = exports2.ClusterAdapterWithHeartbeat = exports2.ClusterAdapter = exports2.SessionAwareAdapter = exports2.Adapter = undefined; - var in_memory_adapter_1 = require_in_memory_adapter(); - Object.defineProperty(exports2, "Adapter", { enumerable: true, get: function() { - return in_memory_adapter_1.Adapter; - } }); - Object.defineProperty(exports2, "SessionAwareAdapter", { enumerable: true, get: function() { - return in_memory_adapter_1.SessionAwareAdapter; - } }); - var cluster_adapter_1 = require_cluster_adapter(); - Object.defineProperty(exports2, "ClusterAdapter", { enumerable: true, get: function() { - return cluster_adapter_1.ClusterAdapter; - } }); - Object.defineProperty(exports2, "ClusterAdapterWithHeartbeat", { enumerable: true, get: function() { - return cluster_adapter_1.ClusterAdapterWithHeartbeat; - } }); - Object.defineProperty(exports2, "MessageType", { enumerable: true, get: function() { - return cluster_adapter_1.MessageType; - } }); -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/parent-namespace.js -var require_parent_namespace = __commonJS((exports2) => { - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ParentNamespace = undefined; - var namespace_1 = require_namespace(); - var socket_io_adapter_1 = require_dist6(); - var debug_1 = __importDefault(require_src2()); - var debug = (0, debug_1.default)("socket.io:parent-namespace"); - - class ParentNamespace extends namespace_1.Namespace { - constructor(server) { - super(server, "/_" + ParentNamespace.count++); - this.children = new Set; - } - _initAdapter() { - this.adapter = new ParentBroadcastAdapter(this); - } - emit(ev, ...args) { - this.children.forEach((nsp) => { - nsp.emit(ev, ...args); - }); - return true; - } - createChild(name21) { - debug("creating child namespace %s", name21); - const namespace = new namespace_1.Namespace(this.server, name21); - this["_fns"].forEach((fn) => namespace.use(fn)); - this.listeners("connect").forEach((listener) => namespace.on("connect", listener)); - this.listeners("connection").forEach((listener) => namespace.on("connection", listener)); - this.children.add(namespace); - if (this.server._opts.cleanupEmptyChildNamespaces) { - const remove2 = namespace._remove; - namespace._remove = (socket) => { - remove2.call(namespace, socket); - if (namespace.sockets.size === 0) { - debug("closing child namespace %s", name21); - namespace.adapter.close(); - this.server._nsps.delete(namespace.name); - this.children.delete(namespace); - } - }; - } - this.server._nsps.set(name21, namespace); - this.server.sockets.emitReserved("new_namespace", namespace); - return namespace; - } - fetchSockets() { - throw new Error("fetchSockets() is not supported on parent namespaces"); - } - } - exports2.ParentNamespace = ParentNamespace; - ParentNamespace.count = 0; - - class ParentBroadcastAdapter extends socket_io_adapter_1.Adapter { - broadcast(packet, opts) { - this.nsp.children.forEach((nsp) => { - nsp.adapter.broadcast(packet, opts); - }); - } - } -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/uws.js -var require_uws = __commonJS((exports2) => { - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.patchAdapter = patchAdapter; - exports2.restoreAdapter = restoreAdapter; - exports2.serveFile = serveFile; - var socket_io_adapter_1 = require_dist6(); - var fs_1 = require("fs"); - var debug_1 = __importDefault(require_src2()); - var debug = (0, debug_1.default)("socket.io:adapter-uws"); - var SEPARATOR = "\x1F"; - var { addAll, del, broadcast } = socket_io_adapter_1.Adapter.prototype; - function patchAdapter(app) { - socket_io_adapter_1.Adapter.prototype.addAll = function(id, rooms) { - const isNew = !this.sids.has(id); - addAll.call(this, id, rooms); - const socket = this.nsp.sockets.get(id) || this.nsp._preConnectSockets.get(id); - if (!socket) { - return; - } - if (socket.conn.transport.name === "websocket") { - subscribe(this.nsp.name, socket, isNew, rooms); - return; - } - if (isNew) { - socket.conn.on("upgrade", () => { - const rooms2 = this.sids.get(id); - if (rooms2) { - subscribe(this.nsp.name, socket, isNew, rooms2); - } - }); - } - }; - socket_io_adapter_1.Adapter.prototype.del = function(id, room) { - del.call(this, id, room); - const socket = this.nsp.sockets.get(id) || this.nsp._preConnectSockets.get(id); - if (socket && socket.conn.transport.name === "websocket") { - const sessionId = socket.conn.id; - const websocket = socket.conn.transport.socket; - const topic = `${this.nsp.name}${SEPARATOR}${room}`; - debug("unsubscribe connection %s from topic %s", sessionId, topic); - websocket.unsubscribe(topic); - } - }; - socket_io_adapter_1.Adapter.prototype.broadcast = function(packet, opts) { - const useFastPublish = opts.rooms.size <= 1 && opts.except.size === 0; - if (!useFastPublish) { - broadcast.call(this, packet, opts); - return; - } - const flags = opts.flags || {}; - const basePacketOpts = { - preEncoded: true, - volatile: flags.volatile, - compress: flags.compress - }; - packet.nsp = this.nsp.name; - const encodedPackets = this.encoder.encode(packet); - const topic = opts.rooms.size === 0 ? this.nsp.name : `${this.nsp.name}${SEPARATOR}${opts.rooms.keys().next().value}`; - debug("fast publish to %s", topic); - encodedPackets.forEach((encodedPacket) => { - const isBinary = typeof encodedPacket !== "string"; - app.publish(topic, isBinary ? encodedPacket : "4" + encodedPacket, isBinary); - }); - this.apply(opts, (socket) => { - if (socket.conn.transport.name !== "websocket") { - socket.client.writeToEngine(encodedPackets, basePacketOpts); - } - }); - }; - } - function subscribe(namespaceName, socket, isNew, rooms) { - const sessionId = socket.conn.id; - const websocket = socket.conn.transport.socket; - if (isNew) { - debug("subscribe connection %s to topic %s", sessionId, namespaceName); - websocket.subscribe(namespaceName); - } - rooms.forEach((room) => { - const topic = `${namespaceName}${SEPARATOR}${room}`; - debug("subscribe connection %s to topic %s", sessionId, topic); - websocket.subscribe(topic); - }); - } - function restoreAdapter() { - socket_io_adapter_1.Adapter.prototype.addAll = addAll; - socket_io_adapter_1.Adapter.prototype.del = del; - socket_io_adapter_1.Adapter.prototype.broadcast = broadcast; - } - var toArrayBuffer = (buffer) => { - const { buffer: arrayBuffer, byteOffset, byteLength } = buffer; - return arrayBuffer.slice(byteOffset, byteOffset + byteLength); - }; - function serveFile(res, filepath) { - const { size } = (0, fs_1.statSync)(filepath); - const readStream = (0, fs_1.createReadStream)(filepath); - const destroyReadStream = () => !readStream.destroyed && readStream.destroy(); - const onError = (error48) => { - destroyReadStream(); - throw error48; - }; - const onDataChunk = (chunk2) => { - const arrayBufferChunk = toArrayBuffer(chunk2); - res.cork(() => { - const lastOffset = res.getWriteOffset(); - const [ok, done] = res.tryEnd(arrayBufferChunk, size); - if (!done && !ok) { - readStream.pause(); - res.onWritable((offset) => { - const [ok2, done2] = res.tryEnd(arrayBufferChunk.slice(offset - lastOffset), size); - if (!done2 && ok2) { - readStream.resume(); - } - return ok2; - }); - } - }); - }; - res.onAborted(destroyReadStream); - readStream.on("data", onDataChunk).on("error", onError).on("end", destroyReadStream); - } -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/package.json -var require_package = __commonJS((exports2, module2) => { - module2.exports = { - name: "socket.io", - version: "4.8.3", - description: "node.js realtime framework server", - keywords: [ - "realtime", - "framework", - "websocket", - "tcp", - "events", - "socket", - "io" - ], - files: [ - "dist/", - "client-dist/", - "wrapper.mjs", - "!**/*.tsbuildinfo" - ], - directories: { - doc: "docs/", - example: "example/", - lib: "lib/", - test: "test/" - }, - type: "commonjs", - main: "./dist/index.js", - exports: { - ".": { - types: "./dist/index.d.ts", - import: "./wrapper.mjs", - require: "./dist/index.js" - }, - "./package.json": "./package.json" - }, - types: "./dist/index.d.ts", - license: "MIT", - homepage: "https://github.com/socketio/socket.io/tree/main/packages/socket.io#readme", - repository: { - type: "git", - url: "git+https://github.com/socketio/socket.io.git" - }, - bugs: { - url: "https://github.com/socketio/socket.io/issues" - }, - scripts: { - compile: "rimraf ./dist && tsc", - test: "npm run format:check && npm run compile && npm run test:types && npm run test:unit", - "test:types": "tsd", - "test:unit": "nyc mocha --import=tsx --reporter spec --slow 200 --bail --timeout 10000 test/index.ts", - "format:check": 'prettier --check "lib/**/*.ts" "test/**/*.ts"', - "format:fix": 'prettier --write "lib/**/*.ts" "test/**/*.ts"', - prepack: "npm run compile" - }, - dependencies: { - accepts: "~1.3.4", - base64id: "~2.0.0", - cors: "~2.8.5", - debug: "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - contributors: [ - { - name: "Guillermo Rauch", - email: "rauchg@gmail.com" - }, - { - name: "Arnout Kazemier", - email: "info@3rd-eden.com" - }, - { - name: "Vladimir Dronnikov", - email: "dronnikov@gmail.com" - }, - { - name: "Einar Otto Stangvik", - email: "einaros@gmail.com" - } - ], - engines: { - node: ">=10.2.0" - }, - tsd: { - directory: "test" - } - }; -}); - -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/dist/index.js -var require_dist7 = __commonJS((exports2, module2) => { - var __dirname = "C:\\Users\\lawrence\\workspace\\smm_github\\node_modules\\.pnpm\\socket.io@4.8.3\\node_modules\\socket.io\\dist"; - var __createBinding = exports2 && exports2.__createBinding || (Object.create ? function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { - return m[k]; - } }; - } - Object.defineProperty(o, k2, desc); - } : function(o, m, k, k2) { - if (k2 === undefined) - k2 = k; - o[k2] = m[k]; - }); - var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); - } : function(o, v) { - o["default"] = v; - }); - var __importStar = exports2 && exports2.__importStar || function(mod) { - if (mod && mod.__esModule) - return mod; - var result = {}; - if (mod != null) { - for (var k in mod) - if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) - __createBinding(result, mod, k); - } - __setModuleDefault(result, mod); - return result; - }; - var __importDefault = exports2 && exports2.__importDefault || function(mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.Namespace = exports2.Socket = exports2.Server = undefined; - var http_1 = __importDefault(require("http")); - var fs_1 = require("fs"); - var zlib_1 = require("zlib"); - var accepts = require_accepts(); - var stream_1 = require("stream"); - var path13 = require("path"); - var engine_io_1 = require_engine_io(); - var client_1 = require_client(); - var events_1 = require("events"); - var namespace_1 = require_namespace(); - Object.defineProperty(exports2, "Namespace", { enumerable: true, get: function() { - return namespace_1.Namespace; - } }); - var parent_namespace_1 = require_parent_namespace(); - var socket_io_adapter_1 = require_dist6(); - var parser = __importStar(require_cjs3()); - var debug_1 = __importDefault(require_src2()); - var socket_1 = require_socket2(); - Object.defineProperty(exports2, "Socket", { enumerable: true, get: function() { - return socket_1.Socket; - } }); - var typed_events_1 = require_typed_events(); - var uws_1 = require_uws(); - var cors_1 = __importDefault(require_lib()); - var debug = (0, debug_1.default)("socket.io:server"); - var clientVersion = require_package().version; - var dotMapRegex = /\.map/; - - class Server extends typed_events_1.StrictEventEmitter { - constructor(srv, opts = {}) { - super(); - this._nsps = new Map; - this.parentNsps = new Map; - this.parentNamespacesFromRegExp = new Map; - if (typeof srv === "object" && srv instanceof Object && !srv.listen) { - opts = srv; - srv = undefined; - } - this.path(opts.path || "/socket.io"); - this.connectTimeout(opts.connectTimeout || 45000); - this.serveClient(opts.serveClient !== false); - this._parser = opts.parser || parser; - this.encoder = new this._parser.Encoder; - this.opts = opts; - if (opts.connectionStateRecovery) { - opts.connectionStateRecovery = Object.assign({ - maxDisconnectionDuration: 2 * 60 * 1000, - skipMiddlewares: true - }, opts.connectionStateRecovery); - this.adapter(opts.adapter || socket_io_adapter_1.SessionAwareAdapter); - } else { - this.adapter(opts.adapter || socket_io_adapter_1.Adapter); - } - opts.cleanupEmptyChildNamespaces = !!opts.cleanupEmptyChildNamespaces; - this.sockets = this.of("/"); - if (srv || typeof srv == "number") - this.attach(srv); - if (this.opts.cors) { - this._corsMiddleware = (0, cors_1.default)(this.opts.cors); - } - } - get _opts() { - return this.opts; - } - serveClient(v) { - if (!arguments.length) - return this._serveClient; - this._serveClient = v; - return this; - } - _checkNamespace(name21, auth, fn) { - if (this.parentNsps.size === 0) - return fn(false); - const keysIterator = this.parentNsps.keys(); - const run = () => { - const nextFn = keysIterator.next(); - if (nextFn.done) { - return fn(false); - } - nextFn.value(name21, auth, (err, allow) => { - if (err || !allow) { - return run(); - } - if (this._nsps.has(name21)) { - debug("dynamic namespace %s already exists", name21); - return fn(this._nsps.get(name21)); - } - const namespace = this.parentNsps.get(nextFn.value).createChild(name21); - debug("dynamic namespace %s was created", name21); - fn(namespace); - }); - }; - run(); - } - path(v) { - if (!arguments.length) - return this._path; - this._path = v.replace(/\/$/, ""); - const escapedPath = this._path.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); - this.clientPathRegex = new RegExp("^" + escapedPath + "/socket\\.io(\\.msgpack|\\.esm)?(\\.min)?\\.js(\\.map)?(?:\\?|$)"); - return this; - } - connectTimeout(v) { - if (v === undefined) - return this._connectTimeout; - this._connectTimeout = v; - return this; - } - adapter(v) { - if (!arguments.length) - return this._adapter; - this._adapter = v; - for (const nsp of this._nsps.values()) { - nsp._initAdapter(); - } - return this; - } - listen(srv, opts = {}) { - return this.attach(srv, opts); - } - attach(srv, opts = {}) { - if (typeof srv == "function") { - const msg = "You are trying to attach socket.io to an express " + "request handler function. Please pass a http.Server instance."; - throw new Error(msg); - } - if (Number(srv) == srv) { - srv = Number(srv); - } - if (typeof srv == "number") { - debug("creating http server and binding to %d", srv); - const port = srv; - srv = http_1.default.createServer((req, res) => { - res.writeHead(404); - res.end(); - }); - srv.listen(port); - } - Object.assign(opts, this.opts); - opts.path = opts.path || this._path; - this.initEngine(srv, opts); - return this; - } - attachApp(app, opts = {}) { - Object.assign(opts, this.opts); - opts.path = opts.path || this._path; - debug("creating uWebSockets.js-based engine with opts %j", opts); - const engine = new engine_io_1.uServer(opts); - engine.attach(app, opts); - this.bind(engine); - if (this._serveClient) { - app.get(`${this._path}/*`, (res, req) => { - if (!this.clientPathRegex.test(req.getUrl())) { - req.setYield(true); - return; - } - const filename = req.getUrl().replace(this._path, "").replace(/\?.*$/, "").replace(/^\//, ""); - const isMap2 = dotMapRegex.test(filename); - const type = isMap2 ? "map" : "source"; - const expectedEtag = '"' + clientVersion + '"'; - const weakEtag = "W/" + expectedEtag; - const etag = req.getHeader("if-none-match"); - if (etag) { - if (expectedEtag === etag || weakEtag === etag) { - debug("serve client %s 304", type); - res.writeStatus("304 Not Modified"); - res.end(); - return; - } - } - debug("serve client %s", type); - res.writeHeader("cache-control", "public, max-age=0"); - res.writeHeader("content-type", "application/" + (isMap2 ? "json" : "javascript") + "; charset=utf-8"); - res.writeHeader("etag", expectedEtag); - const filepath = path13.join(__dirname, "../client-dist/", filename); - (0, uws_1.serveFile)(res, filepath); - }); - } - (0, uws_1.patchAdapter)(app); - } - initEngine(srv, opts) { - debug("creating engine.io instance with opts %j", opts); - this.eio = (0, engine_io_1.attach)(srv, opts); - if (this._serveClient) - this.attachServe(srv); - this.httpServer = srv; - this.bind(this.eio); - } - attachServe(srv) { - debug("attaching client serving req handler"); - const evs = srv.listeners("request").slice(0); - srv.removeAllListeners("request"); - srv.on("request", (req, res) => { - if (this.clientPathRegex.test(req.url)) { - if (this._corsMiddleware) { - this._corsMiddleware(req, res, () => { - this.serve(req, res); - }); - } else { - this.serve(req, res); - } - } else { - for (let i = 0;i < evs.length; i++) { - evs[i].call(srv, req, res); - } - } - }); - } - serve(req, res) { - const filename = req.url.replace(this._path, "").replace(/\?.*$/, ""); - const isMap2 = dotMapRegex.test(filename); - const type = isMap2 ? "map" : "source"; - const expectedEtag = '"' + clientVersion + '"'; - const weakEtag = "W/" + expectedEtag; - const etag = req.headers["if-none-match"]; - if (etag) { - if (expectedEtag === etag || weakEtag === etag) { - debug("serve client %s 304", type); - res.writeHead(304); - res.end(); - return; - } - } - debug("serve client %s", type); - res.setHeader("Cache-Control", "public, max-age=0"); - res.setHeader("Content-Type", "application/" + (isMap2 ? "json" : "javascript") + "; charset=utf-8"); - res.setHeader("ETag", expectedEtag); - Server.sendFile(filename, req, res); - } - static sendFile(filename, req, res) { - const readStream = (0, fs_1.createReadStream)(path13.join(__dirname, "../client-dist/", filename)); - const encoding = accepts(req).encodings(["br", "gzip", "deflate"]); - const onError = (err) => { - if (err) { - res.end(); - } - }; - switch (encoding) { - case "br": - res.writeHead(200, { "content-encoding": "br" }); - (0, stream_1.pipeline)(readStream, (0, zlib_1.createBrotliCompress)(), res, onError); - break; - case "gzip": - res.writeHead(200, { "content-encoding": "gzip" }); - (0, stream_1.pipeline)(readStream, (0, zlib_1.createGzip)(), res, onError); - break; - case "deflate": - res.writeHead(200, { "content-encoding": "deflate" }); - (0, stream_1.pipeline)(readStream, (0, zlib_1.createDeflate)(), res, onError); - break; - default: - res.writeHead(200); - (0, stream_1.pipeline)(readStream, res, onError); - } - } - bind(engine) { - this.engine = engine; - this.engine.on("connection", this.onconnection.bind(this)); - return this; - } - onconnection(conn) { - debug("incoming connection with id %s", conn.id); - const client = new client_1.Client(this, conn); - if (conn.protocol === 3) { - client.connect("/"); - } - return this; - } - of(name21, fn) { - if (typeof name21 === "function" || name21 instanceof RegExp) { - const parentNsp = new parent_namespace_1.ParentNamespace(this); - debug("initializing parent namespace %s", parentNsp.name); - if (typeof name21 === "function") { - this.parentNsps.set(name21, parentNsp); - } else { - this.parentNsps.set((nsp2, conn, next) => next(null, name21.test(nsp2)), parentNsp); - this.parentNamespacesFromRegExp.set(name21, parentNsp); - } - if (fn) { - parentNsp.on("connect", fn); - } - return parentNsp; - } - if (String(name21)[0] !== "/") - name21 = "/" + name21; - let nsp = this._nsps.get(name21); - if (!nsp) { - for (const [regex, parentNamespace] of this.parentNamespacesFromRegExp) { - if (regex.test(name21)) { - debug("attaching namespace %s to parent namespace %s", name21, regex); - return parentNamespace.createChild(name21); - } - } - debug("initializing namespace %s", name21); - nsp = new namespace_1.Namespace(this, name21); - this._nsps.set(name21, nsp); - if (name21 !== "/") { - this.sockets.emitReserved("new_namespace", nsp); - } - } - if (fn) - nsp.on("connect", fn); - return nsp; - } - async close(fn) { - await Promise.allSettled([...this._nsps.values()].map(async (nsp) => { - nsp.sockets.forEach((socket) => { - socket._onclose("server shutting down"); - }); - await nsp.adapter.close(); - })); - this.engine.close(); - (0, uws_1.restoreAdapter)(); - if (this.httpServer) { - return new Promise((resolve2) => { - this.httpServer.close((err) => { - fn && fn(err); - if (err) { - debug("server was not running"); - } - resolve2(); - }); - }); - } else { - fn && fn(); - } - } - use(fn) { - this.sockets.use(fn); - return this; - } - to(room) { - return this.sockets.to(room); - } - in(room) { - return this.sockets.in(room); - } - except(room) { - return this.sockets.except(room); - } - send(...args) { - this.sockets.emit("message", ...args); - return this; - } - write(...args) { - this.sockets.emit("message", ...args); - return this; - } - serverSideEmit(ev, ...args) { - return this.sockets.serverSideEmit(ev, ...args); - } - serverSideEmitWithAck(ev, ...args) { - return this.sockets.serverSideEmitWithAck(ev, ...args); - } - allSockets() { - return this.sockets.allSockets(); - } - compress(compress) { - return this.sockets.compress(compress); - } - get volatile() { - return this.sockets.volatile; - } - get local() { - return this.sockets.local; - } - timeout(timeout2) { - return this.sockets.timeout(timeout2); - } - fetchSockets() { - return this.sockets.fetchSockets(); - } - socketsJoin(room) { - return this.sockets.socketsJoin(room); - } - socketsLeave(room) { - return this.sockets.socketsLeave(room); - } - disconnectSockets(close = false) { - return this.sockets.disconnectSockets(close); - } - } - exports2.Server = Server; - var emitterMethods = Object.keys(events_1.EventEmitter.prototype).filter(function(key) { - return typeof events_1.EventEmitter.prototype[key] === "function"; - }); - emitterMethods.forEach(function(fn) { - Server.prototype[fn] = function() { - return this.sockets[fn].apply(this.sockets, arguments); - }; - }); - module2.exports = (srv, opts) => new Server(srv, opts); - module2.exports.Server = Server; - module2.exports.Namespace = namespace_1.Namespace; - module2.exports.Socket = socket_1.Socket; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/code.js -var require_code = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.regexpCode = exports2.getEsmExportName = exports2.getProperty = exports2.safeStringify = exports2.stringify = exports2.strConcat = exports2.addCodeArg = exports2.str = exports2._ = exports2.nil = exports2._Code = exports2.Name = exports2.IDENTIFIER = exports2._CodeOrName = undefined; - - class _CodeOrName { - } - exports2._CodeOrName = _CodeOrName; - exports2.IDENTIFIER = /^[a-z$_][a-z$_0-9]*$/i; - - class Name extends _CodeOrName { - constructor(s) { - super(); - if (!exports2.IDENTIFIER.test(s)) - throw new Error("CodeGen: name must be a valid identifier"); - this.str = s; - } - toString() { - return this.str; - } - emptyStr() { - return false; - } - get names() { - return { [this.str]: 1 }; - } - } - exports2.Name = Name; - - class _Code extends _CodeOrName { - constructor(code) { - super(); - this._items = typeof code === "string" ? [code] : code; - } - toString() { - return this.str; - } - emptyStr() { - if (this._items.length > 1) - return false; - const item = this._items[0]; - return item === "" || item === '""'; - } - get str() { - var _a21; - return (_a21 = this._str) !== null && _a21 !== undefined ? _a21 : this._str = this._items.reduce((s, c) => `${s}${c}`, ""); - } - get names() { - var _a21; - return (_a21 = this._names) !== null && _a21 !== undefined ? _a21 : this._names = this._items.reduce((names, c) => { - if (c instanceof Name) - names[c.str] = (names[c.str] || 0) + 1; - return names; - }, {}); - } - } - exports2._Code = _Code; - exports2.nil = new _Code(""); - function _(strs, ...args) { - const code = [strs[0]]; - let i = 0; - while (i < args.length) { - addCodeArg(code, args[i]); - code.push(strs[++i]); - } - return new _Code(code); - } - exports2._ = _; - var plus = new _Code("+"); - function str(strs, ...args) { - const expr = [safeStringify(strs[0])]; - let i = 0; - while (i < args.length) { - expr.push(plus); - addCodeArg(expr, args[i]); - expr.push(plus, safeStringify(strs[++i])); - } - optimize(expr); - return new _Code(expr); - } - exports2.str = str; - function addCodeArg(code, arg) { - if (arg instanceof _Code) - code.push(...arg._items); - else if (arg instanceof Name) - code.push(arg); - else - code.push(interpolate(arg)); - } - exports2.addCodeArg = addCodeArg; - function optimize(expr) { - let i = 1; - while (i < expr.length - 1) { - if (expr[i] === plus) { - const res = mergeExprItems(expr[i - 1], expr[i + 1]); - if (res !== undefined) { - expr.splice(i - 1, 3, res); - continue; - } - expr[i++] = "+"; - } - i++; - } - } - function mergeExprItems(a, b) { - if (b === '""') - return a; - if (a === '""') - return b; - if (typeof a == "string") { - if (b instanceof Name || a[a.length - 1] !== '"') - return; - if (typeof b != "string") - return `${a.slice(0, -1)}${b}"`; - if (b[0] === '"') - return a.slice(0, -1) + b.slice(1); - return; - } - if (typeof b == "string" && b[0] === '"' && !(a instanceof Name)) - return `"${a}${b.slice(1)}`; - return; - } - function strConcat(c1, c2) { - return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str`${c1}${c2}`; - } - exports2.strConcat = strConcat; - function interpolate(x) { - return typeof x == "number" || typeof x == "boolean" || x === null ? x : safeStringify(Array.isArray(x) ? x.join(",") : x); - } - function stringify(x) { - return new _Code(safeStringify(x)); - } - exports2.stringify = stringify; - function safeStringify(x) { - return JSON.stringify(x).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); - } - exports2.safeStringify = safeStringify; - function getProperty(key) { - return typeof key == "string" && exports2.IDENTIFIER.test(key) ? new _Code(`.${key}`) : _`[${key}]`; - } - exports2.getProperty = getProperty; - function getEsmExportName(key) { - if (typeof key == "string" && exports2.IDENTIFIER.test(key)) { - return new _Code(`${key}`); - } - throw new Error(`CodeGen: invalid export name: ${key}, use explicit $id name mapping`); - } - exports2.getEsmExportName = getEsmExportName; - function regexpCode(rx) { - return new _Code(rx.toString()); - } - exports2.regexpCode = regexpCode; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/scope.js -var require_scope = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.ValueScope = exports2.ValueScopeName = exports2.Scope = exports2.varKinds = exports2.UsedValueState = undefined; - var code_1 = require_code(); - - class ValueError extends Error { - constructor(name21) { - super(`CodeGen: "code" for ${name21} not defined`); - this.value = name21.value; - } - } - var UsedValueState; - (function(UsedValueState2) { - UsedValueState2[UsedValueState2["Started"] = 0] = "Started"; - UsedValueState2[UsedValueState2["Completed"] = 1] = "Completed"; - })(UsedValueState || (exports2.UsedValueState = UsedValueState = {})); - exports2.varKinds = { - const: new code_1.Name("const"), - let: new code_1.Name("let"), - var: new code_1.Name("var") - }; - - class Scope { - constructor({ prefixes, parent } = {}) { - this._names = {}; - this._prefixes = prefixes; - this._parent = parent; - } - toName(nameOrPrefix) { - return nameOrPrefix instanceof code_1.Name ? nameOrPrefix : this.name(nameOrPrefix); - } - name(prefix) { - return new code_1.Name(this._newName(prefix)); - } - _newName(prefix) { - const ng = this._names[prefix] || this._nameGroup(prefix); - return `${prefix}${ng.index++}`; - } - _nameGroup(prefix) { - var _a21, _b16; - if (((_b16 = (_a21 = this._parent) === null || _a21 === undefined ? undefined : _a21._prefixes) === null || _b16 === undefined ? undefined : _b16.has(prefix)) || this._prefixes && !this._prefixes.has(prefix)) { - throw new Error(`CodeGen: prefix "${prefix}" is not allowed in this scope`); - } - return this._names[prefix] = { prefix, index: 0 }; - } - } - exports2.Scope = Scope; - - class ValueScopeName extends code_1.Name { - constructor(prefix, nameStr) { - super(nameStr); - this.prefix = prefix; - } - setValue(value, { property, itemIndex }) { - this.value = value; - this.scopePath = (0, code_1._)`.${new code_1.Name(property)}[${itemIndex}]`; - } - } - exports2.ValueScopeName = ValueScopeName; - var line = (0, code_1._)`\n`; - - class ValueScope extends Scope { - constructor(opts) { - super(opts); - this._values = {}; - this._scope = opts.scope; - this.opts = { ...opts, _n: opts.lines ? line : code_1.nil }; - } - get() { - return this._scope; - } - name(prefix) { - return new ValueScopeName(prefix, this._newName(prefix)); - } - value(nameOrPrefix, value) { - var _a21; - if (value.ref === undefined) - throw new Error("CodeGen: ref must be passed in value"); - const name21 = this.toName(nameOrPrefix); - const { prefix } = name21; - const valueKey = (_a21 = value.key) !== null && _a21 !== undefined ? _a21 : value.ref; - let vs = this._values[prefix]; - if (vs) { - const _name = vs.get(valueKey); - if (_name) - return _name; - } else { - vs = this._values[prefix] = new Map; - } - vs.set(valueKey, name21); - const s = this._scope[prefix] || (this._scope[prefix] = []); - const itemIndex = s.length; - s[itemIndex] = value.ref; - name21.setValue(value, { property: prefix, itemIndex }); - return name21; - } - getValue(prefix, keyOrRef) { - const vs = this._values[prefix]; - if (!vs) - return; - return vs.get(keyOrRef); - } - scopeRefs(scopeName, values = this._values) { - return this._reduceValues(values, (name21) => { - if (name21.scopePath === undefined) - throw new Error(`CodeGen: name "${name21}" has no value`); - return (0, code_1._)`${scopeName}${name21.scopePath}`; - }); - } - scopeCode(values = this._values, usedValues, getCode) { - return this._reduceValues(values, (name21) => { - if (name21.value === undefined) - throw new Error(`CodeGen: name "${name21}" has no value`); - return name21.value.code; - }, usedValues, getCode); - } - _reduceValues(values, valueCode, usedValues = {}, getCode) { - let code = code_1.nil; - for (const prefix in values) { - const vs = values[prefix]; - if (!vs) - continue; - const nameSet = usedValues[prefix] = usedValues[prefix] || new Map; - vs.forEach((name21) => { - if (nameSet.has(name21)) - return; - nameSet.set(name21, UsedValueState.Started); - let c = valueCode(name21); - if (c) { - const def = this.opts.es5 ? exports2.varKinds.var : exports2.varKinds.const; - code = (0, code_1._)`${code}${def} ${name21} = ${c};${this.opts._n}`; - } else if (c = getCode === null || getCode === undefined ? undefined : getCode(name21)) { - code = (0, code_1._)`${code}${c}${this.opts._n}`; - } else { - throw new ValueError(name21); - } - nameSet.set(name21, UsedValueState.Completed); - }); - } - return code; - } - } - exports2.ValueScope = ValueScope; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/codegen/index.js -var require_codegen = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.or = exports2.and = exports2.not = exports2.CodeGen = exports2.operators = exports2.varKinds = exports2.ValueScopeName = exports2.ValueScope = exports2.Scope = exports2.Name = exports2.regexpCode = exports2.stringify = exports2.getProperty = exports2.nil = exports2.strConcat = exports2.str = exports2._ = undefined; - var code_1 = require_code(); - var scope_1 = require_scope(); - var code_2 = require_code(); - Object.defineProperty(exports2, "_", { enumerable: true, get: function() { - return code_2._; - } }); - Object.defineProperty(exports2, "str", { enumerable: true, get: function() { - return code_2.str; - } }); - Object.defineProperty(exports2, "strConcat", { enumerable: true, get: function() { - return code_2.strConcat; - } }); - Object.defineProperty(exports2, "nil", { enumerable: true, get: function() { - return code_2.nil; - } }); - Object.defineProperty(exports2, "getProperty", { enumerable: true, get: function() { - return code_2.getProperty; - } }); - Object.defineProperty(exports2, "stringify", { enumerable: true, get: function() { - return code_2.stringify; - } }); - Object.defineProperty(exports2, "regexpCode", { enumerable: true, get: function() { - return code_2.regexpCode; - } }); - Object.defineProperty(exports2, "Name", { enumerable: true, get: function() { - return code_2.Name; - } }); - var scope_2 = require_scope(); - Object.defineProperty(exports2, "Scope", { enumerable: true, get: function() { - return scope_2.Scope; - } }); - Object.defineProperty(exports2, "ValueScope", { enumerable: true, get: function() { - return scope_2.ValueScope; - } }); - Object.defineProperty(exports2, "ValueScopeName", { enumerable: true, get: function() { - return scope_2.ValueScopeName; - } }); - Object.defineProperty(exports2, "varKinds", { enumerable: true, get: function() { - return scope_2.varKinds; - } }); - exports2.operators = { - GT: new code_1._Code(">"), - GTE: new code_1._Code(">="), - LT: new code_1._Code("<"), - LTE: new code_1._Code("<="), - EQ: new code_1._Code("==="), - NEQ: new code_1._Code("!=="), - NOT: new code_1._Code("!"), - OR: new code_1._Code("||"), - AND: new code_1._Code("&&"), - ADD: new code_1._Code("+") - }; - - class Node { - optimizeNodes() { - return this; - } - optimizeNames(_names, _constants) { - return this; - } - } - - class Def extends Node { - constructor(varKind, name21, rhs) { - super(); - this.varKind = varKind; - this.name = name21; - this.rhs = rhs; - } - render({ es5, _n }) { - const varKind = es5 ? scope_1.varKinds.var : this.varKind; - const rhs = this.rhs === undefined ? "" : ` = ${this.rhs}`; - return `${varKind} ${this.name}${rhs};` + _n; - } - optimizeNames(names, constants) { - if (!names[this.name.str]) - return; - if (this.rhs) - this.rhs = optimizeExpr(this.rhs, names, constants); - return this; - } - get names() { - return this.rhs instanceof code_1._CodeOrName ? this.rhs.names : {}; - } - } - - class Assign extends Node { - constructor(lhs, rhs, sideEffects) { - super(); - this.lhs = lhs; - this.rhs = rhs; - this.sideEffects = sideEffects; - } - render({ _n }) { - return `${this.lhs} = ${this.rhs};` + _n; - } - optimizeNames(names, constants) { - if (this.lhs instanceof code_1.Name && !names[this.lhs.str] && !this.sideEffects) - return; - this.rhs = optimizeExpr(this.rhs, names, constants); - return this; - } - get names() { - const names = this.lhs instanceof code_1.Name ? {} : { ...this.lhs.names }; - return addExprNames(names, this.rhs); - } - } - - class AssignOp extends Assign { - constructor(lhs, op, rhs, sideEffects) { - super(lhs, rhs, sideEffects); - this.op = op; - } - render({ _n }) { - return `${this.lhs} ${this.op}= ${this.rhs};` + _n; - } - } - - class Label extends Node { - constructor(label) { - super(); - this.label = label; - this.names = {}; - } - render({ _n }) { - return `${this.label}:` + _n; - } - } - - class Break extends Node { - constructor(label) { - super(); - this.label = label; - this.names = {}; - } - render({ _n }) { - const label = this.label ? ` ${this.label}` : ""; - return `break${label};` + _n; - } - } - - class Throw extends Node { - constructor(error48) { - super(); - this.error = error48; - } - render({ _n }) { - return `throw ${this.error};` + _n; - } - get names() { - return this.error.names; - } - } - - class AnyCode extends Node { - constructor(code) { - super(); - this.code = code; - } - render({ _n }) { - return `${this.code};` + _n; - } - optimizeNodes() { - return `${this.code}` ? this : undefined; - } - optimizeNames(names, constants) { - this.code = optimizeExpr(this.code, names, constants); - return this; - } - get names() { - return this.code instanceof code_1._CodeOrName ? this.code.names : {}; - } - } - - class ParentNode extends Node { - constructor(nodes = []) { - super(); - this.nodes = nodes; - } - render(opts) { - return this.nodes.reduce((code, n) => code + n.render(opts), ""); - } - optimizeNodes() { - const { nodes } = this; - let i = nodes.length; - while (i--) { - const n = nodes[i].optimizeNodes(); - if (Array.isArray(n)) - nodes.splice(i, 1, ...n); - else if (n) - nodes[i] = n; - else - nodes.splice(i, 1); - } - return nodes.length > 0 ? this : undefined; - } - optimizeNames(names, constants) { - const { nodes } = this; - let i = nodes.length; - while (i--) { - const n = nodes[i]; - if (n.optimizeNames(names, constants)) - continue; - subtractNames(names, n.names); - nodes.splice(i, 1); - } - return nodes.length > 0 ? this : undefined; - } - get names() { - return this.nodes.reduce((names, n) => addNames(names, n.names), {}); - } - } - - class BlockNode extends ParentNode { - render(opts) { - return "{" + opts._n + super.render(opts) + "}" + opts._n; - } - } - - class Root extends ParentNode { - } - - class Else extends BlockNode { - } - Else.kind = "else"; - - class If extends BlockNode { - constructor(condition, nodes) { - super(nodes); - this.condition = condition; - } - render(opts) { - let code = `if(${this.condition})` + super.render(opts); - if (this.else) - code += "else " + this.else.render(opts); - return code; - } - optimizeNodes() { - super.optimizeNodes(); - const cond = this.condition; - if (cond === true) - return this.nodes; - let e = this.else; - if (e) { - const ns = e.optimizeNodes(); - e = this.else = Array.isArray(ns) ? new Else(ns) : ns; - } - if (e) { - if (cond === false) - return e instanceof If ? e : e.nodes; - if (this.nodes.length) - return this; - return new If(not(cond), e instanceof If ? [e] : e.nodes); - } - if (cond === false || !this.nodes.length) - return; - return this; - } - optimizeNames(names, constants) { - var _a21; - this.else = (_a21 = this.else) === null || _a21 === undefined ? undefined : _a21.optimizeNames(names, constants); - if (!(super.optimizeNames(names, constants) || this.else)) - return; - this.condition = optimizeExpr(this.condition, names, constants); - return this; - } - get names() { - const names = super.names; - addExprNames(names, this.condition); - if (this.else) - addNames(names, this.else.names); - return names; - } - } - If.kind = "if"; - - class For extends BlockNode { - } - For.kind = "for"; - - class ForLoop extends For { - constructor(iteration) { - super(); - this.iteration = iteration; - } - render(opts) { - return `for(${this.iteration})` + super.render(opts); - } - optimizeNames(names, constants) { - if (!super.optimizeNames(names, constants)) - return; - this.iteration = optimizeExpr(this.iteration, names, constants); - return this; - } - get names() { - return addNames(super.names, this.iteration.names); - } - } - - class ForRange extends For { - constructor(varKind, name21, from, to) { - super(); - this.varKind = varKind; - this.name = name21; - this.from = from; - this.to = to; - } - render(opts) { - const varKind = opts.es5 ? scope_1.varKinds.var : this.varKind; - const { name: name21, from, to } = this; - return `for(${varKind} ${name21}=${from}; ${name21}<${to}; ${name21}++)` + super.render(opts); - } - get names() { - const names = addExprNames(super.names, this.from); - return addExprNames(names, this.to); - } - } - - class ForIter extends For { - constructor(loop, varKind, name21, iterable) { - super(); - this.loop = loop; - this.varKind = varKind; - this.name = name21; - this.iterable = iterable; - } - render(opts) { - return `for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})` + super.render(opts); - } - optimizeNames(names, constants) { - if (!super.optimizeNames(names, constants)) - return; - this.iterable = optimizeExpr(this.iterable, names, constants); - return this; - } - get names() { - return addNames(super.names, this.iterable.names); - } - } - - class Func extends BlockNode { - constructor(name21, args, async) { - super(); - this.name = name21; - this.args = args; - this.async = async; - } - render(opts) { - const _async = this.async ? "async " : ""; - return `${_async}function ${this.name}(${this.args})` + super.render(opts); - } - } - Func.kind = "func"; - - class Return extends ParentNode { - render(opts) { - return "return " + super.render(opts); - } - } - Return.kind = "return"; - - class Try extends BlockNode { - render(opts) { - let code = "try" + super.render(opts); - if (this.catch) - code += this.catch.render(opts); - if (this.finally) - code += this.finally.render(opts); - return code; - } - optimizeNodes() { - var _a21, _b16; - super.optimizeNodes(); - (_a21 = this.catch) === null || _a21 === undefined || _a21.optimizeNodes(); - (_b16 = this.finally) === null || _b16 === undefined || _b16.optimizeNodes(); - return this; - } - optimizeNames(names, constants) { - var _a21, _b16; - super.optimizeNames(names, constants); - (_a21 = this.catch) === null || _a21 === undefined || _a21.optimizeNames(names, constants); - (_b16 = this.finally) === null || _b16 === undefined || _b16.optimizeNames(names, constants); - return this; - } - get names() { - const names = super.names; - if (this.catch) - addNames(names, this.catch.names); - if (this.finally) - addNames(names, this.finally.names); - return names; - } - } - - class Catch extends BlockNode { - constructor(error48) { - super(); - this.error = error48; - } - render(opts) { - return `catch(${this.error})` + super.render(opts); - } - } - Catch.kind = "catch"; - - class Finally extends BlockNode { - render(opts) { - return "finally" + super.render(opts); - } - } - Finally.kind = "finally"; - - class CodeGen { - constructor(extScope, opts = {}) { - this._values = {}; - this._blockStarts = []; - this._constants = {}; - this.opts = { ...opts, _n: opts.lines ? ` -` : "" }; - this._extScope = extScope; - this._scope = new scope_1.Scope({ parent: extScope }); - this._nodes = [new Root]; - } - toString() { - return this._root.render(this.opts); - } - name(prefix) { - return this._scope.name(prefix); - } - scopeName(prefix) { - return this._extScope.name(prefix); - } - scopeValue(prefixOrName, value) { - const name21 = this._extScope.value(prefixOrName, value); - const vs = this._values[name21.prefix] || (this._values[name21.prefix] = new Set); - vs.add(name21); - return name21; - } - getScopeValue(prefix, keyOrRef) { - return this._extScope.getValue(prefix, keyOrRef); - } - scopeRefs(scopeName) { - return this._extScope.scopeRefs(scopeName, this._values); - } - scopeCode() { - return this._extScope.scopeCode(this._values); - } - _def(varKind, nameOrPrefix, rhs, constant) { - const name21 = this._scope.toName(nameOrPrefix); - if (rhs !== undefined && constant) - this._constants[name21.str] = rhs; - this._leafNode(new Def(varKind, name21, rhs)); - return name21; - } - const(nameOrPrefix, rhs, _constant) { - return this._def(scope_1.varKinds.const, nameOrPrefix, rhs, _constant); - } - let(nameOrPrefix, rhs, _constant) { - return this._def(scope_1.varKinds.let, nameOrPrefix, rhs, _constant); - } - var(nameOrPrefix, rhs, _constant) { - return this._def(scope_1.varKinds.var, nameOrPrefix, rhs, _constant); - } - assign(lhs, rhs, sideEffects) { - return this._leafNode(new Assign(lhs, rhs, sideEffects)); - } - add(lhs, rhs) { - return this._leafNode(new AssignOp(lhs, exports2.operators.ADD, rhs)); - } - code(c) { - if (typeof c == "function") - c(); - else if (c !== code_1.nil) - this._leafNode(new AnyCode(c)); - return this; - } - object(...keyValues) { - const code = ["{"]; - for (const [key, value] of keyValues) { - if (code.length > 1) - code.push(","); - code.push(key); - if (key !== value || this.opts.es5) { - code.push(":"); - (0, code_1.addCodeArg)(code, value); - } - } - code.push("}"); - return new code_1._Code(code); - } - if(condition, thenBody, elseBody) { - this._blockNode(new If(condition)); - if (thenBody && elseBody) { - this.code(thenBody).else().code(elseBody).endIf(); - } else if (thenBody) { - this.code(thenBody).endIf(); - } else if (elseBody) { - throw new Error('CodeGen: "else" body without "then" body'); - } - return this; - } - elseIf(condition) { - return this._elseNode(new If(condition)); - } - else() { - return this._elseNode(new Else); - } - endIf() { - return this._endBlockNode(If, Else); - } - _for(node, forBody) { - this._blockNode(node); - if (forBody) - this.code(forBody).endFor(); - return this; - } - for(iteration, forBody) { - return this._for(new ForLoop(iteration), forBody); - } - forRange(nameOrPrefix, from, to, forBody, varKind = this.opts.es5 ? scope_1.varKinds.var : scope_1.varKinds.let) { - const name21 = this._scope.toName(nameOrPrefix); - return this._for(new ForRange(varKind, name21, from, to), () => forBody(name21)); - } - forOf(nameOrPrefix, iterable, forBody, varKind = scope_1.varKinds.const) { - const name21 = this._scope.toName(nameOrPrefix); - if (this.opts.es5) { - const arr = iterable instanceof code_1.Name ? iterable : this.var("_arr", iterable); - return this.forRange("_i", 0, (0, code_1._)`${arr}.length`, (i) => { - this.var(name21, (0, code_1._)`${arr}[${i}]`); - forBody(name21); - }); - } - return this._for(new ForIter("of", varKind, name21, iterable), () => forBody(name21)); - } - forIn(nameOrPrefix, obj, forBody, varKind = this.opts.es5 ? scope_1.varKinds.var : scope_1.varKinds.const) { - if (this.opts.ownProperties) { - return this.forOf(nameOrPrefix, (0, code_1._)`Object.keys(${obj})`, forBody); - } - const name21 = this._scope.toName(nameOrPrefix); - return this._for(new ForIter("in", varKind, name21, obj), () => forBody(name21)); - } - endFor() { - return this._endBlockNode(For); - } - label(label) { - return this._leafNode(new Label(label)); - } - break(label) { - return this._leafNode(new Break(label)); - } - return(value) { - const node = new Return; - this._blockNode(node); - this.code(value); - if (node.nodes.length !== 1) - throw new Error('CodeGen: "return" should have one node'); - return this._endBlockNode(Return); - } - try(tryBody, catchCode, finallyCode) { - if (!catchCode && !finallyCode) - throw new Error('CodeGen: "try" without "catch" and "finally"'); - const node = new Try; - this._blockNode(node); - this.code(tryBody); - if (catchCode) { - const error48 = this.name("e"); - this._currNode = node.catch = new Catch(error48); - catchCode(error48); - } - if (finallyCode) { - this._currNode = node.finally = new Finally; - this.code(finallyCode); - } - return this._endBlockNode(Catch, Finally); - } - throw(error48) { - return this._leafNode(new Throw(error48)); - } - block(body, nodeCount) { - this._blockStarts.push(this._nodes.length); - if (body) - this.code(body).endBlock(nodeCount); - return this; - } - endBlock(nodeCount) { - const len = this._blockStarts.pop(); - if (len === undefined) - throw new Error("CodeGen: not in self-balancing block"); - const toClose = this._nodes.length - len; - if (toClose < 0 || nodeCount !== undefined && toClose !== nodeCount) { - throw new Error(`CodeGen: wrong number of nodes: ${toClose} vs ${nodeCount} expected`); - } - this._nodes.length = len; - return this; - } - func(name21, args = code_1.nil, async, funcBody) { - this._blockNode(new Func(name21, args, async)); - if (funcBody) - this.code(funcBody).endFunc(); - return this; - } - endFunc() { - return this._endBlockNode(Func); - } - optimize(n = 1) { - while (n-- > 0) { - this._root.optimizeNodes(); - this._root.optimizeNames(this._root.names, this._constants); - } - } - _leafNode(node) { - this._currNode.nodes.push(node); - return this; - } - _blockNode(node) { - this._currNode.nodes.push(node); - this._nodes.push(node); - } - _endBlockNode(N1, N2) { - const n = this._currNode; - if (n instanceof N1 || N2 && n instanceof N2) { - this._nodes.pop(); - return this; - } - throw new Error(`CodeGen: not in block "${N2 ? `${N1.kind}/${N2.kind}` : N1.kind}"`); - } - _elseNode(node) { - const n = this._currNode; - if (!(n instanceof If)) { - throw new Error('CodeGen: "else" without "if"'); - } - this._currNode = n.else = node; - return this; - } - get _root() { - return this._nodes[0]; - } - get _currNode() { - const ns = this._nodes; - return ns[ns.length - 1]; - } - set _currNode(node) { - const ns = this._nodes; - ns[ns.length - 1] = node; - } - } - exports2.CodeGen = CodeGen; - function addNames(names, from) { - for (const n in from) - names[n] = (names[n] || 0) + (from[n] || 0); - return names; - } - function addExprNames(names, from) { - return from instanceof code_1._CodeOrName ? addNames(names, from.names) : names; - } - function optimizeExpr(expr, names, constants) { - if (expr instanceof code_1.Name) - return replaceName(expr); - if (!canOptimize(expr)) - return expr; - return new code_1._Code(expr._items.reduce((items, c) => { - if (c instanceof code_1.Name) - c = replaceName(c); - if (c instanceof code_1._Code) - items.push(...c._items); - else - items.push(c); - return items; - }, [])); - function replaceName(n) { - const c = constants[n.str]; - if (c === undefined || names[n.str] !== 1) - return n; - delete names[n.str]; - return c; - } - function canOptimize(e) { - return e instanceof code_1._Code && e._items.some((c) => c instanceof code_1.Name && names[c.str] === 1 && constants[c.str] !== undefined); - } - } - function subtractNames(names, from) { - for (const n in from) - names[n] = (names[n] || 0) - (from[n] || 0); - } - function not(x) { - return typeof x == "boolean" || typeof x == "number" || x === null ? !x : (0, code_1._)`!${par(x)}`; - } - exports2.not = not; - var andCode = mappend(exports2.operators.AND); - function and(...args) { - return args.reduce(andCode); - } - exports2.and = and; - var orCode = mappend(exports2.operators.OR); - function or(...args) { - return args.reduce(orCode); - } - exports2.or = or; - function mappend(op) { - return (x, y) => x === code_1.nil ? y : y === code_1.nil ? x : (0, code_1._)`${par(x)} ${op} ${par(y)}`; - } - function par(x) { - return x instanceof code_1.Name ? x : (0, code_1._)`(${x})`; - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/util.js -var require_util2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.checkStrictMode = exports2.getErrorPath = exports2.Type = exports2.useFunc = exports2.setEvaluated = exports2.evaluatedPropsToName = exports2.mergeEvaluated = exports2.eachItem = exports2.unescapeJsonPointer = exports2.escapeJsonPointer = exports2.escapeFragment = exports2.unescapeFragment = exports2.schemaRefOrVal = exports2.schemaHasRulesButRef = exports2.schemaHasRules = exports2.checkUnknownRules = exports2.alwaysValidSchema = exports2.toHash = undefined; - var codegen_1 = require_codegen(); - var code_1 = require_code(); - function toHash(arr) { - const hash2 = {}; - for (const item of arr) - hash2[item] = true; - return hash2; - } - exports2.toHash = toHash; - function alwaysValidSchema(it, schema) { - if (typeof schema == "boolean") - return schema; - if (Object.keys(schema).length === 0) - return true; - checkUnknownRules(it, schema); - return !schemaHasRules(schema, it.self.RULES.all); - } - exports2.alwaysValidSchema = alwaysValidSchema; - function checkUnknownRules(it, schema = it.schema) { - const { opts, self } = it; - if (!opts.strictSchema) - return; - if (typeof schema === "boolean") - return; - const rules = self.RULES.keywords; - for (const key in schema) { - if (!rules[key]) - checkStrictMode(it, `unknown keyword: "${key}"`); - } - } - exports2.checkUnknownRules = checkUnknownRules; - function schemaHasRules(schema, rules) { - if (typeof schema == "boolean") - return !schema; - for (const key in schema) - if (rules[key]) - return true; - return false; - } - exports2.schemaHasRules = schemaHasRules; - function schemaHasRulesButRef(schema, RULES) { - if (typeof schema == "boolean") - return !schema; - for (const key in schema) - if (key !== "$ref" && RULES.all[key]) - return true; - return false; - } - exports2.schemaHasRulesButRef = schemaHasRulesButRef; - function schemaRefOrVal({ topSchemaRef, schemaPath }, schema, keyword, $data) { - if (!$data) { - if (typeof schema == "number" || typeof schema == "boolean") - return schema; - if (typeof schema == "string") - return (0, codegen_1._)`${schema}`; - } - return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`; - } - exports2.schemaRefOrVal = schemaRefOrVal; - function unescapeFragment(str) { - return unescapeJsonPointer(decodeURIComponent(str)); - } - exports2.unescapeFragment = unescapeFragment; - function escapeFragment(str) { - return encodeURIComponent(escapeJsonPointer(str)); - } - exports2.escapeFragment = escapeFragment; - function escapeJsonPointer(str) { - if (typeof str == "number") - return `${str}`; - return str.replace(/~/g, "~0").replace(/\//g, "~1"); - } - exports2.escapeJsonPointer = escapeJsonPointer; - function unescapeJsonPointer(str) { - return str.replace(/~1/g, "/").replace(/~0/g, "~"); - } - exports2.unescapeJsonPointer = unescapeJsonPointer; - function eachItem(xs, f) { - if (Array.isArray(xs)) { - for (const x of xs) - f(x); - } else { - f(xs); - } - } - exports2.eachItem = eachItem; - function makeMergeEvaluated({ mergeNames, mergeToName, mergeValues: mergeValues3, resultToName }) { - return (gen, from, to, toName) => { - const res = to === undefined ? from : to instanceof codegen_1.Name ? (from instanceof codegen_1.Name ? mergeNames(gen, from, to) : mergeToName(gen, from, to), to) : from instanceof codegen_1.Name ? (mergeToName(gen, to, from), from) : mergeValues3(from, to); - return toName === codegen_1.Name && !(res instanceof codegen_1.Name) ? resultToName(gen, res) : res; - }; - } - exports2.mergeEvaluated = { - props: makeMergeEvaluated({ - mergeNames: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true && ${from} !== undefined`, () => { - gen.if((0, codegen_1._)`${from} === true`, () => gen.assign(to, true), () => gen.assign(to, (0, codegen_1._)`${to} || {}`).code((0, codegen_1._)`Object.assign(${to}, ${from})`)); - }), - mergeToName: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true`, () => { - if (from === true) { - gen.assign(to, true); - } else { - gen.assign(to, (0, codegen_1._)`${to} || {}`); - setEvaluated(gen, to, from); - } - }), - mergeValues: (from, to) => from === true ? true : { ...from, ...to }, - resultToName: evaluatedPropsToName - }), - items: makeMergeEvaluated({ - mergeNames: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true && ${from} !== undefined`, () => gen.assign(to, (0, codegen_1._)`${from} === true ? true : ${to} > ${from} ? ${to} : ${from}`)), - mergeToName: (gen, from, to) => gen.if((0, codegen_1._)`${to} !== true`, () => gen.assign(to, from === true ? true : (0, codegen_1._)`${to} > ${from} ? ${to} : ${from}`)), - mergeValues: (from, to) => from === true ? true : Math.max(from, to), - resultToName: (gen, items) => gen.var("items", items) - }) - }; - function evaluatedPropsToName(gen, ps) { - if (ps === true) - return gen.var("props", true); - const props = gen.var("props", (0, codegen_1._)`{}`); - if (ps !== undefined) - setEvaluated(gen, props, ps); - return props; - } - exports2.evaluatedPropsToName = evaluatedPropsToName; - function setEvaluated(gen, props, ps) { - Object.keys(ps).forEach((p) => gen.assign((0, codegen_1._)`${props}${(0, codegen_1.getProperty)(p)}`, true)); - } - exports2.setEvaluated = setEvaluated; - var snippets = {}; - function useFunc(gen, f) { - return gen.scopeValue("func", { - ref: f, - code: snippets[f.code] || (snippets[f.code] = new code_1._Code(f.code)) - }); - } - exports2.useFunc = useFunc; - var Type; - (function(Type2) { - Type2[Type2["Num"] = 0] = "Num"; - Type2[Type2["Str"] = 1] = "Str"; - })(Type || (exports2.Type = Type = {})); - function getErrorPath(dataProp, dataPropType, jsPropertySyntax) { - if (dataProp instanceof codegen_1.Name) { - const isNumber2 = dataPropType === Type.Num; - return jsPropertySyntax ? isNumber2 ? (0, codegen_1._)`"[" + ${dataProp} + "]"` : (0, codegen_1._)`"['" + ${dataProp} + "']"` : isNumber2 ? (0, codegen_1._)`"/" + ${dataProp}` : (0, codegen_1._)`"/" + ${dataProp}.replace(/~/g, "~0").replace(/\\//g, "~1")`; - } - return jsPropertySyntax ? (0, codegen_1.getProperty)(dataProp).toString() : "/" + escapeJsonPointer(dataProp); - } - exports2.getErrorPath = getErrorPath; - function checkStrictMode(it, msg, mode = it.opts.strictSchema) { - if (!mode) - return; - msg = `strict mode: ${msg}`; - if (mode === true) - throw new Error(msg); - it.self.logger.warn(msg); - } - exports2.checkStrictMode = checkStrictMode; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/names.js -var require_names = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var names = { - data: new codegen_1.Name("data"), - valCxt: new codegen_1.Name("valCxt"), - instancePath: new codegen_1.Name("instancePath"), - parentData: new codegen_1.Name("parentData"), - parentDataProperty: new codegen_1.Name("parentDataProperty"), - rootData: new codegen_1.Name("rootData"), - dynamicAnchors: new codegen_1.Name("dynamicAnchors"), - vErrors: new codegen_1.Name("vErrors"), - errors: new codegen_1.Name("errors"), - this: new codegen_1.Name("this"), - self: new codegen_1.Name("self"), - scope: new codegen_1.Name("scope"), - json: new codegen_1.Name("json"), - jsonPos: new codegen_1.Name("jsonPos"), - jsonLen: new codegen_1.Name("jsonLen"), - jsonPart: new codegen_1.Name("jsonPart") - }; - exports2.default = names; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/errors.js -var require_errors = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.extendErrors = exports2.resetErrorsCount = exports2.reportExtraError = exports2.reportError = exports2.keyword$DataError = exports2.keywordError = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var names_1 = require_names(); - exports2.keywordError = { - message: ({ keyword }) => (0, codegen_1.str)`must pass "${keyword}" keyword validation` - }; - exports2.keyword$DataError = { - message: ({ keyword, schemaType }) => schemaType ? (0, codegen_1.str)`"${keyword}" keyword must be ${schemaType} ($data)` : (0, codegen_1.str)`"${keyword}" keyword is invalid ($data)` - }; - function reportError(cxt, error48 = exports2.keywordError, errorPaths, overrideAllErrors) { - const { it } = cxt; - const { gen, compositeRule, allErrors } = it; - const errObj = errorObjectCode(cxt, error48, errorPaths); - if (overrideAllErrors !== null && overrideAllErrors !== undefined ? overrideAllErrors : compositeRule || allErrors) { - addError(gen, errObj); - } else { - returnErrors(it, (0, codegen_1._)`[${errObj}]`); - } - } - exports2.reportError = reportError; - function reportExtraError(cxt, error48 = exports2.keywordError, errorPaths) { - const { it } = cxt; - const { gen, compositeRule, allErrors } = it; - const errObj = errorObjectCode(cxt, error48, errorPaths); - addError(gen, errObj); - if (!(compositeRule || allErrors)) { - returnErrors(it, names_1.default.vErrors); - } - } - exports2.reportExtraError = reportExtraError; - function resetErrorsCount(gen, errsCount) { - gen.assign(names_1.default.errors, errsCount); - gen.if((0, codegen_1._)`${names_1.default.vErrors} !== null`, () => gen.if(errsCount, () => gen.assign((0, codegen_1._)`${names_1.default.vErrors}.length`, errsCount), () => gen.assign(names_1.default.vErrors, null))); - } - exports2.resetErrorsCount = resetErrorsCount; - function extendErrors({ gen, keyword, schemaValue, data, errsCount, it }) { - if (errsCount === undefined) - throw new Error("ajv implementation error"); - const err = gen.name("err"); - gen.forRange("i", errsCount, names_1.default.errors, (i) => { - gen.const(err, (0, codegen_1._)`${names_1.default.vErrors}[${i}]`); - gen.if((0, codegen_1._)`${err}.instancePath === undefined`, () => gen.assign((0, codegen_1._)`${err}.instancePath`, (0, codegen_1.strConcat)(names_1.default.instancePath, it.errorPath))); - gen.assign((0, codegen_1._)`${err}.schemaPath`, (0, codegen_1.str)`${it.errSchemaPath}/${keyword}`); - if (it.opts.verbose) { - gen.assign((0, codegen_1._)`${err}.schema`, schemaValue); - gen.assign((0, codegen_1._)`${err}.data`, data); - } - }); - } - exports2.extendErrors = extendErrors; - function addError(gen, errObj) { - const err = gen.const("err", errObj); - gen.if((0, codegen_1._)`${names_1.default.vErrors} === null`, () => gen.assign(names_1.default.vErrors, (0, codegen_1._)`[${err}]`), (0, codegen_1._)`${names_1.default.vErrors}.push(${err})`); - gen.code((0, codegen_1._)`${names_1.default.errors}++`); - } - function returnErrors(it, errs) { - const { gen, validateName, schemaEnv } = it; - if (schemaEnv.$async) { - gen.throw((0, codegen_1._)`new ${it.ValidationError}(${errs})`); - } else { - gen.assign((0, codegen_1._)`${validateName}.errors`, errs); - gen.return(false); - } - } - var E = { - keyword: new codegen_1.Name("keyword"), - schemaPath: new codegen_1.Name("schemaPath"), - params: new codegen_1.Name("params"), - propertyName: new codegen_1.Name("propertyName"), - message: new codegen_1.Name("message"), - schema: new codegen_1.Name("schema"), - parentSchema: new codegen_1.Name("parentSchema") - }; - function errorObjectCode(cxt, error48, errorPaths) { - const { createErrors } = cxt.it; - if (createErrors === false) - return (0, codegen_1._)`{}`; - return errorObject(cxt, error48, errorPaths); - } - function errorObject(cxt, error48, errorPaths = {}) { - const { gen, it } = cxt; - const keyValues = [ - errorInstancePath(it, errorPaths), - errorSchemaPath(cxt, errorPaths) - ]; - extraErrorProps(cxt, error48, keyValues); - return gen.object(...keyValues); - } - function errorInstancePath({ errorPath }, { instancePath }) { - const instPath = instancePath ? (0, codegen_1.str)`${errorPath}${(0, util_1.getErrorPath)(instancePath, util_1.Type.Str)}` : errorPath; - return [names_1.default.instancePath, (0, codegen_1.strConcat)(names_1.default.instancePath, instPath)]; - } - function errorSchemaPath({ keyword, it: { errSchemaPath } }, { schemaPath, parentSchema }) { - let schPath = parentSchema ? errSchemaPath : (0, codegen_1.str)`${errSchemaPath}/${keyword}`; - if (schemaPath) { - schPath = (0, codegen_1.str)`${schPath}${(0, util_1.getErrorPath)(schemaPath, util_1.Type.Str)}`; - } - return [E.schemaPath, schPath]; - } - function extraErrorProps(cxt, { params, message }, keyValues) { - const { keyword, data, schemaValue, it } = cxt; - const { opts, propertyName, topSchemaRef, schemaPath } = it; - keyValues.push([E.keyword, keyword], [E.params, typeof params == "function" ? params(cxt) : params || (0, codegen_1._)`{}`]); - if (opts.messages) { - keyValues.push([E.message, typeof message == "function" ? message(cxt) : message]); - } - if (opts.verbose) { - keyValues.push([E.schema, schemaValue], [E.parentSchema, (0, codegen_1._)`${topSchemaRef}${schemaPath}`], [names_1.default.data, data]); - } - if (propertyName) - keyValues.push([E.propertyName, propertyName]); - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/boolSchema.js -var require_boolSchema = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.boolOrEmptySchema = exports2.topBoolOrEmptySchema = undefined; - var errors_1 = require_errors(); - var codegen_1 = require_codegen(); - var names_1 = require_names(); - var boolError = { - message: "boolean schema is false" - }; - function topBoolOrEmptySchema(it) { - const { gen, schema, validateName } = it; - if (schema === false) { - falseSchemaError(it, false); - } else if (typeof schema == "object" && schema.$async === true) { - gen.return(names_1.default.data); - } else { - gen.assign((0, codegen_1._)`${validateName}.errors`, null); - gen.return(true); - } - } - exports2.topBoolOrEmptySchema = topBoolOrEmptySchema; - function boolOrEmptySchema(it, valid) { - const { gen, schema } = it; - if (schema === false) { - gen.var(valid, false); - falseSchemaError(it); - } else { - gen.var(valid, true); - } - } - exports2.boolOrEmptySchema = boolOrEmptySchema; - function falseSchemaError(it, overrideAllErrors) { - const { gen, data } = it; - const cxt = { - gen, - keyword: "false schema", - data, - schema: false, - schemaCode: false, - schemaValue: false, - params: {}, - it - }; - (0, errors_1.reportError)(cxt, boolError, undefined, overrideAllErrors); - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/rules.js -var require_rules = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.getRules = exports2.isJSONType = undefined; - var _jsonTypes = ["string", "number", "integer", "boolean", "null", "object", "array"]; - var jsonTypes = new Set(_jsonTypes); - function isJSONType(x) { - return typeof x == "string" && jsonTypes.has(x); - } - exports2.isJSONType = isJSONType; - function getRules() { - const groups = { - number: { type: "number", rules: [] }, - string: { type: "string", rules: [] }, - array: { type: "array", rules: [] }, - object: { type: "object", rules: [] } - }; - return { - types: { ...groups, integer: true, boolean: true, null: true }, - rules: [{ rules: [] }, groups.number, groups.string, groups.array, groups.object], - post: { rules: [] }, - all: {}, - keywords: {} - }; - } - exports2.getRules = getRules; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/applicability.js -var require_applicability = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.shouldUseRule = exports2.shouldUseGroup = exports2.schemaHasRulesForType = undefined; - function schemaHasRulesForType({ schema, self }, type) { - const group = self.RULES.types[type]; - return group && group !== true && shouldUseGroup(schema, group); - } - exports2.schemaHasRulesForType = schemaHasRulesForType; - function shouldUseGroup(schema, group) { - return group.rules.some((rule) => shouldUseRule(schema, rule)); - } - exports2.shouldUseGroup = shouldUseGroup; - function shouldUseRule(schema, rule) { - var _a21; - return schema[rule.keyword] !== undefined || ((_a21 = rule.definition.implements) === null || _a21 === undefined ? undefined : _a21.some((kwd) => schema[kwd] !== undefined)); - } - exports2.shouldUseRule = shouldUseRule; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/dataType.js -var require_dataType = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.reportTypeError = exports2.checkDataTypes = exports2.checkDataType = exports2.coerceAndCheckDataType = exports2.getJSONTypes = exports2.getSchemaTypes = exports2.DataType = undefined; - var rules_1 = require_rules(); - var applicability_1 = require_applicability(); - var errors_1 = require_errors(); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var DataType; - (function(DataType2) { - DataType2[DataType2["Correct"] = 0] = "Correct"; - DataType2[DataType2["Wrong"] = 1] = "Wrong"; - })(DataType || (exports2.DataType = DataType = {})); - function getSchemaTypes(schema) { - const types2 = getJSONTypes(schema.type); - const hasNull = types2.includes("null"); - if (hasNull) { - if (schema.nullable === false) - throw new Error("type: null contradicts nullable: false"); - } else { - if (!types2.length && schema.nullable !== undefined) { - throw new Error('"nullable" cannot be used without "type"'); - } - if (schema.nullable === true) - types2.push("null"); - } - return types2; - } - exports2.getSchemaTypes = getSchemaTypes; - function getJSONTypes(ts) { - const types2 = Array.isArray(ts) ? ts : ts ? [ts] : []; - if (types2.every(rules_1.isJSONType)) - return types2; - throw new Error("type must be JSONType or JSONType[]: " + types2.join(",")); - } - exports2.getJSONTypes = getJSONTypes; - function coerceAndCheckDataType(it, types2) { - const { gen, data, opts } = it; - const coerceTo = coerceToTypes(types2, opts.coerceTypes); - const checkTypes = types2.length > 0 && !(coerceTo.length === 0 && types2.length === 1 && (0, applicability_1.schemaHasRulesForType)(it, types2[0])); - if (checkTypes) { - const wrongType = checkDataTypes(types2, data, opts.strictNumbers, DataType.Wrong); - gen.if(wrongType, () => { - if (coerceTo.length) - coerceData(it, types2, coerceTo); - else - reportTypeError(it); - }); - } - return checkTypes; - } - exports2.coerceAndCheckDataType = coerceAndCheckDataType; - var COERCIBLE = new Set(["string", "number", "integer", "boolean", "null"]); - function coerceToTypes(types2, coerceTypes) { - return coerceTypes ? types2.filter((t) => COERCIBLE.has(t) || coerceTypes === "array" && t === "array") : []; - } - function coerceData(it, types2, coerceTo) { - const { gen, data, opts } = it; - const dataType = gen.let("dataType", (0, codegen_1._)`typeof ${data}`); - const coerced = gen.let("coerced", (0, codegen_1._)`undefined`); - if (opts.coerceTypes === "array") { - gen.if((0, codegen_1._)`${dataType} == 'object' && Array.isArray(${data}) && ${data}.length == 1`, () => gen.assign(data, (0, codegen_1._)`${data}[0]`).assign(dataType, (0, codegen_1._)`typeof ${data}`).if(checkDataTypes(types2, data, opts.strictNumbers), () => gen.assign(coerced, data))); - } - gen.if((0, codegen_1._)`${coerced} !== undefined`); - for (const t of coerceTo) { - if (COERCIBLE.has(t) || t === "array" && opts.coerceTypes === "array") { - coerceSpecificType(t); - } - } - gen.else(); - reportTypeError(it); - gen.endIf(); - gen.if((0, codegen_1._)`${coerced} !== undefined`, () => { - gen.assign(data, coerced); - assignParentData(it, coerced); - }); - function coerceSpecificType(t) { - switch (t) { - case "string": - gen.elseIf((0, codegen_1._)`${dataType} == "number" || ${dataType} == "boolean"`).assign(coerced, (0, codegen_1._)`"" + ${data}`).elseIf((0, codegen_1._)`${data} === null`).assign(coerced, (0, codegen_1._)`""`); - return; - case "number": - gen.elseIf((0, codegen_1._)`${dataType} == "boolean" || ${data} === null - || (${dataType} == "string" && ${data} && ${data} == +${data})`).assign(coerced, (0, codegen_1._)`+${data}`); - return; - case "integer": - gen.elseIf((0, codegen_1._)`${dataType} === "boolean" || ${data} === null - || (${dataType} === "string" && ${data} && ${data} == +${data} && !(${data} % 1))`).assign(coerced, (0, codegen_1._)`+${data}`); - return; - case "boolean": - gen.elseIf((0, codegen_1._)`${data} === "false" || ${data} === 0 || ${data} === null`).assign(coerced, false).elseIf((0, codegen_1._)`${data} === "true" || ${data} === 1`).assign(coerced, true); - return; - case "null": - gen.elseIf((0, codegen_1._)`${data} === "" || ${data} === 0 || ${data} === false`); - gen.assign(coerced, null); - return; - case "array": - gen.elseIf((0, codegen_1._)`${dataType} === "string" || ${dataType} === "number" - || ${dataType} === "boolean" || ${data} === null`).assign(coerced, (0, codegen_1._)`[${data}]`); - } - } - } - function assignParentData({ gen, parentData, parentDataProperty }, expr) { - gen.if((0, codegen_1._)`${parentData} !== undefined`, () => gen.assign((0, codegen_1._)`${parentData}[${parentDataProperty}]`, expr)); - } - function checkDataType(dataType, data, strictNums, correct = DataType.Correct) { - const EQ = correct === DataType.Correct ? codegen_1.operators.EQ : codegen_1.operators.NEQ; - let cond; - switch (dataType) { - case "null": - return (0, codegen_1._)`${data} ${EQ} null`; - case "array": - cond = (0, codegen_1._)`Array.isArray(${data})`; - break; - case "object": - cond = (0, codegen_1._)`${data} && typeof ${data} == "object" && !Array.isArray(${data})`; - break; - case "integer": - cond = numCond((0, codegen_1._)`!(${data} % 1) && !isNaN(${data})`); - break; - case "number": - cond = numCond(); - break; - default: - return (0, codegen_1._)`typeof ${data} ${EQ} ${dataType}`; - } - return correct === DataType.Correct ? cond : (0, codegen_1.not)(cond); - function numCond(_cond = codegen_1.nil) { - return (0, codegen_1.and)((0, codegen_1._)`typeof ${data} == "number"`, _cond, strictNums ? (0, codegen_1._)`isFinite(${data})` : codegen_1.nil); - } - } - exports2.checkDataType = checkDataType; - function checkDataTypes(dataTypes, data, strictNums, correct) { - if (dataTypes.length === 1) { - return checkDataType(dataTypes[0], data, strictNums, correct); - } - let cond; - const types2 = (0, util_1.toHash)(dataTypes); - if (types2.array && types2.object) { - const notObj = (0, codegen_1._)`typeof ${data} != "object"`; - cond = types2.null ? notObj : (0, codegen_1._)`!${data} || ${notObj}`; - delete types2.null; - delete types2.array; - delete types2.object; - } else { - cond = codegen_1.nil; - } - if (types2.number) - delete types2.integer; - for (const t in types2) - cond = (0, codegen_1.and)(cond, checkDataType(t, data, strictNums, correct)); - return cond; - } - exports2.checkDataTypes = checkDataTypes; - var typeError = { - message: ({ schema }) => `must be ${schema}`, - params: ({ schema, schemaValue }) => typeof schema == "string" ? (0, codegen_1._)`{type: ${schema}}` : (0, codegen_1._)`{type: ${schemaValue}}` - }; - function reportTypeError(it) { - const cxt = getTypeErrorContext(it); - (0, errors_1.reportError)(cxt, typeError); - } - exports2.reportTypeError = reportTypeError; - function getTypeErrorContext(it) { - const { gen, data, schema } = it; - const schemaCode = (0, util_1.schemaRefOrVal)(it, schema, "type"); - return { - gen, - keyword: "type", - data, - schema: schema.type, - schemaCode, - schemaValue: schemaCode, - parentSchema: schema, - params: {}, - it - }; - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/defaults.js -var require_defaults = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.assignDefaults = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - function assignDefaults(it, ty) { - const { properties, items } = it.schema; - if (ty === "object" && properties) { - for (const key in properties) { - assignDefault(it, key, properties[key].default); - } - } else if (ty === "array" && Array.isArray(items)) { - items.forEach((sch, i) => assignDefault(it, i, sch.default)); - } - } - exports2.assignDefaults = assignDefaults; - function assignDefault(it, prop, defaultValue) { - const { gen, compositeRule, data, opts } = it; - if (defaultValue === undefined) - return; - const childData = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(prop)}`; - if (compositeRule) { - (0, util_1.checkStrictMode)(it, `default is ignored for: ${childData}`); - return; - } - let condition = (0, codegen_1._)`${childData} === undefined`; - if (opts.useDefaults === "empty") { - condition = (0, codegen_1._)`${condition} || ${childData} === null || ${childData} === ""`; - } - gen.if(condition, (0, codegen_1._)`${childData} = ${(0, codegen_1.stringify)(defaultValue)}`); - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/code.js -var require_code2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.validateUnion = exports2.validateArray = exports2.usePattern = exports2.callValidateCode = exports2.schemaProperties = exports2.allSchemaProperties = exports2.noPropertyInData = exports2.propertyInData = exports2.isOwnProperty = exports2.hasPropFunc = exports2.reportMissingProp = exports2.checkMissingProp = exports2.checkReportMissingProp = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var names_1 = require_names(); - var util_2 = require_util2(); - function checkReportMissingProp(cxt, prop) { - const { gen, data, it } = cxt; - gen.if(noPropertyInData(gen, data, prop, it.opts.ownProperties), () => { - cxt.setParams({ missingProperty: (0, codegen_1._)`${prop}` }, true); - cxt.error(); - }); - } - exports2.checkReportMissingProp = checkReportMissingProp; - function checkMissingProp({ gen, data, it: { opts } }, properties, missing) { - return (0, codegen_1.or)(...properties.map((prop) => (0, codegen_1.and)(noPropertyInData(gen, data, prop, opts.ownProperties), (0, codegen_1._)`${missing} = ${prop}`))); - } - exports2.checkMissingProp = checkMissingProp; - function reportMissingProp(cxt, missing) { - cxt.setParams({ missingProperty: missing }, true); - cxt.error(); - } - exports2.reportMissingProp = reportMissingProp; - function hasPropFunc(gen) { - return gen.scopeValue("func", { - ref: Object.prototype.hasOwnProperty, - code: (0, codegen_1._)`Object.prototype.hasOwnProperty` - }); - } - exports2.hasPropFunc = hasPropFunc; - function isOwnProperty(gen, data, property) { - return (0, codegen_1._)`${hasPropFunc(gen)}.call(${data}, ${property})`; - } - exports2.isOwnProperty = isOwnProperty; - function propertyInData(gen, data, property, ownProperties) { - const cond = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(property)} !== undefined`; - return ownProperties ? (0, codegen_1._)`${cond} && ${isOwnProperty(gen, data, property)}` : cond; - } - exports2.propertyInData = propertyInData; - function noPropertyInData(gen, data, property, ownProperties) { - const cond = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(property)} === undefined`; - return ownProperties ? (0, codegen_1.or)(cond, (0, codegen_1.not)(isOwnProperty(gen, data, property))) : cond; - } - exports2.noPropertyInData = noPropertyInData; - function allSchemaProperties(schemaMap) { - return schemaMap ? Object.keys(schemaMap).filter((p) => p !== "__proto__") : []; - } - exports2.allSchemaProperties = allSchemaProperties; - function schemaProperties(it, schemaMap) { - return allSchemaProperties(schemaMap).filter((p) => !(0, util_1.alwaysValidSchema)(it, schemaMap[p])); - } - exports2.schemaProperties = schemaProperties; - function callValidateCode({ schemaCode, data, it: { gen, topSchemaRef, schemaPath, errorPath }, it }, func, context2, passSchema) { - const dataAndSchema = passSchema ? (0, codegen_1._)`${schemaCode}, ${data}, ${topSchemaRef}${schemaPath}` : data; - const valCxt = [ - [names_1.default.instancePath, (0, codegen_1.strConcat)(names_1.default.instancePath, errorPath)], - [names_1.default.parentData, it.parentData], - [names_1.default.parentDataProperty, it.parentDataProperty], - [names_1.default.rootData, names_1.default.rootData] - ]; - if (it.opts.dynamicRef) - valCxt.push([names_1.default.dynamicAnchors, names_1.default.dynamicAnchors]); - const args = (0, codegen_1._)`${dataAndSchema}, ${gen.object(...valCxt)}`; - return context2 !== codegen_1.nil ? (0, codegen_1._)`${func}.call(${context2}, ${args})` : (0, codegen_1._)`${func}(${args})`; - } - exports2.callValidateCode = callValidateCode; - var newRegExp = (0, codegen_1._)`new RegExp`; - function usePattern({ gen, it: { opts } }, pattern) { - const u = opts.unicodeRegExp ? "u" : ""; - const { regExp } = opts.code; - const rx = regExp(pattern, u); - return gen.scopeValue("pattern", { - key: rx.toString(), - ref: rx, - code: (0, codegen_1._)`${regExp.code === "new RegExp" ? newRegExp : (0, util_2.useFunc)(gen, regExp)}(${pattern}, ${u})` - }); - } - exports2.usePattern = usePattern; - function validateArray(cxt) { - const { gen, data, keyword, it } = cxt; - const valid = gen.name("valid"); - if (it.allErrors) { - const validArr = gen.let("valid", true); - validateItems(() => gen.assign(validArr, false)); - return validArr; - } - gen.var(valid, true); - validateItems(() => gen.break()); - return valid; - function validateItems(notValid) { - const len = gen.const("len", (0, codegen_1._)`${data}.length`); - gen.forRange("i", 0, len, (i) => { - cxt.subschema({ - keyword, - dataProp: i, - dataPropType: util_1.Type.Num - }, valid); - gen.if((0, codegen_1.not)(valid), notValid); - }); - } - } - exports2.validateArray = validateArray; - function validateUnion(cxt) { - const { gen, schema, keyword, it } = cxt; - if (!Array.isArray(schema)) - throw new Error("ajv implementation error"); - const alwaysValid = schema.some((sch) => (0, util_1.alwaysValidSchema)(it, sch)); - if (alwaysValid && !it.opts.unevaluated) - return; - const valid = gen.let("valid", false); - const schValid = gen.name("_valid"); - gen.block(() => schema.forEach((_sch, i) => { - const schCxt = cxt.subschema({ - keyword, - schemaProp: i, - compositeRule: true - }, schValid); - gen.assign(valid, (0, codegen_1._)`${valid} || ${schValid}`); - const merged = cxt.mergeValidEvaluated(schCxt, schValid); - if (!merged) - gen.if((0, codegen_1.not)(valid)); - })); - cxt.result(valid, () => cxt.reset(), () => cxt.error(true)); - } - exports2.validateUnion = validateUnion; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/keyword.js -var require_keyword = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.validateKeywordUsage = exports2.validSchemaType = exports2.funcKeywordCode = exports2.macroKeywordCode = undefined; - var codegen_1 = require_codegen(); - var names_1 = require_names(); - var code_1 = require_code2(); - var errors_1 = require_errors(); - function macroKeywordCode(cxt, def) { - const { gen, keyword, schema, parentSchema, it } = cxt; - const macroSchema = def.macro.call(it.self, schema, parentSchema, it); - const schemaRef = useKeyword(gen, keyword, macroSchema); - if (it.opts.validateSchema !== false) - it.self.validateSchema(macroSchema, true); - const valid = gen.name("valid"); - cxt.subschema({ - schema: macroSchema, - schemaPath: codegen_1.nil, - errSchemaPath: `${it.errSchemaPath}/${keyword}`, - topSchemaRef: schemaRef, - compositeRule: true - }, valid); - cxt.pass(valid, () => cxt.error(true)); - } - exports2.macroKeywordCode = macroKeywordCode; - function funcKeywordCode(cxt, def) { - var _a21; - const { gen, keyword, schema, parentSchema, $data, it } = cxt; - checkAsyncKeyword(it, def); - const validate = !$data && def.compile ? def.compile.call(it.self, schema, parentSchema, it) : def.validate; - const validateRef = useKeyword(gen, keyword, validate); - const valid = gen.let("valid"); - cxt.block$data(valid, validateKeyword); - cxt.ok((_a21 = def.valid) !== null && _a21 !== undefined ? _a21 : valid); - function validateKeyword() { - if (def.errors === false) { - assignValid(); - if (def.modifying) - modifyData(cxt); - reportErrs(() => cxt.error()); - } else { - const ruleErrs = def.async ? validateAsync() : validateSync(); - if (def.modifying) - modifyData(cxt); - reportErrs(() => addErrs(cxt, ruleErrs)); - } - } - function validateAsync() { - const ruleErrs = gen.let("ruleErrs", null); - gen.try(() => assignValid((0, codegen_1._)`await `), (e) => gen.assign(valid, false).if((0, codegen_1._)`${e} instanceof ${it.ValidationError}`, () => gen.assign(ruleErrs, (0, codegen_1._)`${e}.errors`), () => gen.throw(e))); - return ruleErrs; - } - function validateSync() { - const validateErrs = (0, codegen_1._)`${validateRef}.errors`; - gen.assign(validateErrs, null); - assignValid(codegen_1.nil); - return validateErrs; - } - function assignValid(_await = def.async ? (0, codegen_1._)`await ` : codegen_1.nil) { - const passCxt = it.opts.passContext ? names_1.default.this : names_1.default.self; - const passSchema = !(("compile" in def) && !$data || def.schema === false); - gen.assign(valid, (0, codegen_1._)`${_await}${(0, code_1.callValidateCode)(cxt, validateRef, passCxt, passSchema)}`, def.modifying); - } - function reportErrs(errors4) { - var _a24; - gen.if((0, codegen_1.not)((_a24 = def.valid) !== null && _a24 !== undefined ? _a24 : valid), errors4); - } - } - exports2.funcKeywordCode = funcKeywordCode; - function modifyData(cxt) { - const { gen, data, it } = cxt; - gen.if(it.parentData, () => gen.assign(data, (0, codegen_1._)`${it.parentData}[${it.parentDataProperty}]`)); - } - function addErrs(cxt, errs) { - const { gen } = cxt; - gen.if((0, codegen_1._)`Array.isArray(${errs})`, () => { - gen.assign(names_1.default.vErrors, (0, codegen_1._)`${names_1.default.vErrors} === null ? ${errs} : ${names_1.default.vErrors}.concat(${errs})`).assign(names_1.default.errors, (0, codegen_1._)`${names_1.default.vErrors}.length`); - (0, errors_1.extendErrors)(cxt); - }, () => cxt.error()); - } - function checkAsyncKeyword({ schemaEnv }, def) { - if (def.async && !schemaEnv.$async) - throw new Error("async keyword in sync schema"); - } - function useKeyword(gen, keyword, result) { - if (result === undefined) - throw new Error(`keyword "${keyword}" failed to compile`); - return gen.scopeValue("keyword", typeof result == "function" ? { ref: result } : { ref: result, code: (0, codegen_1.stringify)(result) }); - } - function validSchemaType(schema, schemaType, allowUndefined = false) { - return !schemaType.length || schemaType.some((st) => st === "array" ? Array.isArray(schema) : st === "object" ? schema && typeof schema == "object" && !Array.isArray(schema) : typeof schema == st || allowUndefined && typeof schema == "undefined"); - } - exports2.validSchemaType = validSchemaType; - function validateKeywordUsage({ schema, opts, self, errSchemaPath }, def, keyword) { - if (Array.isArray(def.keyword) ? !def.keyword.includes(keyword) : def.keyword !== keyword) { - throw new Error("ajv implementation error"); - } - const deps = def.dependencies; - if (deps === null || deps === undefined ? undefined : deps.some((kwd) => !Object.prototype.hasOwnProperty.call(schema, kwd))) { - throw new Error(`parent schema must have dependencies of ${keyword}: ${deps.join(",")}`); - } - if (def.validateSchema) { - const valid = def.validateSchema(schema[keyword]); - if (!valid) { - const msg = `keyword "${keyword}" value is invalid at path "${errSchemaPath}": ` + self.errorsText(def.validateSchema.errors); - if (opts.validateSchema === "log") - self.logger.error(msg); - else - throw new Error(msg); - } - } - } - exports2.validateKeywordUsage = validateKeywordUsage; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/subschema.js -var require_subschema = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.extendSubschemaMode = exports2.extendSubschemaData = exports2.getSubschema = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - function getSubschema(it, { keyword, schemaProp, schema, schemaPath, errSchemaPath, topSchemaRef }) { - if (keyword !== undefined && schema !== undefined) { - throw new Error('both "keyword" and "schema" passed, only one allowed'); - } - if (keyword !== undefined) { - const sch = it.schema[keyword]; - return schemaProp === undefined ? { - schema: sch, - schemaPath: (0, codegen_1._)`${it.schemaPath}${(0, codegen_1.getProperty)(keyword)}`, - errSchemaPath: `${it.errSchemaPath}/${keyword}` - } : { - schema: sch[schemaProp], - schemaPath: (0, codegen_1._)`${it.schemaPath}${(0, codegen_1.getProperty)(keyword)}${(0, codegen_1.getProperty)(schemaProp)}`, - errSchemaPath: `${it.errSchemaPath}/${keyword}/${(0, util_1.escapeFragment)(schemaProp)}` - }; - } - if (schema !== undefined) { - if (schemaPath === undefined || errSchemaPath === undefined || topSchemaRef === undefined) { - throw new Error('"schemaPath", "errSchemaPath" and "topSchemaRef" are required with "schema"'); - } - return { - schema, - schemaPath, - topSchemaRef, - errSchemaPath - }; - } - throw new Error('either "keyword" or "schema" must be passed'); - } - exports2.getSubschema = getSubschema; - function extendSubschemaData(subschema, it, { dataProp, dataPropType: dpType, data, dataTypes, propertyName }) { - if (data !== undefined && dataProp !== undefined) { - throw new Error('both "data" and "dataProp" passed, only one allowed'); - } - const { gen } = it; - if (dataProp !== undefined) { - const { errorPath, dataPathArr, opts } = it; - const nextData = gen.let("data", (0, codegen_1._)`${it.data}${(0, codegen_1.getProperty)(dataProp)}`, true); - dataContextProps(nextData); - subschema.errorPath = (0, codegen_1.str)`${errorPath}${(0, util_1.getErrorPath)(dataProp, dpType, opts.jsPropertySyntax)}`; - subschema.parentDataProperty = (0, codegen_1._)`${dataProp}`; - subschema.dataPathArr = [...dataPathArr, subschema.parentDataProperty]; - } - if (data !== undefined) { - const nextData = data instanceof codegen_1.Name ? data : gen.let("data", data, true); - dataContextProps(nextData); - if (propertyName !== undefined) - subschema.propertyName = propertyName; - } - if (dataTypes) - subschema.dataTypes = dataTypes; - function dataContextProps(_nextData) { - subschema.data = _nextData; - subschema.dataLevel = it.dataLevel + 1; - subschema.dataTypes = []; - it.definedProperties = new Set; - subschema.parentData = it.data; - subschema.dataNames = [...it.dataNames, _nextData]; - } - } - exports2.extendSubschemaData = extendSubschemaData; - function extendSubschemaMode(subschema, { jtdDiscriminator, jtdMetadata, compositeRule, createErrors, allErrors }) { - if (compositeRule !== undefined) - subschema.compositeRule = compositeRule; - if (createErrors !== undefined) - subschema.createErrors = createErrors; - if (allErrors !== undefined) - subschema.allErrors = allErrors; - subschema.jtdDiscriminator = jtdDiscriminator; - subschema.jtdMetadata = jtdMetadata; - } - exports2.extendSubschemaMode = extendSubschemaMode; -}); - -// ../../node_modules/.pnpm/fast-deep-equal@3.1.3/node_modules/fast-deep-equal/index.js -var require_fast_deep_equal = __commonJS((exports2, module2) => { - module2.exports = function equal(a, b) { - if (a === b) - return true; - if (a && b && typeof a == "object" && typeof b == "object") { - if (a.constructor !== b.constructor) - return false; - var length, i, keys; - if (Array.isArray(a)) { - length = a.length; - if (length != b.length) - return false; - for (i = length;i-- !== 0; ) - if (!equal(a[i], b[i])) - return false; - return true; - } - if (a.constructor === RegExp) - return a.source === b.source && a.flags === b.flags; - if (a.valueOf !== Object.prototype.valueOf) - return a.valueOf() === b.valueOf(); - if (a.toString !== Object.prototype.toString) - return a.toString() === b.toString(); - keys = Object.keys(a); - length = keys.length; - if (length !== Object.keys(b).length) - return false; - for (i = length;i-- !== 0; ) - if (!Object.prototype.hasOwnProperty.call(b, keys[i])) - return false; - for (i = length;i-- !== 0; ) { - var key = keys[i]; - if (!equal(a[key], b[key])) - return false; - } - return true; - } - return a !== a && b !== b; - }; -}); - -// ../../node_modules/.pnpm/json-schema-traverse@1.0.0/node_modules/json-schema-traverse/index.js -var require_json_schema_traverse = __commonJS((exports2, module2) => { - var traverse = module2.exports = function(schema, opts, cb) { - if (typeof opts == "function") { - cb = opts; - opts = {}; - } - cb = opts.cb || cb; - var pre = typeof cb == "function" ? cb : cb.pre || function() {}; - var post = cb.post || function() {}; - _traverse(opts, pre, post, schema, "", schema); - }; - traverse.keywords = { - additionalItems: true, - items: true, - contains: true, - additionalProperties: true, - propertyNames: true, - not: true, - if: true, - then: true, - else: true - }; - traverse.arrayKeywords = { - items: true, - allOf: true, - anyOf: true, - oneOf: true - }; - traverse.propsKeywords = { - $defs: true, - definitions: true, - properties: true, - patternProperties: true, - dependencies: true - }; - traverse.skipKeywords = { - default: true, - enum: true, - const: true, - required: true, - maximum: true, - minimum: true, - exclusiveMaximum: true, - exclusiveMinimum: true, - multipleOf: true, - maxLength: true, - minLength: true, - pattern: true, - format: true, - maxItems: true, - minItems: true, - uniqueItems: true, - maxProperties: true, - minProperties: true - }; - function _traverse(opts, pre, post, schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex) { - if (schema && typeof schema == "object" && !Array.isArray(schema)) { - pre(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex); - for (var key in schema) { - var sch = schema[key]; - if (Array.isArray(sch)) { - if (key in traverse.arrayKeywords) { - for (var i = 0;i < sch.length; i++) - _traverse(opts, pre, post, sch[i], jsonPtr + "/" + key + "/" + i, rootSchema, jsonPtr, key, schema, i); - } - } else if (key in traverse.propsKeywords) { - if (sch && typeof sch == "object") { - for (var prop in sch) - _traverse(opts, pre, post, sch[prop], jsonPtr + "/" + key + "/" + escapeJsonPtr(prop), rootSchema, jsonPtr, key, schema, prop); - } - } else if (key in traverse.keywords || opts.allKeys && !(key in traverse.skipKeywords)) { - _traverse(opts, pre, post, sch, jsonPtr + "/" + key, rootSchema, jsonPtr, key, schema); - } - } - post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex); - } - } - function escapeJsonPtr(str) { - return str.replace(/~/g, "~0").replace(/\//g, "~1"); - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/resolve.js -var require_resolve = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.getSchemaRefs = exports2.resolveUrl = exports2.normalizeId = exports2._getFullPath = exports2.getFullPath = exports2.inlineRef = undefined; - var util_1 = require_util2(); - var equal = require_fast_deep_equal(); - var traverse = require_json_schema_traverse(); - var SIMPLE_INLINED = new Set([ - "type", - "format", - "pattern", - "maxLength", - "minLength", - "maxProperties", - "minProperties", - "maxItems", - "minItems", - "maximum", - "minimum", - "uniqueItems", - "multipleOf", - "required", - "enum", - "const" - ]); - function inlineRef(schema, limit = true) { - if (typeof schema == "boolean") - return true; - if (limit === true) - return !hasRef(schema); - if (!limit) - return false; - return countKeys(schema) <= limit; - } - exports2.inlineRef = inlineRef; - var REF_KEYWORDS = new Set([ - "$ref", - "$recursiveRef", - "$recursiveAnchor", - "$dynamicRef", - "$dynamicAnchor" - ]); - function hasRef(schema) { - for (const key in schema) { - if (REF_KEYWORDS.has(key)) - return true; - const sch = schema[key]; - if (Array.isArray(sch) && sch.some(hasRef)) - return true; - if (typeof sch == "object" && hasRef(sch)) - return true; - } - return false; - } - function countKeys(schema) { - let count = 0; - for (const key in schema) { - if (key === "$ref") - return Infinity; - count++; - if (SIMPLE_INLINED.has(key)) - continue; - if (typeof schema[key] == "object") { - (0, util_1.eachItem)(schema[key], (sch) => count += countKeys(sch)); - } - if (count === Infinity) - return Infinity; - } - return count; - } - function getFullPath(resolver, id = "", normalize) { - if (normalize !== false) - id = normalizeId(id); - const p = resolver.parse(id); - return _getFullPath(resolver, p); - } - exports2.getFullPath = getFullPath; - function _getFullPath(resolver, p) { - const serialized = resolver.serialize(p); - return serialized.split("#")[0] + "#"; - } - exports2._getFullPath = _getFullPath; - var TRAILING_SLASH_HASH = /#\/?$/; - function normalizeId(id) { - return id ? id.replace(TRAILING_SLASH_HASH, "") : ""; - } - exports2.normalizeId = normalizeId; - function resolveUrl2(resolver, baseId, id) { - id = normalizeId(id); - return resolver.resolve(baseId, id); - } - exports2.resolveUrl = resolveUrl2; - var ANCHOR = /^[a-z_][-a-z0-9._]*$/i; - function getSchemaRefs(schema, baseId) { - if (typeof schema == "boolean") - return {}; - const { schemaId, uriResolver } = this.opts; - const schId = normalizeId(schema[schemaId] || baseId); - const baseIds = { "": schId }; - const pathPrefix = getFullPath(uriResolver, schId, false); - const localRefs = {}; - const schemaRefs = new Set; - traverse(schema, { allKeys: true }, (sch, jsonPtr, _, parentJsonPtr) => { - if (parentJsonPtr === undefined) - return; - const fullPath = pathPrefix + jsonPtr; - let innerBaseId = baseIds[parentJsonPtr]; - if (typeof sch[schemaId] == "string") - innerBaseId = addRef.call(this, sch[schemaId]); - addAnchor.call(this, sch.$anchor); - addAnchor.call(this, sch.$dynamicAnchor); - baseIds[jsonPtr] = innerBaseId; - function addRef(ref) { - const _resolve = this.opts.uriResolver.resolve; - ref = normalizeId(innerBaseId ? _resolve(innerBaseId, ref) : ref); - if (schemaRefs.has(ref)) - throw ambiguos(ref); - schemaRefs.add(ref); - let schOrRef = this.refs[ref]; - if (typeof schOrRef == "string") - schOrRef = this.refs[schOrRef]; - if (typeof schOrRef == "object") { - checkAmbiguosRef(sch, schOrRef.schema, ref); - } else if (ref !== normalizeId(fullPath)) { - if (ref[0] === "#") { - checkAmbiguosRef(sch, localRefs[ref], ref); - localRefs[ref] = sch; - } else { - this.refs[ref] = fullPath; - } - } - return ref; - } - function addAnchor(anchor) { - if (typeof anchor == "string") { - if (!ANCHOR.test(anchor)) - throw new Error(`invalid anchor "${anchor}"`); - addRef.call(this, `#${anchor}`); - } - } - }); - return localRefs; - function checkAmbiguosRef(sch1, sch2, ref) { - if (sch2 !== undefined && !equal(sch1, sch2)) - throw ambiguos(ref); - } - function ambiguos(ref) { - return new Error(`reference "${ref}" resolves to more than one schema`); - } - } - exports2.getSchemaRefs = getSchemaRefs; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/validate/index.js -var require_validate = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.getData = exports2.KeywordCxt = exports2.validateFunctionCode = undefined; - var boolSchema_1 = require_boolSchema(); - var dataType_1 = require_dataType(); - var applicability_1 = require_applicability(); - var dataType_2 = require_dataType(); - var defaults_1 = require_defaults(); - var keyword_1 = require_keyword(); - var subschema_1 = require_subschema(); - var codegen_1 = require_codegen(); - var names_1 = require_names(); - var resolve_1 = require_resolve(); - var util_1 = require_util2(); - var errors_1 = require_errors(); - function validateFunctionCode(it) { - if (isSchemaObj(it)) { - checkKeywords(it); - if (schemaCxtHasRules(it)) { - topSchemaObjCode(it); - return; - } - } - validateFunction(it, () => (0, boolSchema_1.topBoolOrEmptySchema)(it)); - } - exports2.validateFunctionCode = validateFunctionCode; - function validateFunction({ gen, validateName, schema, schemaEnv, opts }, body) { - if (opts.code.es5) { - gen.func(validateName, (0, codegen_1._)`${names_1.default.data}, ${names_1.default.valCxt}`, schemaEnv.$async, () => { - gen.code((0, codegen_1._)`"use strict"; ${funcSourceUrl(schema, opts)}`); - destructureValCxtES5(gen, opts); - gen.code(body); - }); - } else { - gen.func(validateName, (0, codegen_1._)`${names_1.default.data}, ${destructureValCxt(opts)}`, schemaEnv.$async, () => gen.code(funcSourceUrl(schema, opts)).code(body)); - } - } - function destructureValCxt(opts) { - return (0, codegen_1._)`{${names_1.default.instancePath}="", ${names_1.default.parentData}, ${names_1.default.parentDataProperty}, ${names_1.default.rootData}=${names_1.default.data}${opts.dynamicRef ? (0, codegen_1._)`, ${names_1.default.dynamicAnchors}={}` : codegen_1.nil}}={}`; - } - function destructureValCxtES5(gen, opts) { - gen.if(names_1.default.valCxt, () => { - gen.var(names_1.default.instancePath, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.instancePath}`); - gen.var(names_1.default.parentData, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.parentData}`); - gen.var(names_1.default.parentDataProperty, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.parentDataProperty}`); - gen.var(names_1.default.rootData, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.rootData}`); - if (opts.dynamicRef) - gen.var(names_1.default.dynamicAnchors, (0, codegen_1._)`${names_1.default.valCxt}.${names_1.default.dynamicAnchors}`); - }, () => { - gen.var(names_1.default.instancePath, (0, codegen_1._)`""`); - gen.var(names_1.default.parentData, (0, codegen_1._)`undefined`); - gen.var(names_1.default.parentDataProperty, (0, codegen_1._)`undefined`); - gen.var(names_1.default.rootData, names_1.default.data); - if (opts.dynamicRef) - gen.var(names_1.default.dynamicAnchors, (0, codegen_1._)`{}`); - }); - } - function topSchemaObjCode(it) { - const { schema, opts, gen } = it; - validateFunction(it, () => { - if (opts.$comment && schema.$comment) - commentKeyword(it); - checkNoDefault(it); - gen.let(names_1.default.vErrors, null); - gen.let(names_1.default.errors, 0); - if (opts.unevaluated) - resetEvaluated(it); - typeAndKeywords(it); - returnResults(it); - }); - return; - } - function resetEvaluated(it) { - const { gen, validateName } = it; - it.evaluated = gen.const("evaluated", (0, codegen_1._)`${validateName}.evaluated`); - gen.if((0, codegen_1._)`${it.evaluated}.dynamicProps`, () => gen.assign((0, codegen_1._)`${it.evaluated}.props`, (0, codegen_1._)`undefined`)); - gen.if((0, codegen_1._)`${it.evaluated}.dynamicItems`, () => gen.assign((0, codegen_1._)`${it.evaluated}.items`, (0, codegen_1._)`undefined`)); - } - function funcSourceUrl(schema, opts) { - const schId = typeof schema == "object" && schema[opts.schemaId]; - return schId && (opts.code.source || opts.code.process) ? (0, codegen_1._)`/*# sourceURL=${schId} */` : codegen_1.nil; - } - function subschemaCode(it, valid) { - if (isSchemaObj(it)) { - checkKeywords(it); - if (schemaCxtHasRules(it)) { - subSchemaObjCode(it, valid); - return; - } - } - (0, boolSchema_1.boolOrEmptySchema)(it, valid); - } - function schemaCxtHasRules({ schema, self }) { - if (typeof schema == "boolean") - return !schema; - for (const key in schema) - if (self.RULES.all[key]) - return true; - return false; - } - function isSchemaObj(it) { - return typeof it.schema != "boolean"; - } - function subSchemaObjCode(it, valid) { - const { schema, gen, opts } = it; - if (opts.$comment && schema.$comment) - commentKeyword(it); - updateContext(it); - checkAsyncSchema(it); - const errsCount = gen.const("_errs", names_1.default.errors); - typeAndKeywords(it, errsCount); - gen.var(valid, (0, codegen_1._)`${errsCount} === ${names_1.default.errors}`); - } - function checkKeywords(it) { - (0, util_1.checkUnknownRules)(it); - checkRefsAndKeywords(it); - } - function typeAndKeywords(it, errsCount) { - if (it.opts.jtd) - return schemaKeywords(it, [], false, errsCount); - const types2 = (0, dataType_1.getSchemaTypes)(it.schema); - const checkedTypes = (0, dataType_1.coerceAndCheckDataType)(it, types2); - schemaKeywords(it, types2, !checkedTypes, errsCount); - } - function checkRefsAndKeywords(it) { - const { schema, errSchemaPath, opts, self } = it; - if (schema.$ref && opts.ignoreKeywordsWithRef && (0, util_1.schemaHasRulesButRef)(schema, self.RULES)) { - self.logger.warn(`$ref: keywords ignored in schema at path "${errSchemaPath}"`); - } - } - function checkNoDefault(it) { - const { schema, opts } = it; - if (schema.default !== undefined && opts.useDefaults && opts.strictSchema) { - (0, util_1.checkStrictMode)(it, "default is ignored in the schema root"); - } - } - function updateContext(it) { - const schId = it.schema[it.opts.schemaId]; - if (schId) - it.baseId = (0, resolve_1.resolveUrl)(it.opts.uriResolver, it.baseId, schId); - } - function checkAsyncSchema(it) { - if (it.schema.$async && !it.schemaEnv.$async) - throw new Error("async schema in sync schema"); - } - function commentKeyword({ gen, schemaEnv, schema, errSchemaPath, opts }) { - const msg = schema.$comment; - if (opts.$comment === true) { - gen.code((0, codegen_1._)`${names_1.default.self}.logger.log(${msg})`); - } else if (typeof opts.$comment == "function") { - const schemaPath = (0, codegen_1.str)`${errSchemaPath}/$comment`; - const rootName = gen.scopeValue("root", { ref: schemaEnv.root }); - gen.code((0, codegen_1._)`${names_1.default.self}.opts.$comment(${msg}, ${schemaPath}, ${rootName}.schema)`); - } - } - function returnResults(it) { - const { gen, schemaEnv, validateName, ValidationError, opts } = it; - if (schemaEnv.$async) { - gen.if((0, codegen_1._)`${names_1.default.errors} === 0`, () => gen.return(names_1.default.data), () => gen.throw((0, codegen_1._)`new ${ValidationError}(${names_1.default.vErrors})`)); - } else { - gen.assign((0, codegen_1._)`${validateName}.errors`, names_1.default.vErrors); - if (opts.unevaluated) - assignEvaluated(it); - gen.return((0, codegen_1._)`${names_1.default.errors} === 0`); - } - } - function assignEvaluated({ gen, evaluated, props, items }) { - if (props instanceof codegen_1.Name) - gen.assign((0, codegen_1._)`${evaluated}.props`, props); - if (items instanceof codegen_1.Name) - gen.assign((0, codegen_1._)`${evaluated}.items`, items); - } - function schemaKeywords(it, types2, typeErrors, errsCount) { - const { gen, schema, data, allErrors, opts, self } = it; - const { RULES } = self; - if (schema.$ref && (opts.ignoreKeywordsWithRef || !(0, util_1.schemaHasRulesButRef)(schema, RULES))) { - gen.block(() => keywordCode(it, "$ref", RULES.all.$ref.definition)); - return; - } - if (!opts.jtd) - checkStrictTypes(it, types2); - gen.block(() => { - for (const group of RULES.rules) - groupKeywords(group); - groupKeywords(RULES.post); - }); - function groupKeywords(group) { - if (!(0, applicability_1.shouldUseGroup)(schema, group)) - return; - if (group.type) { - gen.if((0, dataType_2.checkDataType)(group.type, data, opts.strictNumbers)); - iterateKeywords(it, group); - if (types2.length === 1 && types2[0] === group.type && typeErrors) { - gen.else(); - (0, dataType_2.reportTypeError)(it); - } - gen.endIf(); - } else { - iterateKeywords(it, group); - } - if (!allErrors) - gen.if((0, codegen_1._)`${names_1.default.errors} === ${errsCount || 0}`); - } - } - function iterateKeywords(it, group) { - const { gen, schema, opts: { useDefaults } } = it; - if (useDefaults) - (0, defaults_1.assignDefaults)(it, group.type); - gen.block(() => { - for (const rule of group.rules) { - if ((0, applicability_1.shouldUseRule)(schema, rule)) { - keywordCode(it, rule.keyword, rule.definition, group.type); - } - } - }); - } - function checkStrictTypes(it, types2) { - if (it.schemaEnv.meta || !it.opts.strictTypes) - return; - checkContextTypes(it, types2); - if (!it.opts.allowUnionTypes) - checkMultipleTypes(it, types2); - checkKeywordTypes(it, it.dataTypes); - } - function checkContextTypes(it, types2) { - if (!types2.length) - return; - if (!it.dataTypes.length) { - it.dataTypes = types2; - return; - } - types2.forEach((t) => { - if (!includesType(it.dataTypes, t)) { - strictTypesError(it, `type "${t}" not allowed by context "${it.dataTypes.join(",")}"`); - } - }); - narrowSchemaTypes(it, types2); - } - function checkMultipleTypes(it, ts) { - if (ts.length > 1 && !(ts.length === 2 && ts.includes("null"))) { - strictTypesError(it, "use allowUnionTypes to allow union type keyword"); - } - } - function checkKeywordTypes(it, ts) { - const rules = it.self.RULES.all; - for (const keyword in rules) { - const rule = rules[keyword]; - if (typeof rule == "object" && (0, applicability_1.shouldUseRule)(it.schema, rule)) { - const { type } = rule.definition; - if (type.length && !type.some((t) => hasApplicableType(ts, t))) { - strictTypesError(it, `missing type "${type.join(",")}" for keyword "${keyword}"`); - } - } - } - } - function hasApplicableType(schTs, kwdT) { - return schTs.includes(kwdT) || kwdT === "number" && schTs.includes("integer"); - } - function includesType(ts, t) { - return ts.includes(t) || t === "integer" && ts.includes("number"); - } - function narrowSchemaTypes(it, withTypes) { - const ts = []; - for (const t of it.dataTypes) { - if (includesType(withTypes, t)) - ts.push(t); - else if (withTypes.includes("integer") && t === "number") - ts.push("integer"); - } - it.dataTypes = ts; - } - function strictTypesError(it, msg) { - const schemaPath = it.schemaEnv.baseId + it.errSchemaPath; - msg += ` at "${schemaPath}" (strictTypes)`; - (0, util_1.checkStrictMode)(it, msg, it.opts.strictTypes); - } - - class KeywordCxt { - constructor(it, def, keyword) { - (0, keyword_1.validateKeywordUsage)(it, def, keyword); - this.gen = it.gen; - this.allErrors = it.allErrors; - this.keyword = keyword; - this.data = it.data; - this.schema = it.schema[keyword]; - this.$data = def.$data && it.opts.$data && this.schema && this.schema.$data; - this.schemaValue = (0, util_1.schemaRefOrVal)(it, this.schema, keyword, this.$data); - this.schemaType = def.schemaType; - this.parentSchema = it.schema; - this.params = {}; - this.it = it; - this.def = def; - if (this.$data) { - this.schemaCode = it.gen.const("vSchema", getData(this.$data, it)); - } else { - this.schemaCode = this.schemaValue; - if (!(0, keyword_1.validSchemaType)(this.schema, def.schemaType, def.allowUndefined)) { - throw new Error(`${keyword} value must be ${JSON.stringify(def.schemaType)}`); - } - } - if ("code" in def ? def.trackErrors : def.errors !== false) { - this.errsCount = it.gen.const("_errs", names_1.default.errors); - } - } - result(condition, successAction, failAction) { - this.failResult((0, codegen_1.not)(condition), successAction, failAction); - } - failResult(condition, successAction, failAction) { - this.gen.if(condition); - if (failAction) - failAction(); - else - this.error(); - if (successAction) { - this.gen.else(); - successAction(); - if (this.allErrors) - this.gen.endIf(); - } else { - if (this.allErrors) - this.gen.endIf(); - else - this.gen.else(); - } - } - pass(condition, failAction) { - this.failResult((0, codegen_1.not)(condition), undefined, failAction); - } - fail(condition) { - if (condition === undefined) { - this.error(); - if (!this.allErrors) - this.gen.if(false); - return; - } - this.gen.if(condition); - this.error(); - if (this.allErrors) - this.gen.endIf(); - else - this.gen.else(); - } - fail$data(condition) { - if (!this.$data) - return this.fail(condition); - const { schemaCode } = this; - this.fail((0, codegen_1._)`${schemaCode} !== undefined && (${(0, codegen_1.or)(this.invalid$data(), condition)})`); - } - error(append, errorParams, errorPaths) { - if (errorParams) { - this.setParams(errorParams); - this._error(append, errorPaths); - this.setParams({}); - return; - } - this._error(append, errorPaths); - } - _error(append, errorPaths) { - (append ? errors_1.reportExtraError : errors_1.reportError)(this, this.def.error, errorPaths); - } - $dataError() { - (0, errors_1.reportError)(this, this.def.$dataError || errors_1.keyword$DataError); - } - reset() { - if (this.errsCount === undefined) - throw new Error('add "trackErrors" to keyword definition'); - (0, errors_1.resetErrorsCount)(this.gen, this.errsCount); - } - ok(cond) { - if (!this.allErrors) - this.gen.if(cond); - } - setParams(obj, assign) { - if (assign) - Object.assign(this.params, obj); - else - this.params = obj; - } - block$data(valid, codeBlock, $dataValid = codegen_1.nil) { - this.gen.block(() => { - this.check$data(valid, $dataValid); - codeBlock(); - }); - } - check$data(valid = codegen_1.nil, $dataValid = codegen_1.nil) { - if (!this.$data) - return; - const { gen, schemaCode, schemaType, def } = this; - gen.if((0, codegen_1.or)((0, codegen_1._)`${schemaCode} === undefined`, $dataValid)); - if (valid !== codegen_1.nil) - gen.assign(valid, true); - if (schemaType.length || def.validateSchema) { - gen.elseIf(this.invalid$data()); - this.$dataError(); - if (valid !== codegen_1.nil) - gen.assign(valid, false); - } - gen.else(); - } - invalid$data() { - const { gen, schemaCode, schemaType, def, it } = this; - return (0, codegen_1.or)(wrong$DataType(), invalid$DataSchema()); - function wrong$DataType() { - if (schemaType.length) { - if (!(schemaCode instanceof codegen_1.Name)) - throw new Error("ajv implementation error"); - const st = Array.isArray(schemaType) ? schemaType : [schemaType]; - return (0, codegen_1._)`${(0, dataType_2.checkDataTypes)(st, schemaCode, it.opts.strictNumbers, dataType_2.DataType.Wrong)}`; - } - return codegen_1.nil; - } - function invalid$DataSchema() { - if (def.validateSchema) { - const validateSchemaRef = gen.scopeValue("validate$data", { ref: def.validateSchema }); - return (0, codegen_1._)`!${validateSchemaRef}(${schemaCode})`; - } - return codegen_1.nil; - } - } - subschema(appl, valid) { - const subschema = (0, subschema_1.getSubschema)(this.it, appl); - (0, subschema_1.extendSubschemaData)(subschema, this.it, appl); - (0, subschema_1.extendSubschemaMode)(subschema, appl); - const nextContext = { ...this.it, ...subschema, items: undefined, props: undefined }; - subschemaCode(nextContext, valid); - return nextContext; - } - mergeEvaluated(schemaCxt, toName) { - const { it, gen } = this; - if (!it.opts.unevaluated) - return; - if (it.props !== true && schemaCxt.props !== undefined) { - it.props = util_1.mergeEvaluated.props(gen, schemaCxt.props, it.props, toName); - } - if (it.items !== true && schemaCxt.items !== undefined) { - it.items = util_1.mergeEvaluated.items(gen, schemaCxt.items, it.items, toName); - } - } - mergeValidEvaluated(schemaCxt, valid) { - const { it, gen } = this; - if (it.opts.unevaluated && (it.props !== true || it.items !== true)) { - gen.if(valid, () => this.mergeEvaluated(schemaCxt, codegen_1.Name)); - return true; - } - } - } - exports2.KeywordCxt = KeywordCxt; - function keywordCode(it, keyword, def, ruleType) { - const cxt = new KeywordCxt(it, def, keyword); - if ("code" in def) { - def.code(cxt, ruleType); - } else if (cxt.$data && def.validate) { - (0, keyword_1.funcKeywordCode)(cxt, def); - } else if ("macro" in def) { - (0, keyword_1.macroKeywordCode)(cxt, def); - } else if (def.compile || def.validate) { - (0, keyword_1.funcKeywordCode)(cxt, def); - } - } - var JSON_POINTER = /^\/(?:[^~]|~0|~1)*$/; - var RELATIVE_JSON_POINTER = /^([0-9]+)(#|\/(?:[^~]|~0|~1)*)?$/; - function getData($data, { dataLevel, dataNames, dataPathArr }) { - let jsonPointer; - let data; - if ($data === "") - return names_1.default.rootData; - if ($data[0] === "/") { - if (!JSON_POINTER.test($data)) - throw new Error(`Invalid JSON-pointer: ${$data}`); - jsonPointer = $data; - data = names_1.default.rootData; - } else { - const matches = RELATIVE_JSON_POINTER.exec($data); - if (!matches) - throw new Error(`Invalid JSON-pointer: ${$data}`); - const up = +matches[1]; - jsonPointer = matches[2]; - if (jsonPointer === "#") { - if (up >= dataLevel) - throw new Error(errorMsg("property/index", up)); - return dataPathArr[dataLevel - up]; - } - if (up > dataLevel) - throw new Error(errorMsg("data", up)); - data = dataNames[dataLevel - up]; - if (!jsonPointer) - return data; - } - let expr = data; - const segments = jsonPointer.split("/"); - for (const segment of segments) { - if (segment) { - data = (0, codegen_1._)`${data}${(0, codegen_1.getProperty)((0, util_1.unescapeJsonPointer)(segment))}`; - expr = (0, codegen_1._)`${expr} && ${data}`; - } - } - return expr; - function errorMsg(pointerType, up) { - return `Cannot access ${pointerType} ${up} levels up, current level is ${dataLevel}`; - } - } - exports2.getData = getData; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/validation_error.js -var require_validation_error = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - - class ValidationError extends Error { - constructor(errors4) { - super("validation failed"); - this.errors = errors4; - this.ajv = this.validation = true; - } - } - exports2.default = ValidationError; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/ref_error.js -var require_ref_error = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var resolve_1 = require_resolve(); - - class MissingRefError extends Error { - constructor(resolver, baseId, ref, msg) { - super(msg || `can't resolve reference ${ref} from id ${baseId}`); - this.missingRef = (0, resolve_1.resolveUrl)(resolver, baseId, ref); - this.missingSchema = (0, resolve_1.normalizeId)((0, resolve_1.getFullPath)(resolver, this.missingRef)); - } - } - exports2.default = MissingRefError; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/compile/index.js -var require_compile = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.resolveSchema = exports2.getCompilingSchema = exports2.resolveRef = exports2.compileSchema = exports2.SchemaEnv = undefined; - var codegen_1 = require_codegen(); - var validation_error_1 = require_validation_error(); - var names_1 = require_names(); - var resolve_1 = require_resolve(); - var util_1 = require_util2(); - var validate_1 = require_validate(); - - class SchemaEnv { - constructor(env) { - var _a21; - this.refs = {}; - this.dynamicAnchors = {}; - let schema; - if (typeof env.schema == "object") - schema = env.schema; - this.schema = env.schema; - this.schemaId = env.schemaId; - this.root = env.root || this; - this.baseId = (_a21 = env.baseId) !== null && _a21 !== undefined ? _a21 : (0, resolve_1.normalizeId)(schema === null || schema === undefined ? undefined : schema[env.schemaId || "$id"]); - this.schemaPath = env.schemaPath; - this.localRefs = env.localRefs; - this.meta = env.meta; - this.$async = schema === null || schema === undefined ? undefined : schema.$async; - this.refs = {}; - } - } - exports2.SchemaEnv = SchemaEnv; - function compileSchema(sch) { - const _sch = getCompilingSchema.call(this, sch); - if (_sch) - return _sch; - const rootId = (0, resolve_1.getFullPath)(this.opts.uriResolver, sch.root.baseId); - const { es5, lines } = this.opts.code; - const { ownProperties } = this.opts; - const gen = new codegen_1.CodeGen(this.scope, { es5, lines, ownProperties }); - let _ValidationError; - if (sch.$async) { - _ValidationError = gen.scopeValue("Error", { - ref: validation_error_1.default, - code: (0, codegen_1._)`require("ajv/dist/runtime/validation_error").default` - }); - } - const validateName = gen.scopeName("validate"); - sch.validateName = validateName; - const schemaCxt = { - gen, - allErrors: this.opts.allErrors, - data: names_1.default.data, - parentData: names_1.default.parentData, - parentDataProperty: names_1.default.parentDataProperty, - dataNames: [names_1.default.data], - dataPathArr: [codegen_1.nil], - dataLevel: 0, - dataTypes: [], - definedProperties: new Set, - topSchemaRef: gen.scopeValue("schema", this.opts.code.source === true ? { ref: sch.schema, code: (0, codegen_1.stringify)(sch.schema) } : { ref: sch.schema }), - validateName, - ValidationError: _ValidationError, - schema: sch.schema, - schemaEnv: sch, - rootId, - baseId: sch.baseId || rootId, - schemaPath: codegen_1.nil, - errSchemaPath: sch.schemaPath || (this.opts.jtd ? "" : "#"), - errorPath: (0, codegen_1._)`""`, - opts: this.opts, - self: this - }; - let sourceCode; - try { - this._compilations.add(sch); - (0, validate_1.validateFunctionCode)(schemaCxt); - gen.optimize(this.opts.code.optimize); - const validateCode = gen.toString(); - sourceCode = `${gen.scopeRefs(names_1.default.scope)}return ${validateCode}`; - if (this.opts.code.process) - sourceCode = this.opts.code.process(sourceCode, sch); - const makeValidate = new Function(`${names_1.default.self}`, `${names_1.default.scope}`, sourceCode); - const validate = makeValidate(this, this.scope.get()); - this.scope.value(validateName, { ref: validate }); - validate.errors = null; - validate.schema = sch.schema; - validate.schemaEnv = sch; - if (sch.$async) - validate.$async = true; - if (this.opts.code.source === true) { - validate.source = { validateName, validateCode, scopeValues: gen._values }; - } - if (this.opts.unevaluated) { - const { props, items } = schemaCxt; - validate.evaluated = { - props: props instanceof codegen_1.Name ? undefined : props, - items: items instanceof codegen_1.Name ? undefined : items, - dynamicProps: props instanceof codegen_1.Name, - dynamicItems: items instanceof codegen_1.Name - }; - if (validate.source) - validate.source.evaluated = (0, codegen_1.stringify)(validate.evaluated); - } - sch.validate = validate; - return sch; - } catch (e) { - delete sch.validate; - delete sch.validateName; - if (sourceCode) - this.logger.error("Error compiling schema, function code:", sourceCode); - throw e; - } finally { - this._compilations.delete(sch); - } - } - exports2.compileSchema = compileSchema; - function resolveRef2(root, baseId, ref) { - var _a21; - ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, ref); - const schOrFunc = root.refs[ref]; - if (schOrFunc) - return schOrFunc; - let _sch = resolve2.call(this, root, ref); - if (_sch === undefined) { - const schema = (_a21 = root.localRefs) === null || _a21 === undefined ? undefined : _a21[ref]; - const { schemaId } = this.opts; - if (schema) - _sch = new SchemaEnv({ schema, schemaId, root, baseId }); - } - if (_sch === undefined) - return; - return root.refs[ref] = inlineOrCompile.call(this, _sch); - } - exports2.resolveRef = resolveRef2; - function inlineOrCompile(sch) { - if ((0, resolve_1.inlineRef)(sch.schema, this.opts.inlineRefs)) - return sch.schema; - return sch.validate ? sch : compileSchema.call(this, sch); - } - function getCompilingSchema(schEnv) { - for (const sch of this._compilations) { - if (sameSchemaEnv(sch, schEnv)) - return sch; - } - } - exports2.getCompilingSchema = getCompilingSchema; - function sameSchemaEnv(s1, s2) { - return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId; - } - function resolve2(root, ref) { - let sch; - while (typeof (sch = this.refs[ref]) == "string") - ref = sch; - return sch || this.schemas[ref] || resolveSchema.call(this, root, ref); - } - function resolveSchema(root, ref) { - const p = this.opts.uriResolver.parse(ref); - const refPath = (0, resolve_1._getFullPath)(this.opts.uriResolver, p); - let baseId = (0, resolve_1.getFullPath)(this.opts.uriResolver, root.baseId, undefined); - if (Object.keys(root.schema).length > 0 && refPath === baseId) { - return getJsonPointer.call(this, p, root); - } - const id = (0, resolve_1.normalizeId)(refPath); - const schOrRef = this.refs[id] || this.schemas[id]; - if (typeof schOrRef == "string") { - const sch = resolveSchema.call(this, root, schOrRef); - if (typeof (sch === null || sch === undefined ? undefined : sch.schema) !== "object") - return; - return getJsonPointer.call(this, p, sch); - } - if (typeof (schOrRef === null || schOrRef === undefined ? undefined : schOrRef.schema) !== "object") - return; - if (!schOrRef.validate) - compileSchema.call(this, schOrRef); - if (id === (0, resolve_1.normalizeId)(ref)) { - const { schema } = schOrRef; - const { schemaId } = this.opts; - const schId = schema[schemaId]; - if (schId) - baseId = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schId); - return new SchemaEnv({ schema, schemaId, root, baseId }); - } - return getJsonPointer.call(this, p, schOrRef); - } - exports2.resolveSchema = resolveSchema; - var PREVENT_SCOPE_CHANGE = new Set([ - "properties", - "patternProperties", - "enum", - "dependencies", - "definitions" - ]); - function getJsonPointer(parsedRef, { baseId, schema, root }) { - var _a21; - if (((_a21 = parsedRef.fragment) === null || _a21 === undefined ? undefined : _a21[0]) !== "/") - return; - for (const part of parsedRef.fragment.slice(1).split("/")) { - if (typeof schema === "boolean") - return; - const partSchema = schema[(0, util_1.unescapeFragment)(part)]; - if (partSchema === undefined) - return; - schema = partSchema; - const schId = typeof schema === "object" && schema[this.opts.schemaId]; - if (!PREVENT_SCOPE_CHANGE.has(part) && schId) { - baseId = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schId); - } - } - let env; - if (typeof schema != "boolean" && schema.$ref && !(0, util_1.schemaHasRulesButRef)(schema, this.RULES)) { - const $ref = (0, resolve_1.resolveUrl)(this.opts.uriResolver, baseId, schema.$ref); - env = resolveSchema.call(this, root, $ref); - } - const { schemaId } = this.opts; - env = env || new SchemaEnv({ schema, schemaId, root, baseId }); - if (env.schema !== env.root.schema) - return env; - return; - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/refs/data.json -var require_data = __commonJS((exports2, module2) => { - module2.exports = { - $id: "https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#", - description: "Meta-schema for $data reference (JSON AnySchema extension proposal)", - type: "object", - required: ["$data"], - properties: { - $data: { - type: "string", - anyOf: [{ format: "relative-json-pointer" }, { format: "json-pointer" }] - } - }, - additionalProperties: false - }; -}); - -// ../../node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/utils.js -var require_utils4 = __commonJS((exports2, module2) => { - var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu); - var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u); - function stringArrayToHexStripped(input) { - let acc = ""; - let code = 0; - let i = 0; - for (i = 0;i < input.length; i++) { - code = input[i].charCodeAt(0); - if (code === 48) { - continue; - } - if (!(code >= 48 && code <= 57 || code >= 65 && code <= 70 || code >= 97 && code <= 102)) { - return ""; - } - acc += input[i]; - break; - } - for (i += 1;i < input.length; i++) { - code = input[i].charCodeAt(0); - if (!(code >= 48 && code <= 57 || code >= 65 && code <= 70 || code >= 97 && code <= 102)) { - return ""; - } - acc += input[i]; - } - return acc; - } - var nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u); - function consumeIsZone(buffer) { - buffer.length = 0; - return true; - } - function consumeHextets(buffer, address, output) { - if (buffer.length) { - const hex3 = stringArrayToHexStripped(buffer); - if (hex3 !== "") { - address.push(hex3); - } else { - output.error = true; - return false; - } - buffer.length = 0; - } - return true; - } - function getIPV6(input) { - let tokenCount = 0; - const output = { error: false, address: "", zone: "" }; - const address = []; - const buffer = []; - let endipv6Encountered = false; - let endIpv6 = false; - let consume = consumeHextets; - for (let i = 0;i < input.length; i++) { - const cursor = input[i]; - if (cursor === "[" || cursor === "]") { - continue; - } - if (cursor === ":") { - if (endipv6Encountered === true) { - endIpv6 = true; - } - if (!consume(buffer, address, output)) { - break; - } - if (++tokenCount > 7) { - output.error = true; - break; - } - if (i > 0 && input[i - 1] === ":") { - endipv6Encountered = true; - } - address.push(":"); - continue; - } else if (cursor === "%") { - if (!consume(buffer, address, output)) { - break; - } - consume = consumeIsZone; - } else { - buffer.push(cursor); - continue; - } - } - if (buffer.length) { - if (consume === consumeIsZone) { - output.zone = buffer.join(""); - } else if (endIpv6) { - address.push(buffer.join("")); - } else { - address.push(stringArrayToHexStripped(buffer)); - } - } - output.address = address.join(""); - return output; - } - function normalizeIPv6(host) { - if (findToken(host, ":") < 2) { - return { host, isIPV6: false }; - } - const ipv63 = getIPV6(host); - if (!ipv63.error) { - let newHost = ipv63.address; - let escapedHost = ipv63.address; - if (ipv63.zone) { - newHost += "%" + ipv63.zone; - escapedHost += "%25" + ipv63.zone; - } - return { host: newHost, isIPV6: true, escapedHost }; - } else { - return { host, isIPV6: false }; - } - } - function findToken(str, token) { - let ind = 0; - for (let i = 0;i < str.length; i++) { - if (str[i] === token) - ind++; - } - return ind; - } - function removeDotSegments(path13) { - let input = path13; - const output = []; - let nextSlash = -1; - let len = 0; - while (len = input.length) { - if (len === 1) { - if (input === ".") { - break; - } else if (input === "/") { - output.push("/"); - break; - } else { - output.push(input); - break; - } - } else if (len === 2) { - if (input[0] === ".") { - if (input[1] === ".") { - break; - } else if (input[1] === "/") { - input = input.slice(2); - continue; - } - } else if (input[0] === "/") { - if (input[1] === "." || input[1] === "/") { - output.push("/"); - break; - } - } - } else if (len === 3) { - if (input === "/..") { - if (output.length !== 0) { - output.pop(); - } - output.push("/"); - break; - } - } - if (input[0] === ".") { - if (input[1] === ".") { - if (input[2] === "/") { - input = input.slice(3); - continue; - } - } else if (input[1] === "/") { - input = input.slice(2); - continue; - } - } else if (input[0] === "/") { - if (input[1] === ".") { - if (input[2] === "/") { - input = input.slice(2); - continue; - } else if (input[2] === ".") { - if (input[3] === "/") { - input = input.slice(3); - if (output.length !== 0) { - output.pop(); - } - continue; - } - } - } - } - if ((nextSlash = input.indexOf("/", 1)) === -1) { - output.push(input); - break; - } else { - output.push(input.slice(0, nextSlash)); - input = input.slice(nextSlash); - } - } - return output.join(""); - } - function normalizeComponentEncoding(component, esc2) { - const func = esc2 !== true ? escape : unescape; - if (component.scheme !== undefined) { - component.scheme = func(component.scheme); - } - if (component.userinfo !== undefined) { - component.userinfo = func(component.userinfo); - } - if (component.host !== undefined) { - component.host = func(component.host); - } - if (component.path !== undefined) { - component.path = func(component.path); - } - if (component.query !== undefined) { - component.query = func(component.query); - } - if (component.fragment !== undefined) { - component.fragment = func(component.fragment); - } - return component; - } - function recomposeAuthority(component) { - const uriTokens = []; - if (component.userinfo !== undefined) { - uriTokens.push(component.userinfo); - uriTokens.push("@"); - } - if (component.host !== undefined) { - let host = unescape(component.host); - if (!isIPv4(host)) { - const ipV6res = normalizeIPv6(host); - if (ipV6res.isIPV6 === true) { - host = `[${ipV6res.escapedHost}]`; - } else { - host = component.host; - } - } - uriTokens.push(host); - } - if (typeof component.port === "number" || typeof component.port === "string") { - uriTokens.push(":"); - uriTokens.push(String(component.port)); - } - return uriTokens.length ? uriTokens.join("") : undefined; - } - module2.exports = { - nonSimpleDomain, - recomposeAuthority, - normalizeComponentEncoding, - removeDotSegments, - isIPv4, - isUUID, - normalizeIPv6, - stringArrayToHexStripped - }; -}); - -// ../../node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/lib/schemes.js -var require_schemes = __commonJS((exports2, module2) => { - var { isUUID } = require_utils4(); - var URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu; - var supportedSchemeNames = [ - "http", - "https", - "ws", - "wss", - "urn", - "urn:uuid" - ]; - function isValidSchemeName(name21) { - return supportedSchemeNames.indexOf(name21) !== -1; - } - function wsIsSecure(wsComponent) { - if (wsComponent.secure === true) { - return true; - } else if (wsComponent.secure === false) { - return false; - } else if (wsComponent.scheme) { - return wsComponent.scheme.length === 3 && (wsComponent.scheme[0] === "w" || wsComponent.scheme[0] === "W") && (wsComponent.scheme[1] === "s" || wsComponent.scheme[1] === "S") && (wsComponent.scheme[2] === "s" || wsComponent.scheme[2] === "S"); - } else { - return false; - } - } - function httpParse(component) { - if (!component.host) { - component.error = component.error || "HTTP URIs must have a host."; - } - return component; - } - function httpSerialize(component) { - const secure = String(component.scheme).toLowerCase() === "https"; - if (component.port === (secure ? 443 : 80) || component.port === "") { - component.port = undefined; - } - if (!component.path) { - component.path = "/"; - } - return component; - } - function wsParse(wsComponent) { - wsComponent.secure = wsIsSecure(wsComponent); - wsComponent.resourceName = (wsComponent.path || "/") + (wsComponent.query ? "?" + wsComponent.query : ""); - wsComponent.path = undefined; - wsComponent.query = undefined; - return wsComponent; - } - function wsSerialize(wsComponent) { - if (wsComponent.port === (wsIsSecure(wsComponent) ? 443 : 80) || wsComponent.port === "") { - wsComponent.port = undefined; - } - if (typeof wsComponent.secure === "boolean") { - wsComponent.scheme = wsComponent.secure ? "wss" : "ws"; - wsComponent.secure = undefined; - } - if (wsComponent.resourceName) { - const [path13, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path13 && path13 !== "/" ? path13 : undefined; - wsComponent.query = query; - wsComponent.resourceName = undefined; - } - wsComponent.fragment = undefined; - return wsComponent; - } - function urnParse(urnComponent, options) { - if (!urnComponent.path) { - urnComponent.error = "URN can not be parsed"; - return urnComponent; - } - const matches = urnComponent.path.match(URN_REG); - if (matches) { - const scheme = options.scheme || urnComponent.scheme || "urn"; - urnComponent.nid = matches[1].toLowerCase(); - urnComponent.nss = matches[2]; - const urnScheme = `${scheme}:${options.nid || urnComponent.nid}`; - const schemeHandler = getSchemeHandler(urnScheme); - urnComponent.path = undefined; - if (schemeHandler) { - urnComponent = schemeHandler.parse(urnComponent, options); - } - } else { - urnComponent.error = urnComponent.error || "URN can not be parsed."; - } - return urnComponent; - } - function urnSerialize(urnComponent, options) { - if (urnComponent.nid === undefined) { - throw new Error("URN without nid cannot be serialized"); - } - const scheme = options.scheme || urnComponent.scheme || "urn"; - const nid = urnComponent.nid.toLowerCase(); - const urnScheme = `${scheme}:${options.nid || nid}`; - const schemeHandler = getSchemeHandler(urnScheme); - if (schemeHandler) { - urnComponent = schemeHandler.serialize(urnComponent, options); - } - const uriComponent = urnComponent; - const nss = urnComponent.nss; - uriComponent.path = `${nid || options.nid}:${nss}`; - options.skipEscape = true; - return uriComponent; - } - function urnuuidParse(urnComponent, options) { - const uuidComponent = urnComponent; - uuidComponent.uuid = uuidComponent.nss; - uuidComponent.nss = undefined; - if (!options.tolerant && (!uuidComponent.uuid || !isUUID(uuidComponent.uuid))) { - uuidComponent.error = uuidComponent.error || "UUID is not valid."; - } - return uuidComponent; - } - function urnuuidSerialize(uuidComponent) { - const urnComponent = uuidComponent; - urnComponent.nss = (uuidComponent.uuid || "").toLowerCase(); - return urnComponent; - } - var http4 = { - scheme: "http", - domainHost: true, - parse: httpParse, - serialize: httpSerialize - }; - var https3 = { - scheme: "https", - domainHost: http4.domainHost, - parse: httpParse, - serialize: httpSerialize - }; - var ws = { - scheme: "ws", - domainHost: true, - parse: wsParse, - serialize: wsSerialize - }; - var wss = { - scheme: "wss", - domainHost: ws.domainHost, - parse: ws.parse, - serialize: ws.serialize - }; - var urn = { - scheme: "urn", - parse: urnParse, - serialize: urnSerialize, - skipNormalize: true - }; - var urnuuid = { - scheme: "urn:uuid", - parse: urnuuidParse, - serialize: urnuuidSerialize, - skipNormalize: true - }; - var SCHEMES = { - http: http4, - https: https3, - ws, - wss, - urn, - "urn:uuid": urnuuid - }; - Object.setPrototypeOf(SCHEMES, null); - function getSchemeHandler(scheme) { - return scheme && (SCHEMES[scheme] || SCHEMES[scheme.toLowerCase()]) || undefined; - } - module2.exports = { - wsIsSecure, - SCHEMES, - isValidSchemeName, - getSchemeHandler - }; -}); - -// ../../node_modules/.pnpm/fast-uri@3.1.0/node_modules/fast-uri/index.js -var require_fast_uri = __commonJS((exports2, module2) => { - var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils4(); - var { SCHEMES, getSchemeHandler } = require_schemes(); - function normalize(uri, options) { - if (typeof uri === "string") { - uri = serialize(parse6(uri, options), options); - } else if (typeof uri === "object") { - uri = parse6(serialize(uri, options), options); - } - return uri; - } - function resolve2(baseURI, relativeURI, options) { - const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" }; - const resolved = resolveComponent(parse6(baseURI, schemelessOptions), parse6(relativeURI, schemelessOptions), schemelessOptions, true); - schemelessOptions.skipEscape = true; - return serialize(resolved, schemelessOptions); - } - function resolveComponent(base, relative, options, skipNormalization) { - const target = {}; - if (!skipNormalization) { - base = parse6(serialize(base, options), options); - relative = parse6(serialize(relative, options), options); - } - options = options || {}; - if (!options.tolerant && relative.scheme) { - target.scheme = relative.scheme; - target.userinfo = relative.userinfo; - target.host = relative.host; - target.port = relative.port; - target.path = removeDotSegments(relative.path || ""); - target.query = relative.query; - } else { - if (relative.userinfo !== undefined || relative.host !== undefined || relative.port !== undefined) { - target.userinfo = relative.userinfo; - target.host = relative.host; - target.port = relative.port; - target.path = removeDotSegments(relative.path || ""); - target.query = relative.query; - } else { - if (!relative.path) { - target.path = base.path; - if (relative.query !== undefined) { - target.query = relative.query; - } else { - target.query = base.query; - } - } else { - if (relative.path[0] === "/") { - target.path = removeDotSegments(relative.path); - } else { - if ((base.userinfo !== undefined || base.host !== undefined || base.port !== undefined) && !base.path) { - target.path = "/" + relative.path; - } else if (!base.path) { - target.path = relative.path; - } else { - target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative.path; - } - target.path = removeDotSegments(target.path); - } - target.query = relative.query; - } - target.userinfo = base.userinfo; - target.host = base.host; - target.port = base.port; - } - target.scheme = base.scheme; - } - target.fragment = relative.fragment; - return target; - } - function equal(uriA, uriB, options) { - if (typeof uriA === "string") { - uriA = unescape(uriA); - uriA = serialize(normalizeComponentEncoding(parse6(uriA, options), true), { ...options, skipEscape: true }); - } else if (typeof uriA === "object") { - uriA = serialize(normalizeComponentEncoding(uriA, true), { ...options, skipEscape: true }); - } - if (typeof uriB === "string") { - uriB = unescape(uriB); - uriB = serialize(normalizeComponentEncoding(parse6(uriB, options), true), { ...options, skipEscape: true }); - } else if (typeof uriB === "object") { - uriB = serialize(normalizeComponentEncoding(uriB, true), { ...options, skipEscape: true }); - } - return uriA.toLowerCase() === uriB.toLowerCase(); - } - function serialize(cmpts, opts) { - const component = { - host: cmpts.host, - scheme: cmpts.scheme, - userinfo: cmpts.userinfo, - port: cmpts.port, - path: cmpts.path, - query: cmpts.query, - nid: cmpts.nid, - nss: cmpts.nss, - uuid: cmpts.uuid, - fragment: cmpts.fragment, - reference: cmpts.reference, - resourceName: cmpts.resourceName, - secure: cmpts.secure, - error: "" - }; - const options = Object.assign({}, opts); - const uriTokens = []; - const schemeHandler = getSchemeHandler(options.scheme || component.scheme); - if (schemeHandler && schemeHandler.serialize) - schemeHandler.serialize(component, options); - if (component.path !== undefined) { - if (!options.skipEscape) { - component.path = escape(component.path); - if (component.scheme !== undefined) { - component.path = component.path.split("%3A").join(":"); - } - } else { - component.path = unescape(component.path); - } - } - if (options.reference !== "suffix" && component.scheme) { - uriTokens.push(component.scheme, ":"); - } - const authority = recomposeAuthority(component); - if (authority !== undefined) { - if (options.reference !== "suffix") { - uriTokens.push("//"); - } - uriTokens.push(authority); - if (component.path && component.path[0] !== "/") { - uriTokens.push("/"); - } - } - if (component.path !== undefined) { - let s = component.path; - if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) { - s = removeDotSegments(s); - } - if (authority === undefined && s[0] === "/" && s[1] === "/") { - s = "/%2F" + s.slice(2); - } - uriTokens.push(s); - } - if (component.query !== undefined) { - uriTokens.push("?", component.query); - } - if (component.fragment !== undefined) { - uriTokens.push("#", component.fragment); - } - return uriTokens.join(""); - } - var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u; - function parse6(uri, opts) { - const options = Object.assign({}, opts); - const parsed = { - scheme: undefined, - userinfo: undefined, - host: "", - port: undefined, - path: "", - query: undefined, - fragment: undefined - }; - let isIP = false; - if (options.reference === "suffix") { - if (options.scheme) { - uri = options.scheme + ":" + uri; - } else { - uri = "//" + uri; - } - } - const matches = uri.match(URI_PARSE); - if (matches) { - parsed.scheme = matches[1]; - parsed.userinfo = matches[3]; - parsed.host = matches[4]; - parsed.port = parseInt(matches[5], 10); - parsed.path = matches[6] || ""; - parsed.query = matches[7]; - parsed.fragment = matches[8]; - if (isNaN(parsed.port)) { - parsed.port = matches[5]; - } - if (parsed.host) { - const ipv4result = isIPv4(parsed.host); - if (ipv4result === false) { - const ipv6result = normalizeIPv6(parsed.host); - parsed.host = ipv6result.host.toLowerCase(); - isIP = ipv6result.isIPV6; - } else { - isIP = true; - } - } - if (parsed.scheme === undefined && parsed.userinfo === undefined && parsed.host === undefined && parsed.port === undefined && parsed.query === undefined && !parsed.path) { - parsed.reference = "same-document"; - } else if (parsed.scheme === undefined) { - parsed.reference = "relative"; - } else if (parsed.fragment === undefined) { - parsed.reference = "absolute"; - } else { - parsed.reference = "uri"; - } - if (options.reference && options.reference !== "suffix" && options.reference !== parsed.reference) { - parsed.error = parsed.error || "URI is not a " + options.reference + " reference."; - } - const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme); - if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) { - if (parsed.host && (options.domainHost || schemeHandler && schemeHandler.domainHost) && isIP === false && nonSimpleDomain(parsed.host)) { - try { - parsed.host = URL.domainToASCII(parsed.host.toLowerCase()); - } catch (e) { - parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e; - } - } - } - if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) { - if (uri.indexOf("%") !== -1) { - if (parsed.scheme !== undefined) { - parsed.scheme = unescape(parsed.scheme); - } - if (parsed.host !== undefined) { - parsed.host = unescape(parsed.host); - } - } - if (parsed.path) { - parsed.path = escape(unescape(parsed.path)); - } - if (parsed.fragment) { - parsed.fragment = encodeURI(decodeURIComponent(parsed.fragment)); - } - } - if (schemeHandler && schemeHandler.parse) { - schemeHandler.parse(parsed, options); - } - } else { - parsed.error = parsed.error || "URI can not be parsed."; - } - return parsed; - } - var fastUri = { - SCHEMES, - normalize, - resolve: resolve2, - resolveComponent, - equal, - serialize, - parse: parse6 - }; - module2.exports = fastUri; - module2.exports.default = fastUri; - module2.exports.fastUri = fastUri; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/uri.js -var require_uri = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var uri = require_fast_uri(); - uri.code = 'require("ajv/dist/runtime/uri").default'; - exports2.default = uri; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/core.js -var require_core = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.CodeGen = exports2.Name = exports2.nil = exports2.stringify = exports2.str = exports2._ = exports2.KeywordCxt = undefined; - var validate_1 = require_validate(); - Object.defineProperty(exports2, "KeywordCxt", { enumerable: true, get: function() { - return validate_1.KeywordCxt; - } }); - var codegen_1 = require_codegen(); - Object.defineProperty(exports2, "_", { enumerable: true, get: function() { - return codegen_1._; - } }); - Object.defineProperty(exports2, "str", { enumerable: true, get: function() { - return codegen_1.str; - } }); - Object.defineProperty(exports2, "stringify", { enumerable: true, get: function() { - return codegen_1.stringify; - } }); - Object.defineProperty(exports2, "nil", { enumerable: true, get: function() { - return codegen_1.nil; - } }); - Object.defineProperty(exports2, "Name", { enumerable: true, get: function() { - return codegen_1.Name; - } }); - Object.defineProperty(exports2, "CodeGen", { enumerable: true, get: function() { - return codegen_1.CodeGen; - } }); - var validation_error_1 = require_validation_error(); - var ref_error_1 = require_ref_error(); - var rules_1 = require_rules(); - var compile_1 = require_compile(); - var codegen_2 = require_codegen(); - var resolve_1 = require_resolve(); - var dataType_1 = require_dataType(); - var util_1 = require_util2(); - var $dataRefSchema = require_data(); - var uri_1 = require_uri(); - var defaultRegExp = (str, flags) => new RegExp(str, flags); - defaultRegExp.code = "new RegExp"; - var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"]; - var EXT_SCOPE_NAMES = new Set([ - "validate", - "serialize", - "parse", - "wrapper", - "root", - "schema", - "keyword", - "pattern", - "formats", - "validate$data", - "func", - "obj", - "Error" - ]); - var removedOptions = { - errorDataPath: "", - format: "`validateFormats: false` can be used instead.", - nullable: '"nullable" keyword is supported by default.', - jsonPointers: "Deprecated jsPropertySyntax can be used instead.", - extendRefs: "Deprecated ignoreKeywordsWithRef can be used instead.", - missingRefs: "Pass empty schema with $id that should be ignored to ajv.addSchema.", - processCode: "Use option `code: {process: (code, schemaEnv: object) => string}`", - sourceCode: "Use option `code: {source: true}`", - strictDefaults: "It is default now, see option `strict`.", - strictKeywords: "It is default now, see option `strict`.", - uniqueItems: '"uniqueItems" keyword is always validated.', - unknownFormats: "Disable strict mode or pass `true` to `ajv.addFormat` (or `formats` option).", - cache: "Map is used as cache, schema object as key.", - serialize: "Map is used as cache, schema object as key.", - ajvErrors: "It is default now." - }; - var deprecatedOptions = { - ignoreKeywordsWithRef: "", - jsPropertySyntax: "", - unicode: '"minLength"/"maxLength" account for unicode characters by default.' - }; - var MAX_EXPRESSION = 200; - function requiredOptions(o) { - var _a21, _b16, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0; - const s = o.strict; - const _optz = (_a21 = o.code) === null || _a21 === undefined ? undefined : _a21.optimize; - const optimize = _optz === true || _optz === undefined ? 1 : _optz || 0; - const regExp = (_c = (_b16 = o.code) === null || _b16 === undefined ? undefined : _b16.regExp) !== null && _c !== undefined ? _c : defaultRegExp; - const uriResolver = (_d = o.uriResolver) !== null && _d !== undefined ? _d : uri_1.default; - return { - strictSchema: (_f = (_e = o.strictSchema) !== null && _e !== undefined ? _e : s) !== null && _f !== undefined ? _f : true, - strictNumbers: (_h = (_g = o.strictNumbers) !== null && _g !== undefined ? _g : s) !== null && _h !== undefined ? _h : true, - strictTypes: (_k = (_j = o.strictTypes) !== null && _j !== undefined ? _j : s) !== null && _k !== undefined ? _k : "log", - strictTuples: (_m = (_l = o.strictTuples) !== null && _l !== undefined ? _l : s) !== null && _m !== undefined ? _m : "log", - strictRequired: (_p = (_o = o.strictRequired) !== null && _o !== undefined ? _o : s) !== null && _p !== undefined ? _p : false, - code: o.code ? { ...o.code, optimize, regExp } : { optimize, regExp }, - loopRequired: (_q = o.loopRequired) !== null && _q !== undefined ? _q : MAX_EXPRESSION, - loopEnum: (_r = o.loopEnum) !== null && _r !== undefined ? _r : MAX_EXPRESSION, - meta: (_s = o.meta) !== null && _s !== undefined ? _s : true, - messages: (_t = o.messages) !== null && _t !== undefined ? _t : true, - inlineRefs: (_u = o.inlineRefs) !== null && _u !== undefined ? _u : true, - schemaId: (_v = o.schemaId) !== null && _v !== undefined ? _v : "$id", - addUsedSchema: (_w = o.addUsedSchema) !== null && _w !== undefined ? _w : true, - validateSchema: (_x = o.validateSchema) !== null && _x !== undefined ? _x : true, - validateFormats: (_y = o.validateFormats) !== null && _y !== undefined ? _y : true, - unicodeRegExp: (_z = o.unicodeRegExp) !== null && _z !== undefined ? _z : true, - int32range: (_0 = o.int32range) !== null && _0 !== undefined ? _0 : true, - uriResolver - }; - } - - class Ajv { - constructor(opts = {}) { - this.schemas = {}; - this.refs = {}; - this.formats = {}; - this._compilations = new Set; - this._loading = {}; - this._cache = new Map; - opts = this.opts = { ...opts, ...requiredOptions(opts) }; - const { es5, lines } = this.opts.code; - this.scope = new codegen_2.ValueScope({ scope: {}, prefixes: EXT_SCOPE_NAMES, es5, lines }); - this.logger = getLogger(opts.logger); - const formatOpt = opts.validateFormats; - opts.validateFormats = false; - this.RULES = (0, rules_1.getRules)(); - checkOptions.call(this, removedOptions, opts, "NOT SUPPORTED"); - checkOptions.call(this, deprecatedOptions, opts, "DEPRECATED", "warn"); - this._metaOpts = getMetaSchemaOptions.call(this); - if (opts.formats) - addInitialFormats.call(this); - this._addVocabularies(); - this._addDefaultMetaSchema(); - if (opts.keywords) - addInitialKeywords.call(this, opts.keywords); - if (typeof opts.meta == "object") - this.addMetaSchema(opts.meta); - addInitialSchemas.call(this); - opts.validateFormats = formatOpt; - } - _addVocabularies() { - this.addKeyword("$async"); - } - _addDefaultMetaSchema() { - const { $data, meta: meta3, schemaId } = this.opts; - let _dataRefSchema = $dataRefSchema; - if (schemaId === "id") { - _dataRefSchema = { ...$dataRefSchema }; - _dataRefSchema.id = _dataRefSchema.$id; - delete _dataRefSchema.$id; - } - if (meta3 && $data) - this.addMetaSchema(_dataRefSchema, _dataRefSchema[schemaId], false); - } - defaultMeta() { - const { meta: meta3, schemaId } = this.opts; - return this.opts.defaultMeta = typeof meta3 == "object" ? meta3[schemaId] || meta3 : undefined; - } - validate(schemaKeyRef, data) { - let v; - if (typeof schemaKeyRef == "string") { - v = this.getSchema(schemaKeyRef); - if (!v) - throw new Error(`no schema with key or ref "${schemaKeyRef}"`); - } else { - v = this.compile(schemaKeyRef); - } - const valid = v(data); - if (!("$async" in v)) - this.errors = v.errors; - return valid; - } - compile(schema, _meta) { - const sch = this._addSchema(schema, _meta); - return sch.validate || this._compileSchemaEnv(sch); - } - compileAsync(schema, meta3) { - if (typeof this.opts.loadSchema != "function") { - throw new Error("options.loadSchema should be a function"); - } - const { loadSchema } = this.opts; - return runCompileAsync.call(this, schema, meta3); - async function runCompileAsync(_schema, _meta) { - await loadMetaSchema.call(this, _schema.$schema); - const sch = this._addSchema(_schema, _meta); - return sch.validate || _compileAsync.call(this, sch); - } - async function loadMetaSchema($ref) { - if ($ref && !this.getSchema($ref)) { - await runCompileAsync.call(this, { $ref }, true); - } - } - async function _compileAsync(sch) { - try { - return this._compileSchemaEnv(sch); - } catch (e) { - if (!(e instanceof ref_error_1.default)) - throw e; - checkLoaded.call(this, e); - await loadMissingSchema.call(this, e.missingSchema); - return _compileAsync.call(this, sch); - } - } - function checkLoaded({ missingSchema: ref, missingRef }) { - if (this.refs[ref]) { - throw new Error(`AnySchema ${ref} is loaded but ${missingRef} cannot be resolved`); - } - } - async function loadMissingSchema(ref) { - const _schema = await _loadSchema.call(this, ref); - if (!this.refs[ref]) - await loadMetaSchema.call(this, _schema.$schema); - if (!this.refs[ref]) - this.addSchema(_schema, ref, meta3); - } - async function _loadSchema(ref) { - const p = this._loading[ref]; - if (p) - return p; - try { - return await (this._loading[ref] = loadSchema(ref)); - } finally { - delete this._loading[ref]; - } - } - } - addSchema(schema, key, _meta, _validateSchema = this.opts.validateSchema) { - if (Array.isArray(schema)) { - for (const sch of schema) - this.addSchema(sch, undefined, _meta, _validateSchema); - return this; - } - let id; - if (typeof schema === "object") { - const { schemaId } = this.opts; - id = schema[schemaId]; - if (id !== undefined && typeof id != "string") { - throw new Error(`schema ${schemaId} must be string`); - } - } - key = (0, resolve_1.normalizeId)(key || id); - this._checkUnique(key); - this.schemas[key] = this._addSchema(schema, _meta, key, _validateSchema, true); - return this; - } - addMetaSchema(schema, key, _validateSchema = this.opts.validateSchema) { - this.addSchema(schema, key, true, _validateSchema); - return this; - } - validateSchema(schema, throwOrLogError) { - if (typeof schema == "boolean") - return true; - let $schema; - $schema = schema.$schema; - if ($schema !== undefined && typeof $schema != "string") { - throw new Error("$schema must be a string"); - } - $schema = $schema || this.opts.defaultMeta || this.defaultMeta(); - if (!$schema) { - this.logger.warn("meta-schema not available"); - this.errors = null; - return true; - } - const valid = this.validate($schema, schema); - if (!valid && throwOrLogError) { - const message = "schema is invalid: " + this.errorsText(); - if (this.opts.validateSchema === "log") - this.logger.error(message); - else - throw new Error(message); - } - return valid; - } - getSchema(keyRef) { - let sch; - while (typeof (sch = getSchEnv.call(this, keyRef)) == "string") - keyRef = sch; - if (sch === undefined) { - const { schemaId } = this.opts; - const root = new compile_1.SchemaEnv({ schema: {}, schemaId }); - sch = compile_1.resolveSchema.call(this, root, keyRef); - if (!sch) - return; - this.refs[keyRef] = sch; - } - return sch.validate || this._compileSchemaEnv(sch); - } - removeSchema(schemaKeyRef) { - if (schemaKeyRef instanceof RegExp) { - this._removeAllSchemas(this.schemas, schemaKeyRef); - this._removeAllSchemas(this.refs, schemaKeyRef); - return this; - } - switch (typeof schemaKeyRef) { - case "undefined": - this._removeAllSchemas(this.schemas); - this._removeAllSchemas(this.refs); - this._cache.clear(); - return this; - case "string": { - const sch = getSchEnv.call(this, schemaKeyRef); - if (typeof sch == "object") - this._cache.delete(sch.schema); - delete this.schemas[schemaKeyRef]; - delete this.refs[schemaKeyRef]; - return this; - } - case "object": { - const cacheKey = schemaKeyRef; - this._cache.delete(cacheKey); - let id = schemaKeyRef[this.opts.schemaId]; - if (id) { - id = (0, resolve_1.normalizeId)(id); - delete this.schemas[id]; - delete this.refs[id]; - } - return this; - } - default: - throw new Error("ajv.removeSchema: invalid parameter"); - } - } - addVocabulary(definitions) { - for (const def of definitions) - this.addKeyword(def); - return this; - } - addKeyword(kwdOrDef, def) { - let keyword; - if (typeof kwdOrDef == "string") { - keyword = kwdOrDef; - if (typeof def == "object") { - this.logger.warn("these parameters are deprecated, see docs for addKeyword"); - def.keyword = keyword; - } - } else if (typeof kwdOrDef == "object" && def === undefined) { - def = kwdOrDef; - keyword = def.keyword; - if (Array.isArray(keyword) && !keyword.length) { - throw new Error("addKeywords: keyword must be string or non-empty array"); - } - } else { - throw new Error("invalid addKeywords parameters"); - } - checkKeyword.call(this, keyword, def); - if (!def) { - (0, util_1.eachItem)(keyword, (kwd) => addRule.call(this, kwd)); - return this; - } - keywordMetaschema.call(this, def); - const definition = { - ...def, - type: (0, dataType_1.getJSONTypes)(def.type), - schemaType: (0, dataType_1.getJSONTypes)(def.schemaType) - }; - (0, util_1.eachItem)(keyword, definition.type.length === 0 ? (k) => addRule.call(this, k, definition) : (k) => definition.type.forEach((t) => addRule.call(this, k, definition, t))); - return this; - } - getKeyword(keyword) { - const rule = this.RULES.all[keyword]; - return typeof rule == "object" ? rule.definition : !!rule; - } - removeKeyword(keyword) { - const { RULES } = this; - delete RULES.keywords[keyword]; - delete RULES.all[keyword]; - for (const group of RULES.rules) { - const i = group.rules.findIndex((rule) => rule.keyword === keyword); - if (i >= 0) - group.rules.splice(i, 1); - } - return this; - } - addFormat(name21, format) { - if (typeof format == "string") - format = new RegExp(format); - this.formats[name21] = format; - return this; - } - errorsText(errors4 = this.errors, { separator = ", ", dataVar = "data" } = {}) { - if (!errors4 || errors4.length === 0) - return "No errors"; - return errors4.map((e) => `${dataVar}${e.instancePath} ${e.message}`).reduce((text2, msg) => text2 + separator + msg); - } - $dataMetaSchema(metaSchema, keywordsJsonPointers) { - const rules = this.RULES.all; - metaSchema = JSON.parse(JSON.stringify(metaSchema)); - for (const jsonPointer of keywordsJsonPointers) { - const segments = jsonPointer.split("/").slice(1); - let keywords = metaSchema; - for (const seg of segments) - keywords = keywords[seg]; - for (const key in rules) { - const rule = rules[key]; - if (typeof rule != "object") - continue; - const { $data } = rule.definition; - const schema = keywords[key]; - if ($data && schema) - keywords[key] = schemaOrData(schema); - } - } - return metaSchema; - } - _removeAllSchemas(schemas4, regex) { - for (const keyRef in schemas4) { - const sch = schemas4[keyRef]; - if (!regex || regex.test(keyRef)) { - if (typeof sch == "string") { - delete schemas4[keyRef]; - } else if (sch && !sch.meta) { - this._cache.delete(sch.schema); - delete schemas4[keyRef]; - } - } - } - } - _addSchema(schema, meta3, baseId, validateSchema = this.opts.validateSchema, addSchema = this.opts.addUsedSchema) { - let id; - const { schemaId } = this.opts; - if (typeof schema == "object") { - id = schema[schemaId]; - } else { - if (this.opts.jtd) - throw new Error("schema must be object"); - else if (typeof schema != "boolean") - throw new Error("schema must be object or boolean"); - } - let sch = this._cache.get(schema); - if (sch !== undefined) - return sch; - baseId = (0, resolve_1.normalizeId)(id || baseId); - const localRefs = resolve_1.getSchemaRefs.call(this, schema, baseId); - sch = new compile_1.SchemaEnv({ schema, schemaId, meta: meta3, baseId, localRefs }); - this._cache.set(sch.schema, sch); - if (addSchema && !baseId.startsWith("#")) { - if (baseId) - this._checkUnique(baseId); - this.refs[baseId] = sch; - } - if (validateSchema) - this.validateSchema(schema, true); - return sch; - } - _checkUnique(id) { - if (this.schemas[id] || this.refs[id]) { - throw new Error(`schema with key or id "${id}" already exists`); - } - } - _compileSchemaEnv(sch) { - if (sch.meta) - this._compileMetaSchema(sch); - else - compile_1.compileSchema.call(this, sch); - if (!sch.validate) - throw new Error("ajv implementation error"); - return sch.validate; - } - _compileMetaSchema(sch) { - const currentOpts = this.opts; - this.opts = this._metaOpts; - try { - compile_1.compileSchema.call(this, sch); - } finally { - this.opts = currentOpts; - } - } - } - Ajv.ValidationError = validation_error_1.default; - Ajv.MissingRefError = ref_error_1.default; - exports2.default = Ajv; - function checkOptions(checkOpts, options, msg, log = "error") { - for (const key in checkOpts) { - const opt = key; - if (opt in options) - this.logger[log](`${msg}: option ${key}. ${checkOpts[opt]}`); - } - } - function getSchEnv(keyRef) { - keyRef = (0, resolve_1.normalizeId)(keyRef); - return this.schemas[keyRef] || this.refs[keyRef]; - } - function addInitialSchemas() { - const optsSchemas = this.opts.schemas; - if (!optsSchemas) - return; - if (Array.isArray(optsSchemas)) - this.addSchema(optsSchemas); - else - for (const key in optsSchemas) - this.addSchema(optsSchemas[key], key); - } - function addInitialFormats() { - for (const name21 in this.opts.formats) { - const format = this.opts.formats[name21]; - if (format) - this.addFormat(name21, format); - } - } - function addInitialKeywords(defs) { - if (Array.isArray(defs)) { - this.addVocabulary(defs); - return; - } - this.logger.warn("keywords option as map is deprecated, pass array"); - for (const keyword in defs) { - const def = defs[keyword]; - if (!def.keyword) - def.keyword = keyword; - this.addKeyword(def); - } - } - function getMetaSchemaOptions() { - const metaOpts = { ...this.opts }; - for (const opt of META_IGNORE_OPTIONS) - delete metaOpts[opt]; - return metaOpts; - } - var noLogs = { log() {}, warn() {}, error() {} }; - function getLogger(logger) { - if (logger === false) - return noLogs; - if (logger === undefined) - return console; - if (logger.log && logger.warn && logger.error) - return logger; - throw new Error("logger must implement log, warn and error methods"); - } - var KEYWORD_NAME = /^[a-z_$][a-z0-9_$:-]*$/i; - function checkKeyword(keyword, def) { - const { RULES } = this; - (0, util_1.eachItem)(keyword, (kwd) => { - if (RULES.keywords[kwd]) - throw new Error(`Keyword ${kwd} is already defined`); - if (!KEYWORD_NAME.test(kwd)) - throw new Error(`Keyword ${kwd} has invalid name`); - }); - if (!def) - return; - if (def.$data && !(("code" in def) || ("validate" in def))) { - throw new Error('$data keyword must have "code" or "validate" function'); - } - } - function addRule(keyword, definition, dataType) { - var _a21; - const post = definition === null || definition === undefined ? undefined : definition.post; - if (dataType && post) - throw new Error('keyword with "post" flag cannot have "type"'); - const { RULES } = this; - let ruleGroup = post ? RULES.post : RULES.rules.find(({ type: t }) => t === dataType); - if (!ruleGroup) { - ruleGroup = { type: dataType, rules: [] }; - RULES.rules.push(ruleGroup); - } - RULES.keywords[keyword] = true; - if (!definition) - return; - const rule = { - keyword, - definition: { - ...definition, - type: (0, dataType_1.getJSONTypes)(definition.type), - schemaType: (0, dataType_1.getJSONTypes)(definition.schemaType) - } - }; - if (definition.before) - addBeforeRule.call(this, ruleGroup, rule, definition.before); - else - ruleGroup.rules.push(rule); - RULES.all[keyword] = rule; - (_a21 = definition.implements) === null || _a21 === undefined || _a21.forEach((kwd) => this.addKeyword(kwd)); - } - function addBeforeRule(ruleGroup, rule, before2) { - const i = ruleGroup.rules.findIndex((_rule) => _rule.keyword === before2); - if (i >= 0) { - ruleGroup.rules.splice(i, 0, rule); - } else { - ruleGroup.rules.push(rule); - this.logger.warn(`rule ${before2} is not defined`); - } - } - function keywordMetaschema(def) { - let { metaSchema } = def; - if (metaSchema === undefined) - return; - if (def.$data && this.opts.$data) - metaSchema = schemaOrData(metaSchema); - def.validateSchema = this.compile(metaSchema, true); - } - var $dataRef = { - $ref: "https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#" - }; - function schemaOrData(schema) { - return { anyOf: [schema, $dataRef] }; - } -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/id.js -var require_id = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var def = { - keyword: "id", - code() { - throw new Error('NOT SUPPORTED: keyword "id", use "$id" for schema ID'); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/ref.js -var require_ref = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.callRef = exports2.getValidate = undefined; - var ref_error_1 = require_ref_error(); - var code_1 = require_code2(); - var codegen_1 = require_codegen(); - var names_1 = require_names(); - var compile_1 = require_compile(); - var util_1 = require_util2(); - var def = { - keyword: "$ref", - schemaType: "string", - code(cxt) { - const { gen, schema: $ref, it } = cxt; - const { baseId, schemaEnv: env, validateName, opts, self } = it; - const { root } = env; - if (($ref === "#" || $ref === "#/") && baseId === root.baseId) - return callRootRef(); - const schOrEnv = compile_1.resolveRef.call(self, root, baseId, $ref); - if (schOrEnv === undefined) - throw new ref_error_1.default(it.opts.uriResolver, baseId, $ref); - if (schOrEnv instanceof compile_1.SchemaEnv) - return callValidate(schOrEnv); - return inlineRefSchema(schOrEnv); - function callRootRef() { - if (env === root) - return callRef(cxt, validateName, env, env.$async); - const rootName = gen.scopeValue("root", { ref: root }); - return callRef(cxt, (0, codegen_1._)`${rootName}.validate`, root, root.$async); - } - function callValidate(sch) { - const v = getValidate(cxt, sch); - callRef(cxt, v, sch, sch.$async); - } - function inlineRefSchema(sch) { - const schName = gen.scopeValue("schema", opts.code.source === true ? { ref: sch, code: (0, codegen_1.stringify)(sch) } : { ref: sch }); - const valid = gen.name("valid"); - const schCxt = cxt.subschema({ - schema: sch, - dataTypes: [], - schemaPath: codegen_1.nil, - topSchemaRef: schName, - errSchemaPath: $ref - }, valid); - cxt.mergeEvaluated(schCxt); - cxt.ok(valid); - } - } - }; - function getValidate(cxt, sch) { - const { gen } = cxt; - return sch.validate ? gen.scopeValue("validate", { ref: sch.validate }) : (0, codegen_1._)`${gen.scopeValue("wrapper", { ref: sch })}.validate`; - } - exports2.getValidate = getValidate; - function callRef(cxt, v, sch, $async) { - const { gen, it } = cxt; - const { allErrors, schemaEnv: env, opts } = it; - const passCxt = opts.passContext ? names_1.default.this : codegen_1.nil; - if ($async) - callAsyncRef(); - else - callSyncRef(); - function callAsyncRef() { - if (!env.$async) - throw new Error("async schema referenced by sync schema"); - const valid = gen.let("valid"); - gen.try(() => { - gen.code((0, codegen_1._)`await ${(0, code_1.callValidateCode)(cxt, v, passCxt)}`); - addEvaluatedFrom(v); - if (!allErrors) - gen.assign(valid, true); - }, (e) => { - gen.if((0, codegen_1._)`!(${e} instanceof ${it.ValidationError})`, () => gen.throw(e)); - addErrorsFrom(e); - if (!allErrors) - gen.assign(valid, false); - }); - cxt.ok(valid); - } - function callSyncRef() { - cxt.result((0, code_1.callValidateCode)(cxt, v, passCxt), () => addEvaluatedFrom(v), () => addErrorsFrom(v)); - } - function addErrorsFrom(source) { - const errs = (0, codegen_1._)`${source}.errors`; - gen.assign(names_1.default.vErrors, (0, codegen_1._)`${names_1.default.vErrors} === null ? ${errs} : ${names_1.default.vErrors}.concat(${errs})`); - gen.assign(names_1.default.errors, (0, codegen_1._)`${names_1.default.vErrors}.length`); - } - function addEvaluatedFrom(source) { - var _a21; - if (!it.opts.unevaluated) - return; - const schEvaluated = (_a21 = sch === null || sch === undefined ? undefined : sch.validate) === null || _a21 === undefined ? undefined : _a21.evaluated; - if (it.props !== true) { - if (schEvaluated && !schEvaluated.dynamicProps) { - if (schEvaluated.props !== undefined) { - it.props = util_1.mergeEvaluated.props(gen, schEvaluated.props, it.props); - } - } else { - const props = gen.var("props", (0, codegen_1._)`${source}.evaluated.props`); - it.props = util_1.mergeEvaluated.props(gen, props, it.props, codegen_1.Name); - } - } - if (it.items !== true) { - if (schEvaluated && !schEvaluated.dynamicItems) { - if (schEvaluated.items !== undefined) { - it.items = util_1.mergeEvaluated.items(gen, schEvaluated.items, it.items); - } - } else { - const items = gen.var("items", (0, codegen_1._)`${source}.evaluated.items`); - it.items = util_1.mergeEvaluated.items(gen, items, it.items, codegen_1.Name); - } - } - } - } - exports2.callRef = callRef; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/core/index.js -var require_core2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var id_1 = require_id(); - var ref_1 = require_ref(); - var core2 = [ - "$schema", - "$id", - "$defs", - "$vocabulary", - { keyword: "$comment" }, - "definitions", - id_1.default, - ref_1.default - ]; - exports2.default = core2; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitNumber.js -var require_limitNumber = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var ops = codegen_1.operators; - var KWDs = { - maximum: { okStr: "<=", ok: ops.LTE, fail: ops.GT }, - minimum: { okStr: ">=", ok: ops.GTE, fail: ops.LT }, - exclusiveMaximum: { okStr: "<", ok: ops.LT, fail: ops.GTE }, - exclusiveMinimum: { okStr: ">", ok: ops.GT, fail: ops.LTE } - }; - var error48 = { - message: ({ keyword, schemaCode }) => (0, codegen_1.str)`must be ${KWDs[keyword].okStr} ${schemaCode}`, - params: ({ keyword, schemaCode }) => (0, codegen_1._)`{comparison: ${KWDs[keyword].okStr}, limit: ${schemaCode}}` - }; - var def = { - keyword: Object.keys(KWDs), - type: "number", - schemaType: "number", - $data: true, - error: error48, - code(cxt) { - const { keyword, data, schemaCode } = cxt; - cxt.fail$data((0, codegen_1._)`${data} ${KWDs[keyword].fail} ${schemaCode} || isNaN(${data})`); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/multipleOf.js -var require_multipleOf = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var error48 = { - message: ({ schemaCode }) => (0, codegen_1.str)`must be multiple of ${schemaCode}`, - params: ({ schemaCode }) => (0, codegen_1._)`{multipleOf: ${schemaCode}}` - }; - var def = { - keyword: "multipleOf", - type: "number", - schemaType: "number", - $data: true, - error: error48, - code(cxt) { - const { gen, data, schemaCode, it } = cxt; - const prec = it.opts.multipleOfPrecision; - const res = gen.let("res"); - const invalid = prec ? (0, codegen_1._)`Math.abs(Math.round(${res}) - ${res}) > 1e-${prec}` : (0, codegen_1._)`${res} !== parseInt(${res})`; - cxt.fail$data((0, codegen_1._)`(${schemaCode} === 0 || (${res} = ${data}/${schemaCode}, ${invalid}))`); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/ucs2length.js -var require_ucs2length = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - function ucs2length(str) { - const len = str.length; - let length = 0; - let pos = 0; - let value; - while (pos < len) { - length++; - value = str.charCodeAt(pos++); - if (value >= 55296 && value <= 56319 && pos < len) { - value = str.charCodeAt(pos); - if ((value & 64512) === 56320) - pos++; - } - } - return length; - } - exports2.default = ucs2length; - ucs2length.code = 'require("ajv/dist/runtime/ucs2length").default'; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitLength.js -var require_limitLength = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var ucs2length_1 = require_ucs2length(); - var error48 = { - message({ keyword, schemaCode }) { - const comp = keyword === "maxLength" ? "more" : "fewer"; - return (0, codegen_1.str)`must NOT have ${comp} than ${schemaCode} characters`; - }, - params: ({ schemaCode }) => (0, codegen_1._)`{limit: ${schemaCode}}` - }; - var def = { - keyword: ["maxLength", "minLength"], - type: "string", - schemaType: "number", - $data: true, - error: error48, - code(cxt) { - const { keyword, data, schemaCode, it } = cxt; - const op = keyword === "maxLength" ? codegen_1.operators.GT : codegen_1.operators.LT; - const len = it.opts.unicode === false ? (0, codegen_1._)`${data}.length` : (0, codegen_1._)`${(0, util_1.useFunc)(cxt.gen, ucs2length_1.default)}(${data})`; - cxt.fail$data((0, codegen_1._)`${len} ${op} ${schemaCode}`); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/pattern.js -var require_pattern = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var code_1 = require_code2(); - var util_1 = require_util2(); - var codegen_1 = require_codegen(); - var error48 = { - message: ({ schemaCode }) => (0, codegen_1.str)`must match pattern "${schemaCode}"`, - params: ({ schemaCode }) => (0, codegen_1._)`{pattern: ${schemaCode}}` - }; - var def = { - keyword: "pattern", - type: "string", - schemaType: "string", - $data: true, - error: error48, - code(cxt) { - const { gen, data, $data, schema, schemaCode, it } = cxt; - const u = it.opts.unicodeRegExp ? "u" : ""; - if ($data) { - const { regExp } = it.opts.code; - const regExpCode = regExp.code === "new RegExp" ? (0, codegen_1._)`new RegExp` : (0, util_1.useFunc)(gen, regExp); - const valid = gen.let("valid"); - gen.try(() => gen.assign(valid, (0, codegen_1._)`${regExpCode}(${schemaCode}, ${u}).test(${data})`), () => gen.assign(valid, false)); - cxt.fail$data((0, codegen_1._)`!${valid}`); - } else { - const regExp = (0, code_1.usePattern)(cxt, schema); - cxt.fail$data((0, codegen_1._)`!${regExp}.test(${data})`); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitProperties.js -var require_limitProperties = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var error48 = { - message({ keyword, schemaCode }) { - const comp = keyword === "maxProperties" ? "more" : "fewer"; - return (0, codegen_1.str)`must NOT have ${comp} than ${schemaCode} properties`; - }, - params: ({ schemaCode }) => (0, codegen_1._)`{limit: ${schemaCode}}` - }; - var def = { - keyword: ["maxProperties", "minProperties"], - type: "object", - schemaType: "number", - $data: true, - error: error48, - code(cxt) { - const { keyword, data, schemaCode } = cxt; - const op = keyword === "maxProperties" ? codegen_1.operators.GT : codegen_1.operators.LT; - cxt.fail$data((0, codegen_1._)`Object.keys(${data}).length ${op} ${schemaCode}`); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/required.js -var require_required = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var code_1 = require_code2(); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var error48 = { - message: ({ params: { missingProperty } }) => (0, codegen_1.str)`must have required property '${missingProperty}'`, - params: ({ params: { missingProperty } }) => (0, codegen_1._)`{missingProperty: ${missingProperty}}` - }; - var def = { - keyword: "required", - type: "object", - schemaType: "array", - $data: true, - error: error48, - code(cxt) { - const { gen, schema, schemaCode, data, $data, it } = cxt; - const { opts } = it; - if (!$data && schema.length === 0) - return; - const useLoop = schema.length >= opts.loopRequired; - if (it.allErrors) - allErrorsMode(); - else - exitOnErrorMode(); - if (opts.strictRequired) { - const props = cxt.parentSchema.properties; - const { definedProperties } = cxt.it; - for (const requiredKey of schema) { - if ((props === null || props === undefined ? undefined : props[requiredKey]) === undefined && !definedProperties.has(requiredKey)) { - const schemaPath = it.schemaEnv.baseId + it.errSchemaPath; - const msg = `required property "${requiredKey}" is not defined at "${schemaPath}" (strictRequired)`; - (0, util_1.checkStrictMode)(it, msg, it.opts.strictRequired); - } - } - } - function allErrorsMode() { - if (useLoop || $data) { - cxt.block$data(codegen_1.nil, loopAllRequired); - } else { - for (const prop of schema) { - (0, code_1.checkReportMissingProp)(cxt, prop); - } - } - } - function exitOnErrorMode() { - const missing = gen.let("missing"); - if (useLoop || $data) { - const valid = gen.let("valid", true); - cxt.block$data(valid, () => loopUntilMissing(missing, valid)); - cxt.ok(valid); - } else { - gen.if((0, code_1.checkMissingProp)(cxt, schema, missing)); - (0, code_1.reportMissingProp)(cxt, missing); - gen.else(); - } - } - function loopAllRequired() { - gen.forOf("prop", schemaCode, (prop) => { - cxt.setParams({ missingProperty: prop }); - gen.if((0, code_1.noPropertyInData)(gen, data, prop, opts.ownProperties), () => cxt.error()); - }); - } - function loopUntilMissing(missing, valid) { - cxt.setParams({ missingProperty: missing }); - gen.forOf(missing, schemaCode, () => { - gen.assign(valid, (0, code_1.propertyInData)(gen, data, missing, opts.ownProperties)); - gen.if((0, codegen_1.not)(valid), () => { - cxt.error(); - gen.break(); - }); - }, codegen_1.nil); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/limitItems.js -var require_limitItems = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var error48 = { - message({ keyword, schemaCode }) { - const comp = keyword === "maxItems" ? "more" : "fewer"; - return (0, codegen_1.str)`must NOT have ${comp} than ${schemaCode} items`; - }, - params: ({ schemaCode }) => (0, codegen_1._)`{limit: ${schemaCode}}` - }; - var def = { - keyword: ["maxItems", "minItems"], - type: "array", - schemaType: "number", - $data: true, - error: error48, - code(cxt) { - const { keyword, data, schemaCode } = cxt; - const op = keyword === "maxItems" ? codegen_1.operators.GT : codegen_1.operators.LT; - cxt.fail$data((0, codegen_1._)`${data}.length ${op} ${schemaCode}`); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/runtime/equal.js -var require_equal = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var equal = require_fast_deep_equal(); - equal.code = 'require("ajv/dist/runtime/equal").default'; - exports2.default = equal; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/uniqueItems.js -var require_uniqueItems = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var dataType_1 = require_dataType(); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var equal_1 = require_equal(); - var error48 = { - message: ({ params: { i, j } }) => (0, codegen_1.str)`must NOT have duplicate items (items ## ${j} and ${i} are identical)`, - params: ({ params: { i, j } }) => (0, codegen_1._)`{i: ${i}, j: ${j}}` - }; - var def = { - keyword: "uniqueItems", - type: "array", - schemaType: "boolean", - $data: true, - error: error48, - code(cxt) { - const { gen, data, $data, schema, parentSchema, schemaCode, it } = cxt; - if (!$data && !schema) - return; - const valid = gen.let("valid"); - const itemTypes = parentSchema.items ? (0, dataType_1.getSchemaTypes)(parentSchema.items) : []; - cxt.block$data(valid, validateUniqueItems, (0, codegen_1._)`${schemaCode} === false`); - cxt.ok(valid); - function validateUniqueItems() { - const i = gen.let("i", (0, codegen_1._)`${data}.length`); - const j = gen.let("j"); - cxt.setParams({ i, j }); - gen.assign(valid, true); - gen.if((0, codegen_1._)`${i} > 1`, () => (canOptimize() ? loopN : loopN2)(i, j)); - } - function canOptimize() { - return itemTypes.length > 0 && !itemTypes.some((t) => t === "object" || t === "array"); - } - function loopN(i, j) { - const item = gen.name("item"); - const wrongType = (0, dataType_1.checkDataTypes)(itemTypes, item, it.opts.strictNumbers, dataType_1.DataType.Wrong); - const indices = gen.const("indices", (0, codegen_1._)`{}`); - gen.for((0, codegen_1._)`;${i}--;`, () => { - gen.let(item, (0, codegen_1._)`${data}[${i}]`); - gen.if(wrongType, (0, codegen_1._)`continue`); - if (itemTypes.length > 1) - gen.if((0, codegen_1._)`typeof ${item} == "string"`, (0, codegen_1._)`${item} += "_"`); - gen.if((0, codegen_1._)`typeof ${indices}[${item}] == "number"`, () => { - gen.assign(j, (0, codegen_1._)`${indices}[${item}]`); - cxt.error(); - gen.assign(valid, false).break(); - }).code((0, codegen_1._)`${indices}[${item}] = ${i}`); - }); - } - function loopN2(i, j) { - const eql = (0, util_1.useFunc)(gen, equal_1.default); - const outer = gen.name("outer"); - gen.label(outer).for((0, codegen_1._)`;${i}--;`, () => gen.for((0, codegen_1._)`${j} = ${i}; ${j}--;`, () => gen.if((0, codegen_1._)`${eql}(${data}[${i}], ${data}[${j}])`, () => { - cxt.error(); - gen.assign(valid, false).break(outer); - }))); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/const.js -var require_const = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var equal_1 = require_equal(); - var error48 = { - message: "must be equal to constant", - params: ({ schemaCode }) => (0, codegen_1._)`{allowedValue: ${schemaCode}}` - }; - var def = { - keyword: "const", - $data: true, - error: error48, - code(cxt) { - const { gen, data, $data, schemaCode, schema } = cxt; - if ($data || schema && typeof schema == "object") { - cxt.fail$data((0, codegen_1._)`!${(0, util_1.useFunc)(gen, equal_1.default)}(${data}, ${schemaCode})`); - } else { - cxt.fail((0, codegen_1._)`${schema} !== ${data}`); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/enum.js -var require_enum = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var equal_1 = require_equal(); - var error48 = { - message: "must be equal to one of the allowed values", - params: ({ schemaCode }) => (0, codegen_1._)`{allowedValues: ${schemaCode}}` - }; - var def = { - keyword: "enum", - schemaType: "array", - $data: true, - error: error48, - code(cxt) { - const { gen, data, $data, schema, schemaCode, it } = cxt; - if (!$data && schema.length === 0) - throw new Error("enum must have non-empty array"); - const useLoop = schema.length >= it.opts.loopEnum; - let eql; - const getEql = () => eql !== null && eql !== undefined ? eql : eql = (0, util_1.useFunc)(gen, equal_1.default); - let valid; - if (useLoop || $data) { - valid = gen.let("valid"); - cxt.block$data(valid, loopEnum); - } else { - if (!Array.isArray(schema)) - throw new Error("ajv implementation error"); - const vSchema = gen.const("vSchema", schemaCode); - valid = (0, codegen_1.or)(...schema.map((_x, i) => equalCode(vSchema, i))); - } - cxt.pass(valid); - function loopEnum() { - gen.assign(valid, false); - gen.forOf("v", schemaCode, (v) => gen.if((0, codegen_1._)`${getEql()}(${data}, ${v})`, () => gen.assign(valid, true).break())); - } - function equalCode(vSchema, i) { - const sch = schema[i]; - return typeof sch === "object" && sch !== null ? (0, codegen_1._)`${getEql()}(${data}, ${vSchema}[${i}])` : (0, codegen_1._)`${data} === ${sch}`; - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/validation/index.js -var require_validation2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var limitNumber_1 = require_limitNumber(); - var multipleOf_1 = require_multipleOf(); - var limitLength_1 = require_limitLength(); - var pattern_1 = require_pattern(); - var limitProperties_1 = require_limitProperties(); - var required_1 = require_required(); - var limitItems_1 = require_limitItems(); - var uniqueItems_1 = require_uniqueItems(); - var const_1 = require_const(); - var enum_1 = require_enum(); - var validation = [ - limitNumber_1.default, - multipleOf_1.default, - limitLength_1.default, - pattern_1.default, - limitProperties_1.default, - required_1.default, - limitItems_1.default, - uniqueItems_1.default, - { keyword: "type", schemaType: ["string", "array"] }, - { keyword: "nullable", schemaType: "boolean" }, - const_1.default, - enum_1.default - ]; - exports2.default = validation; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/additionalItems.js -var require_additionalItems = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.validateAdditionalItems = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var error48 = { - message: ({ params: { len } }) => (0, codegen_1.str)`must NOT have more than ${len} items`, - params: ({ params: { len } }) => (0, codegen_1._)`{limit: ${len}}` - }; - var def = { - keyword: "additionalItems", - type: "array", - schemaType: ["boolean", "object"], - before: "uniqueItems", - error: error48, - code(cxt) { - const { parentSchema, it } = cxt; - const { items } = parentSchema; - if (!Array.isArray(items)) { - (0, util_1.checkStrictMode)(it, '"additionalItems" is ignored when "items" is not an array of schemas'); - return; - } - validateAdditionalItems(cxt, items); - } - }; - function validateAdditionalItems(cxt, items) { - const { gen, schema, data, keyword, it } = cxt; - it.items = true; - const len = gen.const("len", (0, codegen_1._)`${data}.length`); - if (schema === false) { - cxt.setParams({ len: items.length }); - cxt.pass((0, codegen_1._)`${len} <= ${items.length}`); - } else if (typeof schema == "object" && !(0, util_1.alwaysValidSchema)(it, schema)) { - const valid = gen.var("valid", (0, codegen_1._)`${len} <= ${items.length}`); - gen.if((0, codegen_1.not)(valid), () => validateItems(valid)); - cxt.ok(valid); - } - function validateItems(valid) { - gen.forRange("i", items.length, len, (i) => { - cxt.subschema({ keyword, dataProp: i, dataPropType: util_1.Type.Num }, valid); - if (!it.allErrors) - gen.if((0, codegen_1.not)(valid), () => gen.break()); - }); - } - } - exports2.validateAdditionalItems = validateAdditionalItems; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/items.js -var require_items = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.validateTuple = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var code_1 = require_code2(); - var def = { - keyword: "items", - type: "array", - schemaType: ["object", "array", "boolean"], - before: "uniqueItems", - code(cxt) { - const { schema, it } = cxt; - if (Array.isArray(schema)) - return validateTuple(cxt, "additionalItems", schema); - it.items = true; - if ((0, util_1.alwaysValidSchema)(it, schema)) - return; - cxt.ok((0, code_1.validateArray)(cxt)); - } - }; - function validateTuple(cxt, extraItems, schArr = cxt.schema) { - const { gen, parentSchema, data, keyword, it } = cxt; - checkStrictTuple(parentSchema); - if (it.opts.unevaluated && schArr.length && it.items !== true) { - it.items = util_1.mergeEvaluated.items(gen, schArr.length, it.items); - } - const valid = gen.name("valid"); - const len = gen.const("len", (0, codegen_1._)`${data}.length`); - schArr.forEach((sch, i) => { - if ((0, util_1.alwaysValidSchema)(it, sch)) - return; - gen.if((0, codegen_1._)`${len} > ${i}`, () => cxt.subschema({ - keyword, - schemaProp: i, - dataProp: i - }, valid)); - cxt.ok(valid); - }); - function checkStrictTuple(sch) { - const { opts, errSchemaPath } = it; - const l = schArr.length; - const fullTuple = l === sch.minItems && (l === sch.maxItems || sch[extraItems] === false); - if (opts.strictTuples && !fullTuple) { - const msg = `"${keyword}" is ${l}-tuple, but minItems or maxItems/${extraItems} are not specified or different at path "${errSchemaPath}"`; - (0, util_1.checkStrictMode)(it, msg, opts.strictTuples); - } - } - } - exports2.validateTuple = validateTuple; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/prefixItems.js -var require_prefixItems = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var items_1 = require_items(); - var def = { - keyword: "prefixItems", - type: "array", - schemaType: ["array"], - before: "uniqueItems", - code: (cxt) => (0, items_1.validateTuple)(cxt, "items") - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/items2020.js -var require_items2020 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var code_1 = require_code2(); - var additionalItems_1 = require_additionalItems(); - var error48 = { - message: ({ params: { len } }) => (0, codegen_1.str)`must NOT have more than ${len} items`, - params: ({ params: { len } }) => (0, codegen_1._)`{limit: ${len}}` - }; - var def = { - keyword: "items", - type: "array", - schemaType: ["object", "boolean"], - before: "uniqueItems", - error: error48, - code(cxt) { - const { schema, parentSchema, it } = cxt; - const { prefixItems } = parentSchema; - it.items = true; - if ((0, util_1.alwaysValidSchema)(it, schema)) - return; - if (prefixItems) - (0, additionalItems_1.validateAdditionalItems)(cxt, prefixItems); - else - cxt.ok((0, code_1.validateArray)(cxt)); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/contains.js -var require_contains = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var error48 = { - message: ({ params: { min, max } }) => max === undefined ? (0, codegen_1.str)`must contain at least ${min} valid item(s)` : (0, codegen_1.str)`must contain at least ${min} and no more than ${max} valid item(s)`, - params: ({ params: { min, max } }) => max === undefined ? (0, codegen_1._)`{minContains: ${min}}` : (0, codegen_1._)`{minContains: ${min}, maxContains: ${max}}` - }; - var def = { - keyword: "contains", - type: "array", - schemaType: ["object", "boolean"], - before: "uniqueItems", - trackErrors: true, - error: error48, - code(cxt) { - const { gen, schema, parentSchema, data, it } = cxt; - let min; - let max; - const { minContains, maxContains } = parentSchema; - if (it.opts.next) { - min = minContains === undefined ? 1 : minContains; - max = maxContains; - } else { - min = 1; - } - const len = gen.const("len", (0, codegen_1._)`${data}.length`); - cxt.setParams({ min, max }); - if (max === undefined && min === 0) { - (0, util_1.checkStrictMode)(it, `"minContains" == 0 without "maxContains": "contains" keyword ignored`); - return; - } - if (max !== undefined && min > max) { - (0, util_1.checkStrictMode)(it, `"minContains" > "maxContains" is always invalid`); - cxt.fail(); - return; - } - if ((0, util_1.alwaysValidSchema)(it, schema)) { - let cond = (0, codegen_1._)`${len} >= ${min}`; - if (max !== undefined) - cond = (0, codegen_1._)`${cond} && ${len} <= ${max}`; - cxt.pass(cond); - return; - } - it.items = true; - const valid = gen.name("valid"); - if (max === undefined && min === 1) { - validateItems(valid, () => gen.if(valid, () => gen.break())); - } else if (min === 0) { - gen.let(valid, true); - if (max !== undefined) - gen.if((0, codegen_1._)`${data}.length > 0`, validateItemsWithCount); - } else { - gen.let(valid, false); - validateItemsWithCount(); - } - cxt.result(valid, () => cxt.reset()); - function validateItemsWithCount() { - const schValid = gen.name("_valid"); - const count = gen.let("count", 0); - validateItems(schValid, () => gen.if(schValid, () => checkLimits(count))); - } - function validateItems(_valid, block) { - gen.forRange("i", 0, len, (i) => { - cxt.subschema({ - keyword: "contains", - dataProp: i, - dataPropType: util_1.Type.Num, - compositeRule: true - }, _valid); - block(); - }); - } - function checkLimits(count) { - gen.code((0, codegen_1._)`${count}++`); - if (max === undefined) { - gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true).break()); - } else { - gen.if((0, codegen_1._)`${count} > ${max}`, () => gen.assign(valid, false).break()); - if (min === 1) - gen.assign(valid, true); - else - gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true)); - } - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/dependencies.js -var require_dependencies = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.validateSchemaDeps = exports2.validatePropertyDeps = exports2.error = undefined; - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var code_1 = require_code2(); - exports2.error = { - message: ({ params: { property, depsCount, deps } }) => { - const property_ies = depsCount === 1 ? "property" : "properties"; - return (0, codegen_1.str)`must have ${property_ies} ${deps} when property ${property} is present`; - }, - params: ({ params: { property, depsCount, deps, missingProperty } }) => (0, codegen_1._)`{property: ${property}, - missingProperty: ${missingProperty}, - depsCount: ${depsCount}, - deps: ${deps}}` - }; - var def = { - keyword: "dependencies", - type: "object", - schemaType: "object", - error: exports2.error, - code(cxt) { - const [propDeps, schDeps] = splitDependencies(cxt); - validatePropertyDeps(cxt, propDeps); - validateSchemaDeps(cxt, schDeps); - } - }; - function splitDependencies({ schema }) { - const propertyDeps = {}; - const schemaDeps = {}; - for (const key in schema) { - if (key === "__proto__") - continue; - const deps = Array.isArray(schema[key]) ? propertyDeps : schemaDeps; - deps[key] = schema[key]; - } - return [propertyDeps, schemaDeps]; - } - function validatePropertyDeps(cxt, propertyDeps = cxt.schema) { - const { gen, data, it } = cxt; - if (Object.keys(propertyDeps).length === 0) - return; - const missing = gen.let("missing"); - for (const prop in propertyDeps) { - const deps = propertyDeps[prop]; - if (deps.length === 0) - continue; - const hasProperty = (0, code_1.propertyInData)(gen, data, prop, it.opts.ownProperties); - cxt.setParams({ - property: prop, - depsCount: deps.length, - deps: deps.join(", ") - }); - if (it.allErrors) { - gen.if(hasProperty, () => { - for (const depProp of deps) { - (0, code_1.checkReportMissingProp)(cxt, depProp); - } - }); - } else { - gen.if((0, codegen_1._)`${hasProperty} && (${(0, code_1.checkMissingProp)(cxt, deps, missing)})`); - (0, code_1.reportMissingProp)(cxt, missing); - gen.else(); - } - } - } - exports2.validatePropertyDeps = validatePropertyDeps; - function validateSchemaDeps(cxt, schemaDeps = cxt.schema) { - const { gen, data, keyword, it } = cxt; - const valid = gen.name("valid"); - for (const prop in schemaDeps) { - if ((0, util_1.alwaysValidSchema)(it, schemaDeps[prop])) - continue; - gen.if((0, code_1.propertyInData)(gen, data, prop, it.opts.ownProperties), () => { - const schCxt = cxt.subschema({ keyword, schemaProp: prop }, valid); - cxt.mergeValidEvaluated(schCxt, valid); - }, () => gen.var(valid, true)); - cxt.ok(valid); - } - } - exports2.validateSchemaDeps = validateSchemaDeps; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/propertyNames.js -var require_propertyNames = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var error48 = { - message: "property name must be valid", - params: ({ params }) => (0, codegen_1._)`{propertyName: ${params.propertyName}}` - }; - var def = { - keyword: "propertyNames", - type: "object", - schemaType: ["object", "boolean"], - error: error48, - code(cxt) { - const { gen, schema, data, it } = cxt; - if ((0, util_1.alwaysValidSchema)(it, schema)) - return; - const valid = gen.name("valid"); - gen.forIn("key", data, (key) => { - cxt.setParams({ propertyName: key }); - cxt.subschema({ - keyword: "propertyNames", - data: key, - dataTypes: ["string"], - propertyName: key, - compositeRule: true - }, valid); - gen.if((0, codegen_1.not)(valid), () => { - cxt.error(true); - if (!it.allErrors) - gen.break(); - }); - }); - cxt.ok(valid); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/additionalProperties.js -var require_additionalProperties = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var code_1 = require_code2(); - var codegen_1 = require_codegen(); - var names_1 = require_names(); - var util_1 = require_util2(); - var error48 = { - message: "must NOT have additional properties", - params: ({ params }) => (0, codegen_1._)`{additionalProperty: ${params.additionalProperty}}` - }; - var def = { - keyword: "additionalProperties", - type: ["object"], - schemaType: ["boolean", "object"], - allowUndefined: true, - trackErrors: true, - error: error48, - code(cxt) { - const { gen, schema, parentSchema, data, errsCount, it } = cxt; - if (!errsCount) - throw new Error("ajv implementation error"); - const { allErrors, opts } = it; - it.props = true; - if (opts.removeAdditional !== "all" && (0, util_1.alwaysValidSchema)(it, schema)) - return; - const props = (0, code_1.allSchemaProperties)(parentSchema.properties); - const patProps = (0, code_1.allSchemaProperties)(parentSchema.patternProperties); - checkAdditionalProperties(); - cxt.ok((0, codegen_1._)`${errsCount} === ${names_1.default.errors}`); - function checkAdditionalProperties() { - gen.forIn("key", data, (key) => { - if (!props.length && !patProps.length) - additionalPropertyCode(key); - else - gen.if(isAdditional(key), () => additionalPropertyCode(key)); - }); - } - function isAdditional(key) { - let definedProp; - if (props.length > 8) { - const propsSchema = (0, util_1.schemaRefOrVal)(it, parentSchema.properties, "properties"); - definedProp = (0, code_1.isOwnProperty)(gen, propsSchema, key); - } else if (props.length) { - definedProp = (0, codegen_1.or)(...props.map((p) => (0, codegen_1._)`${key} === ${p}`)); - } else { - definedProp = codegen_1.nil; - } - if (patProps.length) { - definedProp = (0, codegen_1.or)(definedProp, ...patProps.map((p) => (0, codegen_1._)`${(0, code_1.usePattern)(cxt, p)}.test(${key})`)); - } - return (0, codegen_1.not)(definedProp); - } - function deleteAdditional(key) { - gen.code((0, codegen_1._)`delete ${data}[${key}]`); - } - function additionalPropertyCode(key) { - if (opts.removeAdditional === "all" || opts.removeAdditional && schema === false) { - deleteAdditional(key); - return; - } - if (schema === false) { - cxt.setParams({ additionalProperty: key }); - cxt.error(); - if (!allErrors) - gen.break(); - return; - } - if (typeof schema == "object" && !(0, util_1.alwaysValidSchema)(it, schema)) { - const valid = gen.name("valid"); - if (opts.removeAdditional === "failing") { - applyAdditionalSchema(key, valid, false); - gen.if((0, codegen_1.not)(valid), () => { - cxt.reset(); - deleteAdditional(key); - }); - } else { - applyAdditionalSchema(key, valid); - if (!allErrors) - gen.if((0, codegen_1.not)(valid), () => gen.break()); - } - } - } - function applyAdditionalSchema(key, valid, errors4) { - const subschema = { - keyword: "additionalProperties", - dataProp: key, - dataPropType: util_1.Type.Str - }; - if (errors4 === false) { - Object.assign(subschema, { - compositeRule: true, - createErrors: false, - allErrors: false - }); - } - cxt.subschema(subschema, valid); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/properties.js -var require_properties = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var validate_1 = require_validate(); - var code_1 = require_code2(); - var util_1 = require_util2(); - var additionalProperties_1 = require_additionalProperties(); - var def = { - keyword: "properties", - type: "object", - schemaType: "object", - code(cxt) { - const { gen, schema, parentSchema, data, it } = cxt; - if (it.opts.removeAdditional === "all" && parentSchema.additionalProperties === undefined) { - additionalProperties_1.default.code(new validate_1.KeywordCxt(it, additionalProperties_1.default, "additionalProperties")); - } - const allProps = (0, code_1.allSchemaProperties)(schema); - for (const prop of allProps) { - it.definedProperties.add(prop); - } - if (it.opts.unevaluated && allProps.length && it.props !== true) { - it.props = util_1.mergeEvaluated.props(gen, (0, util_1.toHash)(allProps), it.props); - } - const properties = allProps.filter((p) => !(0, util_1.alwaysValidSchema)(it, schema[p])); - if (properties.length === 0) - return; - const valid = gen.name("valid"); - for (const prop of properties) { - if (hasDefault(prop)) { - applyPropertySchema(prop); - } else { - gen.if((0, code_1.propertyInData)(gen, data, prop, it.opts.ownProperties)); - applyPropertySchema(prop); - if (!it.allErrors) - gen.else().var(valid, true); - gen.endIf(); - } - cxt.it.definedProperties.add(prop); - cxt.ok(valid); - } - function hasDefault(prop) { - return it.opts.useDefaults && !it.compositeRule && schema[prop].default !== undefined; - } - function applyPropertySchema(prop) { - cxt.subschema({ - keyword: "properties", - schemaProp: prop, - dataProp: prop - }, valid); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/patternProperties.js -var require_patternProperties = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var code_1 = require_code2(); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var util_2 = require_util2(); - var def = { - keyword: "patternProperties", - type: "object", - schemaType: "object", - code(cxt) { - const { gen, schema, data, parentSchema, it } = cxt; - const { opts } = it; - const patterns = (0, code_1.allSchemaProperties)(schema); - const alwaysValidPatterns = patterns.filter((p) => (0, util_1.alwaysValidSchema)(it, schema[p])); - if (patterns.length === 0 || alwaysValidPatterns.length === patterns.length && (!it.opts.unevaluated || it.props === true)) { - return; - } - const checkProperties = opts.strictSchema && !opts.allowMatchingProperties && parentSchema.properties; - const valid = gen.name("valid"); - if (it.props !== true && !(it.props instanceof codegen_1.Name)) { - it.props = (0, util_2.evaluatedPropsToName)(gen, it.props); - } - const { props } = it; - validatePatternProperties(); - function validatePatternProperties() { - for (const pat of patterns) { - if (checkProperties) - checkMatchingProperties(pat); - if (it.allErrors) { - validateProperties(pat); - } else { - gen.var(valid, true); - validateProperties(pat); - gen.if(valid); - } - } - } - function checkMatchingProperties(pat) { - for (const prop in checkProperties) { - if (new RegExp(pat).test(prop)) { - (0, util_1.checkStrictMode)(it, `property ${prop} matches pattern ${pat} (use allowMatchingProperties)`); - } - } - } - function validateProperties(pat) { - gen.forIn("key", data, (key) => { - gen.if((0, codegen_1._)`${(0, code_1.usePattern)(cxt, pat)}.test(${key})`, () => { - const alwaysValid = alwaysValidPatterns.includes(pat); - if (!alwaysValid) { - cxt.subschema({ - keyword: "patternProperties", - schemaProp: pat, - dataProp: key, - dataPropType: util_2.Type.Str - }, valid); - } - if (it.opts.unevaluated && props !== true) { - gen.assign((0, codegen_1._)`${props}[${key}]`, true); - } else if (!alwaysValid && !it.allErrors) { - gen.if((0, codegen_1.not)(valid), () => gen.break()); - } - }); - }); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/not.js -var require_not = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var util_1 = require_util2(); - var def = { - keyword: "not", - schemaType: ["object", "boolean"], - trackErrors: true, - code(cxt) { - const { gen, schema, it } = cxt; - if ((0, util_1.alwaysValidSchema)(it, schema)) { - cxt.fail(); - return; - } - const valid = gen.name("valid"); - cxt.subschema({ - keyword: "not", - compositeRule: true, - createErrors: false, - allErrors: false - }, valid); - cxt.failResult(valid, () => cxt.reset(), () => cxt.error()); - }, - error: { message: "must NOT be valid" } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/anyOf.js -var require_anyOf = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var code_1 = require_code2(); - var def = { - keyword: "anyOf", - schemaType: "array", - trackErrors: true, - code: code_1.validateUnion, - error: { message: "must match a schema in anyOf" } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/oneOf.js -var require_oneOf = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var error48 = { - message: "must match exactly one schema in oneOf", - params: ({ params }) => (0, codegen_1._)`{passingSchemas: ${params.passing}}` - }; - var def = { - keyword: "oneOf", - schemaType: "array", - trackErrors: true, - error: error48, - code(cxt) { - const { gen, schema, parentSchema, it } = cxt; - if (!Array.isArray(schema)) - throw new Error("ajv implementation error"); - if (it.opts.discriminator && parentSchema.discriminator) - return; - const schArr = schema; - const valid = gen.let("valid", false); - const passing = gen.let("passing", null); - const schValid = gen.name("_valid"); - cxt.setParams({ passing }); - gen.block(validateOneOf); - cxt.result(valid, () => cxt.reset(), () => cxt.error(true)); - function validateOneOf() { - schArr.forEach((sch, i) => { - let schCxt; - if ((0, util_1.alwaysValidSchema)(it, sch)) { - gen.var(schValid, true); - } else { - schCxt = cxt.subschema({ - keyword: "oneOf", - schemaProp: i, - compositeRule: true - }, schValid); - } - if (i > 0) { - gen.if((0, codegen_1._)`${schValid} && ${valid}`).assign(valid, false).assign(passing, (0, codegen_1._)`[${passing}, ${i}]`).else(); - } - gen.if(schValid, () => { - gen.assign(valid, true); - gen.assign(passing, i); - if (schCxt) - cxt.mergeEvaluated(schCxt, codegen_1.Name); - }); - }); - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/allOf.js -var require_allOf = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var util_1 = require_util2(); - var def = { - keyword: "allOf", - schemaType: "array", - code(cxt) { - const { gen, schema, it } = cxt; - if (!Array.isArray(schema)) - throw new Error("ajv implementation error"); - const valid = gen.name("valid"); - schema.forEach((sch, i) => { - if ((0, util_1.alwaysValidSchema)(it, sch)) - return; - const schCxt = cxt.subschema({ keyword: "allOf", schemaProp: i }, valid); - cxt.ok(valid); - cxt.mergeEvaluated(schCxt); - }); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/if.js -var require_if = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var util_1 = require_util2(); - var error48 = { - message: ({ params }) => (0, codegen_1.str)`must match "${params.ifClause}" schema`, - params: ({ params }) => (0, codegen_1._)`{failingKeyword: ${params.ifClause}}` - }; - var def = { - keyword: "if", - schemaType: ["object", "boolean"], - trackErrors: true, - error: error48, - code(cxt) { - const { gen, parentSchema, it } = cxt; - if (parentSchema.then === undefined && parentSchema.else === undefined) { - (0, util_1.checkStrictMode)(it, '"if" without "then" and "else" is ignored'); - } - const hasThen = hasSchema(it, "then"); - const hasElse = hasSchema(it, "else"); - if (!hasThen && !hasElse) - return; - const valid = gen.let("valid", true); - const schValid = gen.name("_valid"); - validateIf(); - cxt.reset(); - if (hasThen && hasElse) { - const ifClause = gen.let("ifClause"); - cxt.setParams({ ifClause }); - gen.if(schValid, validateClause("then", ifClause), validateClause("else", ifClause)); - } else if (hasThen) { - gen.if(schValid, validateClause("then")); - } else { - gen.if((0, codegen_1.not)(schValid), validateClause("else")); - } - cxt.pass(valid, () => cxt.error(true)); - function validateIf() { - const schCxt = cxt.subschema({ - keyword: "if", - compositeRule: true, - createErrors: false, - allErrors: false - }, schValid); - cxt.mergeEvaluated(schCxt); - } - function validateClause(keyword, ifClause) { - return () => { - const schCxt = cxt.subschema({ keyword }, schValid); - gen.assign(valid, schValid); - cxt.mergeValidEvaluated(schCxt, valid); - if (ifClause) - gen.assign(ifClause, (0, codegen_1._)`${keyword}`); - else - cxt.setParams({ ifClause: keyword }); - }; - } - } - }; - function hasSchema(it, keyword) { - const schema = it.schema[keyword]; - return schema !== undefined && !(0, util_1.alwaysValidSchema)(it, schema); - } - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/thenElse.js -var require_thenElse = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var util_1 = require_util2(); - var def = { - keyword: ["then", "else"], - schemaType: ["object", "boolean"], - code({ keyword, parentSchema, it }) { - if (parentSchema.if === undefined) - (0, util_1.checkStrictMode)(it, `"${keyword}" without "if" is ignored`); - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/applicator/index.js -var require_applicator = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var additionalItems_1 = require_additionalItems(); - var prefixItems_1 = require_prefixItems(); - var items_1 = require_items(); - var items2020_1 = require_items2020(); - var contains_1 = require_contains(); - var dependencies_1 = require_dependencies(); - var propertyNames_1 = require_propertyNames(); - var additionalProperties_1 = require_additionalProperties(); - var properties_1 = require_properties(); - var patternProperties_1 = require_patternProperties(); - var not_1 = require_not(); - var anyOf_1 = require_anyOf(); - var oneOf_1 = require_oneOf(); - var allOf_1 = require_allOf(); - var if_1 = require_if(); - var thenElse_1 = require_thenElse(); - function getApplicator(draft2020 = false) { - const applicator = [ - not_1.default, - anyOf_1.default, - oneOf_1.default, - allOf_1.default, - if_1.default, - thenElse_1.default, - propertyNames_1.default, - additionalProperties_1.default, - dependencies_1.default, - properties_1.default, - patternProperties_1.default - ]; - if (draft2020) - applicator.push(prefixItems_1.default, items2020_1.default); - else - applicator.push(additionalItems_1.default, items_1.default); - applicator.push(contains_1.default); - return applicator; - } - exports2.default = getApplicator; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/format/format.js -var require_format = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var error48 = { - message: ({ schemaCode }) => (0, codegen_1.str)`must match format "${schemaCode}"`, - params: ({ schemaCode }) => (0, codegen_1._)`{format: ${schemaCode}}` - }; - var def = { - keyword: "format", - type: ["number", "string"], - schemaType: "string", - $data: true, - error: error48, - code(cxt, ruleType) { - const { gen, data, $data, schema, schemaCode, it } = cxt; - const { opts, errSchemaPath, schemaEnv, self } = it; - if (!opts.validateFormats) - return; - if ($data) - validate$DataFormat(); - else - validateFormat(); - function validate$DataFormat() { - const fmts = gen.scopeValue("formats", { - ref: self.formats, - code: opts.code.formats - }); - const fDef = gen.const("fDef", (0, codegen_1._)`${fmts}[${schemaCode}]`); - const fType = gen.let("fType"); - const format = gen.let("format"); - gen.if((0, codegen_1._)`typeof ${fDef} == "object" && !(${fDef} instanceof RegExp)`, () => gen.assign(fType, (0, codegen_1._)`${fDef}.type || "string"`).assign(format, (0, codegen_1._)`${fDef}.validate`), () => gen.assign(fType, (0, codegen_1._)`"string"`).assign(format, fDef)); - cxt.fail$data((0, codegen_1.or)(unknownFmt(), invalidFmt())); - function unknownFmt() { - if (opts.strictSchema === false) - return codegen_1.nil; - return (0, codegen_1._)`${schemaCode} && !${format}`; - } - function invalidFmt() { - const callFormat = schemaEnv.$async ? (0, codegen_1._)`(${fDef}.async ? await ${format}(${data}) : ${format}(${data}))` : (0, codegen_1._)`${format}(${data})`; - const validData = (0, codegen_1._)`(typeof ${format} == "function" ? ${callFormat} : ${format}.test(${data}))`; - return (0, codegen_1._)`${format} && ${format} !== true && ${fType} === ${ruleType} && !${validData}`; - } - } - function validateFormat() { - const formatDef = self.formats[schema]; - if (!formatDef) { - unknownFormat(); - return; - } - if (formatDef === true) - return; - const [fmtType, format, fmtRef] = getFormat(formatDef); - if (fmtType === ruleType) - cxt.pass(validCondition()); - function unknownFormat() { - if (opts.strictSchema === false) { - self.logger.warn(unknownMsg()); - return; - } - throw new Error(unknownMsg()); - function unknownMsg() { - return `unknown format "${schema}" ignored in schema at path "${errSchemaPath}"`; - } - } - function getFormat(fmtDef) { - const code = fmtDef instanceof RegExp ? (0, codegen_1.regexpCode)(fmtDef) : opts.code.formats ? (0, codegen_1._)`${opts.code.formats}${(0, codegen_1.getProperty)(schema)}` : undefined; - const fmt = gen.scopeValue("formats", { key: schema, ref: fmtDef, code }); - if (typeof fmtDef == "object" && !(fmtDef instanceof RegExp)) { - return [fmtDef.type || "string", fmtDef.validate, (0, codegen_1._)`${fmt}.validate`]; - } - return ["string", fmtDef, fmt]; - } - function validCondition() { - if (typeof formatDef == "object" && !(formatDef instanceof RegExp) && formatDef.async) { - if (!schemaEnv.$async) - throw new Error("async format in sync schema"); - return (0, codegen_1._)`await ${fmtRef}(${data})`; - } - return typeof format == "function" ? (0, codegen_1._)`${fmtRef}(${data})` : (0, codegen_1._)`${fmtRef}.test(${data})`; - } - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/format/index.js -var require_format2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var format_1 = require_format(); - var format = [format_1.default]; - exports2.default = format; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/metadata.js -var require_metadata = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.contentVocabulary = exports2.metadataVocabulary = undefined; - exports2.metadataVocabulary = [ - "title", - "description", - "default", - "deprecated", - "readOnly", - "writeOnly", - "examples" - ]; - exports2.contentVocabulary = [ - "contentMediaType", - "contentEncoding", - "contentSchema" - ]; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/draft7.js -var require_draft7 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var core_1 = require_core2(); - var validation_1 = require_validation2(); - var applicator_1 = require_applicator(); - var format_1 = require_format2(); - var metadata_1 = require_metadata(); - var draft7Vocabularies = [ - core_1.default, - validation_1.default, - (0, applicator_1.default)(), - format_1.default, - metadata_1.metadataVocabulary, - metadata_1.contentVocabulary - ]; - exports2.default = draft7Vocabularies; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/discriminator/types.js -var require_types2 = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.DiscrError = undefined; - var DiscrError; - (function(DiscrError2) { - DiscrError2["Tag"] = "tag"; - DiscrError2["Mapping"] = "mapping"; - })(DiscrError || (exports2.DiscrError = DiscrError = {})); -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/vocabularies/discriminator/index.js -var require_discriminator = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var codegen_1 = require_codegen(); - var types_1 = require_types2(); - var compile_1 = require_compile(); - var ref_error_1 = require_ref_error(); - var util_1 = require_util2(); - var error48 = { - message: ({ params: { discrError, tagName } }) => discrError === types_1.DiscrError.Tag ? `tag "${tagName}" must be string` : `value of tag "${tagName}" must be in oneOf`, - params: ({ params: { discrError, tag, tagName } }) => (0, codegen_1._)`{error: ${discrError}, tag: ${tagName}, tagValue: ${tag}}` - }; - var def = { - keyword: "discriminator", - type: "object", - schemaType: "object", - error: error48, - code(cxt) { - const { gen, data, schema, parentSchema, it } = cxt; - const { oneOf } = parentSchema; - if (!it.opts.discriminator) { - throw new Error("discriminator: requires discriminator option"); - } - const tagName = schema.propertyName; - if (typeof tagName != "string") - throw new Error("discriminator: requires propertyName"); - if (schema.mapping) - throw new Error("discriminator: mapping is not supported"); - if (!oneOf) - throw new Error("discriminator: requires oneOf keyword"); - const valid = gen.let("valid", false); - const tag = gen.const("tag", (0, codegen_1._)`${data}${(0, codegen_1.getProperty)(tagName)}`); - gen.if((0, codegen_1._)`typeof ${tag} == "string"`, () => validateMapping(), () => cxt.error(false, { discrError: types_1.DiscrError.Tag, tag, tagName })); - cxt.ok(valid); - function validateMapping() { - const mapping = getMapping(); - gen.if(false); - for (const tagValue in mapping) { - gen.elseIf((0, codegen_1._)`${tag} === ${tagValue}`); - gen.assign(valid, applyTagSchema(mapping[tagValue])); - } - gen.else(); - cxt.error(false, { discrError: types_1.DiscrError.Mapping, tag, tagName }); - gen.endIf(); - } - function applyTagSchema(schemaProp) { - const _valid = gen.name("valid"); - const schCxt = cxt.subschema({ keyword: "oneOf", schemaProp }, _valid); - cxt.mergeEvaluated(schCxt, codegen_1.Name); - return _valid; - } - function getMapping() { - var _a21; - const oneOfMapping = {}; - const topRequired = hasRequired(parentSchema); - let tagRequired = true; - for (let i = 0;i < oneOf.length; i++) { - let sch = oneOf[i]; - if ((sch === null || sch === undefined ? undefined : sch.$ref) && !(0, util_1.schemaHasRulesButRef)(sch, it.self.RULES)) { - const ref = sch.$ref; - sch = compile_1.resolveRef.call(it.self, it.schemaEnv.root, it.baseId, ref); - if (sch instanceof compile_1.SchemaEnv) - sch = sch.schema; - if (sch === undefined) - throw new ref_error_1.default(it.opts.uriResolver, it.baseId, ref); - } - const propSch = (_a21 = sch === null || sch === undefined ? undefined : sch.properties) === null || _a21 === undefined ? undefined : _a21[tagName]; - if (typeof propSch != "object") { - throw new Error(`discriminator: oneOf subschemas (or referenced schemas) must have "properties/${tagName}"`); - } - tagRequired = tagRequired && (topRequired || hasRequired(sch)); - addMappings(propSch, i); - } - if (!tagRequired) - throw new Error(`discriminator: "${tagName}" must be required`); - return oneOfMapping; - function hasRequired({ required: required2 }) { - return Array.isArray(required2) && required2.includes(tagName); - } - function addMappings(sch, i) { - if (sch.const) { - addMapping(sch.const, i); - } else if (sch.enum) { - for (const tagValue of sch.enum) { - addMapping(tagValue, i); - } - } else { - throw new Error(`discriminator: "properties/${tagName}" must have "const" or "enum"`); - } - } - function addMapping(tagValue, i) { - if (typeof tagValue != "string" || tagValue in oneOfMapping) { - throw new Error(`discriminator: "${tagName}" values must be unique strings`); - } - oneOfMapping[tagValue] = i; - } - } - } - }; - exports2.default = def; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/refs/json-schema-draft-07.json -var require_json_schema_draft_07 = __commonJS((exports2, module2) => { - module2.exports = { - $schema: "http://json-schema.org/draft-07/schema#", - $id: "http://json-schema.org/draft-07/schema#", - title: "Core schema meta-schema", - definitions: { - schemaArray: { - type: "array", - minItems: 1, - items: { $ref: "#" } - }, - nonNegativeInteger: { - type: "integer", - minimum: 0 - }, - nonNegativeIntegerDefault0: { - allOf: [{ $ref: "#/definitions/nonNegativeInteger" }, { default: 0 }] - }, - simpleTypes: { - enum: ["array", "boolean", "integer", "null", "number", "object", "string"] - }, - stringArray: { - type: "array", - items: { type: "string" }, - uniqueItems: true, - default: [] - } - }, - type: ["object", "boolean"], - properties: { - $id: { - type: "string", - format: "uri-reference" - }, - $schema: { - type: "string", - format: "uri" - }, - $ref: { - type: "string", - format: "uri-reference" - }, - $comment: { - type: "string" - }, - title: { - type: "string" - }, - description: { - type: "string" - }, - default: true, - readOnly: { - type: "boolean", - default: false - }, - examples: { - type: "array", - items: true - }, - multipleOf: { - type: "number", - exclusiveMinimum: 0 - }, - maximum: { - type: "number" - }, - exclusiveMaximum: { - type: "number" - }, - minimum: { - type: "number" - }, - exclusiveMinimum: { - type: "number" - }, - maxLength: { $ref: "#/definitions/nonNegativeInteger" }, - minLength: { $ref: "#/definitions/nonNegativeIntegerDefault0" }, - pattern: { - type: "string", - format: "regex" - }, - additionalItems: { $ref: "#" }, - items: { - anyOf: [{ $ref: "#" }, { $ref: "#/definitions/schemaArray" }], - default: true - }, - maxItems: { $ref: "#/definitions/nonNegativeInteger" }, - minItems: { $ref: "#/definitions/nonNegativeIntegerDefault0" }, - uniqueItems: { - type: "boolean", - default: false - }, - contains: { $ref: "#" }, - maxProperties: { $ref: "#/definitions/nonNegativeInteger" }, - minProperties: { $ref: "#/definitions/nonNegativeIntegerDefault0" }, - required: { $ref: "#/definitions/stringArray" }, - additionalProperties: { $ref: "#" }, - definitions: { - type: "object", - additionalProperties: { $ref: "#" }, - default: {} - }, - properties: { - type: "object", - additionalProperties: { $ref: "#" }, - default: {} - }, - patternProperties: { - type: "object", - additionalProperties: { $ref: "#" }, - propertyNames: { format: "regex" }, - default: {} - }, - dependencies: { - type: "object", - additionalProperties: { - anyOf: [{ $ref: "#" }, { $ref: "#/definitions/stringArray" }] - } - }, - propertyNames: { $ref: "#" }, - const: true, - enum: { - type: "array", - items: true, - minItems: 1, - uniqueItems: true - }, - type: { - anyOf: [ - { $ref: "#/definitions/simpleTypes" }, - { - type: "array", - items: { $ref: "#/definitions/simpleTypes" }, - minItems: 1, - uniqueItems: true - } - ] - }, - format: { type: "string" }, - contentMediaType: { type: "string" }, - contentEncoding: { type: "string" }, - if: { $ref: "#" }, - then: { $ref: "#" }, - else: { $ref: "#" }, - allOf: { $ref: "#/definitions/schemaArray" }, - anyOf: { $ref: "#/definitions/schemaArray" }, - oneOf: { $ref: "#/definitions/schemaArray" }, - not: { $ref: "#" } - }, - default: true - }; -}); - -// ../../node_modules/.pnpm/ajv@8.18.0/node_modules/ajv/dist/ajv.js -var require_ajv = __commonJS((exports2, module2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.MissingRefError = exports2.ValidationError = exports2.CodeGen = exports2.Name = exports2.nil = exports2.stringify = exports2.str = exports2._ = exports2.KeywordCxt = exports2.Ajv = undefined; - var core_1 = require_core(); - var draft7_1 = require_draft7(); - var discriminator_1 = require_discriminator(); - var draft7MetaSchema = require_json_schema_draft_07(); - var META_SUPPORT_DATA = ["/properties"]; - var META_SCHEMA_ID = "http://json-schema.org/draft-07/schema"; - - class Ajv extends core_1.default { - _addVocabularies() { - super._addVocabularies(); - draft7_1.default.forEach((v) => this.addVocabulary(v)); - if (this.opts.discriminator) - this.addKeyword(discriminator_1.default); - } - _addDefaultMetaSchema() { - super._addDefaultMetaSchema(); - if (!this.opts.meta) - return; - const metaSchema = this.opts.$data ? this.$dataMetaSchema(draft7MetaSchema, META_SUPPORT_DATA) : draft7MetaSchema; - this.addMetaSchema(metaSchema, META_SCHEMA_ID, false); - this.refs["http://json-schema.org/schema"] = META_SCHEMA_ID; - } - defaultMeta() { - return this.opts.defaultMeta = super.defaultMeta() || (this.getSchema(META_SCHEMA_ID) ? META_SCHEMA_ID : undefined); - } - } - exports2.Ajv = Ajv; - module2.exports = exports2 = Ajv; - module2.exports.Ajv = Ajv; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.default = Ajv; - var validate_1 = require_validate(); - Object.defineProperty(exports2, "KeywordCxt", { enumerable: true, get: function() { - return validate_1.KeywordCxt; - } }); - var codegen_1 = require_codegen(); - Object.defineProperty(exports2, "_", { enumerable: true, get: function() { - return codegen_1._; - } }); - Object.defineProperty(exports2, "str", { enumerable: true, get: function() { - return codegen_1.str; - } }); - Object.defineProperty(exports2, "stringify", { enumerable: true, get: function() { - return codegen_1.stringify; - } }); - Object.defineProperty(exports2, "nil", { enumerable: true, get: function() { - return codegen_1.nil; - } }); - Object.defineProperty(exports2, "Name", { enumerable: true, get: function() { - return codegen_1.Name; - } }); - Object.defineProperty(exports2, "CodeGen", { enumerable: true, get: function() { - return codegen_1.CodeGen; - } }); - var validation_error_1 = require_validation_error(); - Object.defineProperty(exports2, "ValidationError", { enumerable: true, get: function() { - return validation_error_1.default; - } }); - var ref_error_1 = require_ref_error(); - Object.defineProperty(exports2, "MissingRefError", { enumerable: true, get: function() { - return ref_error_1.default; - } }); -}); - -// ../../node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/formats.js -var require_formats = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.formatNames = exports2.fastFormats = exports2.fullFormats = undefined; - function fmtDef(validate, compare) { - return { validate, compare }; - } - exports2.fullFormats = { - date: fmtDef(date6, compareDate), - time: fmtDef(getTime(true), compareTime), - "date-time": fmtDef(getDateTime(true), compareDateTime), - "iso-time": fmtDef(getTime(), compareIsoTime), - "iso-date-time": fmtDef(getDateTime(), compareIsoDateTime), - duration: /^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$/, - uri, - "uri-reference": /^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'"()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i, - "uri-template": /^(?:(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i, - url: /^(?:https?|ftp):\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)(?:\.(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)*(?:\.(?:[a-z\u{00a1}-\u{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu, - email: /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, - hostname: /^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$/i, - ipv4: /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/, - ipv6: /^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i, - regex, - uuid: /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i, - "json-pointer": /^(?:\/(?:[^~/]|~0|~1)*)*$/, - "json-pointer-uri-fragment": /^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i, - "relative-json-pointer": /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/, - byte, - int32: { type: "number", validate: validateInt32 }, - int64: { type: "number", validate: validateInt64 }, - float: { type: "number", validate: validateNumber }, - double: { type: "number", validate: validateNumber }, - password: true, - binary: true - }; - exports2.fastFormats = { - ...exports2.fullFormats, - date: fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\d$/, compareDate), - time: fmtDef(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, compareTime), - "date-time": fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\dt(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i, compareDateTime), - "iso-time": fmtDef(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, compareIsoTime), - "iso-date-time": fmtDef(/^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i, compareIsoDateTime), - uri: /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/)?[^\s]*$/i, - "uri-reference": /^(?:(?:[a-z][a-z0-9+\-.]*:)?\/?\/)?(?:[^\\\s#][^\s#]*)?(?:#[^\\\s]*)?$/i, - email: /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i - }; - exports2.formatNames = Object.keys(exports2.fullFormats); - function isLeapYear(year) { - return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - } - var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; - var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - function date6(str) { - const matches = DATE.exec(str); - if (!matches) - return false; - const year = +matches[1]; - const month = +matches[2]; - const day = +matches[3]; - return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && isLeapYear(year) ? 29 : DAYS[month]); - } - function compareDate(d1, d2) { - if (!(d1 && d2)) - return; - if (d1 > d2) - return 1; - if (d1 < d2) - return -1; - return 0; - } - var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i; - function getTime(strictTimeZone) { - return function time3(str) { - const matches = TIME.exec(str); - if (!matches) - return false; - const hr = +matches[1]; - const min = +matches[2]; - const sec = +matches[3]; - const tz = matches[4]; - const tzSign = matches[5] === "-" ? -1 : 1; - const tzH = +(matches[6] || 0); - const tzM = +(matches[7] || 0); - if (tzH > 23 || tzM > 59 || strictTimeZone && !tz) - return false; - if (hr <= 23 && min <= 59 && sec < 60) - return true; - const utcMin = min - tzM * tzSign; - const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0); - return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61; - }; - } - function compareTime(s1, s2) { - if (!(s1 && s2)) - return; - const t1 = new Date("2020-01-01T" + s1).valueOf(); - const t2 = new Date("2020-01-01T" + s2).valueOf(); - if (!(t1 && t2)) - return; - return t1 - t2; - } - function compareIsoTime(t1, t2) { - if (!(t1 && t2)) - return; - const a1 = TIME.exec(t1); - const a2 = TIME.exec(t2); - if (!(a1 && a2)) - return; - t1 = a1[1] + a1[2] + a1[3]; - t2 = a2[1] + a2[2] + a2[3]; - if (t1 > t2) - return 1; - if (t1 < t2) - return -1; - return 0; - } - var DATE_TIME_SEPARATOR = /t|\s/i; - function getDateTime(strictTimeZone) { - const time3 = getTime(strictTimeZone); - return function date_time(str) { - const dateTime = str.split(DATE_TIME_SEPARATOR); - return dateTime.length === 2 && date6(dateTime[0]) && time3(dateTime[1]); - }; - } - function compareDateTime(dt1, dt2) { - if (!(dt1 && dt2)) - return; - const d1 = new Date(dt1).valueOf(); - const d2 = new Date(dt2).valueOf(); - if (!(d1 && d2)) - return; - return d1 - d2; - } - function compareIsoDateTime(dt1, dt2) { - if (!(dt1 && dt2)) - return; - const [d1, t1] = dt1.split(DATE_TIME_SEPARATOR); - const [d2, t2] = dt2.split(DATE_TIME_SEPARATOR); - const res = compareDate(d1, d2); - if (res === undefined) - return; - return res || compareTime(t1, t2); - } - var NOT_URI_FRAGMENT = /\/|:/; - var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i; - function uri(str) { - return NOT_URI_FRAGMENT.test(str) && URI.test(str); - } - var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm; - function byte(str) { - BYTE.lastIndex = 0; - return BYTE.test(str); - } - var MIN_INT32 = -(2 ** 31); - var MAX_INT32 = 2 ** 31 - 1; - function validateInt32(value) { - return Number.isInteger(value) && value <= MAX_INT32 && value >= MIN_INT32; - } - function validateInt64(value) { - return Number.isInteger(value); - } - function validateNumber() { - return true; - } - var Z_ANCHOR = /[^\\]\\Z/; - function regex(str) { - if (Z_ANCHOR.test(str)) - return false; - try { - new RegExp(str); - return true; - } catch (e) { - return false; - } - } -}); - -// ../../node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/limit.js -var require_limit = __commonJS((exports2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.formatLimitDefinition = undefined; - var ajv_1 = require_ajv(); - var codegen_1 = require_codegen(); - var ops = codegen_1.operators; - var KWDs = { - formatMaximum: { okStr: "<=", ok: ops.LTE, fail: ops.GT }, - formatMinimum: { okStr: ">=", ok: ops.GTE, fail: ops.LT }, - formatExclusiveMaximum: { okStr: "<", ok: ops.LT, fail: ops.GTE }, - formatExclusiveMinimum: { okStr: ">", ok: ops.GT, fail: ops.LTE } - }; - var error48 = { - message: ({ keyword, schemaCode }) => (0, codegen_1.str)`should be ${KWDs[keyword].okStr} ${schemaCode}`, - params: ({ keyword, schemaCode }) => (0, codegen_1._)`{comparison: ${KWDs[keyword].okStr}, limit: ${schemaCode}}` - }; - exports2.formatLimitDefinition = { - keyword: Object.keys(KWDs), - type: "string", - schemaType: "string", - $data: true, - error: error48, - code(cxt) { - const { gen, data, schemaCode, keyword, it } = cxt; - const { opts, self } = it; - if (!opts.validateFormats) - return; - const fCxt = new ajv_1.KeywordCxt(it, self.RULES.all.format.definition, "format"); - if (fCxt.$data) - validate$DataFormat(); - else - validateFormat(); - function validate$DataFormat() { - const fmts = gen.scopeValue("formats", { - ref: self.formats, - code: opts.code.formats - }); - const fmt = gen.const("fmt", (0, codegen_1._)`${fmts}[${fCxt.schemaCode}]`); - cxt.fail$data((0, codegen_1.or)((0, codegen_1._)`typeof ${fmt} != "object"`, (0, codegen_1._)`${fmt} instanceof RegExp`, (0, codegen_1._)`typeof ${fmt}.compare != "function"`, compareCode(fmt))); - } - function validateFormat() { - const format = fCxt.schema; - const fmtDef = self.formats[format]; - if (!fmtDef || fmtDef === true) - return; - if (typeof fmtDef != "object" || fmtDef instanceof RegExp || typeof fmtDef.compare != "function") { - throw new Error(`"${keyword}": format "${format}" does not define "compare" function`); - } - const fmt = gen.scopeValue("formats", { - key: format, - ref: fmtDef, - code: opts.code.formats ? (0, codegen_1._)`${opts.code.formats}${(0, codegen_1.getProperty)(format)}` : undefined - }); - cxt.fail$data(compareCode(fmt)); - } - function compareCode(fmt) { - return (0, codegen_1._)`${fmt}.compare(${data}, ${schemaCode}) ${KWDs[keyword].fail} 0`; - } - }, - dependencies: ["format"] - }; - var formatLimitPlugin = (ajv) => { - ajv.addKeyword(exports2.formatLimitDefinition); - return ajv; - }; - exports2.default = formatLimitPlugin; -}); - -// ../../node_modules/.pnpm/ajv-formats@3.0.1_ajv@8.18.0/node_modules/ajv-formats/dist/index.js -var require_dist8 = __commonJS((exports2, module2) => { - Object.defineProperty(exports2, "__esModule", { value: true }); - var formats_1 = require_formats(); - var limit_1 = require_limit(); - var codegen_1 = require_codegen(); - var fullName = new codegen_1.Name("fullFormats"); - var fastName = new codegen_1.Name("fastFormats"); - var formatsPlugin = (ajv, opts = { keywords: true }) => { - if (Array.isArray(opts)) { - addFormats(ajv, opts, formats_1.fullFormats, fullName); - return ajv; - } - const [formats, exportName] = opts.mode === "fast" ? [formats_1.fastFormats, fastName] : [formats_1.fullFormats, fullName]; - const list = opts.formats || formats_1.formatNames; - addFormats(ajv, list, formats, exportName); - if (opts.keywords) - (0, limit_1.default)(ajv); - return ajv; - }; - formatsPlugin.get = (name21, mode = "full") => { - const formats = mode === "fast" ? formats_1.fastFormats : formats_1.fullFormats; - const f = formats[name21]; - if (!f) - throw new Error(`Unknown format "${name21}"`); - return f; - }; - function addFormats(ajv, list, fs, exportName) { - var _a21; - var _b16; - (_a21 = (_b16 = ajv.opts.code).formats) !== null && _a21 !== undefined || (_b16.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`); - for (const f of list) - ajv.addFormat(f, fs[f]); - } - module2.exports = exports2 = formatsPlugin; - Object.defineProperty(exports2, "__esModule", { value: true }); - exports2.default = formatsPlugin; -}); - -// src/index.ts -var exports_src = {}; -__export(exports_src, { - validateUpstreamBaseURL: () => validateUpstreamBaseURL, - validatePathIsInAllowlist: () => validatePathIsInAllowlist, - resolveWebUiBindAddress: () => resolveWebUiBindAddress, - resolveReverseProxyBindAddress: () => resolveReverseProxyBindAddress, - resolveReverseProxyAdvertisedHost: () => resolveReverseProxyAdvertisedHost, - resolveMcpBindAddress: () => resolveMcpBindAddress, - resolveMcpAdvertisedHost: () => resolveMcpAdvertisedHost, - resolveFolderExistence: () => resolveFolderExistence, - rejectUnauthorized: () => rejectUnauthorized, - registerCoreRoutes: () => registerCoreRoutes, - parseBearerToken: () => parseBearerToken, - migrateAIConfig: () => migrateAIConfig, - isRequestAuthorized: () => isRequestAuthorized, - isError: () => isError2, - isAuthTokenValid: () => isAuthTokenValid, - handleWriteFilePost: () => handleWriteFilePost, - handleUpdatePlanPost: () => handleUpdatePlanPost, - handleRenameFolderPost: () => handleRenameFolderPost, - handleRenameFilesPost: () => handleRenameFilesPost, - handleReadImagePost: () => handleReadImagePost, - handleReadFilePost: () => handleReadFilePost, - handleProxyRequest: () => handleProxyRequest, - handleMcpStopPut: () => handleMcpStopPut, - handleMcpStatusGet: () => handleMcpStatusGet, - handleMcpStartPut: () => handleMcpStartPut, - handleListFilesPost: () => handleListFilesPost, - handleListFilesInMediaFolderPost: () => handleListFilesInMediaFolderPost, - handleListFilesGet: () => handleListFilesGet, - handleIsFolderAvailablePost: () => handleIsFolderAvailablePost, - handleHelloPost: () => handleHelloPost, - handleGetPlansPost: () => handleGetPlansPost, - handleGetEpisodesPost: () => handleGetEpisodesPost, - handleDownloadImageGet: () => handleDownloadImageGet, - handleDownloadImageAsFilePost: () => handleDownloadImageAsFilePost, - handleDiscoverGet: () => handleDiscoverGet, - handleDeleteFolderPost: () => handleDeleteFolderPost, - handleDeleteFilePost: () => handleDeleteFilePost, - handleCreatePlanPost: () => handleCreatePlanPost, - handleCoreRoutesRequest: () => handleCoreRoutesRequest, - handleChatPost: () => handleChatPost, - findAvailableReverseProxyPort: () => findAvailableReverseProxyPort, - filterResponseHeaders: () => filterResponseHeaders, - filterRequestHeaders: () => filterRequestHeaders, - enforceCoreRoutesAuth: () => enforceCoreRoutesAuth, - doWriteFile: () => doWriteFile, - doUpdatePlan: () => doUpdatePlan, - doRenameFolder: () => doRenameFolder, - doRenameFiles: () => doRenameFiles, - doReadImage: () => doReadImage, - doReadFile: () => doReadFile, - doMcpStop: () => doMcpStop, - doMcpStart: () => doMcpStart, - doMcpGetStatus: () => doMcpGetStatus, - doListFilesInMediaFolder: () => doListFilesInMediaFolder, - doListFiles: () => doListFiles, - doIsFolderAvailable: () => doIsFolderAvailable, - doHello: () => doHello, - doGetPlans: () => doGetPlans, - doGetPlanById: () => doGetPlanById, - doGetEpisodes: () => doGetEpisodes, - doFetchDiscoveredMediaDatabases: () => doFetchDiscoveredMediaDatabases, - doFetchDiscoverConfig: () => doFetchDiscoverConfig, - doDownloadImageAsFile: () => doDownloadImageAsFile, - doDownloadImage: () => doDownloadImage, - doDeleteFolder: () => doDeleteFolder, - doDeleteFile: () => doDeleteFile, - doCreatePlan: () => doCreatePlan, - doChat: () => doChat, - defaultChatFs: () => defaultChatFs, - createSuccessResponse: () => createSuccessResponse, - createStreamingNodeHttpFetch: () => createStreamingNodeHttpFetch, - createSocketIOManager: () => createSocketIOManager, - createReverseProxyRequestHandler: () => createReverseProxyRequestHandler, - createReverseProxyManager: () => createReverseProxyManager, - createProxiedFetch: () => createProxiedFetch, - createOpenAICompatible: () => createOpenAICompatible, - createNodeHttpFetch: () => createNodeHttpFetch, - createMcpStreamableHttpHandler: () => createMcpStreamableHttpHandler, - createErrorResponse: () => createErrorResponse, - createCoreRoutesRequestHandler: () => createCoreRoutesRequestHandler, - createChatTools: () => createChatTools, - coreRouteHandlers: () => coreRouteHandlers, - cleanupStalePlans: () => cleanupStalePlans, - cleanPreparingPlans: () => cleanPreparingPlans, - checkFolderPathAvailable: () => checkFolderPathAvailable, - checkFileIsReadable: () => checkFileIsReadable, - buildUpstreamUrl: () => buildUpstreamUrl, - applyMcpLifecycleFromConfig: () => applyMcpLifecycleFromConfig, - PORT_RANGE_START: () => PORT_RANGE_START, - PORT_RANGE_END: () => PORT_RANGE_END, - MCP_TOOL_NAMES: () => MCP_TOOL_NAMES, - FALLBACK_DISCOVER_CONFIG: () => FALLBACK_DISCOVER_CONFIG, - ExistedFileError: () => ExistedFileError, - EMPTY_DISCOVER_CONFIG: () => EMPTY_DISCOVER_CONFIG, - DISCOVER_TIMEOUT_MS: () => DISCOVER_TIMEOUT_MS, - DEFAULT_DISCOVER_CONFIG_URL: () => DEFAULT_DISCOVER_CONFIG_URL, - DEFAULT_ALLOWED_UPSTREAM_HOSTS: () => DEFAULT_ALLOWED_UPSTREAM_HOSTS -}); -module.exports = __toCommonJS(exports_src); - -// src/auth.ts -var import_node_crypto = require("node:crypto"); - -// src/http.ts -function createRequestUrl(req, fallbackPort) { - const host = req.headers.host ?? `127.0.0.1:${fallbackPort}`; - return new URL(req.url ?? "/", `http://${host}`); -} -function sendJson(res, status, body) { - const payload = JSON.stringify(body); - res.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Content-Length": Buffer.byteLength(payload, "utf8") - }); - res.end(payload); -} -async function readJsonBody(req) { - const chunks = []; - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const raw = Buffer.concat(chunks).toString("utf8").trim(); - if (!raw) { - return {}; - } - return JSON.parse(raw); -} -function parseBooleanQuery(value) { - if (value === undefined || value === null) { - return; - } - return value === "true"; -} - -// src/auth.ts -function parseBearerToken(authorizationHeader) { - const raw = Array.isArray(authorizationHeader) ? authorizationHeader[0] : authorizationHeader; - if (!raw) { - return null; - } - const match = /^Bearer\s+(.+)$/i.exec(raw.trim()); - const token = match?.[1]?.trim(); - return token ? token : null; -} -function isAuthTokenValid(authorizationHeader, expectedToken) { - const token = parseBearerToken(authorizationHeader); - if (!token) { - return false; - } - const provided = Buffer.from(token, "utf8"); - const expected = Buffer.from(expectedToken, "utf8"); - if (provided.length !== expected.length) { - return false; - } - return import_node_crypto.timingSafeEqual(provided, expected); -} -function rejectUnauthorized(res) { - sendJson(res, 401, { error: "Unauthorized: invalid or missing token" }); -} -function enforceCoreRoutesAuth(req, res, config) { - const auth = config.auth; - if (!auth?.enabled) { - return false; - } - if (req.method === "OPTIONS") { - return false; - } - if (isAuthTokenValid(req.headers.authorization, auth.token)) { - return false; - } - rejectUnauthorized(res); - return true; -} -function isRequestAuthorized(authorizationHeader, auth) { - if (!auth?.enabled) { - return true; - } - return isAuthTokenValid(authorizationHeader, auth.token); -} -// src/allowlist.ts -function validatePathIsInAllowlist(filePath, allowlist) { - return allowlist.some((allowlistItem) => filePath.startsWith(allowlistItem)); -} -// src/bindAddresses.ts -var DEFAULT_BIND_ADDRESS = "127.0.0.1"; -function resolveWebUiBindAddress() { - const fromEnv = process.env.WEBUI_ADDRESS?.trim(); - return fromEnv || DEFAULT_BIND_ADDRESS; -} -function resolveReverseProxyBindAddress() { - const fromEnv = process.env.REVERSE_PROXY_ADDRESS?.trim(); - return fromEnv || DEFAULT_BIND_ADDRESS; -} -function resolveMcpBindAddress(fallback) { - const fromEnv = process.env.MCP_ADDRESS?.trim(); - if (fromEnv) { - return fromEnv; - } - const fb = fallback?.trim(); - return fb || DEFAULT_BIND_ADDRESS; -} -function resolveMcpAdvertisedHost(bindAddress) { - return resolveReverseProxyAdvertisedHost(bindAddress); -} -function resolveReverseProxyAdvertisedHost(bindAddress) { - if (bindAddress === "0.0.0.0" || bindAddress === "::") { - return DEFAULT_BIND_ADDRESS; - } - return bindAddress; -} -// src/hello.ts -function doHello(options) { - return { - uptime: process.uptime(), - ...options, - platform: options.platform ?? process.platform - }; -} -// ../../node_modules/.pnpm/@ai-sdk+provider@3.0.8/node_modules/@ai-sdk/provider/dist/index.mjs -var marker = "vercel.ai.error"; -var symbol = Symbol.for(marker); -var _a; -var _b; -var AISDKError = class _AISDKError extends (_b = Error, _a = symbol, _b) { - constructor({ - name: name14, - message, - cause - }) { - super(message); - this[_a] = true; - this.name = name14; - this.cause = cause; - } - static isInstance(error) { - return _AISDKError.hasMarker(error, marker); - } - static hasMarker(error, marker15) { - const markerSymbol = Symbol.for(marker15); - return error != null && typeof error === "object" && markerSymbol in error && typeof error[markerSymbol] === "boolean" && error[markerSymbol] === true; - } -}; -var name = "AI_APICallError"; -var marker2 = `vercel.ai.error.${name}`; -var symbol2 = Symbol.for(marker2); -var _a2; -var _b2; -var APICallError = class extends (_b2 = AISDKError, _a2 = symbol2, _b2) { - constructor({ - message, - url, - requestBodyValues, - statusCode, - responseHeaders, - responseBody, - cause, - isRetryable = statusCode != null && (statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500), - data - }) { - super({ name, message, cause }); - this[_a2] = true; - this.url = url; - this.requestBodyValues = requestBodyValues; - this.statusCode = statusCode; - this.responseHeaders = responseHeaders; - this.responseBody = responseBody; - this.isRetryable = isRetryable; - this.data = data; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker2); - } -}; -var name2 = "AI_EmptyResponseBodyError"; -var marker3 = `vercel.ai.error.${name2}`; -var symbol3 = Symbol.for(marker3); -var _a3; -var _b3; -var EmptyResponseBodyError = class extends (_b3 = AISDKError, _a3 = symbol3, _b3) { - constructor({ message = "Empty response body" } = {}) { - super({ name: name2, message }); - this[_a3] = true; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker3); - } -}; -function getErrorMessage(error) { - if (error == null) { - return "unknown error"; - } - if (typeof error === "string") { - return error; - } - if (error instanceof Error) { - return error.message; - } - return JSON.stringify(error); -} -var name3 = "AI_InvalidArgumentError"; -var marker4 = `vercel.ai.error.${name3}`; -var symbol4 = Symbol.for(marker4); -var _a4; -var _b4; -var InvalidArgumentError = class extends (_b4 = AISDKError, _a4 = symbol4, _b4) { - constructor({ - message, - cause, - argument - }) { - super({ name: name3, message, cause }); - this[_a4] = true; - this.argument = argument; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker4); - } -}; -var name4 = "AI_InvalidPromptError"; -var marker5 = `vercel.ai.error.${name4}`; -var symbol5 = Symbol.for(marker5); -var _a5; -var _b5; -var InvalidPromptError = class extends (_b5 = AISDKError, _a5 = symbol5, _b5) { - constructor({ - prompt, - message, - cause - }) { - super({ name: name4, message: `Invalid prompt: ${message}`, cause }); - this[_a5] = true; - this.prompt = prompt; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker5); - } -}; -var name5 = "AI_InvalidResponseDataError"; -var marker6 = `vercel.ai.error.${name5}`; -var symbol6 = Symbol.for(marker6); -var _a6; -var _b6; -var InvalidResponseDataError = class extends (_b6 = AISDKError, _a6 = symbol6, _b6) { - constructor({ - data, - message = `Invalid response data: ${JSON.stringify(data)}.` - }) { - super({ name: name5, message }); - this[_a6] = true; - this.data = data; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker6); - } -}; -var name6 = "AI_JSONParseError"; -var marker7 = `vercel.ai.error.${name6}`; -var symbol7 = Symbol.for(marker7); -var _a7; -var _b7; -var JSONParseError = class extends (_b7 = AISDKError, _a7 = symbol7, _b7) { - constructor({ text, cause }) { - super({ - name: name6, - message: `JSON parsing failed: Text: ${text}. -Error message: ${getErrorMessage(cause)}`, - cause - }); - this[_a7] = true; - this.text = text; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker7); - } -}; -var name7 = "AI_LoadAPIKeyError"; -var marker8 = `vercel.ai.error.${name7}`; -var symbol8 = Symbol.for(marker8); -var _a8; -var _b8; -var LoadAPIKeyError = class extends (_b8 = AISDKError, _a8 = symbol8, _b8) { - constructor({ message }) { - super({ name: name7, message }); - this[_a8] = true; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker8); - } -}; -var name8 = "AI_LoadSettingError"; -var marker9 = `vercel.ai.error.${name8}`; -var symbol9 = Symbol.for(marker9); -var _a9; -var _b9; -var LoadSettingError = class extends (_b9 = AISDKError, _a9 = symbol9, _b9) { - constructor({ message }) { - super({ name: name8, message }); - this[_a9] = true; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker9); - } -}; -var name9 = "AI_NoContentGeneratedError"; -var marker10 = `vercel.ai.error.${name9}`; -var symbol10 = Symbol.for(marker10); -var _a10; -var _b10; -var NoContentGeneratedError = class extends (_b10 = AISDKError, _a10 = symbol10, _b10) { - constructor({ - message = "No content generated." - } = {}) { - super({ name: name9, message }); - this[_a10] = true; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker10); - } -}; -var name10 = "AI_NoSuchModelError"; -var marker11 = `vercel.ai.error.${name10}`; -var symbol11 = Symbol.for(marker11); -var _a11; -var _b11; -var NoSuchModelError = class extends (_b11 = AISDKError, _a11 = symbol11, _b11) { - constructor({ - errorName = name10, - modelId, - modelType, - message = `No such ${modelType}: ${modelId}` - }) { - super({ name: errorName, message }); - this[_a11] = true; - this.modelId = modelId; - this.modelType = modelType; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker11); - } -}; -var name11 = "AI_TooManyEmbeddingValuesForCallError"; -var marker12 = `vercel.ai.error.${name11}`; -var symbol12 = Symbol.for(marker12); -var _a12; -var _b12; -var TooManyEmbeddingValuesForCallError = class extends (_b12 = AISDKError, _a12 = symbol12, _b12) { - constructor(options) { - super({ - name: name11, - message: `Too many values for a single embedding call. The ${options.provider} model "${options.modelId}" can only embed up to ${options.maxEmbeddingsPerCall} values per call, but ${options.values.length} values were provided.` - }); - this[_a12] = true; - this.provider = options.provider; - this.modelId = options.modelId; - this.maxEmbeddingsPerCall = options.maxEmbeddingsPerCall; - this.values = options.values; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker12); - } -}; -var name12 = "AI_TypeValidationError"; -var marker13 = `vercel.ai.error.${name12}`; -var symbol13 = Symbol.for(marker13); -var _a13; -var _b13; -var TypeValidationError = class _TypeValidationError extends (_b13 = AISDKError, _a13 = symbol13, _b13) { - constructor({ - value, - cause, - context - }) { - let contextPrefix = "Type validation failed"; - if (context == null ? undefined : context.field) { - contextPrefix += ` for ${context.field}`; - } - if ((context == null ? undefined : context.entityName) || (context == null ? undefined : context.entityId)) { - contextPrefix += " ("; - const parts = []; - if (context.entityName) { - parts.push(context.entityName); - } - if (context.entityId) { - parts.push(`id: "${context.entityId}"`); - } - contextPrefix += parts.join(", "); - contextPrefix += ")"; - } - super({ - name: name12, - message: `${contextPrefix}: Value: ${JSON.stringify(value)}. -Error message: ${getErrorMessage(cause)}`, - cause - }); - this[_a13] = true; - this.value = value; - this.context = context; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker13); - } - static wrap({ - value, - cause, - context - }) { - var _a15, _b15, _c; - if (_TypeValidationError.isInstance(cause) && cause.value === value && ((_a15 = cause.context) == null ? undefined : _a15.field) === (context == null ? undefined : context.field) && ((_b15 = cause.context) == null ? undefined : _b15.entityName) === (context == null ? undefined : context.entityName) && ((_c = cause.context) == null ? undefined : _c.entityId) === (context == null ? undefined : context.entityId)) { - return cause; - } - return new _TypeValidationError({ value, cause, context }); - } -}; -var name13 = "AI_UnsupportedFunctionalityError"; -var marker14 = `vercel.ai.error.${name13}`; -var symbol14 = Symbol.for(marker14); -var _a14; -var _b14; -var UnsupportedFunctionalityError = class extends (_b14 = AISDKError, _a14 = symbol14, _b14) { - constructor({ - functionality, - message = `'${functionality}' functionality not supported.` - }) { - super({ name: name13, message }); - this[_a14] = true; - this.functionality = functionality; - } - static isInstance(error) { - return AISDKError.hasMarker(error, marker14); - } -}; - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.js -var exports_external = {}; -__export(exports_external, { - xor: () => xor, - xid: () => xid2, - void: () => _void2, - uuidv7: () => uuidv7, - uuidv6: () => uuidv6, - uuidv4: () => uuidv4, - uuid: () => uuid2, - util: () => exports_util, - url: () => url, - uppercase: () => _uppercase, - unknown: () => unknown, - union: () => union, - undefined: () => _undefined3, - ulid: () => ulid2, - uint64: () => uint64, - uint32: () => uint32, - tuple: () => tuple, - trim: () => _trim, - treeifyError: () => treeifyError, - transform: () => transform, - toUpperCase: () => _toUpperCase, - toLowerCase: () => _toLowerCase, - toJSONSchema: () => toJSONSchema, - templateLiteral: () => templateLiteral, - symbol: () => symbol15, - superRefine: () => superRefine, - success: () => success, - stringbool: () => stringbool, - stringFormat: () => stringFormat, - string: () => string2, - strictObject: () => strictObject, - startsWith: () => _startsWith, - slugify: () => _slugify, - size: () => _size, - setErrorMap: () => setErrorMap, - set: () => set, - safeParseAsync: () => safeParseAsync2, - safeParse: () => safeParse2, - safeEncodeAsync: () => safeEncodeAsync2, - safeEncode: () => safeEncode2, - safeDecodeAsync: () => safeDecodeAsync2, - safeDecode: () => safeDecode2, - registry: () => registry, - regexes: () => exports_regexes, - regex: () => _regex, - refine: () => refine, - record: () => record, - readonly: () => readonly, - property: () => _property, - promise: () => promise, - prettifyError: () => prettifyError, - preprocess: () => preprocess, - prefault: () => prefault, - positive: () => _positive, - pipe: () => pipe, - partialRecord: () => partialRecord, - parseAsync: () => parseAsync2, - parse: () => parse3, - overwrite: () => _overwrite, - optional: () => optional, - object: () => object, - number: () => number2, - nullish: () => nullish2, - nullable: () => nullable, - null: () => _null3, - normalize: () => _normalize, - nonpositive: () => _nonpositive, - nonoptional: () => nonoptional, - nonnegative: () => _nonnegative, - never: () => never, - negative: () => _negative, - nativeEnum: () => nativeEnum, - nanoid: () => nanoid2, - nan: () => nan, - multipleOf: () => _multipleOf, - minSize: () => _minSize, - minLength: () => _minLength, - mime: () => _mime, - meta: () => meta2, - maxSize: () => _maxSize, - maxLength: () => _maxLength, - map: () => map, - mac: () => mac2, - lte: () => _lte, - lt: () => _lt, - lowercase: () => _lowercase, - looseRecord: () => looseRecord, - looseObject: () => looseObject, - locales: () => exports_locales, - literal: () => literal, - length: () => _length, - lazy: () => lazy, - ksuid: () => ksuid2, - keyof: () => keyof, - jwt: () => jwt, - json: () => json, - iso: () => exports_iso, - ipv6: () => ipv62, - ipv4: () => ipv42, - intersection: () => intersection, - int64: () => int64, - int32: () => int32, - int: () => int, - instanceof: () => _instanceof, - includes: () => _includes, - httpUrl: () => httpUrl, - hostname: () => hostname2, - hex: () => hex2, - hash: () => hash, - guid: () => guid2, - gte: () => _gte, - gt: () => _gt, - globalRegistry: () => globalRegistry, - getErrorMap: () => getErrorMap, - function: () => _function, - fromJSONSchema: () => fromJSONSchema, - formatError: () => formatError, - float64: () => float64, - float32: () => float32, - flattenError: () => flattenError, - file: () => file, - exactOptional: () => exactOptional, - enum: () => _enum2, - endsWith: () => _endsWith, - encodeAsync: () => encodeAsync2, - encode: () => encode2, - emoji: () => emoji2, - email: () => email2, - e164: () => e1642, - discriminatedUnion: () => discriminatedUnion, - describe: () => describe2, - decodeAsync: () => decodeAsync2, - decode: () => decode2, - date: () => date3, - custom: () => custom, - cuid2: () => cuid22, - cuid: () => cuid3, - core: () => exports_core2, - config: () => config, - coerce: () => exports_coerce, - codec: () => codec, - clone: () => clone, - cidrv6: () => cidrv62, - cidrv4: () => cidrv42, - check: () => check, - catch: () => _catch2, - boolean: () => boolean2, - bigint: () => bigint2, - base64url: () => base64url2, - base64: () => base642, - array: () => array, - any: () => any, - _function: () => _function, - _default: () => _default2, - _ZodString: () => _ZodString, - ZodXor: () => ZodXor, - ZodXID: () => ZodXID, - ZodVoid: () => ZodVoid, - ZodUnknown: () => ZodUnknown, - ZodUnion: () => ZodUnion, - ZodUndefined: () => ZodUndefined, - ZodUUID: () => ZodUUID, - ZodURL: () => ZodURL, - ZodULID: () => ZodULID, - ZodType: () => ZodType, - ZodTuple: () => ZodTuple, - ZodTransform: () => ZodTransform, - ZodTemplateLiteral: () => ZodTemplateLiteral, - ZodSymbol: () => ZodSymbol, - ZodSuccess: () => ZodSuccess, - ZodStringFormat: () => ZodStringFormat, - ZodString: () => ZodString, - ZodSet: () => ZodSet, - ZodRecord: () => ZodRecord, - ZodRealError: () => ZodRealError, - ZodReadonly: () => ZodReadonly, - ZodPromise: () => ZodPromise, - ZodPrefault: () => ZodPrefault, - ZodPipe: () => ZodPipe, - ZodOptional: () => ZodOptional, - ZodObject: () => ZodObject, - ZodNumberFormat: () => ZodNumberFormat, - ZodNumber: () => ZodNumber, - ZodNullable: () => ZodNullable, - ZodNull: () => ZodNull, - ZodNonOptional: () => ZodNonOptional, - ZodNever: () => ZodNever, - ZodNanoID: () => ZodNanoID, - ZodNaN: () => ZodNaN, - ZodMap: () => ZodMap, - ZodMAC: () => ZodMAC, - ZodLiteral: () => ZodLiteral, - ZodLazy: () => ZodLazy, - ZodKSUID: () => ZodKSUID, - ZodJWT: () => ZodJWT, - ZodIssueCode: () => ZodIssueCode, - ZodIntersection: () => ZodIntersection, - ZodISOTime: () => ZodISOTime, - ZodISODuration: () => ZodISODuration, - ZodISODateTime: () => ZodISODateTime, - ZodISODate: () => ZodISODate, - ZodIPv6: () => ZodIPv6, - ZodIPv4: () => ZodIPv4, - ZodGUID: () => ZodGUID, - ZodFunction: () => ZodFunction, - ZodFirstPartyTypeKind: () => ZodFirstPartyTypeKind, - ZodFile: () => ZodFile, - ZodExactOptional: () => ZodExactOptional, - ZodError: () => ZodError, - ZodEnum: () => ZodEnum, - ZodEmoji: () => ZodEmoji, - ZodEmail: () => ZodEmail, - ZodE164: () => ZodE164, - ZodDiscriminatedUnion: () => ZodDiscriminatedUnion, - ZodDefault: () => ZodDefault, - ZodDate: () => ZodDate, - ZodCustomStringFormat: () => ZodCustomStringFormat, - ZodCustom: () => ZodCustom, - ZodCodec: () => ZodCodec, - ZodCatch: () => ZodCatch, - ZodCUID2: () => ZodCUID2, - ZodCUID: () => ZodCUID, - ZodCIDRv6: () => ZodCIDRv6, - ZodCIDRv4: () => ZodCIDRv4, - ZodBoolean: () => ZodBoolean, - ZodBigIntFormat: () => ZodBigIntFormat, - ZodBigInt: () => ZodBigInt, - ZodBase64URL: () => ZodBase64URL, - ZodBase64: () => ZodBase64, - ZodArray: () => ZodArray, - ZodAny: () => ZodAny, - TimePrecision: () => TimePrecision, - NEVER: () => NEVER, - $output: () => $output, - $input: () => $input, - $brand: () => $brand -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/index.js -var exports_core2 = {}; -__export(exports_core2, { - version: () => version, - util: () => exports_util, - treeifyError: () => treeifyError, - toJSONSchema: () => toJSONSchema, - toDotPath: () => toDotPath, - safeParseAsync: () => safeParseAsync, - safeParse: () => safeParse, - safeEncodeAsync: () => safeEncodeAsync, - safeEncode: () => safeEncode, - safeDecodeAsync: () => safeDecodeAsync, - safeDecode: () => safeDecode, - registry: () => registry, - regexes: () => exports_regexes, - process: () => process2, - prettifyError: () => prettifyError, - parseAsync: () => parseAsync, - parse: () => parse, - meta: () => meta, - locales: () => exports_locales, - isValidJWT: () => isValidJWT, - isValidBase64URL: () => isValidBase64URL, - isValidBase64: () => isValidBase64, - initializeContext: () => initializeContext, - globalRegistry: () => globalRegistry, - globalConfig: () => globalConfig, - formatError: () => formatError, - flattenError: () => flattenError, - finalize: () => finalize, - extractDefs: () => extractDefs, - encodeAsync: () => encodeAsync, - encode: () => encode, - describe: () => describe, - decodeAsync: () => decodeAsync, - decode: () => decode, - createToJSONSchemaMethod: () => createToJSONSchemaMethod, - createStandardJSONSchemaMethod: () => createStandardJSONSchemaMethod, - config: () => config, - clone: () => clone, - _xor: () => _xor, - _xid: () => _xid, - _void: () => _void, - _uuidv7: () => _uuidv7, - _uuidv6: () => _uuidv6, - _uuidv4: () => _uuidv4, - _uuid: () => _uuid, - _url: () => _url, - _uppercase: () => _uppercase, - _unknown: () => _unknown, - _union: () => _union, - _undefined: () => _undefined2, - _ulid: () => _ulid, - _uint64: () => _uint64, - _uint32: () => _uint32, - _tuple: () => _tuple, - _trim: () => _trim, - _transform: () => _transform, - _toUpperCase: () => _toUpperCase, - _toLowerCase: () => _toLowerCase, - _templateLiteral: () => _templateLiteral, - _symbol: () => _symbol, - _superRefine: () => _superRefine, - _success: () => _success, - _stringbool: () => _stringbool, - _stringFormat: () => _stringFormat, - _string: () => _string, - _startsWith: () => _startsWith, - _slugify: () => _slugify, - _size: () => _size, - _set: () => _set, - _safeParseAsync: () => _safeParseAsync, - _safeParse: () => _safeParse, - _safeEncodeAsync: () => _safeEncodeAsync, - _safeEncode: () => _safeEncode, - _safeDecodeAsync: () => _safeDecodeAsync, - _safeDecode: () => _safeDecode, - _regex: () => _regex, - _refine: () => _refine, - _record: () => _record, - _readonly: () => _readonly, - _property: () => _property, - _promise: () => _promise, - _positive: () => _positive, - _pipe: () => _pipe, - _parseAsync: () => _parseAsync, - _parse: () => _parse, - _overwrite: () => _overwrite, - _optional: () => _optional, - _number: () => _number, - _nullable: () => _nullable, - _null: () => _null2, - _normalize: () => _normalize, - _nonpositive: () => _nonpositive, - _nonoptional: () => _nonoptional, - _nonnegative: () => _nonnegative, - _never: () => _never, - _negative: () => _negative, - _nativeEnum: () => _nativeEnum, - _nanoid: () => _nanoid, - _nan: () => _nan, - _multipleOf: () => _multipleOf, - _minSize: () => _minSize, - _minLength: () => _minLength, - _min: () => _gte, - _mime: () => _mime, - _maxSize: () => _maxSize, - _maxLength: () => _maxLength, - _max: () => _lte, - _map: () => _map, - _mac: () => _mac, - _lte: () => _lte, - _lt: () => _lt, - _lowercase: () => _lowercase, - _literal: () => _literal, - _length: () => _length, - _lazy: () => _lazy, - _ksuid: () => _ksuid, - _jwt: () => _jwt, - _isoTime: () => _isoTime, - _isoDuration: () => _isoDuration, - _isoDateTime: () => _isoDateTime, - _isoDate: () => _isoDate, - _ipv6: () => _ipv6, - _ipv4: () => _ipv4, - _intersection: () => _intersection, - _int64: () => _int64, - _int32: () => _int32, - _int: () => _int, - _includes: () => _includes, - _guid: () => _guid, - _gte: () => _gte, - _gt: () => _gt, - _float64: () => _float64, - _float32: () => _float32, - _file: () => _file, - _enum: () => _enum, - _endsWith: () => _endsWith, - _encodeAsync: () => _encodeAsync, - _encode: () => _encode, - _emoji: () => _emoji2, - _email: () => _email, - _e164: () => _e164, - _discriminatedUnion: () => _discriminatedUnion, - _default: () => _default, - _decodeAsync: () => _decodeAsync, - _decode: () => _decode, - _date: () => _date, - _custom: () => _custom, - _cuid2: () => _cuid2, - _cuid: () => _cuid, - _coercedString: () => _coercedString, - _coercedNumber: () => _coercedNumber, - _coercedDate: () => _coercedDate, - _coercedBoolean: () => _coercedBoolean, - _coercedBigint: () => _coercedBigint, - _cidrv6: () => _cidrv6, - _cidrv4: () => _cidrv4, - _check: () => _check, - _catch: () => _catch, - _boolean: () => _boolean, - _bigint: () => _bigint, - _base64url: () => _base64url, - _base64: () => _base64, - _array: () => _array, - _any: () => _any, - TimePrecision: () => TimePrecision, - NEVER: () => NEVER, - JSONSchemaGenerator: () => JSONSchemaGenerator, - JSONSchema: () => exports_json_schema, - Doc: () => Doc, - $output: () => $output, - $input: () => $input, - $constructor: () => $constructor, - $brand: () => $brand, - $ZodXor: () => $ZodXor, - $ZodXID: () => $ZodXID, - $ZodVoid: () => $ZodVoid, - $ZodUnknown: () => $ZodUnknown, - $ZodUnion: () => $ZodUnion, - $ZodUndefined: () => $ZodUndefined, - $ZodUUID: () => $ZodUUID, - $ZodURL: () => $ZodURL, - $ZodULID: () => $ZodULID, - $ZodType: () => $ZodType, - $ZodTuple: () => $ZodTuple, - $ZodTransform: () => $ZodTransform, - $ZodTemplateLiteral: () => $ZodTemplateLiteral, - $ZodSymbol: () => $ZodSymbol, - $ZodSuccess: () => $ZodSuccess, - $ZodStringFormat: () => $ZodStringFormat, - $ZodString: () => $ZodString, - $ZodSet: () => $ZodSet, - $ZodRegistry: () => $ZodRegistry, - $ZodRecord: () => $ZodRecord, - $ZodRealError: () => $ZodRealError, - $ZodReadonly: () => $ZodReadonly, - $ZodPromise: () => $ZodPromise, - $ZodPrefault: () => $ZodPrefault, - $ZodPipe: () => $ZodPipe, - $ZodOptional: () => $ZodOptional, - $ZodObjectJIT: () => $ZodObjectJIT, - $ZodObject: () => $ZodObject, - $ZodNumberFormat: () => $ZodNumberFormat, - $ZodNumber: () => $ZodNumber, - $ZodNullable: () => $ZodNullable, - $ZodNull: () => $ZodNull, - $ZodNonOptional: () => $ZodNonOptional, - $ZodNever: () => $ZodNever, - $ZodNanoID: () => $ZodNanoID, - $ZodNaN: () => $ZodNaN, - $ZodMap: () => $ZodMap, - $ZodMAC: () => $ZodMAC, - $ZodLiteral: () => $ZodLiteral, - $ZodLazy: () => $ZodLazy, - $ZodKSUID: () => $ZodKSUID, - $ZodJWT: () => $ZodJWT, - $ZodIntersection: () => $ZodIntersection, - $ZodISOTime: () => $ZodISOTime, - $ZodISODuration: () => $ZodISODuration, - $ZodISODateTime: () => $ZodISODateTime, - $ZodISODate: () => $ZodISODate, - $ZodIPv6: () => $ZodIPv6, - $ZodIPv4: () => $ZodIPv4, - $ZodGUID: () => $ZodGUID, - $ZodFunction: () => $ZodFunction, - $ZodFile: () => $ZodFile, - $ZodExactOptional: () => $ZodExactOptional, - $ZodError: () => $ZodError, - $ZodEnum: () => $ZodEnum, - $ZodEncodeError: () => $ZodEncodeError, - $ZodEmoji: () => $ZodEmoji, - $ZodEmail: () => $ZodEmail, - $ZodE164: () => $ZodE164, - $ZodDiscriminatedUnion: () => $ZodDiscriminatedUnion, - $ZodDefault: () => $ZodDefault, - $ZodDate: () => $ZodDate, - $ZodCustomStringFormat: () => $ZodCustomStringFormat, - $ZodCustom: () => $ZodCustom, - $ZodCodec: () => $ZodCodec, - $ZodCheckUpperCase: () => $ZodCheckUpperCase, - $ZodCheckStringFormat: () => $ZodCheckStringFormat, - $ZodCheckStartsWith: () => $ZodCheckStartsWith, - $ZodCheckSizeEquals: () => $ZodCheckSizeEquals, - $ZodCheckRegex: () => $ZodCheckRegex, - $ZodCheckProperty: () => $ZodCheckProperty, - $ZodCheckOverwrite: () => $ZodCheckOverwrite, - $ZodCheckNumberFormat: () => $ZodCheckNumberFormat, - $ZodCheckMultipleOf: () => $ZodCheckMultipleOf, - $ZodCheckMinSize: () => $ZodCheckMinSize, - $ZodCheckMinLength: () => $ZodCheckMinLength, - $ZodCheckMimeType: () => $ZodCheckMimeType, - $ZodCheckMaxSize: () => $ZodCheckMaxSize, - $ZodCheckMaxLength: () => $ZodCheckMaxLength, - $ZodCheckLowerCase: () => $ZodCheckLowerCase, - $ZodCheckLessThan: () => $ZodCheckLessThan, - $ZodCheckLengthEquals: () => $ZodCheckLengthEquals, - $ZodCheckIncludes: () => $ZodCheckIncludes, - $ZodCheckGreaterThan: () => $ZodCheckGreaterThan, - $ZodCheckEndsWith: () => $ZodCheckEndsWith, - $ZodCheckBigIntFormat: () => $ZodCheckBigIntFormat, - $ZodCheck: () => $ZodCheck, - $ZodCatch: () => $ZodCatch, - $ZodCUID2: () => $ZodCUID2, - $ZodCUID: () => $ZodCUID, - $ZodCIDRv6: () => $ZodCIDRv6, - $ZodCIDRv4: () => $ZodCIDRv4, - $ZodBoolean: () => $ZodBoolean, - $ZodBigIntFormat: () => $ZodBigIntFormat, - $ZodBigInt: () => $ZodBigInt, - $ZodBase64URL: () => $ZodBase64URL, - $ZodBase64: () => $ZodBase64, - $ZodAsyncError: () => $ZodAsyncError, - $ZodArray: () => $ZodArray, - $ZodAny: () => $ZodAny -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/core.js -var NEVER = Object.freeze({ - status: "aborted" -}); -function $constructor(name14, initializer, params) { - function init(inst, def) { - if (!inst._zod) { - Object.defineProperty(inst, "_zod", { - value: { - def, - constr: _, - traits: new Set - }, - enumerable: false - }); - } - if (inst._zod.traits.has(name14)) { - return; - } - inst._zod.traits.add(name14); - initializer(inst, def); - const proto = _.prototype; - const keys = Object.keys(proto); - for (let i = 0;i < keys.length; i++) { - const k = keys[i]; - if (!(k in inst)) { - inst[k] = proto[k].bind(inst); - } - } - } - const Parent = params?.Parent ?? Object; - - class Definition extends Parent { - } - Object.defineProperty(Definition, "name", { value: name14 }); - function _(def) { - var _a15; - const inst = params?.Parent ? new Definition : this; - init(inst, def); - (_a15 = inst._zod).deferred ?? (_a15.deferred = []); - for (const fn of inst._zod.deferred) { - fn(); - } - return inst; - } - Object.defineProperty(_, "init", { value: init }); - Object.defineProperty(_, Symbol.hasInstance, { - value: (inst) => { - if (params?.Parent && inst instanceof params.Parent) - return true; - return inst?._zod?.traits?.has(name14); - } - }); - Object.defineProperty(_, "name", { value: name14 }); - return _; -} -var $brand = Symbol("zod_brand"); - -class $ZodAsyncError extends Error { - constructor() { - super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`); - } -} - -class $ZodEncodeError extends Error { - constructor(name14) { - super(`Encountered unidirectional transform during encode: ${name14}`); - this.name = "ZodEncodeError"; - } -} -var globalConfig = {}; -function config(newConfig) { - if (newConfig) - Object.assign(globalConfig, newConfig); - return globalConfig; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/util.js -var exports_util = {}; -__export(exports_util, { - unwrapMessage: () => unwrapMessage, - uint8ArrayToHex: () => uint8ArrayToHex, - uint8ArrayToBase64url: () => uint8ArrayToBase64url, - uint8ArrayToBase64: () => uint8ArrayToBase64, - stringifyPrimitive: () => stringifyPrimitive, - slugify: () => slugify, - shallowClone: () => shallowClone, - safeExtend: () => safeExtend, - required: () => required, - randomString: () => randomString, - propertyKeyTypes: () => propertyKeyTypes, - promiseAllObject: () => promiseAllObject, - primitiveTypes: () => primitiveTypes, - prefixIssues: () => prefixIssues, - pick: () => pick, - partial: () => partial, - parsedType: () => parsedType, - optionalKeys: () => optionalKeys, - omit: () => omit, - objectClone: () => objectClone, - numKeys: () => numKeys, - nullish: () => nullish, - normalizeParams: () => normalizeParams, - mergeDefs: () => mergeDefs, - merge: () => merge, - jsonStringifyReplacer: () => jsonStringifyReplacer, - joinValues: () => joinValues, - issue: () => issue, - isPlainObject: () => isPlainObject, - isObject: () => isObject, - hexToUint8Array: () => hexToUint8Array, - getSizableOrigin: () => getSizableOrigin, - getParsedType: () => getParsedType, - getLengthableOrigin: () => getLengthableOrigin, - getEnumValues: () => getEnumValues, - getElementAtPath: () => getElementAtPath, - floatSafeRemainder: () => floatSafeRemainder, - finalizeIssue: () => finalizeIssue, - extend: () => extend, - escapeRegex: () => escapeRegex, - esc: () => esc, - defineLazy: () => defineLazy, - createTransparentProxy: () => createTransparentProxy, - cloneDef: () => cloneDef, - clone: () => clone, - cleanRegex: () => cleanRegex, - cleanEnum: () => cleanEnum, - captureStackTrace: () => captureStackTrace, - cached: () => cached, - base64urlToUint8Array: () => base64urlToUint8Array, - base64ToUint8Array: () => base64ToUint8Array, - assignProp: () => assignProp, - assertNotEqual: () => assertNotEqual, - assertNever: () => assertNever, - assertIs: () => assertIs, - assertEqual: () => assertEqual, - assert: () => assert, - allowsEval: () => allowsEval, - aborted: () => aborted, - NUMBER_FORMAT_RANGES: () => NUMBER_FORMAT_RANGES, - Class: () => Class, - BIGINT_FORMAT_RANGES: () => BIGINT_FORMAT_RANGES -}); -function assertEqual(val) { - return val; -} -function assertNotEqual(val) { - return val; -} -function assertIs(_arg) {} -function assertNever(_x) { - throw new Error("Unexpected value in exhaustive check"); -} -function assert(_) {} -function getEnumValues(entries) { - const numericValues = Object.values(entries).filter((v) => typeof v === "number"); - const values = Object.entries(entries).filter(([k, _]) => numericValues.indexOf(+k) === -1).map(([_, v]) => v); - return values; -} -function joinValues(array, separator = "|") { - return array.map((val) => stringifyPrimitive(val)).join(separator); -} -function jsonStringifyReplacer(_, value) { - if (typeof value === "bigint") - return value.toString(); - return value; -} -function cached(getter) { - const set = false; - return { - get value() { - if (!set) { - const value = getter(); - Object.defineProperty(this, "value", { value }); - return value; - } - throw new Error("cached value already set"); - } - }; -} -function nullish(input) { - return input === null || input === undefined; -} -function cleanRegex(source) { - const start = source.startsWith("^") ? 1 : 0; - const end = source.endsWith("$") ? source.length - 1 : source.length; - return source.slice(start, end); -} -function floatSafeRemainder(val, step) { - const valDecCount = (val.toString().split(".")[1] || "").length; - const stepString = step.toString(); - let stepDecCount = (stepString.split(".")[1] || "").length; - if (stepDecCount === 0 && /\d?e-\d?/.test(stepString)) { - const match = stepString.match(/\d?e-(\d?)/); - if (match?.[1]) { - stepDecCount = Number.parseInt(match[1]); - } - } - const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount; - const valInt = Number.parseInt(val.toFixed(decCount).replace(".", "")); - const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", "")); - return valInt % stepInt / 10 ** decCount; -} -var EVALUATING = Symbol("evaluating"); -function defineLazy(object, key, getter) { - let value = undefined; - Object.defineProperty(object, key, { - get() { - if (value === EVALUATING) { - return; - } - if (value === undefined) { - value = EVALUATING; - value = getter(); - } - return value; - }, - set(v) { - Object.defineProperty(object, key, { - value: v - }); - }, - configurable: true - }); -} -function objectClone(obj) { - return Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj)); -} -function assignProp(target, prop, value) { - Object.defineProperty(target, prop, { - value, - writable: true, - enumerable: true, - configurable: true - }); -} -function mergeDefs(...defs) { - const mergedDescriptors = {}; - for (const def of defs) { - const descriptors = Object.getOwnPropertyDescriptors(def); - Object.assign(mergedDescriptors, descriptors); - } - return Object.defineProperties({}, mergedDescriptors); -} -function cloneDef(schema) { - return mergeDefs(schema._zod.def); -} -function getElementAtPath(obj, path) { - if (!path) - return obj; - return path.reduce((acc, key) => acc?.[key], obj); -} -function promiseAllObject(promisesObj) { - const keys = Object.keys(promisesObj); - const promises = keys.map((key) => promisesObj[key]); - return Promise.all(promises).then((results) => { - const resolvedObj = {}; - for (let i = 0;i < keys.length; i++) { - resolvedObj[keys[i]] = results[i]; - } - return resolvedObj; - }); -} -function randomString(length = 10) { - const chars = "abcdefghijklmnopqrstuvwxyz"; - let str = ""; - for (let i = 0;i < length; i++) { - str += chars[Math.floor(Math.random() * chars.length)]; - } - return str; -} -function esc(str) { - return JSON.stringify(str); -} -function slugify(input) { - return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); -} -var captureStackTrace = "captureStackTrace" in Error ? Error.captureStackTrace : (..._args) => {}; -function isObject(data) { - return typeof data === "object" && data !== null && !Array.isArray(data); -} -var allowsEval = cached(() => { - if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) { - return false; - } - try { - const F = Function; - new F(""); - return true; - } catch (_) { - return false; - } -}); -function isPlainObject(o) { - if (isObject(o) === false) - return false; - const ctor = o.constructor; - if (ctor === undefined) - return true; - if (typeof ctor !== "function") - return true; - const prot = ctor.prototype; - if (isObject(prot) === false) - return false; - if (Object.prototype.hasOwnProperty.call(prot, "isPrototypeOf") === false) { - return false; - } - return true; -} -function shallowClone(o) { - if (isPlainObject(o)) - return { ...o }; - if (Array.isArray(o)) - return [...o]; - return o; -} -function numKeys(data) { - let keyCount = 0; - for (const key in data) { - if (Object.prototype.hasOwnProperty.call(data, key)) { - keyCount++; - } - } - return keyCount; -} -var getParsedType = (data) => { - const t = typeof data; - switch (t) { - case "undefined": - return "undefined"; - case "string": - return "string"; - case "number": - return Number.isNaN(data) ? "nan" : "number"; - case "boolean": - return "boolean"; - case "function": - return "function"; - case "bigint": - return "bigint"; - case "symbol": - return "symbol"; - case "object": - if (Array.isArray(data)) { - return "array"; - } - if (data === null) { - return "null"; - } - if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") { - return "promise"; - } - if (typeof Map !== "undefined" && data instanceof Map) { - return "map"; - } - if (typeof Set !== "undefined" && data instanceof Set) { - return "set"; - } - if (typeof Date !== "undefined" && data instanceof Date) { - return "date"; - } - if (typeof File !== "undefined" && data instanceof File) { - return "file"; - } - return "object"; - default: - throw new Error(`Unknown data type: ${t}`); - } -}; -var propertyKeyTypes = new Set(["string", "number", "symbol"]); -var primitiveTypes = new Set(["string", "number", "bigint", "boolean", "symbol", "undefined"]); -function escapeRegex(str) { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} -function clone(inst, def, params) { - const cl = new inst._zod.constr(def ?? inst._zod.def); - if (!def || params?.parent) - cl._zod.parent = inst; - return cl; -} -function normalizeParams(_params) { - const params = _params; - if (!params) - return {}; - if (typeof params === "string") - return { error: () => params }; - if (params?.message !== undefined) { - if (params?.error !== undefined) - throw new Error("Cannot specify both `message` and `error` params"); - params.error = params.message; - } - delete params.message; - if (typeof params.error === "string") - return { ...params, error: () => params.error }; - return params; -} -function createTransparentProxy(getter) { - let target; - return new Proxy({}, { - get(_, prop, receiver) { - target ?? (target = getter()); - return Reflect.get(target, prop, receiver); - }, - set(_, prop, value, receiver) { - target ?? (target = getter()); - return Reflect.set(target, prop, value, receiver); - }, - has(_, prop) { - target ?? (target = getter()); - return Reflect.has(target, prop); - }, - deleteProperty(_, prop) { - target ?? (target = getter()); - return Reflect.deleteProperty(target, prop); - }, - ownKeys(_) { - target ?? (target = getter()); - return Reflect.ownKeys(target); - }, - getOwnPropertyDescriptor(_, prop) { - target ?? (target = getter()); - return Reflect.getOwnPropertyDescriptor(target, prop); - }, - defineProperty(_, prop, descriptor) { - target ?? (target = getter()); - return Reflect.defineProperty(target, prop, descriptor); - } - }); -} -function stringifyPrimitive(value) { - if (typeof value === "bigint") - return value.toString() + "n"; - if (typeof value === "string") - return `"${value}"`; - return `${value}`; -} -function optionalKeys(shape) { - return Object.keys(shape).filter((k) => { - return shape[k]._zod.optin === "optional" && shape[k]._zod.optout === "optional"; - }); -} -var NUMBER_FORMAT_RANGES = { - safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], - int32: [-2147483648, 2147483647], - uint32: [0, 4294967295], - float32: [-340282346638528860000000000000000000000, 340282346638528860000000000000000000000], - float64: [-Number.MAX_VALUE, Number.MAX_VALUE] -}; -var BIGINT_FORMAT_RANGES = { - int64: [/* @__PURE__ */ BigInt("-9223372036854775808"), /* @__PURE__ */ BigInt("9223372036854775807")], - uint64: [/* @__PURE__ */ BigInt(0), /* @__PURE__ */ BigInt("18446744073709551615")] -}; -function pick(schema, mask) { - const currDef = schema._zod.def; - const checks = currDef.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - throw new Error(".pick() cannot be used on object schemas containing refinements"); - } - const def = mergeDefs(schema._zod.def, { - get shape() { - const newShape = {}; - for (const key in mask) { - if (!(key in currDef.shape)) { - throw new Error(`Unrecognized key: "${key}"`); - } - if (!mask[key]) - continue; - newShape[key] = currDef.shape[key]; - } - assignProp(this, "shape", newShape); - return newShape; - }, - checks: [] - }); - return clone(schema, def); -} -function omit(schema, mask) { - const currDef = schema._zod.def; - const checks = currDef.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - throw new Error(".omit() cannot be used on object schemas containing refinements"); - } - const def = mergeDefs(schema._zod.def, { - get shape() { - const newShape = { ...schema._zod.def.shape }; - for (const key in mask) { - if (!(key in currDef.shape)) { - throw new Error(`Unrecognized key: "${key}"`); - } - if (!mask[key]) - continue; - delete newShape[key]; - } - assignProp(this, "shape", newShape); - return newShape; - }, - checks: [] - }); - return clone(schema, def); -} -function extend(schema, shape) { - if (!isPlainObject(shape)) { - throw new Error("Invalid input to extend: expected a plain object"); - } - const checks = schema._zod.def.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - const existingShape = schema._zod.def.shape; - for (const key in shape) { - if (Object.getOwnPropertyDescriptor(existingShape, key) !== undefined) { - throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead."); - } - } - } - const def = mergeDefs(schema._zod.def, { - get shape() { - const _shape = { ...schema._zod.def.shape, ...shape }; - assignProp(this, "shape", _shape); - return _shape; - } - }); - return clone(schema, def); -} -function safeExtend(schema, shape) { - if (!isPlainObject(shape)) { - throw new Error("Invalid input to safeExtend: expected a plain object"); - } - const def = mergeDefs(schema._zod.def, { - get shape() { - const _shape = { ...schema._zod.def.shape, ...shape }; - assignProp(this, "shape", _shape); - return _shape; - } - }); - return clone(schema, def); -} -function merge(a, b) { - const def = mergeDefs(a._zod.def, { - get shape() { - const _shape = { ...a._zod.def.shape, ...b._zod.def.shape }; - assignProp(this, "shape", _shape); - return _shape; - }, - get catchall() { - return b._zod.def.catchall; - }, - checks: [] - }); - return clone(a, def); -} -function partial(Class, schema, mask) { - const currDef = schema._zod.def; - const checks = currDef.checks; - const hasChecks = checks && checks.length > 0; - if (hasChecks) { - throw new Error(".partial() cannot be used on object schemas containing refinements"); - } - const def = mergeDefs(schema._zod.def, { - get shape() { - const oldShape = schema._zod.def.shape; - const shape = { ...oldShape }; - if (mask) { - for (const key in mask) { - if (!(key in oldShape)) { - throw new Error(`Unrecognized key: "${key}"`); - } - if (!mask[key]) - continue; - shape[key] = Class ? new Class({ - type: "optional", - innerType: oldShape[key] - }) : oldShape[key]; - } - } else { - for (const key in oldShape) { - shape[key] = Class ? new Class({ - type: "optional", - innerType: oldShape[key] - }) : oldShape[key]; - } - } - assignProp(this, "shape", shape); - return shape; - }, - checks: [] - }); - return clone(schema, def); -} -function required(Class, schema, mask) { - const def = mergeDefs(schema._zod.def, { - get shape() { - const oldShape = schema._zod.def.shape; - const shape = { ...oldShape }; - if (mask) { - for (const key in mask) { - if (!(key in shape)) { - throw new Error(`Unrecognized key: "${key}"`); - } - if (!mask[key]) - continue; - shape[key] = new Class({ - type: "nonoptional", - innerType: oldShape[key] - }); - } - } else { - for (const key in oldShape) { - shape[key] = new Class({ - type: "nonoptional", - innerType: oldShape[key] - }); - } - } - assignProp(this, "shape", shape); - return shape; - } - }); - return clone(schema, def); -} -function aborted(x, startIndex = 0) { - if (x.aborted === true) - return true; - for (let i = startIndex;i < x.issues.length; i++) { - if (x.issues[i]?.continue !== true) { - return true; - } - } - return false; -} -function prefixIssues(path, issues) { - return issues.map((iss) => { - var _a15; - (_a15 = iss).path ?? (_a15.path = []); - iss.path.unshift(path); - return iss; - }); -} -function unwrapMessage(message) { - return typeof message === "string" ? message : message?.message; -} -function finalizeIssue(iss, ctx, config2) { - const full = { ...iss, path: iss.path ?? [] }; - if (!iss.message) { - const message = unwrapMessage(iss.inst?._zod.def?.error?.(iss)) ?? unwrapMessage(ctx?.error?.(iss)) ?? unwrapMessage(config2.customError?.(iss)) ?? unwrapMessage(config2.localeError?.(iss)) ?? "Invalid input"; - full.message = message; - } - delete full.inst; - delete full.continue; - if (!ctx?.reportInput) { - delete full.input; - } - return full; -} -function getSizableOrigin(input) { - if (input instanceof Set) - return "set"; - if (input instanceof Map) - return "map"; - if (input instanceof File) - return "file"; - return "unknown"; -} -function getLengthableOrigin(input) { - if (Array.isArray(input)) - return "array"; - if (typeof input === "string") - return "string"; - return "unknown"; -} -function parsedType(data) { - const t = typeof data; - switch (t) { - case "number": { - return Number.isNaN(data) ? "nan" : "number"; - } - case "object": { - if (data === null) { - return "null"; - } - if (Array.isArray(data)) { - return "array"; - } - const obj = data; - if (obj && Object.getPrototypeOf(obj) !== Object.prototype && "constructor" in obj && obj.constructor) { - return obj.constructor.name; - } - } - } - return t; -} -function issue(...args) { - const [iss, input, inst] = args; - if (typeof iss === "string") { - return { - message: iss, - code: "custom", - input, - inst - }; - } - return { ...iss }; -} -function cleanEnum(obj) { - return Object.entries(obj).filter(([k, _]) => { - return Number.isNaN(Number.parseInt(k, 10)); - }).map((el) => el[1]); -} -function base64ToUint8Array(base64) { - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0;i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; -} -function uint8ArrayToBase64(bytes) { - let binaryString = ""; - for (let i = 0;i < bytes.length; i++) { - binaryString += String.fromCharCode(bytes[i]); - } - return btoa(binaryString); -} -function base64urlToUint8Array(base64url) { - const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); - const padding = "=".repeat((4 - base64.length % 4) % 4); - return base64ToUint8Array(base64 + padding); -} -function uint8ArrayToBase64url(bytes) { - return uint8ArrayToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} -function hexToUint8Array(hex) { - const cleanHex = hex.replace(/^0x/, ""); - if (cleanHex.length % 2 !== 0) { - throw new Error("Invalid hex string length"); - } - const bytes = new Uint8Array(cleanHex.length / 2); - for (let i = 0;i < cleanHex.length; i += 2) { - bytes[i / 2] = Number.parseInt(cleanHex.slice(i, i + 2), 16); - } - return bytes; -} -function uint8ArrayToHex(bytes) { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -class Class { - constructor(..._args) {} -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/errors.js -var initializer = (inst, def) => { - inst.name = "$ZodError"; - Object.defineProperty(inst, "_zod", { - value: inst._zod, - enumerable: false - }); - Object.defineProperty(inst, "issues", { - value: def, - enumerable: false - }); - inst.message = JSON.stringify(def, jsonStringifyReplacer, 2); - Object.defineProperty(inst, "toString", { - value: () => inst.message, - enumerable: false - }); -}; -var $ZodError = $constructor("$ZodError", initializer); -var $ZodRealError = $constructor("$ZodError", initializer, { Parent: Error }); -function flattenError(error, mapper = (issue2) => issue2.message) { - const fieldErrors = {}; - const formErrors = []; - for (const sub of error.issues) { - if (sub.path.length > 0) { - fieldErrors[sub.path[0]] = fieldErrors[sub.path[0]] || []; - fieldErrors[sub.path[0]].push(mapper(sub)); - } else { - formErrors.push(mapper(sub)); - } - } - return { formErrors, fieldErrors }; -} -function formatError(error, mapper = (issue2) => issue2.message) { - const fieldErrors = { _errors: [] }; - const processError = (error2) => { - for (const issue2 of error2.issues) { - if (issue2.code === "invalid_union" && issue2.errors.length) { - issue2.errors.map((issues) => processError({ issues })); - } else if (issue2.code === "invalid_key") { - processError({ issues: issue2.issues }); - } else if (issue2.code === "invalid_element") { - processError({ issues: issue2.issues }); - } else if (issue2.path.length === 0) { - fieldErrors._errors.push(mapper(issue2)); - } else { - let curr = fieldErrors; - let i = 0; - while (i < issue2.path.length) { - const el = issue2.path[i]; - const terminal = i === issue2.path.length - 1; - if (!terminal) { - curr[el] = curr[el] || { _errors: [] }; - } else { - curr[el] = curr[el] || { _errors: [] }; - curr[el]._errors.push(mapper(issue2)); - } - curr = curr[el]; - i++; - } - } - } - }; - processError(error); - return fieldErrors; -} -function treeifyError(error, mapper = (issue2) => issue2.message) { - const result = { errors: [] }; - const processError = (error2, path = []) => { - var _a15, _b15; - for (const issue2 of error2.issues) { - if (issue2.code === "invalid_union" && issue2.errors.length) { - issue2.errors.map((issues) => processError({ issues }, issue2.path)); - } else if (issue2.code === "invalid_key") { - processError({ issues: issue2.issues }, issue2.path); - } else if (issue2.code === "invalid_element") { - processError({ issues: issue2.issues }, issue2.path); - } else { - const fullpath = [...path, ...issue2.path]; - if (fullpath.length === 0) { - result.errors.push(mapper(issue2)); - continue; - } - let curr = result; - let i = 0; - while (i < fullpath.length) { - const el = fullpath[i]; - const terminal = i === fullpath.length - 1; - if (typeof el === "string") { - curr.properties ?? (curr.properties = {}); - (_a15 = curr.properties)[el] ?? (_a15[el] = { errors: [] }); - curr = curr.properties[el]; - } else { - curr.items ?? (curr.items = []); - (_b15 = curr.items)[el] ?? (_b15[el] = { errors: [] }); - curr = curr.items[el]; - } - if (terminal) { - curr.errors.push(mapper(issue2)); - } - i++; - } - } - } - }; - processError(error); - return result; -} -function toDotPath(_path) { - const segs = []; - const path = _path.map((seg) => typeof seg === "object" ? seg.key : seg); - for (const seg of path) { - if (typeof seg === "number") - segs.push(`[${seg}]`); - else if (typeof seg === "symbol") - segs.push(`[${JSON.stringify(String(seg))}]`); - else if (/[^\w$]/.test(seg)) - segs.push(`[${JSON.stringify(seg)}]`); - else { - if (segs.length) - segs.push("."); - segs.push(seg); - } - } - return segs.join(""); -} -function prettifyError(error) { - const lines = []; - const issues = [...error.issues].sort((a, b) => (a.path ?? []).length - (b.path ?? []).length); - for (const issue2 of issues) { - lines.push(`✖ ${issue2.message}`); - if (issue2.path?.length) - lines.push(` → at ${toDotPath(issue2.path)}`); - } - return lines.join(` -`); -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/parse.js -var _parse = (_Err) => (schema, value, _ctx, _params) => { - const ctx = _ctx ? Object.assign(_ctx, { async: false }) : { async: false }; - const result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) { - throw new $ZodAsyncError; - } - if (result.issues.length) { - const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); - captureStackTrace(e, _params?.callee); - throw e; - } - return result.value; -}; -var parse = /* @__PURE__ */ _parse($ZodRealError); -var _parseAsync = (_Err) => async (schema, value, _ctx, params) => { - const ctx = _ctx ? Object.assign(_ctx, { async: true }) : { async: true }; - let result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) - result = await result; - if (result.issues.length) { - const e = new (params?.Err ?? _Err)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))); - captureStackTrace(e, params?.callee); - throw e; - } - return result.value; -}; -var parseAsync = /* @__PURE__ */ _parseAsync($ZodRealError); -var _safeParse = (_Err) => (schema, value, _ctx) => { - const ctx = _ctx ? { ..._ctx, async: false } : { async: false }; - const result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) { - throw new $ZodAsyncError; - } - return result.issues.length ? { - success: false, - error: new (_Err ?? $ZodError)(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) - } : { success: true, data: result.value }; -}; -var safeParse = /* @__PURE__ */ _safeParse($ZodRealError); -var _safeParseAsync = (_Err) => async (schema, value, _ctx) => { - const ctx = _ctx ? Object.assign(_ctx, { async: true }) : { async: true }; - let result = schema._zod.run({ value, issues: [] }, ctx); - if (result instanceof Promise) - result = await result; - return result.issues.length ? { - success: false, - error: new _Err(result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) - } : { success: true, data: result.value }; -}; -var safeParseAsync = /* @__PURE__ */ _safeParseAsync($ZodRealError); -var _encode = (_Err) => (schema, value, _ctx) => { - const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; - return _parse(_Err)(schema, value, ctx); -}; -var encode = /* @__PURE__ */ _encode($ZodRealError); -var _decode = (_Err) => (schema, value, _ctx) => { - return _parse(_Err)(schema, value, _ctx); -}; -var decode = /* @__PURE__ */ _decode($ZodRealError); -var _encodeAsync = (_Err) => async (schema, value, _ctx) => { - const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; - return _parseAsync(_Err)(schema, value, ctx); -}; -var encodeAsync = /* @__PURE__ */ _encodeAsync($ZodRealError); -var _decodeAsync = (_Err) => async (schema, value, _ctx) => { - return _parseAsync(_Err)(schema, value, _ctx); -}; -var decodeAsync = /* @__PURE__ */ _decodeAsync($ZodRealError); -var _safeEncode = (_Err) => (schema, value, _ctx) => { - const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; - return _safeParse(_Err)(schema, value, ctx); -}; -var safeEncode = /* @__PURE__ */ _safeEncode($ZodRealError); -var _safeDecode = (_Err) => (schema, value, _ctx) => { - return _safeParse(_Err)(schema, value, _ctx); -}; -var safeDecode = /* @__PURE__ */ _safeDecode($ZodRealError); -var _safeEncodeAsync = (_Err) => async (schema, value, _ctx) => { - const ctx = _ctx ? Object.assign(_ctx, { direction: "backward" }) : { direction: "backward" }; - return _safeParseAsync(_Err)(schema, value, ctx); -}; -var safeEncodeAsync = /* @__PURE__ */ _safeEncodeAsync($ZodRealError); -var _safeDecodeAsync = (_Err) => async (schema, value, _ctx) => { - return _safeParseAsync(_Err)(schema, value, _ctx); -}; -var safeDecodeAsync = /* @__PURE__ */ _safeDecodeAsync($ZodRealError); -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/regexes.js -var exports_regexes = {}; -__export(exports_regexes, { - xid: () => xid, - uuid7: () => uuid7, - uuid6: () => uuid6, - uuid4: () => uuid4, - uuid: () => uuid, - uppercase: () => uppercase, - unicodeEmail: () => unicodeEmail, - undefined: () => _undefined, - ulid: () => ulid, - time: () => time, - string: () => string, - sha512_hex: () => sha512_hex, - sha512_base64url: () => sha512_base64url, - sha512_base64: () => sha512_base64, - sha384_hex: () => sha384_hex, - sha384_base64url: () => sha384_base64url, - sha384_base64: () => sha384_base64, - sha256_hex: () => sha256_hex, - sha256_base64url: () => sha256_base64url, - sha256_base64: () => sha256_base64, - sha1_hex: () => sha1_hex, - sha1_base64url: () => sha1_base64url, - sha1_base64: () => sha1_base64, - rfc5322Email: () => rfc5322Email, - number: () => number, - null: () => _null, - nanoid: () => nanoid, - md5_hex: () => md5_hex, - md5_base64url: () => md5_base64url, - md5_base64: () => md5_base64, - mac: () => mac, - lowercase: () => lowercase, - ksuid: () => ksuid, - ipv6: () => ipv6, - ipv4: () => ipv4, - integer: () => integer, - idnEmail: () => idnEmail, - html5Email: () => html5Email, - hostname: () => hostname, - hex: () => hex, - guid: () => guid, - extendedDuration: () => extendedDuration, - emoji: () => emoji, - email: () => email, - e164: () => e164, - duration: () => duration, - domain: () => domain, - datetime: () => datetime, - date: () => date, - cuid2: () => cuid2, - cuid: () => cuid, - cidrv6: () => cidrv6, - cidrv4: () => cidrv4, - browserEmail: () => browserEmail, - boolean: () => boolean, - bigint: () => bigint, - base64url: () => base64url, - base64: () => base64 -}); -var cuid = /^[cC][^\s-]{8,}$/; -var cuid2 = /^[0-9a-z]+$/; -var ulid = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/; -var xid = /^[0-9a-vA-V]{20}$/; -var ksuid = /^[A-Za-z0-9]{27}$/; -var nanoid = /^[a-zA-Z0-9_-]{21}$/; -var duration = /^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/; -var extendedDuration = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; -var guid = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; -var uuid = (version) => { - if (!version) - return /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/; - return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`); -}; -var uuid4 = /* @__PURE__ */ uuid(4); -var uuid6 = /* @__PURE__ */ uuid(6); -var uuid7 = /* @__PURE__ */ uuid(7); -var email = /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/; -var html5Email = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -var rfc5322Email = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -var unicodeEmail = /^[^\s@"]{1,64}@[^\s@]{1,255}$/u; -var idnEmail = unicodeEmail; -var browserEmail = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -var _emoji = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; -function emoji() { - return new RegExp(_emoji, "u"); -} -var ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -var ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/; -var mac = (delimiter) => { - const escapedDelim = escapeRegex(delimiter ?? ":"); - return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`); -}; -var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/; -var cidrv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; -var base64 = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/; -var base64url = /^[A-Za-z0-9_-]*$/; -var hostname = /^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/; -var domain = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; -var e164 = /^\+[1-9]\d{6,14}$/; -var dateSource = `(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))`; -var date = /* @__PURE__ */ new RegExp(`^${dateSource}$`); -function timeSource(args) { - const hhmm = `(?:[01]\\d|2[0-3]):[0-5]\\d`; - const regex = typeof args.precision === "number" ? args.precision === -1 ? `${hhmm}` : args.precision === 0 ? `${hhmm}:[0-5]\\d` : `${hhmm}:[0-5]\\d\\.\\d{${args.precision}}` : `${hhmm}(?::[0-5]\\d(?:\\.\\d+)?)?`; - return regex; -} -function time(args) { - return new RegExp(`^${timeSource(args)}$`); -} -function datetime(args) { - const time2 = timeSource({ precision: args.precision }); - const opts = ["Z"]; - if (args.local) - opts.push(""); - if (args.offset) - opts.push(`([+-](?:[01]\\d|2[0-3]):[0-5]\\d)`); - const timeRegex = `${time2}(?:${opts.join("|")})`; - return new RegExp(`^${dateSource}T(?:${timeRegex})$`); -} -var string = (params) => { - const regex = params ? `[\\s\\S]{${params?.minimum ?? 0},${params?.maximum ?? ""}}` : `[\\s\\S]*`; - return new RegExp(`^${regex}$`); -}; -var bigint = /^-?\d+n?$/; -var integer = /^-?\d+$/; -var number = /^-?\d+(?:\.\d+)?$/; -var boolean = /^(?:true|false)$/i; -var _null = /^null$/i; -var _undefined = /^undefined$/i; -var lowercase = /^[^A-Z]*$/; -var uppercase = /^[^a-z]*$/; -var hex = /^[0-9a-fA-F]*$/; -function fixedBase64(bodyLength, padding) { - return new RegExp(`^[A-Za-z0-9+/]{${bodyLength}}${padding}$`); -} -function fixedBase64url(length) { - return new RegExp(`^[A-Za-z0-9_-]{${length}}$`); -} -var md5_hex = /^[0-9a-fA-F]{32}$/; -var md5_base64 = /* @__PURE__ */ fixedBase64(22, "=="); -var md5_base64url = /* @__PURE__ */ fixedBase64url(22); -var sha1_hex = /^[0-9a-fA-F]{40}$/; -var sha1_base64 = /* @__PURE__ */ fixedBase64(27, "="); -var sha1_base64url = /* @__PURE__ */ fixedBase64url(27); -var sha256_hex = /^[0-9a-fA-F]{64}$/; -var sha256_base64 = /* @__PURE__ */ fixedBase64(43, "="); -var sha256_base64url = /* @__PURE__ */ fixedBase64url(43); -var sha384_hex = /^[0-9a-fA-F]{96}$/; -var sha384_base64 = /* @__PURE__ */ fixedBase64(64, ""); -var sha384_base64url = /* @__PURE__ */ fixedBase64url(64); -var sha512_hex = /^[0-9a-fA-F]{128}$/; -var sha512_base64 = /* @__PURE__ */ fixedBase64(86, "=="); -var sha512_base64url = /* @__PURE__ */ fixedBase64url(86); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/checks.js -var $ZodCheck = /* @__PURE__ */ $constructor("$ZodCheck", (inst, def) => { - var _a15; - inst._zod ?? (inst._zod = {}); - inst._zod.def = def; - (_a15 = inst._zod).onattach ?? (_a15.onattach = []); -}); -var numericOriginMap = { - number: "number", - bigint: "bigint", - object: "date" -}; -var $ZodCheckLessThan = /* @__PURE__ */ $constructor("$ZodCheckLessThan", (inst, def) => { - $ZodCheck.init(inst, def); - const origin = numericOriginMap[typeof def.value]; - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - const curr = (def.inclusive ? bag.maximum : bag.exclusiveMaximum) ?? Number.POSITIVE_INFINITY; - if (def.value < curr) { - if (def.inclusive) - bag.maximum = def.value; - else - bag.exclusiveMaximum = def.value; - } - }); - inst._zod.check = (payload) => { - if (def.inclusive ? payload.value <= def.value : payload.value < def.value) { - return; - } - payload.issues.push({ - origin, - code: "too_big", - maximum: typeof def.value === "object" ? def.value.getTime() : def.value, - input: payload.value, - inclusive: def.inclusive, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckGreaterThan = /* @__PURE__ */ $constructor("$ZodCheckGreaterThan", (inst, def) => { - $ZodCheck.init(inst, def); - const origin = numericOriginMap[typeof def.value]; - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - const curr = (def.inclusive ? bag.minimum : bag.exclusiveMinimum) ?? Number.NEGATIVE_INFINITY; - if (def.value > curr) { - if (def.inclusive) - bag.minimum = def.value; - else - bag.exclusiveMinimum = def.value; - } - }); - inst._zod.check = (payload) => { - if (def.inclusive ? payload.value >= def.value : payload.value > def.value) { - return; - } - payload.issues.push({ - origin, - code: "too_small", - minimum: typeof def.value === "object" ? def.value.getTime() : def.value, - input: payload.value, - inclusive: def.inclusive, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckMultipleOf = /* @__PURE__ */ $constructor("$ZodCheckMultipleOf", (inst, def) => { - $ZodCheck.init(inst, def); - inst._zod.onattach.push((inst2) => { - var _a15; - (_a15 = inst2._zod.bag).multipleOf ?? (_a15.multipleOf = def.value); - }); - inst._zod.check = (payload) => { - if (typeof payload.value !== typeof def.value) - throw new Error("Cannot mix number and bigint in multiple_of check."); - const isMultiple = typeof payload.value === "bigint" ? payload.value % def.value === BigInt(0) : floatSafeRemainder(payload.value, def.value) === 0; - if (isMultiple) - return; - payload.issues.push({ - origin: typeof payload.value, - code: "not_multiple_of", - divisor: def.value, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckNumberFormat = /* @__PURE__ */ $constructor("$ZodCheckNumberFormat", (inst, def) => { - $ZodCheck.init(inst, def); - def.format = def.format || "float64"; - const isInt = def.format?.includes("int"); - const origin = isInt ? "int" : "number"; - const [minimum, maximum] = NUMBER_FORMAT_RANGES[def.format]; - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.format = def.format; - bag.minimum = minimum; - bag.maximum = maximum; - if (isInt) - bag.pattern = integer; - }); - inst._zod.check = (payload) => { - const input = payload.value; - if (isInt) { - if (!Number.isInteger(input)) { - payload.issues.push({ - expected: origin, - format: def.format, - code: "invalid_type", - continue: false, - input, - inst - }); - return; - } - if (!Number.isSafeInteger(input)) { - if (input > 0) { - payload.issues.push({ - input, - code: "too_big", - maximum: Number.MAX_SAFE_INTEGER, - note: "Integers must be within the safe integer range.", - inst, - origin, - inclusive: true, - continue: !def.abort - }); - } else { - payload.issues.push({ - input, - code: "too_small", - minimum: Number.MIN_SAFE_INTEGER, - note: "Integers must be within the safe integer range.", - inst, - origin, - inclusive: true, - continue: !def.abort - }); - } - return; - } - } - if (input < minimum) { - payload.issues.push({ - origin: "number", - input, - code: "too_small", - minimum, - inclusive: true, - inst, - continue: !def.abort - }); - } - if (input > maximum) { - payload.issues.push({ - origin: "number", - input, - code: "too_big", - maximum, - inclusive: true, - inst, - continue: !def.abort - }); - } - }; -}); -var $ZodCheckBigIntFormat = /* @__PURE__ */ $constructor("$ZodCheckBigIntFormat", (inst, def) => { - $ZodCheck.init(inst, def); - const [minimum, maximum] = BIGINT_FORMAT_RANGES[def.format]; - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.format = def.format; - bag.minimum = minimum; - bag.maximum = maximum; - }); - inst._zod.check = (payload) => { - const input = payload.value; - if (input < minimum) { - payload.issues.push({ - origin: "bigint", - input, - code: "too_small", - minimum, - inclusive: true, - inst, - continue: !def.abort - }); - } - if (input > maximum) { - payload.issues.push({ - origin: "bigint", - input, - code: "too_big", - maximum, - inclusive: true, - inst, - continue: !def.abort - }); - } - }; -}); -var $ZodCheckMaxSize = /* @__PURE__ */ $constructor("$ZodCheckMaxSize", (inst, def) => { - var _a15; - $ZodCheck.init(inst, def); - (_a15 = inst._zod.def).when ?? (_a15.when = (payload) => { - const val = payload.value; - return !nullish(val) && val.size !== undefined; - }); - inst._zod.onattach.push((inst2) => { - const curr = inst2._zod.bag.maximum ?? Number.POSITIVE_INFINITY; - if (def.maximum < curr) - inst2._zod.bag.maximum = def.maximum; - }); - inst._zod.check = (payload) => { - const input = payload.value; - const size = input.size; - if (size <= def.maximum) - return; - payload.issues.push({ - origin: getSizableOrigin(input), - code: "too_big", - maximum: def.maximum, - inclusive: true, - input, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckMinSize = /* @__PURE__ */ $constructor("$ZodCheckMinSize", (inst, def) => { - var _a15; - $ZodCheck.init(inst, def); - (_a15 = inst._zod.def).when ?? (_a15.when = (payload) => { - const val = payload.value; - return !nullish(val) && val.size !== undefined; - }); - inst._zod.onattach.push((inst2) => { - const curr = inst2._zod.bag.minimum ?? Number.NEGATIVE_INFINITY; - if (def.minimum > curr) - inst2._zod.bag.minimum = def.minimum; - }); - inst._zod.check = (payload) => { - const input = payload.value; - const size = input.size; - if (size >= def.minimum) - return; - payload.issues.push({ - origin: getSizableOrigin(input), - code: "too_small", - minimum: def.minimum, - inclusive: true, - input, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckSizeEquals = /* @__PURE__ */ $constructor("$ZodCheckSizeEquals", (inst, def) => { - var _a15; - $ZodCheck.init(inst, def); - (_a15 = inst._zod.def).when ?? (_a15.when = (payload) => { - const val = payload.value; - return !nullish(val) && val.size !== undefined; - }); - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.minimum = def.size; - bag.maximum = def.size; - bag.size = def.size; - }); - inst._zod.check = (payload) => { - const input = payload.value; - const size = input.size; - if (size === def.size) - return; - const tooBig = size > def.size; - payload.issues.push({ - origin: getSizableOrigin(input), - ...tooBig ? { code: "too_big", maximum: def.size } : { code: "too_small", minimum: def.size }, - inclusive: true, - exact: true, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckMaxLength = /* @__PURE__ */ $constructor("$ZodCheckMaxLength", (inst, def) => { - var _a15; - $ZodCheck.init(inst, def); - (_a15 = inst._zod.def).when ?? (_a15.when = (payload) => { - const val = payload.value; - return !nullish(val) && val.length !== undefined; - }); - inst._zod.onattach.push((inst2) => { - const curr = inst2._zod.bag.maximum ?? Number.POSITIVE_INFINITY; - if (def.maximum < curr) - inst2._zod.bag.maximum = def.maximum; - }); - inst._zod.check = (payload) => { - const input = payload.value; - const length = input.length; - if (length <= def.maximum) - return; - const origin = getLengthableOrigin(input); - payload.issues.push({ - origin, - code: "too_big", - maximum: def.maximum, - inclusive: true, - input, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckMinLength = /* @__PURE__ */ $constructor("$ZodCheckMinLength", (inst, def) => { - var _a15; - $ZodCheck.init(inst, def); - (_a15 = inst._zod.def).when ?? (_a15.when = (payload) => { - const val = payload.value; - return !nullish(val) && val.length !== undefined; - }); - inst._zod.onattach.push((inst2) => { - const curr = inst2._zod.bag.minimum ?? Number.NEGATIVE_INFINITY; - if (def.minimum > curr) - inst2._zod.bag.minimum = def.minimum; - }); - inst._zod.check = (payload) => { - const input = payload.value; - const length = input.length; - if (length >= def.minimum) - return; - const origin = getLengthableOrigin(input); - payload.issues.push({ - origin, - code: "too_small", - minimum: def.minimum, - inclusive: true, - input, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckLengthEquals = /* @__PURE__ */ $constructor("$ZodCheckLengthEquals", (inst, def) => { - var _a15; - $ZodCheck.init(inst, def); - (_a15 = inst._zod.def).when ?? (_a15.when = (payload) => { - const val = payload.value; - return !nullish(val) && val.length !== undefined; - }); - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.minimum = def.length; - bag.maximum = def.length; - bag.length = def.length; - }); - inst._zod.check = (payload) => { - const input = payload.value; - const length = input.length; - if (length === def.length) - return; - const origin = getLengthableOrigin(input); - const tooBig = length > def.length; - payload.issues.push({ - origin, - ...tooBig ? { code: "too_big", maximum: def.length } : { code: "too_small", minimum: def.length }, - inclusive: true, - exact: true, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckStringFormat = /* @__PURE__ */ $constructor("$ZodCheckStringFormat", (inst, def) => { - var _a15, _b15; - $ZodCheck.init(inst, def); - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.format = def.format; - if (def.pattern) { - bag.patterns ?? (bag.patterns = new Set); - bag.patterns.add(def.pattern); - } - }); - if (def.pattern) - (_a15 = inst._zod).check ?? (_a15.check = (payload) => { - def.pattern.lastIndex = 0; - if (def.pattern.test(payload.value)) - return; - payload.issues.push({ - origin: "string", - code: "invalid_format", - format: def.format, - input: payload.value, - ...def.pattern ? { pattern: def.pattern.toString() } : {}, - inst, - continue: !def.abort - }); - }); - else - (_b15 = inst._zod).check ?? (_b15.check = () => {}); -}); -var $ZodCheckRegex = /* @__PURE__ */ $constructor("$ZodCheckRegex", (inst, def) => { - $ZodCheckStringFormat.init(inst, def); - inst._zod.check = (payload) => { - def.pattern.lastIndex = 0; - if (def.pattern.test(payload.value)) - return; - payload.issues.push({ - origin: "string", - code: "invalid_format", - format: "regex", - input: payload.value, - pattern: def.pattern.toString(), - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckLowerCase = /* @__PURE__ */ $constructor("$ZodCheckLowerCase", (inst, def) => { - def.pattern ?? (def.pattern = lowercase); - $ZodCheckStringFormat.init(inst, def); -}); -var $ZodCheckUpperCase = /* @__PURE__ */ $constructor("$ZodCheckUpperCase", (inst, def) => { - def.pattern ?? (def.pattern = uppercase); - $ZodCheckStringFormat.init(inst, def); -}); -var $ZodCheckIncludes = /* @__PURE__ */ $constructor("$ZodCheckIncludes", (inst, def) => { - $ZodCheck.init(inst, def); - const escapedRegex = escapeRegex(def.includes); - const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex); - def.pattern = pattern; - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.patterns ?? (bag.patterns = new Set); - bag.patterns.add(pattern); - }); - inst._zod.check = (payload) => { - if (payload.value.includes(def.includes, def.position)) - return; - payload.issues.push({ - origin: "string", - code: "invalid_format", - format: "includes", - includes: def.includes, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckStartsWith = /* @__PURE__ */ $constructor("$ZodCheckStartsWith", (inst, def) => { - $ZodCheck.init(inst, def); - const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`); - def.pattern ?? (def.pattern = pattern); - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.patterns ?? (bag.patterns = new Set); - bag.patterns.add(pattern); - }); - inst._zod.check = (payload) => { - if (payload.value.startsWith(def.prefix)) - return; - payload.issues.push({ - origin: "string", - code: "invalid_format", - format: "starts_with", - prefix: def.prefix, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckEndsWith = /* @__PURE__ */ $constructor("$ZodCheckEndsWith", (inst, def) => { - $ZodCheck.init(inst, def); - const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`); - def.pattern ?? (def.pattern = pattern); - inst._zod.onattach.push((inst2) => { - const bag = inst2._zod.bag; - bag.patterns ?? (bag.patterns = new Set); - bag.patterns.add(pattern); - }); - inst._zod.check = (payload) => { - if (payload.value.endsWith(def.suffix)) - return; - payload.issues.push({ - origin: "string", - code: "invalid_format", - format: "ends_with", - suffix: def.suffix, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -function handleCheckPropertyResult(result, payload, property) { - if (result.issues.length) { - payload.issues.push(...prefixIssues(property, result.issues)); - } -} -var $ZodCheckProperty = /* @__PURE__ */ $constructor("$ZodCheckProperty", (inst, def) => { - $ZodCheck.init(inst, def); - inst._zod.check = (payload) => { - const result = def.schema._zod.run({ - value: payload.value[def.property], - issues: [] - }, {}); - if (result instanceof Promise) { - return result.then((result2) => handleCheckPropertyResult(result2, payload, def.property)); - } - handleCheckPropertyResult(result, payload, def.property); - return; - }; -}); -var $ZodCheckMimeType = /* @__PURE__ */ $constructor("$ZodCheckMimeType", (inst, def) => { - $ZodCheck.init(inst, def); - const mimeSet = new Set(def.mime); - inst._zod.onattach.push((inst2) => { - inst2._zod.bag.mime = def.mime; - }); - inst._zod.check = (payload) => { - if (mimeSet.has(payload.value.type)) - return; - payload.issues.push({ - code: "invalid_value", - values: def.mime, - input: payload.value.type, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCheckOverwrite = /* @__PURE__ */ $constructor("$ZodCheckOverwrite", (inst, def) => { - $ZodCheck.init(inst, def); - inst._zod.check = (payload) => { - payload.value = def.tx(payload.value); - }; -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/doc.js -class Doc { - constructor(args = []) { - this.content = []; - this.indent = 0; - if (this) - this.args = args; - } - indented(fn) { - this.indent += 1; - fn(this); - this.indent -= 1; - } - write(arg) { - if (typeof arg === "function") { - arg(this, { execution: "sync" }); - arg(this, { execution: "async" }); - return; - } - const content = arg; - const lines = content.split(` -`).filter((x) => x); - const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length)); - const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x); - for (const line of dedented) { - this.content.push(line); - } - } - compile() { - const F = Function; - const args = this?.args; - const content = this?.content ?? [``]; - const lines = [...content.map((x) => ` ${x}`)]; - return new F(...args, lines.join(` -`)); - } -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/versions.js -var version = { - major: 4, - minor: 3, - patch: 6 -}; - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/schemas.js -var $ZodType = /* @__PURE__ */ $constructor("$ZodType", (inst, def) => { - var _a15; - inst ?? (inst = {}); - inst._zod.def = def; - inst._zod.bag = inst._zod.bag || {}; - inst._zod.version = version; - const checks = [...inst._zod.def.checks ?? []]; - if (inst._zod.traits.has("$ZodCheck")) { - checks.unshift(inst); - } - for (const ch of checks) { - for (const fn of ch._zod.onattach) { - fn(inst); - } - } - if (checks.length === 0) { - (_a15 = inst._zod).deferred ?? (_a15.deferred = []); - inst._zod.deferred?.push(() => { - inst._zod.run = inst._zod.parse; - }); - } else { - const runChecks = (payload, checks2, ctx) => { - let isAborted = aborted(payload); - let asyncResult; - for (const ch of checks2) { - if (ch._zod.def.when) { - const shouldRun = ch._zod.def.when(payload); - if (!shouldRun) - continue; - } else if (isAborted) { - continue; - } - const currLen = payload.issues.length; - const _ = ch._zod.check(payload); - if (_ instanceof Promise && ctx?.async === false) { - throw new $ZodAsyncError; - } - if (asyncResult || _ instanceof Promise) { - asyncResult = (asyncResult ?? Promise.resolve()).then(async () => { - await _; - const nextLen = payload.issues.length; - if (nextLen === currLen) - return; - if (!isAborted) - isAborted = aborted(payload, currLen); - }); - } else { - const nextLen = payload.issues.length; - if (nextLen === currLen) - continue; - if (!isAborted) - isAborted = aborted(payload, currLen); - } - } - if (asyncResult) { - return asyncResult.then(() => { - return payload; - }); - } - return payload; - }; - const handleCanaryResult = (canary, payload, ctx) => { - if (aborted(canary)) { - canary.aborted = true; - return canary; - } - const checkResult = runChecks(payload, checks, ctx); - if (checkResult instanceof Promise) { - if (ctx.async === false) - throw new $ZodAsyncError; - return checkResult.then((checkResult2) => inst._zod.parse(checkResult2, ctx)); - } - return inst._zod.parse(checkResult, ctx); - }; - inst._zod.run = (payload, ctx) => { - if (ctx.skipChecks) { - return inst._zod.parse(payload, ctx); - } - if (ctx.direction === "backward") { - const canary = inst._zod.parse({ value: payload.value, issues: [] }, { ...ctx, skipChecks: true }); - if (canary instanceof Promise) { - return canary.then((canary2) => { - return handleCanaryResult(canary2, payload, ctx); - }); - } - return handleCanaryResult(canary, payload, ctx); - } - const result = inst._zod.parse(payload, ctx); - if (result instanceof Promise) { - if (ctx.async === false) - throw new $ZodAsyncError; - return result.then((result2) => runChecks(result2, checks, ctx)); - } - return runChecks(result, checks, ctx); - }; - } - defineLazy(inst, "~standard", () => ({ - validate: (value) => { - try { - const r = safeParse(inst, value); - return r.success ? { value: r.data } : { issues: r.error?.issues }; - } catch (_) { - return safeParseAsync(inst, value).then((r) => r.success ? { value: r.data } : { issues: r.error?.issues }); - } - }, - vendor: "zod", - version: 1 - })); -}); -var $ZodString = /* @__PURE__ */ $constructor("$ZodString", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.pattern = [...inst?._zod.bag?.patterns ?? []].pop() ?? string(inst._zod.bag); - inst._zod.parse = (payload, _) => { - if (def.coerce) - try { - payload.value = String(payload.value); - } catch (_2) {} - if (typeof payload.value === "string") - return payload; - payload.issues.push({ - expected: "string", - code: "invalid_type", - input: payload.value, - inst - }); - return payload; - }; -}); -var $ZodStringFormat = /* @__PURE__ */ $constructor("$ZodStringFormat", (inst, def) => { - $ZodCheckStringFormat.init(inst, def); - $ZodString.init(inst, def); -}); -var $ZodGUID = /* @__PURE__ */ $constructor("$ZodGUID", (inst, def) => { - def.pattern ?? (def.pattern = guid); - $ZodStringFormat.init(inst, def); -}); -var $ZodUUID = /* @__PURE__ */ $constructor("$ZodUUID", (inst, def) => { - if (def.version) { - const versionMap = { - v1: 1, - v2: 2, - v3: 3, - v4: 4, - v5: 5, - v6: 6, - v7: 7, - v8: 8 - }; - const v = versionMap[def.version]; - if (v === undefined) - throw new Error(`Invalid UUID version: "${def.version}"`); - def.pattern ?? (def.pattern = uuid(v)); - } else - def.pattern ?? (def.pattern = uuid()); - $ZodStringFormat.init(inst, def); -}); -var $ZodEmail = /* @__PURE__ */ $constructor("$ZodEmail", (inst, def) => { - def.pattern ?? (def.pattern = email); - $ZodStringFormat.init(inst, def); -}); -var $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => { - $ZodStringFormat.init(inst, def); - inst._zod.check = (payload) => { - try { - const trimmed = payload.value.trim(); - const url = new URL(trimmed); - if (def.hostname) { - def.hostname.lastIndex = 0; - if (!def.hostname.test(url.hostname)) { - payload.issues.push({ - code: "invalid_format", - format: "url", - note: "Invalid hostname", - pattern: def.hostname.source, - input: payload.value, - inst, - continue: !def.abort - }); - } - } - if (def.protocol) { - def.protocol.lastIndex = 0; - if (!def.protocol.test(url.protocol.endsWith(":") ? url.protocol.slice(0, -1) : url.protocol)) { - payload.issues.push({ - code: "invalid_format", - format: "url", - note: "Invalid protocol", - pattern: def.protocol.source, - input: payload.value, - inst, - continue: !def.abort - }); - } - } - if (def.normalize) { - payload.value = url.href; - } else { - payload.value = trimmed; - } - return; - } catch (_) { - payload.issues.push({ - code: "invalid_format", - format: "url", - input: payload.value, - inst, - continue: !def.abort - }); - } - }; -}); -var $ZodEmoji = /* @__PURE__ */ $constructor("$ZodEmoji", (inst, def) => { - def.pattern ?? (def.pattern = emoji()); - $ZodStringFormat.init(inst, def); -}); -var $ZodNanoID = /* @__PURE__ */ $constructor("$ZodNanoID", (inst, def) => { - def.pattern ?? (def.pattern = nanoid); - $ZodStringFormat.init(inst, def); -}); -var $ZodCUID = /* @__PURE__ */ $constructor("$ZodCUID", (inst, def) => { - def.pattern ?? (def.pattern = cuid); - $ZodStringFormat.init(inst, def); -}); -var $ZodCUID2 = /* @__PURE__ */ $constructor("$ZodCUID2", (inst, def) => { - def.pattern ?? (def.pattern = cuid2); - $ZodStringFormat.init(inst, def); -}); -var $ZodULID = /* @__PURE__ */ $constructor("$ZodULID", (inst, def) => { - def.pattern ?? (def.pattern = ulid); - $ZodStringFormat.init(inst, def); -}); -var $ZodXID = /* @__PURE__ */ $constructor("$ZodXID", (inst, def) => { - def.pattern ?? (def.pattern = xid); - $ZodStringFormat.init(inst, def); -}); -var $ZodKSUID = /* @__PURE__ */ $constructor("$ZodKSUID", (inst, def) => { - def.pattern ?? (def.pattern = ksuid); - $ZodStringFormat.init(inst, def); -}); -var $ZodISODateTime = /* @__PURE__ */ $constructor("$ZodISODateTime", (inst, def) => { - def.pattern ?? (def.pattern = datetime(def)); - $ZodStringFormat.init(inst, def); -}); -var $ZodISODate = /* @__PURE__ */ $constructor("$ZodISODate", (inst, def) => { - def.pattern ?? (def.pattern = date); - $ZodStringFormat.init(inst, def); -}); -var $ZodISOTime = /* @__PURE__ */ $constructor("$ZodISOTime", (inst, def) => { - def.pattern ?? (def.pattern = time(def)); - $ZodStringFormat.init(inst, def); -}); -var $ZodISODuration = /* @__PURE__ */ $constructor("$ZodISODuration", (inst, def) => { - def.pattern ?? (def.pattern = duration); - $ZodStringFormat.init(inst, def); -}); -var $ZodIPv4 = /* @__PURE__ */ $constructor("$ZodIPv4", (inst, def) => { - def.pattern ?? (def.pattern = ipv4); - $ZodStringFormat.init(inst, def); - inst._zod.bag.format = `ipv4`; -}); -var $ZodIPv6 = /* @__PURE__ */ $constructor("$ZodIPv6", (inst, def) => { - def.pattern ?? (def.pattern = ipv6); - $ZodStringFormat.init(inst, def); - inst._zod.bag.format = `ipv6`; - inst._zod.check = (payload) => { - try { - new URL(`http://[${payload.value}]`); - } catch { - payload.issues.push({ - code: "invalid_format", - format: "ipv6", - input: payload.value, - inst, - continue: !def.abort - }); - } - }; -}); -var $ZodMAC = /* @__PURE__ */ $constructor("$ZodMAC", (inst, def) => { - def.pattern ?? (def.pattern = mac(def.delimiter)); - $ZodStringFormat.init(inst, def); - inst._zod.bag.format = `mac`; -}); -var $ZodCIDRv4 = /* @__PURE__ */ $constructor("$ZodCIDRv4", (inst, def) => { - def.pattern ?? (def.pattern = cidrv4); - $ZodStringFormat.init(inst, def); -}); -var $ZodCIDRv6 = /* @__PURE__ */ $constructor("$ZodCIDRv6", (inst, def) => { - def.pattern ?? (def.pattern = cidrv6); - $ZodStringFormat.init(inst, def); - inst._zod.check = (payload) => { - const parts = payload.value.split("/"); - try { - if (parts.length !== 2) - throw new Error; - const [address, prefix] = parts; - if (!prefix) - throw new Error; - const prefixNum = Number(prefix); - if (`${prefixNum}` !== prefix) - throw new Error; - if (prefixNum < 0 || prefixNum > 128) - throw new Error; - new URL(`http://[${address}]`); - } catch { - payload.issues.push({ - code: "invalid_format", - format: "cidrv6", - input: payload.value, - inst, - continue: !def.abort - }); - } - }; -}); -function isValidBase64(data) { - if (data === "") - return true; - if (data.length % 4 !== 0) - return false; - try { - atob(data); - return true; - } catch { - return false; - } -} -var $ZodBase64 = /* @__PURE__ */ $constructor("$ZodBase64", (inst, def) => { - def.pattern ?? (def.pattern = base64); - $ZodStringFormat.init(inst, def); - inst._zod.bag.contentEncoding = "base64"; - inst._zod.check = (payload) => { - if (isValidBase64(payload.value)) - return; - payload.issues.push({ - code: "invalid_format", - format: "base64", - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -function isValidBase64URL(data) { - if (!base64url.test(data)) - return false; - const base642 = data.replace(/[-_]/g, (c) => c === "-" ? "+" : "/"); - const padded = base642.padEnd(Math.ceil(base642.length / 4) * 4, "="); - return isValidBase64(padded); -} -var $ZodBase64URL = /* @__PURE__ */ $constructor("$ZodBase64URL", (inst, def) => { - def.pattern ?? (def.pattern = base64url); - $ZodStringFormat.init(inst, def); - inst._zod.bag.contentEncoding = "base64url"; - inst._zod.check = (payload) => { - if (isValidBase64URL(payload.value)) - return; - payload.issues.push({ - code: "invalid_format", - format: "base64url", - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodE164 = /* @__PURE__ */ $constructor("$ZodE164", (inst, def) => { - def.pattern ?? (def.pattern = e164); - $ZodStringFormat.init(inst, def); -}); -function isValidJWT(token, algorithm = null) { - try { - const tokensParts = token.split("."); - if (tokensParts.length !== 3) - return false; - const [header] = tokensParts; - if (!header) - return false; - const parsedHeader = JSON.parse(atob(header)); - if ("typ" in parsedHeader && parsedHeader?.typ !== "JWT") - return false; - if (!parsedHeader.alg) - return false; - if (algorithm && (!("alg" in parsedHeader) || parsedHeader.alg !== algorithm)) - return false; - return true; - } catch { - return false; - } -} -var $ZodJWT = /* @__PURE__ */ $constructor("$ZodJWT", (inst, def) => { - $ZodStringFormat.init(inst, def); - inst._zod.check = (payload) => { - if (isValidJWT(payload.value, def.alg)) - return; - payload.issues.push({ - code: "invalid_format", - format: "jwt", - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodCustomStringFormat = /* @__PURE__ */ $constructor("$ZodCustomStringFormat", (inst, def) => { - $ZodStringFormat.init(inst, def); - inst._zod.check = (payload) => { - if (def.fn(payload.value)) - return; - payload.issues.push({ - code: "invalid_format", - format: def.format, - input: payload.value, - inst, - continue: !def.abort - }); - }; -}); -var $ZodNumber = /* @__PURE__ */ $constructor("$ZodNumber", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.pattern = inst._zod.bag.pattern ?? number; - inst._zod.parse = (payload, _ctx) => { - if (def.coerce) - try { - payload.value = Number(payload.value); - } catch (_) {} - const input = payload.value; - if (typeof input === "number" && !Number.isNaN(input) && Number.isFinite(input)) { - return payload; - } - const received = typeof input === "number" ? Number.isNaN(input) ? "NaN" : !Number.isFinite(input) ? "Infinity" : undefined : undefined; - payload.issues.push({ - expected: "number", - code: "invalid_type", - input, - inst, - ...received ? { received } : {} - }); - return payload; - }; -}); -var $ZodNumberFormat = /* @__PURE__ */ $constructor("$ZodNumberFormat", (inst, def) => { - $ZodCheckNumberFormat.init(inst, def); - $ZodNumber.init(inst, def); -}); -var $ZodBoolean = /* @__PURE__ */ $constructor("$ZodBoolean", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.pattern = boolean; - inst._zod.parse = (payload, _ctx) => { - if (def.coerce) - try { - payload.value = Boolean(payload.value); - } catch (_) {} - const input = payload.value; - if (typeof input === "boolean") - return payload; - payload.issues.push({ - expected: "boolean", - code: "invalid_type", - input, - inst - }); - return payload; - }; -}); -var $ZodBigInt = /* @__PURE__ */ $constructor("$ZodBigInt", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.pattern = bigint; - inst._zod.parse = (payload, _ctx) => { - if (def.coerce) - try { - payload.value = BigInt(payload.value); - } catch (_) {} - if (typeof payload.value === "bigint") - return payload; - payload.issues.push({ - expected: "bigint", - code: "invalid_type", - input: payload.value, - inst - }); - return payload; - }; -}); -var $ZodBigIntFormat = /* @__PURE__ */ $constructor("$ZodBigIntFormat", (inst, def) => { - $ZodCheckBigIntFormat.init(inst, def); - $ZodBigInt.init(inst, def); -}); -var $ZodSymbol = /* @__PURE__ */ $constructor("$ZodSymbol", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (typeof input === "symbol") - return payload; - payload.issues.push({ - expected: "symbol", - code: "invalid_type", - input, - inst - }); - return payload; - }; -}); -var $ZodUndefined = /* @__PURE__ */ $constructor("$ZodUndefined", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.pattern = _undefined; - inst._zod.values = new Set([undefined]); - inst._zod.optin = "optional"; - inst._zod.optout = "optional"; - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (typeof input === "undefined") - return payload; - payload.issues.push({ - expected: "undefined", - code: "invalid_type", - input, - inst - }); - return payload; - }; -}); -var $ZodNull = /* @__PURE__ */ $constructor("$ZodNull", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.pattern = _null; - inst._zod.values = new Set([null]); - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (input === null) - return payload; - payload.issues.push({ - expected: "null", - code: "invalid_type", - input, - inst - }); - return payload; - }; -}); -var $ZodAny = /* @__PURE__ */ $constructor("$ZodAny", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload) => payload; -}); -var $ZodUnknown = /* @__PURE__ */ $constructor("$ZodUnknown", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload) => payload; -}); -var $ZodNever = /* @__PURE__ */ $constructor("$ZodNever", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, _ctx) => { - payload.issues.push({ - expected: "never", - code: "invalid_type", - input: payload.value, - inst - }); - return payload; - }; -}); -var $ZodVoid = /* @__PURE__ */ $constructor("$ZodVoid", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (typeof input === "undefined") - return payload; - payload.issues.push({ - expected: "void", - code: "invalid_type", - input, - inst - }); - return payload; - }; -}); -var $ZodDate = /* @__PURE__ */ $constructor("$ZodDate", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, _ctx) => { - if (def.coerce) { - try { - payload.value = new Date(payload.value); - } catch (_err) {} - } - const input = payload.value; - const isDate = input instanceof Date; - const isValidDate = isDate && !Number.isNaN(input.getTime()); - if (isValidDate) - return payload; - payload.issues.push({ - expected: "date", - code: "invalid_type", - input, - ...isDate ? { received: "Invalid Date" } : {}, - inst - }); - return payload; - }; -}); -function handleArrayResult(result, final, index) { - if (result.issues.length) { - final.issues.push(...prefixIssues(index, result.issues)); - } - final.value[index] = result.value; -} -var $ZodArray = /* @__PURE__ */ $constructor("$ZodArray", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - if (!Array.isArray(input)) { - payload.issues.push({ - expected: "array", - code: "invalid_type", - input, - inst - }); - return payload; - } - payload.value = Array(input.length); - const proms = []; - for (let i = 0;i < input.length; i++) { - const item = input[i]; - const result = def.element._zod.run({ - value: item, - issues: [] - }, ctx); - if (result instanceof Promise) { - proms.push(result.then((result2) => handleArrayResult(result2, payload, i))); - } else { - handleArrayResult(result, payload, i); - } - } - if (proms.length) { - return Promise.all(proms).then(() => payload); - } - return payload; - }; -}); -function handlePropertyResult(result, final, key, input, isOptionalOut) { - if (result.issues.length) { - if (isOptionalOut && !(key in input)) { - return; - } - final.issues.push(...prefixIssues(key, result.issues)); - } - if (result.value === undefined) { - if (key in input) { - final.value[key] = undefined; - } - } else { - final.value[key] = result.value; - } -} -function normalizeDef(def) { - const keys = Object.keys(def.shape); - for (const k of keys) { - if (!def.shape?.[k]?._zod?.traits?.has("$ZodType")) { - throw new Error(`Invalid element at key "${k}": expected a Zod schema`); - } - } - const okeys = optionalKeys(def.shape); - return { - ...def, - keys, - keySet: new Set(keys), - numKeys: keys.length, - optionalKeys: new Set(okeys) - }; -} -function handleCatchall(proms, input, payload, ctx, def, inst) { - const unrecognized = []; - const keySet = def.keySet; - const _catchall = def.catchall._zod; - const t = _catchall.def.type; - const isOptionalOut = _catchall.optout === "optional"; - for (const key in input) { - if (keySet.has(key)) - continue; - if (t === "never") { - unrecognized.push(key); - continue; - } - const r = _catchall.run({ value: input[key], issues: [] }, ctx); - if (r instanceof Promise) { - proms.push(r.then((r2) => handlePropertyResult(r2, payload, key, input, isOptionalOut))); - } else { - handlePropertyResult(r, payload, key, input, isOptionalOut); - } - } - if (unrecognized.length) { - payload.issues.push({ - code: "unrecognized_keys", - keys: unrecognized, - input, - inst - }); - } - if (!proms.length) - return payload; - return Promise.all(proms).then(() => { - return payload; - }); -} -var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => { - $ZodType.init(inst, def); - const desc = Object.getOwnPropertyDescriptor(def, "shape"); - if (!desc?.get) { - const sh = def.shape; - Object.defineProperty(def, "shape", { - get: () => { - const newSh = { ...sh }; - Object.defineProperty(def, "shape", { - value: newSh - }); - return newSh; - } - }); - } - const _normalized = cached(() => normalizeDef(def)); - defineLazy(inst._zod, "propValues", () => { - const shape = def.shape; - const propValues = {}; - for (const key in shape) { - const field = shape[key]._zod; - if (field.values) { - propValues[key] ?? (propValues[key] = new Set); - for (const v of field.values) - propValues[key].add(v); - } - } - return propValues; - }); - const isObject2 = isObject; - const catchall = def.catchall; - let value; - inst._zod.parse = (payload, ctx) => { - value ?? (value = _normalized.value); - const input = payload.value; - if (!isObject2(input)) { - payload.issues.push({ - expected: "object", - code: "invalid_type", - input, - inst - }); - return payload; - } - payload.value = {}; - const proms = []; - const shape = value.shape; - for (const key of value.keys) { - const el = shape[key]; - const isOptionalOut = el._zod.optout === "optional"; - const r = el._zod.run({ value: input[key], issues: [] }, ctx); - if (r instanceof Promise) { - proms.push(r.then((r2) => handlePropertyResult(r2, payload, key, input, isOptionalOut))); - } else { - handlePropertyResult(r, payload, key, input, isOptionalOut); - } - } - if (!catchall) { - return proms.length ? Promise.all(proms).then(() => payload) : payload; - } - return handleCatchall(proms, input, payload, ctx, _normalized.value, inst); - }; -}); -var $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def) => { - $ZodObject.init(inst, def); - const superParse = inst._zod.parse; - const _normalized = cached(() => normalizeDef(def)); - const generateFastpass = (shape) => { - const doc = new Doc(["shape", "payload", "ctx"]); - const normalized = _normalized.value; - const parseStr = (key) => { - const k = esc(key); - return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`; - }; - doc.write(`const input = payload.value;`); - const ids = Object.create(null); - let counter = 0; - for (const key of normalized.keys) { - ids[key] = `key_${counter++}`; - } - doc.write(`const newResult = {};`); - for (const key of normalized.keys) { - const id = ids[key]; - const k = esc(key); - const schema = shape[key]; - const isOptionalOut = schema?._zod?.optout === "optional"; - doc.write(`const ${id} = ${parseStr(key)};`); - if (isOptionalOut) { - doc.write(` - if (${id}.issues.length) { - if (${k} in input) { - payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ - ...iss, - path: iss.path ? [${k}, ...iss.path] : [${k}] - }))); - } - } - - if (${id}.value === undefined) { - if (${k} in input) { - newResult[${k}] = undefined; - } - } else { - newResult[${k}] = ${id}.value; - } - - `); - } else { - doc.write(` - if (${id}.issues.length) { - payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ - ...iss, - path: iss.path ? [${k}, ...iss.path] : [${k}] - }))); - } - - if (${id}.value === undefined) { - if (${k} in input) { - newResult[${k}] = undefined; - } - } else { - newResult[${k}] = ${id}.value; - } - - `); - } - } - doc.write(`payload.value = newResult;`); - doc.write(`return payload;`); - const fn = doc.compile(); - return (payload, ctx) => fn(shape, payload, ctx); - }; - let fastpass; - const isObject2 = isObject; - const jit = !globalConfig.jitless; - const allowsEval2 = allowsEval; - const fastEnabled = jit && allowsEval2.value; - const catchall = def.catchall; - let value; - inst._zod.parse = (payload, ctx) => { - value ?? (value = _normalized.value); - const input = payload.value; - if (!isObject2(input)) { - payload.issues.push({ - expected: "object", - code: "invalid_type", - input, - inst - }); - return payload; - } - if (jit && fastEnabled && ctx?.async === false && ctx.jitless !== true) { - if (!fastpass) - fastpass = generateFastpass(def.shape); - payload = fastpass(payload, ctx); - if (!catchall) - return payload; - return handleCatchall([], input, payload, ctx, value, inst); - } - return superParse(payload, ctx); - }; -}); -function handleUnionResults(results, final, inst, ctx) { - for (const result of results) { - if (result.issues.length === 0) { - final.value = result.value; - return final; - } - } - const nonaborted = results.filter((r) => !aborted(r)); - if (nonaborted.length === 1) { - final.value = nonaborted[0].value; - return nonaborted[0]; - } - final.issues.push({ - code: "invalid_union", - input: final.value, - inst, - errors: results.map((result) => result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) - }); - return final; -} -var $ZodUnion = /* @__PURE__ */ $constructor("$ZodUnion", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "optin", () => def.options.some((o) => o._zod.optin === "optional") ? "optional" : undefined); - defineLazy(inst._zod, "optout", () => def.options.some((o) => o._zod.optout === "optional") ? "optional" : undefined); - defineLazy(inst._zod, "values", () => { - if (def.options.every((o) => o._zod.values)) { - return new Set(def.options.flatMap((option) => Array.from(option._zod.values))); - } - return; - }); - defineLazy(inst._zod, "pattern", () => { - if (def.options.every((o) => o._zod.pattern)) { - const patterns = def.options.map((o) => o._zod.pattern); - return new RegExp(`^(${patterns.map((p) => cleanRegex(p.source)).join("|")})$`); - } - return; - }); - const single = def.options.length === 1; - const first = def.options[0]._zod.run; - inst._zod.parse = (payload, ctx) => { - if (single) { - return first(payload, ctx); - } - let async = false; - const results = []; - for (const option of def.options) { - const result = option._zod.run({ - value: payload.value, - issues: [] - }, ctx); - if (result instanceof Promise) { - results.push(result); - async = true; - } else { - if (result.issues.length === 0) - return result; - results.push(result); - } - } - if (!async) - return handleUnionResults(results, payload, inst, ctx); - return Promise.all(results).then((results2) => { - return handleUnionResults(results2, payload, inst, ctx); - }); - }; -}); -function handleExclusiveUnionResults(results, final, inst, ctx) { - const successes = results.filter((r) => r.issues.length === 0); - if (successes.length === 1) { - final.value = successes[0].value; - return final; - } - if (successes.length === 0) { - final.issues.push({ - code: "invalid_union", - input: final.value, - inst, - errors: results.map((result) => result.issues.map((iss) => finalizeIssue(iss, ctx, config()))) - }); - } else { - final.issues.push({ - code: "invalid_union", - input: final.value, - inst, - errors: [], - inclusive: false - }); - } - return final; -} -var $ZodXor = /* @__PURE__ */ $constructor("$ZodXor", (inst, def) => { - $ZodUnion.init(inst, def); - def.inclusive = false; - const single = def.options.length === 1; - const first = def.options[0]._zod.run; - inst._zod.parse = (payload, ctx) => { - if (single) { - return first(payload, ctx); - } - let async = false; - const results = []; - for (const option of def.options) { - const result = option._zod.run({ - value: payload.value, - issues: [] - }, ctx); - if (result instanceof Promise) { - results.push(result); - async = true; - } else { - results.push(result); - } - } - if (!async) - return handleExclusiveUnionResults(results, payload, inst, ctx); - return Promise.all(results).then((results2) => { - return handleExclusiveUnionResults(results2, payload, inst, ctx); - }); - }; -}); -var $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnion", (inst, def) => { - def.inclusive = false; - $ZodUnion.init(inst, def); - const _super = inst._zod.parse; - defineLazy(inst._zod, "propValues", () => { - const propValues = {}; - for (const option of def.options) { - const pv = option._zod.propValues; - if (!pv || Object.keys(pv).length === 0) - throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option)}"`); - for (const [k, v] of Object.entries(pv)) { - if (!propValues[k]) - propValues[k] = new Set; - for (const val of v) { - propValues[k].add(val); - } - } - } - return propValues; - }); - const disc = cached(() => { - const opts = def.options; - const map = new Map; - for (const o of opts) { - const values = o._zod.propValues?.[def.discriminator]; - if (!values || values.size === 0) - throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(o)}"`); - for (const v of values) { - if (map.has(v)) { - throw new Error(`Duplicate discriminator value "${String(v)}"`); - } - map.set(v, o); - } - } - return map; - }); - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - if (!isObject(input)) { - payload.issues.push({ - code: "invalid_type", - expected: "object", - input, - inst - }); - return payload; - } - const opt = disc.value.get(input?.[def.discriminator]); - if (opt) { - return opt._zod.run(payload, ctx); - } - if (def.unionFallback) { - return _super(payload, ctx); - } - payload.issues.push({ - code: "invalid_union", - errors: [], - note: "No matching discriminator", - discriminator: def.discriminator, - input, - path: [def.discriminator], - inst - }); - return payload; - }; -}); -var $ZodIntersection = /* @__PURE__ */ $constructor("$ZodIntersection", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - const left = def.left._zod.run({ value: input, issues: [] }, ctx); - const right = def.right._zod.run({ value: input, issues: [] }, ctx); - const async = left instanceof Promise || right instanceof Promise; - if (async) { - return Promise.all([left, right]).then(([left2, right2]) => { - return handleIntersectionResults(payload, left2, right2); - }); - } - return handleIntersectionResults(payload, left, right); - }; -}); -function mergeValues(a, b) { - if (a === b) { - return { valid: true, data: a }; - } - if (a instanceof Date && b instanceof Date && +a === +b) { - return { valid: true, data: a }; - } - if (isPlainObject(a) && isPlainObject(b)) { - const bKeys = Object.keys(b); - const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1); - const newObj = { ...a, ...b }; - for (const key of sharedKeys) { - const sharedValue = mergeValues(a[key], b[key]); - if (!sharedValue.valid) { - return { - valid: false, - mergeErrorPath: [key, ...sharedValue.mergeErrorPath] - }; - } - newObj[key] = sharedValue.data; - } - return { valid: true, data: newObj }; - } - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) { - return { valid: false, mergeErrorPath: [] }; - } - const newArray = []; - for (let index = 0;index < a.length; index++) { - const itemA = a[index]; - const itemB = b[index]; - const sharedValue = mergeValues(itemA, itemB); - if (!sharedValue.valid) { - return { - valid: false, - mergeErrorPath: [index, ...sharedValue.mergeErrorPath] - }; - } - newArray.push(sharedValue.data); - } - return { valid: true, data: newArray }; - } - return { valid: false, mergeErrorPath: [] }; -} -function handleIntersectionResults(result, left, right) { - const unrecKeys = new Map; - let unrecIssue; - for (const iss of left.issues) { - if (iss.code === "unrecognized_keys") { - unrecIssue ?? (unrecIssue = iss); - for (const k of iss.keys) { - if (!unrecKeys.has(k)) - unrecKeys.set(k, {}); - unrecKeys.get(k).l = true; - } - } else { - result.issues.push(iss); - } - } - for (const iss of right.issues) { - if (iss.code === "unrecognized_keys") { - for (const k of iss.keys) { - if (!unrecKeys.has(k)) - unrecKeys.set(k, {}); - unrecKeys.get(k).r = true; - } - } else { - result.issues.push(iss); - } - } - const bothKeys = [...unrecKeys].filter(([, f]) => f.l && f.r).map(([k]) => k); - if (bothKeys.length && unrecIssue) { - result.issues.push({ ...unrecIssue, keys: bothKeys }); - } - if (aborted(result)) - return result; - const merged = mergeValues(left.value, right.value); - if (!merged.valid) { - throw new Error(`Unmergable intersection. Error path: ` + `${JSON.stringify(merged.mergeErrorPath)}`); - } - result.value = merged.data; - return result; -} -var $ZodTuple = /* @__PURE__ */ $constructor("$ZodTuple", (inst, def) => { - $ZodType.init(inst, def); - const items = def.items; - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - if (!Array.isArray(input)) { - payload.issues.push({ - input, - inst, - expected: "tuple", - code: "invalid_type" - }); - return payload; - } - payload.value = []; - const proms = []; - const reversedIndex = [...items].reverse().findIndex((item) => item._zod.optin !== "optional"); - const optStart = reversedIndex === -1 ? 0 : items.length - reversedIndex; - if (!def.rest) { - const tooBig = input.length > items.length; - const tooSmall = input.length < optStart - 1; - if (tooBig || tooSmall) { - payload.issues.push({ - ...tooBig ? { code: "too_big", maximum: items.length, inclusive: true } : { code: "too_small", minimum: items.length }, - input, - inst, - origin: "array" - }); - return payload; - } - } - let i = -1; - for (const item of items) { - i++; - if (i >= input.length) { - if (i >= optStart) - continue; - } - const result = item._zod.run({ - value: input[i], - issues: [] - }, ctx); - if (result instanceof Promise) { - proms.push(result.then((result2) => handleTupleResult(result2, payload, i))); - } else { - handleTupleResult(result, payload, i); - } - } - if (def.rest) { - const rest = input.slice(items.length); - for (const el of rest) { - i++; - const result = def.rest._zod.run({ - value: el, - issues: [] - }, ctx); - if (result instanceof Promise) { - proms.push(result.then((result2) => handleTupleResult(result2, payload, i))); - } else { - handleTupleResult(result, payload, i); - } - } - } - if (proms.length) - return Promise.all(proms).then(() => payload); - return payload; - }; -}); -function handleTupleResult(result, final, index) { - if (result.issues.length) { - final.issues.push(...prefixIssues(index, result.issues)); - } - final.value[index] = result.value; -} -var $ZodRecord = /* @__PURE__ */ $constructor("$ZodRecord", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - if (!isPlainObject(input)) { - payload.issues.push({ - expected: "record", - code: "invalid_type", - input, - inst - }); - return payload; - } - const proms = []; - const values = def.keyType._zod.values; - if (values) { - payload.value = {}; - const recordKeys = new Set; - for (const key of values) { - if (typeof key === "string" || typeof key === "number" || typeof key === "symbol") { - recordKeys.add(typeof key === "number" ? key.toString() : key); - const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx); - if (result instanceof Promise) { - proms.push(result.then((result2) => { - if (result2.issues.length) { - payload.issues.push(...prefixIssues(key, result2.issues)); - } - payload.value[key] = result2.value; - })); - } else { - if (result.issues.length) { - payload.issues.push(...prefixIssues(key, result.issues)); - } - payload.value[key] = result.value; - } - } - } - let unrecognized; - for (const key in input) { - if (!recordKeys.has(key)) { - unrecognized = unrecognized ?? []; - unrecognized.push(key); - } - } - if (unrecognized && unrecognized.length > 0) { - payload.issues.push({ - code: "unrecognized_keys", - input, - inst, - keys: unrecognized - }); - } - } else { - payload.value = {}; - for (const key of Reflect.ownKeys(input)) { - if (key === "__proto__") - continue; - let keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx); - if (keyResult instanceof Promise) { - throw new Error("Async schemas not supported in object keys currently"); - } - const checkNumericKey = typeof key === "string" && number.test(key) && keyResult.issues.length; - if (checkNumericKey) { - const retryResult = def.keyType._zod.run({ value: Number(key), issues: [] }, ctx); - if (retryResult instanceof Promise) { - throw new Error("Async schemas not supported in object keys currently"); - } - if (retryResult.issues.length === 0) { - keyResult = retryResult; - } - } - if (keyResult.issues.length) { - if (def.mode === "loose") { - payload.value[key] = input[key]; - } else { - payload.issues.push({ - code: "invalid_key", - origin: "record", - issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())), - input: key, - path: [key], - inst - }); - } - continue; - } - const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx); - if (result instanceof Promise) { - proms.push(result.then((result2) => { - if (result2.issues.length) { - payload.issues.push(...prefixIssues(key, result2.issues)); - } - payload.value[keyResult.value] = result2.value; - })); - } else { - if (result.issues.length) { - payload.issues.push(...prefixIssues(key, result.issues)); - } - payload.value[keyResult.value] = result.value; - } - } - } - if (proms.length) { - return Promise.all(proms).then(() => payload); - } - return payload; - }; -}); -var $ZodMap = /* @__PURE__ */ $constructor("$ZodMap", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - if (!(input instanceof Map)) { - payload.issues.push({ - expected: "map", - code: "invalid_type", - input, - inst - }); - return payload; - } - const proms = []; - payload.value = new Map; - for (const [key, value] of input) { - const keyResult = def.keyType._zod.run({ value: key, issues: [] }, ctx); - const valueResult = def.valueType._zod.run({ value, issues: [] }, ctx); - if (keyResult instanceof Promise || valueResult instanceof Promise) { - proms.push(Promise.all([keyResult, valueResult]).then(([keyResult2, valueResult2]) => { - handleMapResult(keyResult2, valueResult2, payload, key, input, inst, ctx); - })); - } else { - handleMapResult(keyResult, valueResult, payload, key, input, inst, ctx); - } - } - if (proms.length) - return Promise.all(proms).then(() => payload); - return payload; - }; -}); -function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) { - if (keyResult.issues.length) { - if (propertyKeyTypes.has(typeof key)) { - final.issues.push(...prefixIssues(key, keyResult.issues)); - } else { - final.issues.push({ - code: "invalid_key", - origin: "map", - input, - inst, - issues: keyResult.issues.map((iss) => finalizeIssue(iss, ctx, config())) - }); - } - } - if (valueResult.issues.length) { - if (propertyKeyTypes.has(typeof key)) { - final.issues.push(...prefixIssues(key, valueResult.issues)); - } else { - final.issues.push({ - origin: "map", - code: "invalid_element", - input, - inst, - key, - issues: valueResult.issues.map((iss) => finalizeIssue(iss, ctx, config())) - }); - } - } - final.value.set(keyResult.value, valueResult.value); -} -var $ZodSet = /* @__PURE__ */ $constructor("$ZodSet", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - const input = payload.value; - if (!(input instanceof Set)) { - payload.issues.push({ - input, - inst, - expected: "set", - code: "invalid_type" - }); - return payload; - } - const proms = []; - payload.value = new Set; - for (const item of input) { - const result = def.valueType._zod.run({ value: item, issues: [] }, ctx); - if (result instanceof Promise) { - proms.push(result.then((result2) => handleSetResult(result2, payload))); - } else - handleSetResult(result, payload); - } - if (proms.length) - return Promise.all(proms).then(() => payload); - return payload; - }; -}); -function handleSetResult(result, final) { - if (result.issues.length) { - final.issues.push(...result.issues); - } - final.value.add(result.value); -} -var $ZodEnum = /* @__PURE__ */ $constructor("$ZodEnum", (inst, def) => { - $ZodType.init(inst, def); - const values = getEnumValues(def.entries); - const valuesSet = new Set(values); - inst._zod.values = valuesSet; - inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`); - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (valuesSet.has(input)) { - return payload; - } - payload.issues.push({ - code: "invalid_value", - values, - input, - inst - }); - return payload; - }; -}); -var $ZodLiteral = /* @__PURE__ */ $constructor("$ZodLiteral", (inst, def) => { - $ZodType.init(inst, def); - if (def.values.length === 0) { - throw new Error("Cannot create literal schema with no valid values"); - } - const values = new Set(def.values); - inst._zod.values = values; - inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`); - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (values.has(input)) { - return payload; - } - payload.issues.push({ - code: "invalid_value", - values: def.values, - input, - inst - }); - return payload; - }; -}); -var $ZodFile = /* @__PURE__ */ $constructor("$ZodFile", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, _ctx) => { - const input = payload.value; - if (input instanceof File) - return payload; - payload.issues.push({ - expected: "file", - code: "invalid_type", - input, - inst - }); - return payload; - }; -}); -var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - throw new $ZodEncodeError(inst.constructor.name); - } - const _out = def.transform(payload.value, payload); - if (ctx.async) { - const output = _out instanceof Promise ? _out : Promise.resolve(_out); - return output.then((output2) => { - payload.value = output2; - return payload; - }); - } - if (_out instanceof Promise) { - throw new $ZodAsyncError; - } - payload.value = _out; - return payload; - }; -}); -function handleOptionalResult(result, input) { - if (result.issues.length && input === undefined) { - return { issues: [], value: undefined }; - } - return result; -} -var $ZodOptional = /* @__PURE__ */ $constructor("$ZodOptional", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.optin = "optional"; - inst._zod.optout = "optional"; - defineLazy(inst._zod, "values", () => { - return def.innerType._zod.values ? new Set([...def.innerType._zod.values, undefined]) : undefined; - }); - defineLazy(inst._zod, "pattern", () => { - const pattern = def.innerType._zod.pattern; - return pattern ? new RegExp(`^(${cleanRegex(pattern.source)})?$`) : undefined; - }); - inst._zod.parse = (payload, ctx) => { - if (def.innerType._zod.optin === "optional") { - const result = def.innerType._zod.run(payload, ctx); - if (result instanceof Promise) - return result.then((r) => handleOptionalResult(r, payload.value)); - return handleOptionalResult(result, payload.value); - } - if (payload.value === undefined) { - return payload; - } - return def.innerType._zod.run(payload, ctx); - }; -}); -var $ZodExactOptional = /* @__PURE__ */ $constructor("$ZodExactOptional", (inst, def) => { - $ZodOptional.init(inst, def); - defineLazy(inst._zod, "values", () => def.innerType._zod.values); - defineLazy(inst._zod, "pattern", () => def.innerType._zod.pattern); - inst._zod.parse = (payload, ctx) => { - return def.innerType._zod.run(payload, ctx); - }; -}); -var $ZodNullable = /* @__PURE__ */ $constructor("$ZodNullable", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "optin", () => def.innerType._zod.optin); - defineLazy(inst._zod, "optout", () => def.innerType._zod.optout); - defineLazy(inst._zod, "pattern", () => { - const pattern = def.innerType._zod.pattern; - return pattern ? new RegExp(`^(${cleanRegex(pattern.source)}|null)$`) : undefined; - }); - defineLazy(inst._zod, "values", () => { - return def.innerType._zod.values ? new Set([...def.innerType._zod.values, null]) : undefined; - }); - inst._zod.parse = (payload, ctx) => { - if (payload.value === null) - return payload; - return def.innerType._zod.run(payload, ctx); - }; -}); -var $ZodDefault = /* @__PURE__ */ $constructor("$ZodDefault", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.optin = "optional"; - defineLazy(inst._zod, "values", () => def.innerType._zod.values); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - return def.innerType._zod.run(payload, ctx); - } - if (payload.value === undefined) { - payload.value = def.defaultValue; - return payload; - } - const result = def.innerType._zod.run(payload, ctx); - if (result instanceof Promise) { - return result.then((result2) => handleDefaultResult(result2, def)); - } - return handleDefaultResult(result, def); - }; -}); -function handleDefaultResult(payload, def) { - if (payload.value === undefined) { - payload.value = def.defaultValue; - } - return payload; -} -var $ZodPrefault = /* @__PURE__ */ $constructor("$ZodPrefault", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.optin = "optional"; - defineLazy(inst._zod, "values", () => def.innerType._zod.values); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - return def.innerType._zod.run(payload, ctx); - } - if (payload.value === undefined) { - payload.value = def.defaultValue; - } - return def.innerType._zod.run(payload, ctx); - }; -}); -var $ZodNonOptional = /* @__PURE__ */ $constructor("$ZodNonOptional", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "values", () => { - const v = def.innerType._zod.values; - return v ? new Set([...v].filter((x) => x !== undefined)) : undefined; - }); - inst._zod.parse = (payload, ctx) => { - const result = def.innerType._zod.run(payload, ctx); - if (result instanceof Promise) { - return result.then((result2) => handleNonOptionalResult(result2, inst)); - } - return handleNonOptionalResult(result, inst); - }; -}); -function handleNonOptionalResult(payload, inst) { - if (!payload.issues.length && payload.value === undefined) { - payload.issues.push({ - code: "invalid_type", - expected: "nonoptional", - input: payload.value, - inst - }); - } - return payload; -} -var $ZodSuccess = /* @__PURE__ */ $constructor("$ZodSuccess", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - throw new $ZodEncodeError("ZodSuccess"); - } - const result = def.innerType._zod.run(payload, ctx); - if (result instanceof Promise) { - return result.then((result2) => { - payload.value = result2.issues.length === 0; - return payload; - }); - } - payload.value = result.issues.length === 0; - return payload; - }; -}); -var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "optin", () => def.innerType._zod.optin); - defineLazy(inst._zod, "optout", () => def.innerType._zod.optout); - defineLazy(inst._zod, "values", () => def.innerType._zod.values); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - return def.innerType._zod.run(payload, ctx); - } - const result = def.innerType._zod.run(payload, ctx); - if (result instanceof Promise) { - return result.then((result2) => { - payload.value = result2.value; - if (result2.issues.length) { - payload.value = def.catchValue({ - ...payload, - error: { - issues: result2.issues.map((iss) => finalizeIssue(iss, ctx, config())) - }, - input: payload.value - }); - payload.issues = []; - } - return payload; - }); - } - payload.value = result.value; - if (result.issues.length) { - payload.value = def.catchValue({ - ...payload, - error: { - issues: result.issues.map((iss) => finalizeIssue(iss, ctx, config())) - }, - input: payload.value - }); - payload.issues = []; - } - return payload; - }; -}); -var $ZodNaN = /* @__PURE__ */ $constructor("$ZodNaN", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, _ctx) => { - if (typeof payload.value !== "number" || !Number.isNaN(payload.value)) { - payload.issues.push({ - input: payload.value, - inst, - expected: "nan", - code: "invalid_type" - }); - return payload; - } - return payload; - }; -}); -var $ZodPipe = /* @__PURE__ */ $constructor("$ZodPipe", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "values", () => def.in._zod.values); - defineLazy(inst._zod, "optin", () => def.in._zod.optin); - defineLazy(inst._zod, "optout", () => def.out._zod.optout); - defineLazy(inst._zod, "propValues", () => def.in._zod.propValues); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - const right = def.out._zod.run(payload, ctx); - if (right instanceof Promise) { - return right.then((right2) => handlePipeResult(right2, def.in, ctx)); - } - return handlePipeResult(right, def.in, ctx); - } - const left = def.in._zod.run(payload, ctx); - if (left instanceof Promise) { - return left.then((left2) => handlePipeResult(left2, def.out, ctx)); - } - return handlePipeResult(left, def.out, ctx); - }; -}); -function handlePipeResult(left, next, ctx) { - if (left.issues.length) { - left.aborted = true; - return left; - } - return next._zod.run({ value: left.value, issues: left.issues }, ctx); -} -var $ZodCodec = /* @__PURE__ */ $constructor("$ZodCodec", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "values", () => def.in._zod.values); - defineLazy(inst._zod, "optin", () => def.in._zod.optin); - defineLazy(inst._zod, "optout", () => def.out._zod.optout); - defineLazy(inst._zod, "propValues", () => def.in._zod.propValues); - inst._zod.parse = (payload, ctx) => { - const direction = ctx.direction || "forward"; - if (direction === "forward") { - const left = def.in._zod.run(payload, ctx); - if (left instanceof Promise) { - return left.then((left2) => handleCodecAResult(left2, def, ctx)); - } - return handleCodecAResult(left, def, ctx); - } else { - const right = def.out._zod.run(payload, ctx); - if (right instanceof Promise) { - return right.then((right2) => handleCodecAResult(right2, def, ctx)); - } - return handleCodecAResult(right, def, ctx); - } - }; -}); -function handleCodecAResult(result, def, ctx) { - if (result.issues.length) { - result.aborted = true; - return result; - } - const direction = ctx.direction || "forward"; - if (direction === "forward") { - const transformed = def.transform(result.value, result); - if (transformed instanceof Promise) { - return transformed.then((value) => handleCodecTxResult(result, value, def.out, ctx)); - } - return handleCodecTxResult(result, transformed, def.out, ctx); - } else { - const transformed = def.reverseTransform(result.value, result); - if (transformed instanceof Promise) { - return transformed.then((value) => handleCodecTxResult(result, value, def.in, ctx)); - } - return handleCodecTxResult(result, transformed, def.in, ctx); - } -} -function handleCodecTxResult(left, value, nextSchema, ctx) { - if (left.issues.length) { - left.aborted = true; - return left; - } - return nextSchema._zod.run({ value, issues: left.issues }, ctx); -} -var $ZodReadonly = /* @__PURE__ */ $constructor("$ZodReadonly", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "propValues", () => def.innerType._zod.propValues); - defineLazy(inst._zod, "values", () => def.innerType._zod.values); - defineLazy(inst._zod, "optin", () => def.innerType?._zod?.optin); - defineLazy(inst._zod, "optout", () => def.innerType?._zod?.optout); - inst._zod.parse = (payload, ctx) => { - if (ctx.direction === "backward") { - return def.innerType._zod.run(payload, ctx); - } - const result = def.innerType._zod.run(payload, ctx); - if (result instanceof Promise) { - return result.then(handleReadonlyResult); - } - return handleReadonlyResult(result); - }; -}); -function handleReadonlyResult(payload) { - payload.value = Object.freeze(payload.value); - return payload; -} -var $ZodTemplateLiteral = /* @__PURE__ */ $constructor("$ZodTemplateLiteral", (inst, def) => { - $ZodType.init(inst, def); - const regexParts = []; - for (const part of def.parts) { - if (typeof part === "object" && part !== null) { - if (!part._zod.pattern) { - throw new Error(`Invalid template literal part, no pattern found: ${[...part._zod.traits].shift()}`); - } - const source = part._zod.pattern instanceof RegExp ? part._zod.pattern.source : part._zod.pattern; - if (!source) - throw new Error(`Invalid template literal part: ${part._zod.traits}`); - const start = source.startsWith("^") ? 1 : 0; - const end = source.endsWith("$") ? source.length - 1 : source.length; - regexParts.push(source.slice(start, end)); - } else if (part === null || primitiveTypes.has(typeof part)) { - regexParts.push(escapeRegex(`${part}`)); - } else { - throw new Error(`Invalid template literal part: ${part}`); - } - } - inst._zod.pattern = new RegExp(`^${regexParts.join("")}$`); - inst._zod.parse = (payload, _ctx) => { - if (typeof payload.value !== "string") { - payload.issues.push({ - input: payload.value, - inst, - expected: "string", - code: "invalid_type" - }); - return payload; - } - inst._zod.pattern.lastIndex = 0; - if (!inst._zod.pattern.test(payload.value)) { - payload.issues.push({ - input: payload.value, - inst, - code: "invalid_format", - format: def.format ?? "template_literal", - pattern: inst._zod.pattern.source - }); - return payload; - } - return payload; - }; -}); -var $ZodFunction = /* @__PURE__ */ $constructor("$ZodFunction", (inst, def) => { - $ZodType.init(inst, def); - inst._def = def; - inst._zod.def = def; - inst.implement = (func) => { - if (typeof func !== "function") { - throw new Error("implement() must be called with a function"); - } - return function(...args) { - const parsedArgs = inst._def.input ? parse(inst._def.input, args) : args; - const result = Reflect.apply(func, this, parsedArgs); - if (inst._def.output) { - return parse(inst._def.output, result); - } - return result; - }; - }; - inst.implementAsync = (func) => { - if (typeof func !== "function") { - throw new Error("implementAsync() must be called with a function"); - } - return async function(...args) { - const parsedArgs = inst._def.input ? await parseAsync(inst._def.input, args) : args; - const result = await Reflect.apply(func, this, parsedArgs); - if (inst._def.output) { - return await parseAsync(inst._def.output, result); - } - return result; - }; - }; - inst._zod.parse = (payload, _ctx) => { - if (typeof payload.value !== "function") { - payload.issues.push({ - code: "invalid_type", - expected: "function", - input: payload.value, - inst - }); - return payload; - } - const hasPromiseOutput = inst._def.output && inst._def.output._zod.def.type === "promise"; - if (hasPromiseOutput) { - payload.value = inst.implementAsync(payload.value); - } else { - payload.value = inst.implement(payload.value); - } - return payload; - }; - inst.input = (...args) => { - const F = inst.constructor; - if (Array.isArray(args[0])) { - return new F({ - type: "function", - input: new $ZodTuple({ - type: "tuple", - items: args[0], - rest: args[1] - }), - output: inst._def.output - }); - } - return new F({ - type: "function", - input: args[0], - output: inst._def.output - }); - }; - inst.output = (output) => { - const F = inst.constructor; - return new F({ - type: "function", - input: inst._def.input, - output - }); - }; - return inst; -}); -var $ZodPromise = /* @__PURE__ */ $constructor("$ZodPromise", (inst, def) => { - $ZodType.init(inst, def); - inst._zod.parse = (payload, ctx) => { - return Promise.resolve(payload.value).then((inner) => def.innerType._zod.run({ value: inner, issues: [] }, ctx)); - }; -}); -var $ZodLazy = /* @__PURE__ */ $constructor("$ZodLazy", (inst, def) => { - $ZodType.init(inst, def); - defineLazy(inst._zod, "innerType", () => def.getter()); - defineLazy(inst._zod, "pattern", () => inst._zod.innerType?._zod?.pattern); - defineLazy(inst._zod, "propValues", () => inst._zod.innerType?._zod?.propValues); - defineLazy(inst._zod, "optin", () => inst._zod.innerType?._zod?.optin ?? undefined); - defineLazy(inst._zod, "optout", () => inst._zod.innerType?._zod?.optout ?? undefined); - inst._zod.parse = (payload, ctx) => { - const inner = inst._zod.innerType; - return inner._zod.run(payload, ctx); - }; -}); -var $ZodCustom = /* @__PURE__ */ $constructor("$ZodCustom", (inst, def) => { - $ZodCheck.init(inst, def); - $ZodType.init(inst, def); - inst._zod.parse = (payload, _) => { - return payload; - }; - inst._zod.check = (payload) => { - const input = payload.value; - const r = def.fn(input); - if (r instanceof Promise) { - return r.then((r2) => handleRefineResult(r2, payload, input, inst)); - } - handleRefineResult(r, payload, input, inst); - return; - }; -}); -function handleRefineResult(result, payload, input, inst) { - if (!result) { - const _iss = { - code: "custom", - input, - inst, - path: [...inst._zod.def.path ?? []], - continue: !inst._zod.def.abort - }; - if (inst._zod.def.params) - _iss.params = inst._zod.def.params; - payload.issues.push(issue(_iss)); - } -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/index.js -var exports_locales = {}; -__export(exports_locales, { - zhTW: () => zh_TW_default, - zhCN: () => zh_CN_default, - yo: () => yo_default, - vi: () => vi_default, - uz: () => uz_default, - ur: () => ur_default, - uk: () => uk_default, - ua: () => ua_default, - tr: () => tr_default, - th: () => th_default, - ta: () => ta_default, - sv: () => sv_default, - sl: () => sl_default, - ru: () => ru_default, - pt: () => pt_default, - ps: () => ps_default, - pl: () => pl_default, - ota: () => ota_default, - no: () => no_default, - nl: () => nl_default, - ms: () => ms_default, - mk: () => mk_default, - lt: () => lt_default, - ko: () => ko_default, - km: () => km_default, - kh: () => kh_default, - ka: () => ka_default, - ja: () => ja_default, - it: () => it_default, - is: () => is_default, - id: () => id_default, - hy: () => hy_default, - hu: () => hu_default, - he: () => he_default, - frCA: () => fr_CA_default, - fr: () => fr_default, - fi: () => fi_default, - fa: () => fa_default, - es: () => es_default, - eo: () => eo_default, - en: () => en_default, - de: () => de_default, - da: () => da_default, - cs: () => cs_default, - ca: () => ca_default, - bg: () => bg_default, - be: () => be_default, - az: () => az_default, - ar: () => ar_default -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ar.js -var error = () => { - const Sizable = { - string: { unit: "حرف", verb: "أن يحوي" }, - file: { unit: "بايت", verb: "أن يحوي" }, - array: { unit: "عنصر", verb: "أن يحوي" }, - set: { unit: "عنصر", verb: "أن يحوي" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "مدخل", - email: "بريد إلكتروني", - url: "رابط", - emoji: "إيموجي", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "تاريخ ووقت بمعيار ISO", - date: "تاريخ بمعيار ISO", - time: "وقت بمعيار ISO", - duration: "مدة بمعيار ISO", - ipv4: "عنوان IPv4", - ipv6: "عنوان IPv6", - cidrv4: "مدى عناوين بصيغة IPv4", - cidrv6: "مدى عناوين بصيغة IPv6", - base64: "نَص بترميز base64-encoded", - base64url: "نَص بترميز base64url-encoded", - json_string: "نَص على هيئة JSON", - e164: "رقم هاتف بمعيار E.164", - jwt: "JWT", - template_literal: "مدخل" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `مدخلات غير مقبولة: يفترض إدخال instanceof ${issue2.expected}، ولكن تم إدخال ${received}`; - } - return `مدخلات غير مقبولة: يفترض إدخال ${expected}، ولكن تم إدخال ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `مدخلات غير مقبولة: يفترض إدخال ${stringifyPrimitive(issue2.values[0])}`; - return `اختيار غير مقبول: يتوقع انتقاء أحد هذه الخيارات: ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return ` أكبر من اللازم: يفترض أن تكون ${issue2.origin ?? "القيمة"} ${adj} ${issue2.maximum.toString()} ${sizing.unit ?? "عنصر"}`; - return `أكبر من اللازم: يفترض أن تكون ${issue2.origin ?? "القيمة"} ${adj} ${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `أصغر من اللازم: يفترض لـ ${issue2.origin} أن يكون ${adj} ${issue2.minimum.toString()} ${sizing.unit}`; - } - return `أصغر من اللازم: يفترض لـ ${issue2.origin} أن يكون ${adj} ${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `نَص غير مقبول: يجب أن يبدأ بـ "${issue2.prefix}"`; - if (_issue.format === "ends_with") - return `نَص غير مقبول: يجب أن ينتهي بـ "${_issue.suffix}"`; - if (_issue.format === "includes") - return `نَص غير مقبول: يجب أن يتضمَّن "${_issue.includes}"`; - if (_issue.format === "regex") - return `نَص غير مقبول: يجب أن يطابق النمط ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} غير مقبول`; - } - case "not_multiple_of": - return `رقم غير مقبول: يجب أن يكون من مضاعفات ${issue2.divisor}`; - case "unrecognized_keys": - return `معرف${issue2.keys.length > 1 ? "ات" : ""} غريب${issue2.keys.length > 1 ? "ة" : ""}: ${joinValues(issue2.keys, "، ")}`; - case "invalid_key": - return `معرف غير مقبول في ${issue2.origin}`; - case "invalid_union": - return "مدخل غير مقبول"; - case "invalid_element": - return `مدخل غير مقبول في ${issue2.origin}`; - default: - return "مدخل غير مقبول"; - } - }; -}; -function ar_default() { - return { - localeError: error() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/az.js -var error2 = () => { - const Sizable = { - string: { unit: "simvol", verb: "olmalıdır" }, - file: { unit: "bayt", verb: "olmalıdır" }, - array: { unit: "element", verb: "olmalıdır" }, - set: { unit: "element", verb: "olmalıdır" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "email address", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datetime", - date: "ISO date", - time: "ISO time", - duration: "ISO duration", - ipv4: "IPv4 address", - ipv6: "IPv6 address", - cidrv4: "IPv4 range", - cidrv6: "IPv6 range", - base64: "base64-encoded string", - base64url: "base64url-encoded string", - json_string: "JSON string", - e164: "E.164 number", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Yanlış dəyər: gözlənilən instanceof ${issue2.expected}, daxil olan ${received}`; - } - return `Yanlış dəyər: gözlənilən ${expected}, daxil olan ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Yanlış dəyər: gözlənilən ${stringifyPrimitive(issue2.values[0])}`; - return `Yanlış seçim: aşağıdakılardan biri olmalıdır: ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Çox böyük: gözlənilən ${issue2.origin ?? "dəyər"} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "element"}`; - return `Çox böyük: gözlənilən ${issue2.origin ?? "dəyər"} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Çox kiçik: gözlənilən ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - return `Çox kiçik: gözlənilən ${issue2.origin} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Yanlış mətn: "${_issue.prefix}" ilə başlamalıdır`; - if (_issue.format === "ends_with") - return `Yanlış mətn: "${_issue.suffix}" ilə bitməlidir`; - if (_issue.format === "includes") - return `Yanlış mətn: "${_issue.includes}" daxil olmalıdır`; - if (_issue.format === "regex") - return `Yanlış mətn: ${_issue.pattern} şablonuna uyğun olmalıdır`; - return `Yanlış ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Yanlış ədəd: ${issue2.divisor} ilə bölünə bilən olmalıdır`; - case "unrecognized_keys": - return `Tanınmayan açar${issue2.keys.length > 1 ? "lar" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `${issue2.origin} daxilində yanlış açar`; - case "invalid_union": - return "Yanlış dəyər"; - case "invalid_element": - return `${issue2.origin} daxilində yanlış dəyər`; - default: - return `Yanlış dəyər`; - } - }; -}; -function az_default() { - return { - localeError: error2() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/be.js -function getBelarusianPlural(count, one, few, many) { - const absCount = Math.abs(count); - const lastDigit = absCount % 10; - const lastTwoDigits = absCount % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { - return many; - } - if (lastDigit === 1) { - return one; - } - if (lastDigit >= 2 && lastDigit <= 4) { - return few; - } - return many; -} -var error3 = () => { - const Sizable = { - string: { - unit: { - one: "сімвал", - few: "сімвалы", - many: "сімвалаў" - }, - verb: "мець" - }, - array: { - unit: { - one: "элемент", - few: "элементы", - many: "элементаў" - }, - verb: "мець" - }, - set: { - unit: { - one: "элемент", - few: "элементы", - many: "элементаў" - }, - verb: "мець" - }, - file: { - unit: { - one: "байт", - few: "байты", - many: "байтаў" - }, - verb: "мець" - } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "увод", - email: "email адрас", - url: "URL", - emoji: "эмодзі", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO дата і час", - date: "ISO дата", - time: "ISO час", - duration: "ISO працягласць", - ipv4: "IPv4 адрас", - ipv6: "IPv6 адрас", - cidrv4: "IPv4 дыяпазон", - cidrv6: "IPv6 дыяпазон", - base64: "радок у фармаце base64", - base64url: "радок у фармаце base64url", - json_string: "JSON радок", - e164: "нумар E.164", - jwt: "JWT", - template_literal: "увод" - }; - const TypeDictionary = { - nan: "NaN", - number: "лік", - array: "масіў" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Няправільны ўвод: чакаўся instanceof ${issue2.expected}, атрымана ${received}`; - } - return `Няправільны ўвод: чакаўся ${expected}, атрымана ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Няправільны ўвод: чакалася ${stringifyPrimitive(issue2.values[0])}`; - return `Няправільны варыянт: чакаўся адзін з ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - const maxValue = Number(issue2.maximum); - const unit = getBelarusianPlural(maxValue, sizing.unit.one, sizing.unit.few, sizing.unit.many); - return `Занадта вялікі: чакалася, што ${issue2.origin ?? "значэнне"} павінна ${sizing.verb} ${adj}${issue2.maximum.toString()} ${unit}`; - } - return `Занадта вялікі: чакалася, што ${issue2.origin ?? "значэнне"} павінна быць ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - const minValue = Number(issue2.minimum); - const unit = getBelarusianPlural(minValue, sizing.unit.one, sizing.unit.few, sizing.unit.many); - return `Занадта малы: чакалася, што ${issue2.origin} павінна ${sizing.verb} ${adj}${issue2.minimum.toString()} ${unit}`; - } - return `Занадта малы: чакалася, што ${issue2.origin} павінна быць ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Няправільны радок: павінен пачынацца з "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Няправільны радок: павінен заканчвацца на "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Няправільны радок: павінен змяшчаць "${_issue.includes}"`; - if (_issue.format === "regex") - return `Няправільны радок: павінен адпавядаць шаблону ${_issue.pattern}`; - return `Няправільны ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Няправільны лік: павінен быць кратным ${issue2.divisor}`; - case "unrecognized_keys": - return `Нераспазнаны ${issue2.keys.length > 1 ? "ключы" : "ключ"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Няправільны ключ у ${issue2.origin}`; - case "invalid_union": - return "Няправільны ўвод"; - case "invalid_element": - return `Няправільнае значэнне ў ${issue2.origin}`; - default: - return `Няправільны ўвод`; - } - }; -}; -function be_default() { - return { - localeError: error3() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/bg.js -var error4 = () => { - const Sizable = { - string: { unit: "символа", verb: "да съдържа" }, - file: { unit: "байта", verb: "да съдържа" }, - array: { unit: "елемента", verb: "да съдържа" }, - set: { unit: "елемента", verb: "да съдържа" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "вход", - email: "имейл адрес", - url: "URL", - emoji: "емоджи", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO време", - date: "ISO дата", - time: "ISO време", - duration: "ISO продължителност", - ipv4: "IPv4 адрес", - ipv6: "IPv6 адрес", - cidrv4: "IPv4 диапазон", - cidrv6: "IPv6 диапазон", - base64: "base64-кодиран низ", - base64url: "base64url-кодиран низ", - json_string: "JSON низ", - e164: "E.164 номер", - jwt: "JWT", - template_literal: "вход" - }; - const TypeDictionary = { - nan: "NaN", - number: "число", - array: "масив" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Невалиден вход: очакван instanceof ${issue2.expected}, получен ${received}`; - } - return `Невалиден вход: очакван ${expected}, получен ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Невалиден вход: очакван ${stringifyPrimitive(issue2.values[0])}`; - return `Невалидна опция: очаквано едно от ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Твърде голямо: очаква се ${issue2.origin ?? "стойност"} да съдържа ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "елемента"}`; - return `Твърде голямо: очаква се ${issue2.origin ?? "стойност"} да бъде ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Твърде малко: очаква се ${issue2.origin} да съдържа ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Твърде малко: очаква се ${issue2.origin} да бъде ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Невалиден низ: трябва да започва с "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Невалиден низ: трябва да завършва с "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Невалиден низ: трябва да включва "${_issue.includes}"`; - if (_issue.format === "regex") - return `Невалиден низ: трябва да съвпада с ${_issue.pattern}`; - let invalid_adj = "Невалиден"; - if (_issue.format === "emoji") - invalid_adj = "Невалидно"; - if (_issue.format === "datetime") - invalid_adj = "Невалидно"; - if (_issue.format === "date") - invalid_adj = "Невалидна"; - if (_issue.format === "time") - invalid_adj = "Невалидно"; - if (_issue.format === "duration") - invalid_adj = "Невалидна"; - return `${invalid_adj} ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Невалидно число: трябва да бъде кратно на ${issue2.divisor}`; - case "unrecognized_keys": - return `Неразпознат${issue2.keys.length > 1 ? "и" : ""} ключ${issue2.keys.length > 1 ? "ове" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Невалиден ключ в ${issue2.origin}`; - case "invalid_union": - return "Невалиден вход"; - case "invalid_element": - return `Невалидна стойност в ${issue2.origin}`; - default: - return `Невалиден вход`; - } - }; -}; -function bg_default() { - return { - localeError: error4() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ca.js -var error5 = () => { - const Sizable = { - string: { unit: "caràcters", verb: "contenir" }, - file: { unit: "bytes", verb: "contenir" }, - array: { unit: "elements", verb: "contenir" }, - set: { unit: "elements", verb: "contenir" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "entrada", - email: "adreça electrònica", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data i hora ISO", - date: "data ISO", - time: "hora ISO", - duration: "durada ISO", - ipv4: "adreça IPv4", - ipv6: "adreça IPv6", - cidrv4: "rang IPv4", - cidrv6: "rang IPv6", - base64: "cadena codificada en base64", - base64url: "cadena codificada en base64url", - json_string: "cadena JSON", - e164: "número E.164", - jwt: "JWT", - template_literal: "entrada" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Tipus invàlid: s'esperava instanceof ${issue2.expected}, s'ha rebut ${received}`; - } - return `Tipus invàlid: s'esperava ${expected}, s'ha rebut ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Valor invàlid: s'esperava ${stringifyPrimitive(issue2.values[0])}`; - return `Opció invàlida: s'esperava una de ${joinValues(issue2.values, " o ")}`; - case "too_big": { - const adj = issue2.inclusive ? "com a màxim" : "menys de"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Massa gran: s'esperava que ${issue2.origin ?? "el valor"} contingués ${adj} ${issue2.maximum.toString()} ${sizing.unit ?? "elements"}`; - return `Massa gran: s'esperava que ${issue2.origin ?? "el valor"} fos ${adj} ${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? "com a mínim" : "més de"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Massa petit: s'esperava que ${issue2.origin} contingués ${adj} ${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Massa petit: s'esperava que ${issue2.origin} fos ${adj} ${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Format invàlid: ha de començar amb "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Format invàlid: ha d'acabar amb "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Format invàlid: ha d'incloure "${_issue.includes}"`; - if (_issue.format === "regex") - return `Format invàlid: ha de coincidir amb el patró ${_issue.pattern}`; - return `Format invàlid per a ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Número invàlid: ha de ser múltiple de ${issue2.divisor}`; - case "unrecognized_keys": - return `Clau${issue2.keys.length > 1 ? "s" : ""} no reconeguda${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Clau invàlida a ${issue2.origin}`; - case "invalid_union": - return "Entrada invàlida"; - case "invalid_element": - return `Element invàlid a ${issue2.origin}`; - default: - return `Entrada invàlida`; - } - }; -}; -function ca_default() { - return { - localeError: error5() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/cs.js -var error6 = () => { - const Sizable = { - string: { unit: "znaků", verb: "mít" }, - file: { unit: "bajtů", verb: "mít" }, - array: { unit: "prvků", verb: "mít" }, - set: { unit: "prvků", verb: "mít" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "regulární výraz", - email: "e-mailová adresa", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "datum a čas ve formátu ISO", - date: "datum ve formátu ISO", - time: "čas ve formátu ISO", - duration: "doba trvání ISO", - ipv4: "IPv4 adresa", - ipv6: "IPv6 adresa", - cidrv4: "rozsah IPv4", - cidrv6: "rozsah IPv6", - base64: "řetězec zakódovaný ve formátu base64", - base64url: "řetězec zakódovaný ve formátu base64url", - json_string: "řetězec ve formátu JSON", - e164: "číslo E.164", - jwt: "JWT", - template_literal: "vstup" - }; - const TypeDictionary = { - nan: "NaN", - number: "číslo", - string: "řetězec", - function: "funkce", - array: "pole" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Neplatný vstup: očekáváno instanceof ${issue2.expected}, obdrženo ${received}`; - } - return `Neplatný vstup: očekáváno ${expected}, obdrženo ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Neplatný vstup: očekáváno ${stringifyPrimitive(issue2.values[0])}`; - return `Neplatná možnost: očekávána jedna z hodnot ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Hodnota je příliš velká: ${issue2.origin ?? "hodnota"} musí mít ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "prvků"}`; - } - return `Hodnota je příliš velká: ${issue2.origin ?? "hodnota"} musí být ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Hodnota je příliš malá: ${issue2.origin ?? "hodnota"} musí mít ${adj}${issue2.minimum.toString()} ${sizing.unit ?? "prvků"}`; - } - return `Hodnota je příliš malá: ${issue2.origin ?? "hodnota"} musí být ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Neplatný řetězec: musí začínat na "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Neplatný řetězec: musí končit na "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Neplatný řetězec: musí obsahovat "${_issue.includes}"`; - if (_issue.format === "regex") - return `Neplatný řetězec: musí odpovídat vzoru ${_issue.pattern}`; - return `Neplatný formát ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Neplatné číslo: musí být násobkem ${issue2.divisor}`; - case "unrecognized_keys": - return `Neznámé klíče: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Neplatný klíč v ${issue2.origin}`; - case "invalid_union": - return "Neplatný vstup"; - case "invalid_element": - return `Neplatná hodnota v ${issue2.origin}`; - default: - return `Neplatný vstup`; - } - }; -}; -function cs_default() { - return { - localeError: error6() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/da.js -var error7 = () => { - const Sizable = { - string: { unit: "tegn", verb: "havde" }, - file: { unit: "bytes", verb: "havde" }, - array: { unit: "elementer", verb: "indeholdt" }, - set: { unit: "elementer", verb: "indeholdt" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "e-mailadresse", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO dato- og klokkeslæt", - date: "ISO-dato", - time: "ISO-klokkeslæt", - duration: "ISO-varighed", - ipv4: "IPv4-område", - ipv6: "IPv6-område", - cidrv4: "IPv4-spektrum", - cidrv6: "IPv6-spektrum", - base64: "base64-kodet streng", - base64url: "base64url-kodet streng", - json_string: "JSON-streng", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN", - string: "streng", - number: "tal", - boolean: "boolean", - array: "liste", - object: "objekt", - set: "sæt", - file: "fil" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Ugyldigt input: forventede instanceof ${issue2.expected}, fik ${received}`; - } - return `Ugyldigt input: forventede ${expected}, fik ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Ugyldig værdi: forventede ${stringifyPrimitive(issue2.values[0])}`; - return `Ugyldigt valg: forventede en af følgende ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - if (sizing) - return `For stor: forventede ${origin ?? "value"} ${sizing.verb} ${adj} ${issue2.maximum.toString()} ${sizing.unit ?? "elementer"}`; - return `For stor: forventede ${origin ?? "value"} havde ${adj} ${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - if (sizing) { - return `For lille: forventede ${origin} ${sizing.verb} ${adj} ${issue2.minimum.toString()} ${sizing.unit}`; - } - return `For lille: forventede ${origin} havde ${adj} ${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Ugyldig streng: skal starte med "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Ugyldig streng: skal ende med "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Ugyldig streng: skal indeholde "${_issue.includes}"`; - if (_issue.format === "regex") - return `Ugyldig streng: skal matche mønsteret ${_issue.pattern}`; - return `Ugyldig ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Ugyldigt tal: skal være deleligt med ${issue2.divisor}`; - case "unrecognized_keys": - return `${issue2.keys.length > 1 ? "Ukendte nøgler" : "Ukendt nøgle"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Ugyldig nøgle i ${issue2.origin}`; - case "invalid_union": - return "Ugyldigt input: matcher ingen af de tilladte typer"; - case "invalid_element": - return `Ugyldig værdi i ${issue2.origin}`; - default: - return `Ugyldigt input`; - } - }; -}; -function da_default() { - return { - localeError: error7() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/de.js -var error8 = () => { - const Sizable = { - string: { unit: "Zeichen", verb: "zu haben" }, - file: { unit: "Bytes", verb: "zu haben" }, - array: { unit: "Elemente", verb: "zu haben" }, - set: { unit: "Elemente", verb: "zu haben" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "Eingabe", - email: "E-Mail-Adresse", - url: "URL", - emoji: "Emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-Datum und -Uhrzeit", - date: "ISO-Datum", - time: "ISO-Uhrzeit", - duration: "ISO-Dauer", - ipv4: "IPv4-Adresse", - ipv6: "IPv6-Adresse", - cidrv4: "IPv4-Bereich", - cidrv6: "IPv6-Bereich", - base64: "Base64-codierter String", - base64url: "Base64-URL-codierter String", - json_string: "JSON-String", - e164: "E.164-Nummer", - jwt: "JWT", - template_literal: "Eingabe" - }; - const TypeDictionary = { - nan: "NaN", - number: "Zahl", - array: "Array" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Ungültige Eingabe: erwartet instanceof ${issue2.expected}, erhalten ${received}`; - } - return `Ungültige Eingabe: erwartet ${expected}, erhalten ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Ungültige Eingabe: erwartet ${stringifyPrimitive(issue2.values[0])}`; - return `Ungültige Option: erwartet eine von ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Zu groß: erwartet, dass ${issue2.origin ?? "Wert"} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "Elemente"} hat`; - return `Zu groß: erwartet, dass ${issue2.origin ?? "Wert"} ${adj}${issue2.maximum.toString()} ist`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Zu klein: erwartet, dass ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit} hat`; - } - return `Zu klein: erwartet, dass ${issue2.origin} ${adj}${issue2.minimum.toString()} ist`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Ungültiger String: muss mit "${_issue.prefix}" beginnen`; - if (_issue.format === "ends_with") - return `Ungültiger String: muss mit "${_issue.suffix}" enden`; - if (_issue.format === "includes") - return `Ungültiger String: muss "${_issue.includes}" enthalten`; - if (_issue.format === "regex") - return `Ungültiger String: muss dem Muster ${_issue.pattern} entsprechen`; - return `Ungültig: ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Ungültige Zahl: muss ein Vielfaches von ${issue2.divisor} sein`; - case "unrecognized_keys": - return `${issue2.keys.length > 1 ? "Unbekannte Schlüssel" : "Unbekannter Schlüssel"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Ungültiger Schlüssel in ${issue2.origin}`; - case "invalid_union": - return "Ungültige Eingabe"; - case "invalid_element": - return `Ungültiger Wert in ${issue2.origin}`; - default: - return `Ungültige Eingabe`; - } - }; -}; -function de_default() { - return { - localeError: error8() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/en.js -var error9 = () => { - const Sizable = { - string: { unit: "characters", verb: "to have" }, - file: { unit: "bytes", verb: "to have" }, - array: { unit: "items", verb: "to have" }, - set: { unit: "items", verb: "to have" }, - map: { unit: "entries", verb: "to have" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "email address", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datetime", - date: "ISO date", - time: "ISO time", - duration: "ISO duration", - ipv4: "IPv4 address", - ipv6: "IPv6 address", - mac: "MAC address", - cidrv4: "IPv4 range", - cidrv6: "IPv6 range", - base64: "base64-encoded string", - base64url: "base64url-encoded string", - json_string: "JSON string", - e164: "E.164 number", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - return `Invalid input: expected ${expected}, received ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Invalid input: expected ${stringifyPrimitive(issue2.values[0])}`; - return `Invalid option: expected one of ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Too big: expected ${issue2.origin ?? "value"} to have ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elements"}`; - return `Too big: expected ${issue2.origin ?? "value"} to be ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Too small: expected ${issue2.origin} to have ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Too small: expected ${issue2.origin} to be ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Invalid string: must start with "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Invalid string: must end with "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Invalid string: must include "${_issue.includes}"`; - if (_issue.format === "regex") - return `Invalid string: must match pattern ${_issue.pattern}`; - return `Invalid ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Invalid number: must be a multiple of ${issue2.divisor}`; - case "unrecognized_keys": - return `Unrecognized key${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Invalid key in ${issue2.origin}`; - case "invalid_union": - return "Invalid input"; - case "invalid_element": - return `Invalid value in ${issue2.origin}`; - default: - return `Invalid input`; - } - }; -}; -function en_default() { - return { - localeError: error9() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/eo.js -var error10 = () => { - const Sizable = { - string: { unit: "karaktrojn", verb: "havi" }, - file: { unit: "bajtojn", verb: "havi" }, - array: { unit: "elementojn", verb: "havi" }, - set: { unit: "elementojn", verb: "havi" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "enigo", - email: "retadreso", - url: "URL", - emoji: "emoĝio", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-datotempo", - date: "ISO-dato", - time: "ISO-tempo", - duration: "ISO-daŭro", - ipv4: "IPv4-adreso", - ipv6: "IPv6-adreso", - cidrv4: "IPv4-rango", - cidrv6: "IPv6-rango", - base64: "64-ume kodita karaktraro", - base64url: "URL-64-ume kodita karaktraro", - json_string: "JSON-karaktraro", - e164: "E.164-nombro", - jwt: "JWT", - template_literal: "enigo" - }; - const TypeDictionary = { - nan: "NaN", - number: "nombro", - array: "tabelo", - null: "senvalora" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Nevalida enigo: atendiĝis instanceof ${issue2.expected}, riceviĝis ${received}`; - } - return `Nevalida enigo: atendiĝis ${expected}, riceviĝis ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Nevalida enigo: atendiĝis ${stringifyPrimitive(issue2.values[0])}`; - return `Nevalida opcio: atendiĝis unu el ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Tro granda: atendiĝis ke ${issue2.origin ?? "valoro"} havu ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementojn"}`; - return `Tro granda: atendiĝis ke ${issue2.origin ?? "valoro"} havu ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Tro malgranda: atendiĝis ke ${issue2.origin} havu ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Tro malgranda: atendiĝis ke ${issue2.origin} estu ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Nevalida karaktraro: devas komenciĝi per "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Nevalida karaktraro: devas finiĝi per "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Nevalida karaktraro: devas inkluzivi "${_issue.includes}"`; - if (_issue.format === "regex") - return `Nevalida karaktraro: devas kongrui kun la modelo ${_issue.pattern}`; - return `Nevalida ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Nevalida nombro: devas esti oblo de ${issue2.divisor}`; - case "unrecognized_keys": - return `Nekonata${issue2.keys.length > 1 ? "j" : ""} ŝlosilo${issue2.keys.length > 1 ? "j" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Nevalida ŝlosilo en ${issue2.origin}`; - case "invalid_union": - return "Nevalida enigo"; - case "invalid_element": - return `Nevalida valoro en ${issue2.origin}`; - default: - return `Nevalida enigo`; - } - }; -}; -function eo_default() { - return { - localeError: error10() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/es.js -var error11 = () => { - const Sizable = { - string: { unit: "caracteres", verb: "tener" }, - file: { unit: "bytes", verb: "tener" }, - array: { unit: "elementos", verb: "tener" }, - set: { unit: "elementos", verb: "tener" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "entrada", - email: "dirección de correo electrónico", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "fecha y hora ISO", - date: "fecha ISO", - time: "hora ISO", - duration: "duración ISO", - ipv4: "dirección IPv4", - ipv6: "dirección IPv6", - cidrv4: "rango IPv4", - cidrv6: "rango IPv6", - base64: "cadena codificada en base64", - base64url: "URL codificada en base64", - json_string: "cadena JSON", - e164: "número E.164", - jwt: "JWT", - template_literal: "entrada" - }; - const TypeDictionary = { - nan: "NaN", - string: "texto", - number: "número", - boolean: "booleano", - array: "arreglo", - object: "objeto", - set: "conjunto", - file: "archivo", - date: "fecha", - bigint: "número grande", - symbol: "símbolo", - undefined: "indefinido", - null: "nulo", - function: "función", - map: "mapa", - record: "registro", - tuple: "tupla", - enum: "enumeración", - union: "unión", - literal: "literal", - promise: "promesa", - void: "vacío", - never: "nunca", - unknown: "desconocido", - any: "cualquiera" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Entrada inválida: se esperaba instanceof ${issue2.expected}, recibido ${received}`; - } - return `Entrada inválida: se esperaba ${expected}, recibido ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Entrada inválida: se esperaba ${stringifyPrimitive(issue2.values[0])}`; - return `Opción inválida: se esperaba una de ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - if (sizing) - return `Demasiado grande: se esperaba que ${origin ?? "valor"} tuviera ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementos"}`; - return `Demasiado grande: se esperaba que ${origin ?? "valor"} fuera ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - if (sizing) { - return `Demasiado pequeño: se esperaba que ${origin} tuviera ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Demasiado pequeño: se esperaba que ${origin} fuera ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Cadena inválida: debe comenzar con "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Cadena inválida: debe terminar en "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Cadena inválida: debe incluir "${_issue.includes}"`; - if (_issue.format === "regex") - return `Cadena inválida: debe coincidir con el patrón ${_issue.pattern}`; - return `Inválido ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Número inválido: debe ser múltiplo de ${issue2.divisor}`; - case "unrecognized_keys": - return `Llave${issue2.keys.length > 1 ? "s" : ""} desconocida${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Llave inválida en ${TypeDictionary[issue2.origin] ?? issue2.origin}`; - case "invalid_union": - return "Entrada inválida"; - case "invalid_element": - return `Valor inválido en ${TypeDictionary[issue2.origin] ?? issue2.origin}`; - default: - return `Entrada inválida`; - } - }; -}; -function es_default() { - return { - localeError: error11() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fa.js -var error12 = () => { - const Sizable = { - string: { unit: "کاراکتر", verb: "داشته باشد" }, - file: { unit: "بایت", verb: "داشته باشد" }, - array: { unit: "آیتم", verb: "داشته باشد" }, - set: { unit: "آیتم", verb: "داشته باشد" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ورودی", - email: "آدرس ایمیل", - url: "URL", - emoji: "ایموجی", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "تاریخ و زمان ایزو", - date: "تاریخ ایزو", - time: "زمان ایزو", - duration: "مدت زمان ایزو", - ipv4: "IPv4 آدرس", - ipv6: "IPv6 آدرس", - cidrv4: "IPv4 دامنه", - cidrv6: "IPv6 دامنه", - base64: "base64-encoded رشته", - base64url: "base64url-encoded رشته", - json_string: "JSON رشته", - e164: "E.164 عدد", - jwt: "JWT", - template_literal: "ورودی" - }; - const TypeDictionary = { - nan: "NaN", - number: "عدد", - array: "آرایه" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `ورودی نامعتبر: می‌بایست instanceof ${issue2.expected} می‌بود، ${received} دریافت شد`; - } - return `ورودی نامعتبر: می‌بایست ${expected} می‌بود، ${received} دریافت شد`; - } - case "invalid_value": - if (issue2.values.length === 1) { - return `ورودی نامعتبر: می‌بایست ${stringifyPrimitive(issue2.values[0])} می‌بود`; - } - return `گزینه نامعتبر: می‌بایست یکی از ${joinValues(issue2.values, "|")} می‌بود`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `خیلی بزرگ: ${issue2.origin ?? "مقدار"} باید ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "عنصر"} باشد`; - } - return `خیلی بزرگ: ${issue2.origin ?? "مقدار"} باید ${adj}${issue2.maximum.toString()} باشد`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `خیلی کوچک: ${issue2.origin} باید ${adj}${issue2.minimum.toString()} ${sizing.unit} باشد`; - } - return `خیلی کوچک: ${issue2.origin} باید ${adj}${issue2.minimum.toString()} باشد`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `رشته نامعتبر: باید با "${_issue.prefix}" شروع شود`; - } - if (_issue.format === "ends_with") { - return `رشته نامعتبر: باید با "${_issue.suffix}" تمام شود`; - } - if (_issue.format === "includes") { - return `رشته نامعتبر: باید شامل "${_issue.includes}" باشد`; - } - if (_issue.format === "regex") { - return `رشته نامعتبر: باید با الگوی ${_issue.pattern} مطابقت داشته باشد`; - } - return `${FormatDictionary[_issue.format] ?? issue2.format} نامعتبر`; - } - case "not_multiple_of": - return `عدد نامعتبر: باید مضرب ${issue2.divisor} باشد`; - case "unrecognized_keys": - return `کلید${issue2.keys.length > 1 ? "های" : ""} ناشناس: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `کلید ناشناس در ${issue2.origin}`; - case "invalid_union": - return `ورودی نامعتبر`; - case "invalid_element": - return `مقدار نامعتبر در ${issue2.origin}`; - default: - return `ورودی نامعتبر`; - } - }; -}; -function fa_default() { - return { - localeError: error12() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fi.js -var error13 = () => { - const Sizable = { - string: { unit: "merkkiä", subject: "merkkijonon" }, - file: { unit: "tavua", subject: "tiedoston" }, - array: { unit: "alkiota", subject: "listan" }, - set: { unit: "alkiota", subject: "joukon" }, - number: { unit: "", subject: "luvun" }, - bigint: { unit: "", subject: "suuren kokonaisluvun" }, - int: { unit: "", subject: "kokonaisluvun" }, - date: { unit: "", subject: "päivämäärän" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "säännöllinen lauseke", - email: "sähköpostiosoite", - url: "URL-osoite", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-aikaleima", - date: "ISO-päivämäärä", - time: "ISO-aika", - duration: "ISO-kesto", - ipv4: "IPv4-osoite", - ipv6: "IPv6-osoite", - cidrv4: "IPv4-alue", - cidrv6: "IPv6-alue", - base64: "base64-koodattu merkkijono", - base64url: "base64url-koodattu merkkijono", - json_string: "JSON-merkkijono", - e164: "E.164-luku", - jwt: "JWT", - template_literal: "templaattimerkkijono" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Virheellinen tyyppi: odotettiin instanceof ${issue2.expected}, oli ${received}`; - } - return `Virheellinen tyyppi: odotettiin ${expected}, oli ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Virheellinen syöte: täytyy olla ${stringifyPrimitive(issue2.values[0])}`; - return `Virheellinen valinta: täytyy olla yksi seuraavista: ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Liian suuri: ${sizing.subject} täytyy olla ${adj}${issue2.maximum.toString()} ${sizing.unit}`.trim(); - } - return `Liian suuri: arvon täytyy olla ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Liian pieni: ${sizing.subject} täytyy olla ${adj}${issue2.minimum.toString()} ${sizing.unit}`.trim(); - } - return `Liian pieni: arvon täytyy olla ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Virheellinen syöte: täytyy alkaa "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Virheellinen syöte: täytyy loppua "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Virheellinen syöte: täytyy sisältää "${_issue.includes}"`; - if (_issue.format === "regex") { - return `Virheellinen syöte: täytyy vastata säännöllistä lauseketta ${_issue.pattern}`; - } - return `Virheellinen ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Virheellinen luku: täytyy olla luvun ${issue2.divisor} monikerta`; - case "unrecognized_keys": - return `${issue2.keys.length > 1 ? "Tuntemattomat avaimet" : "Tuntematon avain"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return "Virheellinen avain tietueessa"; - case "invalid_union": - return "Virheellinen unioni"; - case "invalid_element": - return "Virheellinen arvo joukossa"; - default: - return `Virheellinen syöte`; - } - }; -}; -function fi_default() { - return { - localeError: error13() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fr.js -var error14 = () => { - const Sizable = { - string: { unit: "caractères", verb: "avoir" }, - file: { unit: "octets", verb: "avoir" }, - array: { unit: "éléments", verb: "avoir" }, - set: { unit: "éléments", verb: "avoir" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "entrée", - email: "adresse e-mail", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "date et heure ISO", - date: "date ISO", - time: "heure ISO", - duration: "durée ISO", - ipv4: "adresse IPv4", - ipv6: "adresse IPv6", - cidrv4: "plage IPv4", - cidrv6: "plage IPv6", - base64: "chaîne encodée en base64", - base64url: "chaîne encodée en base64url", - json_string: "chaîne JSON", - e164: "numéro E.164", - jwt: "JWT", - template_literal: "entrée" - }; - const TypeDictionary = { - nan: "NaN", - number: "nombre", - array: "tableau" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Entrée invalide : instanceof ${issue2.expected} attendu, ${received} reçu`; - } - return `Entrée invalide : ${expected} attendu, ${received} reçu`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Entrée invalide : ${stringifyPrimitive(issue2.values[0])} attendu`; - return `Option invalide : une valeur parmi ${joinValues(issue2.values, "|")} attendue`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Trop grand : ${issue2.origin ?? "valeur"} doit ${sizing.verb} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "élément(s)"}`; - return `Trop grand : ${issue2.origin ?? "valeur"} doit être ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Trop petit : ${issue2.origin} doit ${sizing.verb} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Trop petit : ${issue2.origin} doit être ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Chaîne invalide : doit commencer par "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Chaîne invalide : doit se terminer par "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Chaîne invalide : doit inclure "${_issue.includes}"`; - if (_issue.format === "regex") - return `Chaîne invalide : doit correspondre au modèle ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} invalide`; - } - case "not_multiple_of": - return `Nombre invalide : doit être un multiple de ${issue2.divisor}`; - case "unrecognized_keys": - return `Clé${issue2.keys.length > 1 ? "s" : ""} non reconnue${issue2.keys.length > 1 ? "s" : ""} : ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Clé invalide dans ${issue2.origin}`; - case "invalid_union": - return "Entrée invalide"; - case "invalid_element": - return `Valeur invalide dans ${issue2.origin}`; - default: - return `Entrée invalide`; - } - }; -}; -function fr_default() { - return { - localeError: error14() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fr-CA.js -var error15 = () => { - const Sizable = { - string: { unit: "caractères", verb: "avoir" }, - file: { unit: "octets", verb: "avoir" }, - array: { unit: "éléments", verb: "avoir" }, - set: { unit: "éléments", verb: "avoir" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "entrée", - email: "adresse courriel", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "date-heure ISO", - date: "date ISO", - time: "heure ISO", - duration: "durée ISO", - ipv4: "adresse IPv4", - ipv6: "adresse IPv6", - cidrv4: "plage IPv4", - cidrv6: "plage IPv6", - base64: "chaîne encodée en base64", - base64url: "chaîne encodée en base64url", - json_string: "chaîne JSON", - e164: "numéro E.164", - jwt: "JWT", - template_literal: "entrée" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Entrée invalide : attendu instanceof ${issue2.expected}, reçu ${received}`; - } - return `Entrée invalide : attendu ${expected}, reçu ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Entrée invalide : attendu ${stringifyPrimitive(issue2.values[0])}`; - return `Option invalide : attendu l'une des valeurs suivantes ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "≤" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Trop grand : attendu que ${issue2.origin ?? "la valeur"} ait ${adj}${issue2.maximum.toString()} ${sizing.unit}`; - return `Trop grand : attendu que ${issue2.origin ?? "la valeur"} soit ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? "≥" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Trop petit : attendu que ${issue2.origin} ait ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Trop petit : attendu que ${issue2.origin} soit ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Chaîne invalide : doit commencer par "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Chaîne invalide : doit se terminer par "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Chaîne invalide : doit inclure "${_issue.includes}"`; - if (_issue.format === "regex") - return `Chaîne invalide : doit correspondre au motif ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} invalide`; - } - case "not_multiple_of": - return `Nombre invalide : doit être un multiple de ${issue2.divisor}`; - case "unrecognized_keys": - return `Clé${issue2.keys.length > 1 ? "s" : ""} non reconnue${issue2.keys.length > 1 ? "s" : ""} : ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Clé invalide dans ${issue2.origin}`; - case "invalid_union": - return "Entrée invalide"; - case "invalid_element": - return `Valeur invalide dans ${issue2.origin}`; - default: - return `Entrée invalide`; - } - }; -}; -function fr_CA_default() { - return { - localeError: error15() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/he.js -var error16 = () => { - const TypeNames = { - string: { label: "מחרוזת", gender: "f" }, - number: { label: "מספר", gender: "m" }, - boolean: { label: "ערך בוליאני", gender: "m" }, - bigint: { label: "BigInt", gender: "m" }, - date: { label: "תאריך", gender: "m" }, - array: { label: "מערך", gender: "m" }, - object: { label: "אובייקט", gender: "m" }, - null: { label: "ערך ריק (null)", gender: "m" }, - undefined: { label: "ערך לא מוגדר (undefined)", gender: "m" }, - symbol: { label: "סימבול (Symbol)", gender: "m" }, - function: { label: "פונקציה", gender: "f" }, - map: { label: "מפה (Map)", gender: "f" }, - set: { label: "קבוצה (Set)", gender: "f" }, - file: { label: "קובץ", gender: "m" }, - promise: { label: "Promise", gender: "m" }, - NaN: { label: "NaN", gender: "m" }, - unknown: { label: "ערך לא ידוע", gender: "m" }, - value: { label: "ערך", gender: "m" } - }; - const Sizable = { - string: { unit: "תווים", shortLabel: "קצר", longLabel: "ארוך" }, - file: { unit: "בייטים", shortLabel: "קטן", longLabel: "גדול" }, - array: { unit: "פריטים", shortLabel: "קטן", longLabel: "גדול" }, - set: { unit: "פריטים", shortLabel: "קטן", longLabel: "גדול" }, - number: { unit: "", shortLabel: "קטן", longLabel: "גדול" } - }; - const typeEntry = (t) => t ? TypeNames[t] : undefined; - const typeLabel = (t) => { - const e = typeEntry(t); - if (e) - return e.label; - return t ?? TypeNames.unknown.label; - }; - const withDefinite = (t) => `ה${typeLabel(t)}`; - const verbFor = (t) => { - const e = typeEntry(t); - const gender = e?.gender ?? "m"; - return gender === "f" ? "צריכה להיות" : "צריך להיות"; - }; - const getSizing = (origin) => { - if (!origin) - return null; - return Sizable[origin] ?? null; - }; - const FormatDictionary = { - regex: { label: "קלט", gender: "m" }, - email: { label: "כתובת אימייל", gender: "f" }, - url: { label: "כתובת רשת", gender: "f" }, - emoji: { label: "אימוג'י", gender: "m" }, - uuid: { label: "UUID", gender: "m" }, - nanoid: { label: "nanoid", gender: "m" }, - guid: { label: "GUID", gender: "m" }, - cuid: { label: "cuid", gender: "m" }, - cuid2: { label: "cuid2", gender: "m" }, - ulid: { label: "ULID", gender: "m" }, - xid: { label: "XID", gender: "m" }, - ksuid: { label: "KSUID", gender: "m" }, - datetime: { label: "תאריך וזמן ISO", gender: "m" }, - date: { label: "תאריך ISO", gender: "m" }, - time: { label: "זמן ISO", gender: "m" }, - duration: { label: "משך זמן ISO", gender: "m" }, - ipv4: { label: "כתובת IPv4", gender: "f" }, - ipv6: { label: "כתובת IPv6", gender: "f" }, - cidrv4: { label: "טווח IPv4", gender: "m" }, - cidrv6: { label: "טווח IPv6", gender: "m" }, - base64: { label: "מחרוזת בבסיס 64", gender: "f" }, - base64url: { label: "מחרוזת בבסיס 64 לכתובות רשת", gender: "f" }, - json_string: { label: "מחרוזת JSON", gender: "f" }, - e164: { label: "מספר E.164", gender: "m" }, - jwt: { label: "JWT", gender: "m" }, - ends_with: { label: "קלט", gender: "m" }, - includes: { label: "קלט", gender: "m" }, - lowercase: { label: "קלט", gender: "m" }, - starts_with: { label: "קלט", gender: "m" }, - uppercase: { label: "קלט", gender: "m" } - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expectedKey = issue2.expected; - const expected = TypeDictionary[expectedKey ?? ""] ?? typeLabel(expectedKey); - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? TypeNames[receivedType]?.label ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `קלט לא תקין: צריך להיות instanceof ${issue2.expected}, התקבל ${received}`; - } - return `קלט לא תקין: צריך להיות ${expected}, התקבל ${received}`; - } - case "invalid_value": { - if (issue2.values.length === 1) { - return `ערך לא תקין: הערך חייב להיות ${stringifyPrimitive(issue2.values[0])}`; - } - const stringified = issue2.values.map((v) => stringifyPrimitive(v)); - if (issue2.values.length === 2) { - return `ערך לא תקין: האפשרויות המתאימות הן ${stringified[0]} או ${stringified[1]}`; - } - const lastValue = stringified[stringified.length - 1]; - const restValues = stringified.slice(0, -1).join(", "); - return `ערך לא תקין: האפשרויות המתאימות הן ${restValues} או ${lastValue}`; - } - case "too_big": { - const sizing = getSizing(issue2.origin); - const subject = withDefinite(issue2.origin ?? "value"); - if (issue2.origin === "string") { - return `${sizing?.longLabel ?? "ארוך"} מדי: ${subject} צריכה להכיל ${issue2.maximum.toString()} ${sizing?.unit ?? ""} ${issue2.inclusive ? "או פחות" : "לכל היותר"}`.trim(); - } - if (issue2.origin === "number") { - const comparison = issue2.inclusive ? `קטן או שווה ל-${issue2.maximum}` : `קטן מ-${issue2.maximum}`; - return `גדול מדי: ${subject} צריך להיות ${comparison}`; - } - if (issue2.origin === "array" || issue2.origin === "set") { - const verb = issue2.origin === "set" ? "צריכה" : "צריך"; - const comparison = issue2.inclusive ? `${issue2.maximum} ${sizing?.unit ?? ""} או פחות` : `פחות מ-${issue2.maximum} ${sizing?.unit ?? ""}`; - return `גדול מדי: ${subject} ${verb} להכיל ${comparison}`.trim(); - } - const adj = issue2.inclusive ? "<=" : "<"; - const be = verbFor(issue2.origin ?? "value"); - if (sizing?.unit) { - return `${sizing.longLabel} מדי: ${subject} ${be} ${adj}${issue2.maximum.toString()} ${sizing.unit}`; - } - return `${sizing?.longLabel ?? "גדול"} מדי: ${subject} ${be} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const sizing = getSizing(issue2.origin); - const subject = withDefinite(issue2.origin ?? "value"); - if (issue2.origin === "string") { - return `${sizing?.shortLabel ?? "קצר"} מדי: ${subject} צריכה להכיל ${issue2.minimum.toString()} ${sizing?.unit ?? ""} ${issue2.inclusive ? "או יותר" : "לפחות"}`.trim(); - } - if (issue2.origin === "number") { - const comparison = issue2.inclusive ? `גדול או שווה ל-${issue2.minimum}` : `גדול מ-${issue2.minimum}`; - return `קטן מדי: ${subject} צריך להיות ${comparison}`; - } - if (issue2.origin === "array" || issue2.origin === "set") { - const verb = issue2.origin === "set" ? "צריכה" : "צריך"; - if (issue2.minimum === 1 && issue2.inclusive) { - const singularPhrase = issue2.origin === "set" ? "לפחות פריט אחד" : "לפחות פריט אחד"; - return `קטן מדי: ${subject} ${verb} להכיל ${singularPhrase}`; - } - const comparison = issue2.inclusive ? `${issue2.minimum} ${sizing?.unit ?? ""} או יותר` : `יותר מ-${issue2.minimum} ${sizing?.unit ?? ""}`; - return `קטן מדי: ${subject} ${verb} להכיל ${comparison}`.trim(); - } - const adj = issue2.inclusive ? ">=" : ">"; - const be = verbFor(issue2.origin ?? "value"); - if (sizing?.unit) { - return `${sizing.shortLabel} מדי: ${subject} ${be} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `${sizing?.shortLabel ?? "קטן"} מדי: ${subject} ${be} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `המחרוזת חייבת להתחיל ב "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `המחרוזת חייבת להסתיים ב "${_issue.suffix}"`; - if (_issue.format === "includes") - return `המחרוזת חייבת לכלול "${_issue.includes}"`; - if (_issue.format === "regex") - return `המחרוזת חייבת להתאים לתבנית ${_issue.pattern}`; - const nounEntry = FormatDictionary[_issue.format]; - const noun = nounEntry?.label ?? _issue.format; - const gender = nounEntry?.gender ?? "m"; - const adjective = gender === "f" ? "תקינה" : "תקין"; - return `${noun} לא ${adjective}`; - } - case "not_multiple_of": - return `מספר לא תקין: חייב להיות מכפלה של ${issue2.divisor}`; - case "unrecognized_keys": - return `מפתח${issue2.keys.length > 1 ? "ות" : ""} לא מזוה${issue2.keys.length > 1 ? "ים" : "ה"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": { - return `שדה לא תקין באובייקט`; - } - case "invalid_union": - return "קלט לא תקין"; - case "invalid_element": { - const place = withDefinite(issue2.origin ?? "array"); - return `ערך לא תקין ב${place}`; - } - default: - return `קלט לא תקין`; - } - }; -}; -function he_default() { - return { - localeError: error16() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/hu.js -var error17 = () => { - const Sizable = { - string: { unit: "karakter", verb: "legyen" }, - file: { unit: "byte", verb: "legyen" }, - array: { unit: "elem", verb: "legyen" }, - set: { unit: "elem", verb: "legyen" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "bemenet", - email: "email cím", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO időbélyeg", - date: "ISO dátum", - time: "ISO idő", - duration: "ISO időintervallum", - ipv4: "IPv4 cím", - ipv6: "IPv6 cím", - cidrv4: "IPv4 tartomány", - cidrv6: "IPv6 tartomány", - base64: "base64-kódolt string", - base64url: "base64url-kódolt string", - json_string: "JSON string", - e164: "E.164 szám", - jwt: "JWT", - template_literal: "bemenet" - }; - const TypeDictionary = { - nan: "NaN", - number: "szám", - array: "tömb" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Érvénytelen bemenet: a várt érték instanceof ${issue2.expected}, a kapott érték ${received}`; - } - return `Érvénytelen bemenet: a várt érték ${expected}, a kapott érték ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Érvénytelen bemenet: a várt érték ${stringifyPrimitive(issue2.values[0])}`; - return `Érvénytelen opció: valamelyik érték várt ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Túl nagy: ${issue2.origin ?? "érték"} mérete túl nagy ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elem"}`; - return `Túl nagy: a bemeneti érték ${issue2.origin ?? "érték"} túl nagy: ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Túl kicsi: a bemeneti érték ${issue2.origin} mérete túl kicsi ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Túl kicsi: a bemeneti érték ${issue2.origin} túl kicsi ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Érvénytelen string: "${_issue.prefix}" értékkel kell kezdődnie`; - if (_issue.format === "ends_with") - return `Érvénytelen string: "${_issue.suffix}" értékkel kell végződnie`; - if (_issue.format === "includes") - return `Érvénytelen string: "${_issue.includes}" értéket kell tartalmaznia`; - if (_issue.format === "regex") - return `Érvénytelen string: ${_issue.pattern} mintának kell megfelelnie`; - return `Érvénytelen ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Érvénytelen szám: ${issue2.divisor} többszörösének kell lennie`; - case "unrecognized_keys": - return `Ismeretlen kulcs${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Érvénytelen kulcs ${issue2.origin}`; - case "invalid_union": - return "Érvénytelen bemenet"; - case "invalid_element": - return `Érvénytelen érték: ${issue2.origin}`; - default: - return `Érvénytelen bemenet`; - } - }; -}; -function hu_default() { - return { - localeError: error17() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/hy.js -function getArmenianPlural(count, one, many) { - return Math.abs(count) === 1 ? one : many; -} -function withDefiniteArticle(word) { - if (!word) - return ""; - const vowels = ["ա", "ե", "ը", "ի", "ո", "ու", "օ"]; - const lastChar = word[word.length - 1]; - return word + (vowels.includes(lastChar) ? "ն" : "ը"); -} -var error18 = () => { - const Sizable = { - string: { - unit: { - one: "նշան", - many: "նշաններ" - }, - verb: "ունենալ" - }, - file: { - unit: { - one: "բայթ", - many: "բայթեր" - }, - verb: "ունենալ" - }, - array: { - unit: { - one: "տարր", - many: "տարրեր" - }, - verb: "ունենալ" - }, - set: { - unit: { - one: "տարր", - many: "տարրեր" - }, - verb: "ունենալ" - } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "մուտք", - email: "էլ. հասցե", - url: "URL", - emoji: "էմոջի", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO ամսաթիվ և ժամ", - date: "ISO ամսաթիվ", - time: "ISO ժամ", - duration: "ISO տևողություն", - ipv4: "IPv4 հասցե", - ipv6: "IPv6 հասցե", - cidrv4: "IPv4 միջակայք", - cidrv6: "IPv6 միջակայք", - base64: "base64 ձևաչափով տող", - base64url: "base64url ձևաչափով տող", - json_string: "JSON տող", - e164: "E.164 համար", - jwt: "JWT", - template_literal: "մուտք" - }; - const TypeDictionary = { - nan: "NaN", - number: "թիվ", - array: "զանգված" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Սխալ մուտքագրում․ սպասվում էր instanceof ${issue2.expected}, ստացվել է ${received}`; - } - return `Սխալ մուտքագրում․ սպասվում էր ${expected}, ստացվել է ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Սխալ մուտքագրում․ սպասվում էր ${stringifyPrimitive(issue2.values[1])}`; - return `Սխալ տարբերակ․ սպասվում էր հետևյալներից մեկը՝ ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - const maxValue = Number(issue2.maximum); - const unit = getArmenianPlural(maxValue, sizing.unit.one, sizing.unit.many); - return `Չափազանց մեծ արժեք․ սպասվում է, որ ${withDefiniteArticle(issue2.origin ?? "արժեք")} կունենա ${adj}${issue2.maximum.toString()} ${unit}`; - } - return `Չափազանց մեծ արժեք․ սպասվում է, որ ${withDefiniteArticle(issue2.origin ?? "արժեք")} լինի ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - const minValue = Number(issue2.minimum); - const unit = getArmenianPlural(minValue, sizing.unit.one, sizing.unit.many); - return `Չափազանց փոքր արժեք․ սպասվում է, որ ${withDefiniteArticle(issue2.origin)} կունենա ${adj}${issue2.minimum.toString()} ${unit}`; - } - return `Չափազանց փոքր արժեք․ սպասվում է, որ ${withDefiniteArticle(issue2.origin)} լինի ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Սխալ տող․ պետք է սկսվի "${_issue.prefix}"-ով`; - if (_issue.format === "ends_with") - return `Սխալ տող․ պետք է ավարտվի "${_issue.suffix}"-ով`; - if (_issue.format === "includes") - return `Սխալ տող․ պետք է պարունակի "${_issue.includes}"`; - if (_issue.format === "regex") - return `Սխալ տող․ պետք է համապատասխանի ${_issue.pattern} ձևաչափին`; - return `Սխալ ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Սխալ թիվ․ պետք է բազմապատիկ լինի ${issue2.divisor}-ի`; - case "unrecognized_keys": - return `Չճանաչված բանալի${issue2.keys.length > 1 ? "ներ" : ""}. ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Սխալ բանալի ${withDefiniteArticle(issue2.origin)}-ում`; - case "invalid_union": - return "Սխալ մուտքագրում"; - case "invalid_element": - return `Սխալ արժեք ${withDefiniteArticle(issue2.origin)}-ում`; - default: - return `Սխալ մուտքագրում`; - } - }; -}; -function hy_default() { - return { - localeError: error18() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/id.js -var error19 = () => { - const Sizable = { - string: { unit: "karakter", verb: "memiliki" }, - file: { unit: "byte", verb: "memiliki" }, - array: { unit: "item", verb: "memiliki" }, - set: { unit: "item", verb: "memiliki" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "alamat email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "tanggal dan waktu format ISO", - date: "tanggal format ISO", - time: "jam format ISO", - duration: "durasi format ISO", - ipv4: "alamat IPv4", - ipv6: "alamat IPv6", - cidrv4: "rentang alamat IPv4", - cidrv6: "rentang alamat IPv6", - base64: "string dengan enkode base64", - base64url: "string dengan enkode base64url", - json_string: "string JSON", - e164: "angka E.164", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Input tidak valid: diharapkan instanceof ${issue2.expected}, diterima ${received}`; - } - return `Input tidak valid: diharapkan ${expected}, diterima ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Input tidak valid: diharapkan ${stringifyPrimitive(issue2.values[0])}`; - return `Pilihan tidak valid: diharapkan salah satu dari ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Terlalu besar: diharapkan ${issue2.origin ?? "value"} memiliki ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elemen"}`; - return `Terlalu besar: diharapkan ${issue2.origin ?? "value"} menjadi ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Terlalu kecil: diharapkan ${issue2.origin} memiliki ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Terlalu kecil: diharapkan ${issue2.origin} menjadi ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `String tidak valid: harus dimulai dengan "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `String tidak valid: harus berakhir dengan "${_issue.suffix}"`; - if (_issue.format === "includes") - return `String tidak valid: harus menyertakan "${_issue.includes}"`; - if (_issue.format === "regex") - return `String tidak valid: harus sesuai pola ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} tidak valid`; - } - case "not_multiple_of": - return `Angka tidak valid: harus kelipatan dari ${issue2.divisor}`; - case "unrecognized_keys": - return `Kunci tidak dikenali ${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Kunci tidak valid di ${issue2.origin}`; - case "invalid_union": - return "Input tidak valid"; - case "invalid_element": - return `Nilai tidak valid di ${issue2.origin}`; - default: - return `Input tidak valid`; - } - }; -}; -function id_default() { - return { - localeError: error19() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/is.js -var error20 = () => { - const Sizable = { - string: { unit: "stafi", verb: "að hafa" }, - file: { unit: "bæti", verb: "að hafa" }, - array: { unit: "hluti", verb: "að hafa" }, - set: { unit: "hluti", verb: "að hafa" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "gildi", - email: "netfang", - url: "vefslóð", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO dagsetning og tími", - date: "ISO dagsetning", - time: "ISO tími", - duration: "ISO tímalengd", - ipv4: "IPv4 address", - ipv6: "IPv6 address", - cidrv4: "IPv4 range", - cidrv6: "IPv6 range", - base64: "base64-encoded strengur", - base64url: "base64url-encoded strengur", - json_string: "JSON strengur", - e164: "E.164 tölugildi", - jwt: "JWT", - template_literal: "gildi" - }; - const TypeDictionary = { - nan: "NaN", - number: "númer", - array: "fylki" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Rangt gildi: Þú slóst inn ${received} þar sem á að vera instanceof ${issue2.expected}`; - } - return `Rangt gildi: Þú slóst inn ${received} þar sem á að vera ${expected}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Rangt gildi: gert ráð fyrir ${stringifyPrimitive(issue2.values[0])}`; - return `Ógilt val: má vera eitt af eftirfarandi ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Of stórt: gert er ráð fyrir að ${issue2.origin ?? "gildi"} hafi ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "hluti"}`; - return `Of stórt: gert er ráð fyrir að ${issue2.origin ?? "gildi"} sé ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Of lítið: gert er ráð fyrir að ${issue2.origin} hafi ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Of lítið: gert er ráð fyrir að ${issue2.origin} sé ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Ógildur strengur: verður að byrja á "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Ógildur strengur: verður að enda á "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Ógildur strengur: verður að innihalda "${_issue.includes}"`; - if (_issue.format === "regex") - return `Ógildur strengur: verður að fylgja mynstri ${_issue.pattern}`; - return `Rangt ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Röng tala: verður að vera margfeldi af ${issue2.divisor}`; - case "unrecognized_keys": - return `Óþekkt ${issue2.keys.length > 1 ? "ir lyklar" : "ur lykill"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Rangur lykill í ${issue2.origin}`; - case "invalid_union": - return "Rangt gildi"; - case "invalid_element": - return `Rangt gildi í ${issue2.origin}`; - default: - return `Rangt gildi`; - } - }; -}; -function is_default() { - return { - localeError: error20() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/it.js -var error21 = () => { - const Sizable = { - string: { unit: "caratteri", verb: "avere" }, - file: { unit: "byte", verb: "avere" }, - array: { unit: "elementi", verb: "avere" }, - set: { unit: "elementi", verb: "avere" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "indirizzo email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data e ora ISO", - date: "data ISO", - time: "ora ISO", - duration: "durata ISO", - ipv4: "indirizzo IPv4", - ipv6: "indirizzo IPv6", - cidrv4: "intervallo IPv4", - cidrv6: "intervallo IPv6", - base64: "stringa codificata in base64", - base64url: "URL codificata in base64", - json_string: "stringa JSON", - e164: "numero E.164", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN", - number: "numero", - array: "vettore" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Input non valido: atteso instanceof ${issue2.expected}, ricevuto ${received}`; - } - return `Input non valido: atteso ${expected}, ricevuto ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Input non valido: atteso ${stringifyPrimitive(issue2.values[0])}`; - return `Opzione non valida: atteso uno tra ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Troppo grande: ${issue2.origin ?? "valore"} deve avere ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementi"}`; - return `Troppo grande: ${issue2.origin ?? "valore"} deve essere ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Troppo piccolo: ${issue2.origin} deve avere ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Troppo piccolo: ${issue2.origin} deve essere ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Stringa non valida: deve iniziare con "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Stringa non valida: deve terminare con "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Stringa non valida: deve includere "${_issue.includes}"`; - if (_issue.format === "regex") - return `Stringa non valida: deve corrispondere al pattern ${_issue.pattern}`; - return `Invalid ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Numero non valido: deve essere un multiplo di ${issue2.divisor}`; - case "unrecognized_keys": - return `Chiav${issue2.keys.length > 1 ? "i" : "e"} non riconosciut${issue2.keys.length > 1 ? "e" : "a"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Chiave non valida in ${issue2.origin}`; - case "invalid_union": - return "Input non valido"; - case "invalid_element": - return `Valore non valido in ${issue2.origin}`; - default: - return `Input non valido`; - } - }; -}; -function it_default() { - return { - localeError: error21() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ja.js -var error22 = () => { - const Sizable = { - string: { unit: "文字", verb: "である" }, - file: { unit: "バイト", verb: "である" }, - array: { unit: "要素", verb: "である" }, - set: { unit: "要素", verb: "である" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "入力値", - email: "メールアドレス", - url: "URL", - emoji: "絵文字", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO日時", - date: "ISO日付", - time: "ISO時刻", - duration: "ISO期間", - ipv4: "IPv4アドレス", - ipv6: "IPv6アドレス", - cidrv4: "IPv4範囲", - cidrv6: "IPv6範囲", - base64: "base64エンコード文字列", - base64url: "base64urlエンコード文字列", - json_string: "JSON文字列", - e164: "E.164番号", - jwt: "JWT", - template_literal: "入力値" - }; - const TypeDictionary = { - nan: "NaN", - number: "数値", - array: "配列" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `無効な入力: instanceof ${issue2.expected}が期待されましたが、${received}が入力されました`; - } - return `無効な入力: ${expected}が期待されましたが、${received}が入力されました`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `無効な入力: ${stringifyPrimitive(issue2.values[0])}が期待されました`; - return `無効な選択: ${joinValues(issue2.values, "、")}のいずれかである必要があります`; - case "too_big": { - const adj = issue2.inclusive ? "以下である" : "より小さい"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `大きすぎる値: ${issue2.origin ?? "値"}は${issue2.maximum.toString()}${sizing.unit ?? "要素"}${adj}必要があります`; - return `大きすぎる値: ${issue2.origin ?? "値"}は${issue2.maximum.toString()}${adj}必要があります`; - } - case "too_small": { - const adj = issue2.inclusive ? "以上である" : "より大きい"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `小さすぎる値: ${issue2.origin}は${issue2.minimum.toString()}${sizing.unit}${adj}必要があります`; - return `小さすぎる値: ${issue2.origin}は${issue2.minimum.toString()}${adj}必要があります`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `無効な文字列: "${_issue.prefix}"で始まる必要があります`; - if (_issue.format === "ends_with") - return `無効な文字列: "${_issue.suffix}"で終わる必要があります`; - if (_issue.format === "includes") - return `無効な文字列: "${_issue.includes}"を含む必要があります`; - if (_issue.format === "regex") - return `無効な文字列: パターン${_issue.pattern}に一致する必要があります`; - return `無効な${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `無効な数値: ${issue2.divisor}の倍数である必要があります`; - case "unrecognized_keys": - return `認識されていないキー${issue2.keys.length > 1 ? "群" : ""}: ${joinValues(issue2.keys, "、")}`; - case "invalid_key": - return `${issue2.origin}内の無効なキー`; - case "invalid_union": - return "無効な入力"; - case "invalid_element": - return `${issue2.origin}内の無効な値`; - default: - return `無効な入力`; - } - }; -}; -function ja_default() { - return { - localeError: error22() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ka.js -var error23 = () => { - const Sizable = { - string: { unit: "სიმბოლო", verb: "უნდა შეიცავდეს" }, - file: { unit: "ბაიტი", verb: "უნდა შეიცავდეს" }, - array: { unit: "ელემენტი", verb: "უნდა შეიცავდეს" }, - set: { unit: "ელემენტი", verb: "უნდა შეიცავდეს" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "შეყვანა", - email: "ელ-ფოსტის მისამართი", - url: "URL", - emoji: "ემოჯი", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "თარიღი-დრო", - date: "თარიღი", - time: "დრო", - duration: "ხანგრძლივობა", - ipv4: "IPv4 მისამართი", - ipv6: "IPv6 მისამართი", - cidrv4: "IPv4 დიაპაზონი", - cidrv6: "IPv6 დიაპაზონი", - base64: "base64-კოდირებული სტრინგი", - base64url: "base64url-კოდირებული სტრინგი", - json_string: "JSON სტრინგი", - e164: "E.164 ნომერი", - jwt: "JWT", - template_literal: "შეყვანა" - }; - const TypeDictionary = { - nan: "NaN", - number: "რიცხვი", - string: "სტრინგი", - boolean: "ბულეანი", - function: "ფუნქცია", - array: "მასივი" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `არასწორი შეყვანა: მოსალოდნელი instanceof ${issue2.expected}, მიღებული ${received}`; - } - return `არასწორი შეყვანა: მოსალოდნელი ${expected}, მიღებული ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `არასწორი შეყვანა: მოსალოდნელი ${stringifyPrimitive(issue2.values[0])}`; - return `არასწორი ვარიანტი: მოსალოდნელია ერთ-ერთი ${joinValues(issue2.values, "|")}-დან`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `ზედმეტად დიდი: მოსალოდნელი ${issue2.origin ?? "მნიშვნელობა"} ${sizing.verb} ${adj}${issue2.maximum.toString()} ${sizing.unit}`; - return `ზედმეტად დიდი: მოსალოდნელი ${issue2.origin ?? "მნიშვნელობა"} იყოს ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `ზედმეტად პატარა: მოსალოდნელი ${issue2.origin} ${sizing.verb} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `ზედმეტად პატარა: მოსალოდნელი ${issue2.origin} იყოს ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `არასწორი სტრინგი: უნდა იწყებოდეს "${_issue.prefix}"-ით`; - } - if (_issue.format === "ends_with") - return `არასწორი სტრინგი: უნდა მთავრდებოდეს "${_issue.suffix}"-ით`; - if (_issue.format === "includes") - return `არასწორი სტრინგი: უნდა შეიცავდეს "${_issue.includes}"-ს`; - if (_issue.format === "regex") - return `არასწორი სტრინგი: უნდა შეესაბამებოდეს შაბლონს ${_issue.pattern}`; - return `არასწორი ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `არასწორი რიცხვი: უნდა იყოს ${issue2.divisor}-ის ჯერადი`; - case "unrecognized_keys": - return `უცნობი გასაღებ${issue2.keys.length > 1 ? "ები" : "ი"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `არასწორი გასაღები ${issue2.origin}-ში`; - case "invalid_union": - return "არასწორი შეყვანა"; - case "invalid_element": - return `არასწორი მნიშვნელობა ${issue2.origin}-ში`; - default: - return `არასწორი შეყვანა`; - } - }; -}; -function ka_default() { - return { - localeError: error23() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/km.js -var error24 = () => { - const Sizable = { - string: { unit: "តួអក្សរ", verb: "គួរមាន" }, - file: { unit: "បៃ", verb: "គួរមាន" }, - array: { unit: "ធាតុ", verb: "គួរមាន" }, - set: { unit: "ធាតុ", verb: "គួរមាន" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ទិន្នន័យបញ្ចូល", - email: "អាសយដ្ឋានអ៊ីមែល", - url: "URL", - emoji: "សញ្ញាអារម្មណ៍", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "កាលបរិច្ឆេទ និងម៉ោង ISO", - date: "កាលបរិច្ឆេទ ISO", - time: "ម៉ោង ISO", - duration: "រយៈពេល ISO", - ipv4: "អាសយដ្ឋាន IPv4", - ipv6: "អាសយដ្ឋាន IPv6", - cidrv4: "ដែនអាសយដ្ឋាន IPv4", - cidrv6: "ដែនអាសយដ្ឋាន IPv6", - base64: "ខ្សែអក្សរអ៊ិកូដ base64", - base64url: "ខ្សែអក្សរអ៊ិកូដ base64url", - json_string: "ខ្សែអក្សរ JSON", - e164: "លេខ E.164", - jwt: "JWT", - template_literal: "ទិន្នន័យបញ្ចូល" - }; - const TypeDictionary = { - nan: "NaN", - number: "លេខ", - array: "អារេ (Array)", - null: "គ្មានតម្លៃ (null)" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `ទិន្នន័យបញ្ចូលមិនត្រឹមត្រូវ៖ ត្រូវការ instanceof ${issue2.expected} ប៉ុន្តែទទួលបាន ${received}`; - } - return `ទិន្នន័យបញ្ចូលមិនត្រឹមត្រូវ៖ ត្រូវការ ${expected} ប៉ុន្តែទទួលបាន ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `ទិន្នន័យបញ្ចូលមិនត្រឹមត្រូវ៖ ត្រូវការ ${stringifyPrimitive(issue2.values[0])}`; - return `ជម្រើសមិនត្រឹមត្រូវ៖ ត្រូវជាមួយក្នុងចំណោម ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `ធំពេក៖ ត្រូវការ ${issue2.origin ?? "តម្លៃ"} ${adj} ${issue2.maximum.toString()} ${sizing.unit ?? "ធាតុ"}`; - return `ធំពេក៖ ត្រូវការ ${issue2.origin ?? "តម្លៃ"} ${adj} ${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `តូចពេក៖ ត្រូវការ ${issue2.origin} ${adj} ${issue2.minimum.toString()} ${sizing.unit}`; - } - return `តូចពេក៖ ត្រូវការ ${issue2.origin} ${adj} ${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវចាប់ផ្តើមដោយ "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវបញ្ចប់ដោយ "${_issue.suffix}"`; - if (_issue.format === "includes") - return `ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវមាន "${_issue.includes}"`; - if (_issue.format === "regex") - return `ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវតែផ្គូផ្គងនឹងទម្រង់ដែលបានកំណត់ ${_issue.pattern}`; - return `មិនត្រឹមត្រូវ៖ ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `លេខមិនត្រឹមត្រូវ៖ ត្រូវតែជាពហុគុណនៃ ${issue2.divisor}`; - case "unrecognized_keys": - return `រកឃើញសោមិនស្គាល់៖ ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `សោមិនត្រឹមត្រូវនៅក្នុង ${issue2.origin}`; - case "invalid_union": - return `ទិន្នន័យមិនត្រឹមត្រូវ`; - case "invalid_element": - return `ទិន្នន័យមិនត្រឹមត្រូវនៅក្នុង ${issue2.origin}`; - default: - return `ទិន្នន័យមិនត្រឹមត្រូវ`; - } - }; -}; -function km_default() { - return { - localeError: error24() - }; -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/kh.js -function kh_default() { - return km_default(); -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ko.js -var error25 = () => { - const Sizable = { - string: { unit: "문자", verb: "to have" }, - file: { unit: "바이트", verb: "to have" }, - array: { unit: "개", verb: "to have" }, - set: { unit: "개", verb: "to have" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "입력", - email: "이메일 주소", - url: "URL", - emoji: "이모지", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO 날짜시간", - date: "ISO 날짜", - time: "ISO 시간", - duration: "ISO 기간", - ipv4: "IPv4 주소", - ipv6: "IPv6 주소", - cidrv4: "IPv4 범위", - cidrv6: "IPv6 범위", - base64: "base64 인코딩 문자열", - base64url: "base64url 인코딩 문자열", - json_string: "JSON 문자열", - e164: "E.164 번호", - jwt: "JWT", - template_literal: "입력" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `잘못된 입력: 예상 타입은 instanceof ${issue2.expected}, 받은 타입은 ${received}입니다`; - } - return `잘못된 입력: 예상 타입은 ${expected}, 받은 타입은 ${received}입니다`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `잘못된 입력: 값은 ${stringifyPrimitive(issue2.values[0])} 이어야 합니다`; - return `잘못된 옵션: ${joinValues(issue2.values, "또는 ")} 중 하나여야 합니다`; - case "too_big": { - const adj = issue2.inclusive ? "이하" : "미만"; - const suffix = adj === "미만" ? "이어야 합니다" : "여야 합니다"; - const sizing = getSizing(issue2.origin); - const unit = sizing?.unit ?? "요소"; - if (sizing) - return `${issue2.origin ?? "값"}이 너무 큽니다: ${issue2.maximum.toString()}${unit} ${adj}${suffix}`; - return `${issue2.origin ?? "값"}이 너무 큽니다: ${issue2.maximum.toString()} ${adj}${suffix}`; - } - case "too_small": { - const adj = issue2.inclusive ? "이상" : "초과"; - const suffix = adj === "이상" ? "이어야 합니다" : "여야 합니다"; - const sizing = getSizing(issue2.origin); - const unit = sizing?.unit ?? "요소"; - if (sizing) { - return `${issue2.origin ?? "값"}이 너무 작습니다: ${issue2.minimum.toString()}${unit} ${adj}${suffix}`; - } - return `${issue2.origin ?? "값"}이 너무 작습니다: ${issue2.minimum.toString()} ${adj}${suffix}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `잘못된 문자열: "${_issue.prefix}"(으)로 시작해야 합니다`; - } - if (_issue.format === "ends_with") - return `잘못된 문자열: "${_issue.suffix}"(으)로 끝나야 합니다`; - if (_issue.format === "includes") - return `잘못된 문자열: "${_issue.includes}"을(를) 포함해야 합니다`; - if (_issue.format === "regex") - return `잘못된 문자열: 정규식 ${_issue.pattern} 패턴과 일치해야 합니다`; - return `잘못된 ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `잘못된 숫자: ${issue2.divisor}의 배수여야 합니다`; - case "unrecognized_keys": - return `인식할 수 없는 키: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `잘못된 키: ${issue2.origin}`; - case "invalid_union": - return `잘못된 입력`; - case "invalid_element": - return `잘못된 값: ${issue2.origin}`; - default: - return `잘못된 입력`; - } - }; -}; -function ko_default() { - return { - localeError: error25() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/lt.js -var capitalizeFirstCharacter = (text) => { - return text.charAt(0).toUpperCase() + text.slice(1); -}; -function getUnitTypeFromNumber(number2) { - const abs = Math.abs(number2); - const last = abs % 10; - const last2 = abs % 100; - if (last2 >= 11 && last2 <= 19 || last === 0) - return "many"; - if (last === 1) - return "one"; - return "few"; -} -var error26 = () => { - const Sizable = { - string: { - unit: { - one: "simbolis", - few: "simboliai", - many: "simbolių" - }, - verb: { - smaller: { - inclusive: "turi būti ne ilgesnė kaip", - notInclusive: "turi būti trumpesnė kaip" - }, - bigger: { - inclusive: "turi būti ne trumpesnė kaip", - notInclusive: "turi būti ilgesnė kaip" - } - } - }, - file: { - unit: { - one: "baitas", - few: "baitai", - many: "baitų" - }, - verb: { - smaller: { - inclusive: "turi būti ne didesnis kaip", - notInclusive: "turi būti mažesnis kaip" - }, - bigger: { - inclusive: "turi būti ne mažesnis kaip", - notInclusive: "turi būti didesnis kaip" - } - } - }, - array: { - unit: { - one: "elementą", - few: "elementus", - many: "elementų" - }, - verb: { - smaller: { - inclusive: "turi turėti ne daugiau kaip", - notInclusive: "turi turėti mažiau kaip" - }, - bigger: { - inclusive: "turi turėti ne mažiau kaip", - notInclusive: "turi turėti daugiau kaip" - } - } - }, - set: { - unit: { - one: "elementą", - few: "elementus", - many: "elementų" - }, - verb: { - smaller: { - inclusive: "turi turėti ne daugiau kaip", - notInclusive: "turi turėti mažiau kaip" - }, - bigger: { - inclusive: "turi turėti ne mažiau kaip", - notInclusive: "turi turėti daugiau kaip" - } - } - } - }; - function getSizing(origin, unitType, inclusive, targetShouldBe) { - const result = Sizable[origin] ?? null; - if (result === null) - return result; - return { - unit: result.unit[unitType], - verb: result.verb[targetShouldBe][inclusive ? "inclusive" : "notInclusive"] - }; - } - const FormatDictionary = { - regex: "įvestis", - email: "el. pašto adresas", - url: "URL", - emoji: "jaustukas", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO data ir laikas", - date: "ISO data", - time: "ISO laikas", - duration: "ISO trukmė", - ipv4: "IPv4 adresas", - ipv6: "IPv6 adresas", - cidrv4: "IPv4 tinklo prefiksas (CIDR)", - cidrv6: "IPv6 tinklo prefiksas (CIDR)", - base64: "base64 užkoduota eilutė", - base64url: "base64url užkoduota eilutė", - json_string: "JSON eilutė", - e164: "E.164 numeris", - jwt: "JWT", - template_literal: "įvestis" - }; - const TypeDictionary = { - nan: "NaN", - number: "skaičius", - bigint: "sveikasis skaičius", - string: "eilutė", - boolean: "loginė reikšmė", - undefined: "neapibrėžta reikšmė", - function: "funkcija", - symbol: "simbolis", - array: "masyvas", - object: "objektas", - null: "nulinė reikšmė" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Gautas tipas ${received}, o tikėtasi - instanceof ${issue2.expected}`; - } - return `Gautas tipas ${received}, o tikėtasi - ${expected}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Privalo būti ${stringifyPrimitive(issue2.values[0])}`; - return `Privalo būti vienas iš ${joinValues(issue2.values, "|")} pasirinkimų`; - case "too_big": { - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - const sizing = getSizing(issue2.origin, getUnitTypeFromNumber(Number(issue2.maximum)), issue2.inclusive ?? false, "smaller"); - if (sizing?.verb) - return `${capitalizeFirstCharacter(origin ?? issue2.origin ?? "reikšmė")} ${sizing.verb} ${issue2.maximum.toString()} ${sizing.unit ?? "elementų"}`; - const adj = issue2.inclusive ? "ne didesnis kaip" : "mažesnis kaip"; - return `${capitalizeFirstCharacter(origin ?? issue2.origin ?? "reikšmė")} turi būti ${adj} ${issue2.maximum.toString()} ${sizing?.unit}`; - } - case "too_small": { - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - const sizing = getSizing(issue2.origin, getUnitTypeFromNumber(Number(issue2.minimum)), issue2.inclusive ?? false, "bigger"); - if (sizing?.verb) - return `${capitalizeFirstCharacter(origin ?? issue2.origin ?? "reikšmė")} ${sizing.verb} ${issue2.minimum.toString()} ${sizing.unit ?? "elementų"}`; - const adj = issue2.inclusive ? "ne mažesnis kaip" : "didesnis kaip"; - return `${capitalizeFirstCharacter(origin ?? issue2.origin ?? "reikšmė")} turi būti ${adj} ${issue2.minimum.toString()} ${sizing?.unit}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Eilutė privalo prasidėti "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Eilutė privalo pasibaigti "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Eilutė privalo įtraukti "${_issue.includes}"`; - if (_issue.format === "regex") - return `Eilutė privalo atitikti ${_issue.pattern}`; - return `Neteisingas ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Skaičius privalo būti ${issue2.divisor} kartotinis.`; - case "unrecognized_keys": - return `Neatpažint${issue2.keys.length > 1 ? "i" : "as"} rakt${issue2.keys.length > 1 ? "ai" : "as"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return "Rastas klaidingas raktas"; - case "invalid_union": - return "Klaidinga įvestis"; - case "invalid_element": { - const origin = TypeDictionary[issue2.origin] ?? issue2.origin; - return `${capitalizeFirstCharacter(origin ?? issue2.origin ?? "reikšmė")} turi klaidingą įvestį`; - } - default: - return "Klaidinga įvestis"; - } - }; -}; -function lt_default() { - return { - localeError: error26() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/mk.js -var error27 = () => { - const Sizable = { - string: { unit: "знаци", verb: "да имаат" }, - file: { unit: "бајти", verb: "да имаат" }, - array: { unit: "ставки", verb: "да имаат" }, - set: { unit: "ставки", verb: "да имаат" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "внес", - email: "адреса на е-пошта", - url: "URL", - emoji: "емоџи", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO датум и време", - date: "ISO датум", - time: "ISO време", - duration: "ISO времетраење", - ipv4: "IPv4 адреса", - ipv6: "IPv6 адреса", - cidrv4: "IPv4 опсег", - cidrv6: "IPv6 опсег", - base64: "base64-енкодирана низа", - base64url: "base64url-енкодирана низа", - json_string: "JSON низа", - e164: "E.164 број", - jwt: "JWT", - template_literal: "внес" - }; - const TypeDictionary = { - nan: "NaN", - number: "број", - array: "низа" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Грешен внес: се очекува instanceof ${issue2.expected}, примено ${received}`; - } - return `Грешен внес: се очекува ${expected}, примено ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Invalid input: expected ${stringifyPrimitive(issue2.values[0])}`; - return `Грешана опција: се очекува една ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Премногу голем: се очекува ${issue2.origin ?? "вредноста"} да има ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "елементи"}`; - return `Премногу голем: се очекува ${issue2.origin ?? "вредноста"} да биде ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Премногу мал: се очекува ${issue2.origin} да има ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Премногу мал: се очекува ${issue2.origin} да биде ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Неважечка низа: мора да започнува со "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Неважечка низа: мора да завршува со "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Неважечка низа: мора да вклучува "${_issue.includes}"`; - if (_issue.format === "regex") - return `Неважечка низа: мора да одгоара на патернот ${_issue.pattern}`; - return `Invalid ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Грешен број: мора да биде делив со ${issue2.divisor}`; - case "unrecognized_keys": - return `${issue2.keys.length > 1 ? "Непрепознаени клучеви" : "Непрепознаен клуч"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Грешен клуч во ${issue2.origin}`; - case "invalid_union": - return "Грешен внес"; - case "invalid_element": - return `Грешна вредност во ${issue2.origin}`; - default: - return `Грешен внес`; - } - }; -}; -function mk_default() { - return { - localeError: error27() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ms.js -var error28 = () => { - const Sizable = { - string: { unit: "aksara", verb: "mempunyai" }, - file: { unit: "bait", verb: "mempunyai" }, - array: { unit: "elemen", verb: "mempunyai" }, - set: { unit: "elemen", verb: "mempunyai" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "alamat e-mel", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "tarikh masa ISO", - date: "tarikh ISO", - time: "masa ISO", - duration: "tempoh ISO", - ipv4: "alamat IPv4", - ipv6: "alamat IPv6", - cidrv4: "julat IPv4", - cidrv6: "julat IPv6", - base64: "string dikodkan base64", - base64url: "string dikodkan base64url", - json_string: "string JSON", - e164: "nombor E.164", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN", - number: "nombor" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Input tidak sah: dijangka instanceof ${issue2.expected}, diterima ${received}`; - } - return `Input tidak sah: dijangka ${expected}, diterima ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Input tidak sah: dijangka ${stringifyPrimitive(issue2.values[0])}`; - return `Pilihan tidak sah: dijangka salah satu daripada ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Terlalu besar: dijangka ${issue2.origin ?? "nilai"} ${sizing.verb} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elemen"}`; - return `Terlalu besar: dijangka ${issue2.origin ?? "nilai"} adalah ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Terlalu kecil: dijangka ${issue2.origin} ${sizing.verb} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Terlalu kecil: dijangka ${issue2.origin} adalah ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `String tidak sah: mesti bermula dengan "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `String tidak sah: mesti berakhir dengan "${_issue.suffix}"`; - if (_issue.format === "includes") - return `String tidak sah: mesti mengandungi "${_issue.includes}"`; - if (_issue.format === "regex") - return `String tidak sah: mesti sepadan dengan corak ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} tidak sah`; - } - case "not_multiple_of": - return `Nombor tidak sah: perlu gandaan ${issue2.divisor}`; - case "unrecognized_keys": - return `Kunci tidak dikenali: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Kunci tidak sah dalam ${issue2.origin}`; - case "invalid_union": - return "Input tidak sah"; - case "invalid_element": - return `Nilai tidak sah dalam ${issue2.origin}`; - default: - return `Input tidak sah`; - } - }; -}; -function ms_default() { - return { - localeError: error28() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/nl.js -var error29 = () => { - const Sizable = { - string: { unit: "tekens", verb: "heeft" }, - file: { unit: "bytes", verb: "heeft" }, - array: { unit: "elementen", verb: "heeft" }, - set: { unit: "elementen", verb: "heeft" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "invoer", - email: "emailadres", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datum en tijd", - date: "ISO datum", - time: "ISO tijd", - duration: "ISO duur", - ipv4: "IPv4-adres", - ipv6: "IPv6-adres", - cidrv4: "IPv4-bereik", - cidrv6: "IPv6-bereik", - base64: "base64-gecodeerde tekst", - base64url: "base64 URL-gecodeerde tekst", - json_string: "JSON string", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "invoer" - }; - const TypeDictionary = { - nan: "NaN", - number: "getal" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Ongeldige invoer: verwacht instanceof ${issue2.expected}, ontving ${received}`; - } - return `Ongeldige invoer: verwacht ${expected}, ontving ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Ongeldige invoer: verwacht ${stringifyPrimitive(issue2.values[0])}`; - return `Ongeldige optie: verwacht één van ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - const longName = issue2.origin === "date" ? "laat" : issue2.origin === "string" ? "lang" : "groot"; - if (sizing) - return `Te ${longName}: verwacht dat ${issue2.origin ?? "waarde"} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementen"} ${sizing.verb}`; - return `Te ${longName}: verwacht dat ${issue2.origin ?? "waarde"} ${adj}${issue2.maximum.toString()} is`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - const shortName = issue2.origin === "date" ? "vroeg" : issue2.origin === "string" ? "kort" : "klein"; - if (sizing) { - return `Te ${shortName}: verwacht dat ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit} ${sizing.verb}`; - } - return `Te ${shortName}: verwacht dat ${issue2.origin} ${adj}${issue2.minimum.toString()} is`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Ongeldige tekst: moet met "${_issue.prefix}" beginnen`; - } - if (_issue.format === "ends_with") - return `Ongeldige tekst: moet op "${_issue.suffix}" eindigen`; - if (_issue.format === "includes") - return `Ongeldige tekst: moet "${_issue.includes}" bevatten`; - if (_issue.format === "regex") - return `Ongeldige tekst: moet overeenkomen met patroon ${_issue.pattern}`; - return `Ongeldig: ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Ongeldig getal: moet een veelvoud van ${issue2.divisor} zijn`; - case "unrecognized_keys": - return `Onbekende key${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Ongeldige key in ${issue2.origin}`; - case "invalid_union": - return "Ongeldige invoer"; - case "invalid_element": - return `Ongeldige waarde in ${issue2.origin}`; - default: - return `Ongeldige invoer`; - } - }; -}; -function nl_default() { - return { - localeError: error29() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/no.js -var error30 = () => { - const Sizable = { - string: { unit: "tegn", verb: "å ha" }, - file: { unit: "bytes", verb: "å ha" }, - array: { unit: "elementer", verb: "å inneholde" }, - set: { unit: "elementer", verb: "å inneholde" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "input", - email: "e-postadresse", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO dato- og klokkeslett", - date: "ISO-dato", - time: "ISO-klokkeslett", - duration: "ISO-varighet", - ipv4: "IPv4-område", - ipv6: "IPv6-område", - cidrv4: "IPv4-spekter", - cidrv6: "IPv6-spekter", - base64: "base64-enkodet streng", - base64url: "base64url-enkodet streng", - json_string: "JSON-streng", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN", - number: "tall", - array: "liste" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Ugyldig input: forventet instanceof ${issue2.expected}, fikk ${received}`; - } - return `Ugyldig input: forventet ${expected}, fikk ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Ugyldig verdi: forventet ${stringifyPrimitive(issue2.values[0])}`; - return `Ugyldig valg: forventet en av ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `For stor(t): forventet ${issue2.origin ?? "value"} til å ha ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementer"}`; - return `For stor(t): forventet ${issue2.origin ?? "value"} til å ha ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `For lite(n): forventet ${issue2.origin} til å ha ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `For lite(n): forventet ${issue2.origin} til å ha ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Ugyldig streng: må starte med "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Ugyldig streng: må ende med "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Ugyldig streng: må inneholde "${_issue.includes}"`; - if (_issue.format === "regex") - return `Ugyldig streng: må matche mønsteret ${_issue.pattern}`; - return `Ugyldig ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Ugyldig tall: må være et multiplum av ${issue2.divisor}`; - case "unrecognized_keys": - return `${issue2.keys.length > 1 ? "Ukjente nøkler" : "Ukjent nøkkel"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Ugyldig nøkkel i ${issue2.origin}`; - case "invalid_union": - return "Ugyldig input"; - case "invalid_element": - return `Ugyldig verdi i ${issue2.origin}`; - default: - return `Ugyldig input`; - } - }; -}; -function no_default() { - return { - localeError: error30() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ota.js -var error31 = () => { - const Sizable = { - string: { unit: "harf", verb: "olmalıdır" }, - file: { unit: "bayt", verb: "olmalıdır" }, - array: { unit: "unsur", verb: "olmalıdır" }, - set: { unit: "unsur", verb: "olmalıdır" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "giren", - email: "epostagâh", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO hengâmı", - date: "ISO tarihi", - time: "ISO zamanı", - duration: "ISO müddeti", - ipv4: "IPv4 nişânı", - ipv6: "IPv6 nişânı", - cidrv4: "IPv4 menzili", - cidrv6: "IPv6 menzili", - base64: "base64-şifreli metin", - base64url: "base64url-şifreli metin", - json_string: "JSON metin", - e164: "E.164 sayısı", - jwt: "JWT", - template_literal: "giren" - }; - const TypeDictionary = { - nan: "NaN", - number: "numara", - array: "saf", - null: "gayb" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Fâsit giren: umulan instanceof ${issue2.expected}, alınan ${received}`; - } - return `Fâsit giren: umulan ${expected}, alınan ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Fâsit giren: umulan ${stringifyPrimitive(issue2.values[0])}`; - return `Fâsit tercih: mûteberler ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Fazla büyük: ${issue2.origin ?? "value"}, ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elements"} sahip olmalıydı.`; - return `Fazla büyük: ${issue2.origin ?? "value"}, ${adj}${issue2.maximum.toString()} olmalıydı.`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Fazla küçük: ${issue2.origin}, ${adj}${issue2.minimum.toString()} ${sizing.unit} sahip olmalıydı.`; - } - return `Fazla küçük: ${issue2.origin}, ${adj}${issue2.minimum.toString()} olmalıydı.`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Fâsit metin: "${_issue.prefix}" ile başlamalı.`; - if (_issue.format === "ends_with") - return `Fâsit metin: "${_issue.suffix}" ile bitmeli.`; - if (_issue.format === "includes") - return `Fâsit metin: "${_issue.includes}" ihtivâ etmeli.`; - if (_issue.format === "regex") - return `Fâsit metin: ${_issue.pattern} nakşına uymalı.`; - return `Fâsit ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Fâsit sayı: ${issue2.divisor} katı olmalıydı.`; - case "unrecognized_keys": - return `Tanınmayan anahtar ${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `${issue2.origin} için tanınmayan anahtar var.`; - case "invalid_union": - return "Giren tanınamadı."; - case "invalid_element": - return `${issue2.origin} için tanınmayan kıymet var.`; - default: - return `Kıymet tanınamadı.`; - } - }; -}; -function ota_default() { - return { - localeError: error31() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ps.js -var error32 = () => { - const Sizable = { - string: { unit: "توکي", verb: "ولري" }, - file: { unit: "بایټس", verb: "ولري" }, - array: { unit: "توکي", verb: "ولري" }, - set: { unit: "توکي", verb: "ولري" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ورودي", - email: "بریښنالیک", - url: "یو آر ال", - emoji: "ایموجي", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "نیټه او وخت", - date: "نېټه", - time: "وخت", - duration: "موده", - ipv4: "د IPv4 پته", - ipv6: "د IPv6 پته", - cidrv4: "د IPv4 ساحه", - cidrv6: "د IPv6 ساحه", - base64: "base64-encoded متن", - base64url: "base64url-encoded متن", - json_string: "JSON متن", - e164: "د E.164 شمېره", - jwt: "JWT", - template_literal: "ورودي" - }; - const TypeDictionary = { - nan: "NaN", - number: "عدد", - array: "ارې" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `ناسم ورودي: باید instanceof ${issue2.expected} وای, مګر ${received} ترلاسه شو`; - } - return `ناسم ورودي: باید ${expected} وای, مګر ${received} ترلاسه شو`; - } - case "invalid_value": - if (issue2.values.length === 1) { - return `ناسم ورودي: باید ${stringifyPrimitive(issue2.values[0])} وای`; - } - return `ناسم انتخاب: باید یو له ${joinValues(issue2.values, "|")} څخه وای`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `ډیر لوی: ${issue2.origin ?? "ارزښت"} باید ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "عنصرونه"} ولري`; - } - return `ډیر لوی: ${issue2.origin ?? "ارزښت"} باید ${adj}${issue2.maximum.toString()} وي`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `ډیر کوچنی: ${issue2.origin} باید ${adj}${issue2.minimum.toString()} ${sizing.unit} ولري`; - } - return `ډیر کوچنی: ${issue2.origin} باید ${adj}${issue2.minimum.toString()} وي`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `ناسم متن: باید د "${_issue.prefix}" سره پیل شي`; - } - if (_issue.format === "ends_with") { - return `ناسم متن: باید د "${_issue.suffix}" سره پای ته ورسيږي`; - } - if (_issue.format === "includes") { - return `ناسم متن: باید "${_issue.includes}" ولري`; - } - if (_issue.format === "regex") { - return `ناسم متن: باید د ${_issue.pattern} سره مطابقت ولري`; - } - return `${FormatDictionary[_issue.format] ?? issue2.format} ناسم دی`; - } - case "not_multiple_of": - return `ناسم عدد: باید د ${issue2.divisor} مضرب وي`; - case "unrecognized_keys": - return `ناسم ${issue2.keys.length > 1 ? "کلیډونه" : "کلیډ"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `ناسم کلیډ په ${issue2.origin} کې`; - case "invalid_union": - return `ناسمه ورودي`; - case "invalid_element": - return `ناسم عنصر په ${issue2.origin} کې`; - default: - return `ناسمه ورودي`; - } - }; -}; -function ps_default() { - return { - localeError: error32() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/pl.js -var error33 = () => { - const Sizable = { - string: { unit: "znaków", verb: "mieć" }, - file: { unit: "bajtów", verb: "mieć" }, - array: { unit: "elementów", verb: "mieć" }, - set: { unit: "elementów", verb: "mieć" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "wyrażenie", - email: "adres email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data i godzina w formacie ISO", - date: "data w formacie ISO", - time: "godzina w formacie ISO", - duration: "czas trwania ISO", - ipv4: "adres IPv4", - ipv6: "adres IPv6", - cidrv4: "zakres IPv4", - cidrv6: "zakres IPv6", - base64: "ciąg znaków zakodowany w formacie base64", - base64url: "ciąg znaków zakodowany w formacie base64url", - json_string: "ciąg znaków w formacie JSON", - e164: "liczba E.164", - jwt: "JWT", - template_literal: "wejście" - }; - const TypeDictionary = { - nan: "NaN", - number: "liczba", - array: "tablica" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Nieprawidłowe dane wejściowe: oczekiwano instanceof ${issue2.expected}, otrzymano ${received}`; - } - return `Nieprawidłowe dane wejściowe: oczekiwano ${expected}, otrzymano ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Nieprawidłowe dane wejściowe: oczekiwano ${stringifyPrimitive(issue2.values[0])}`; - return `Nieprawidłowa opcja: oczekiwano jednej z wartości ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Za duża wartość: oczekiwano, że ${issue2.origin ?? "wartość"} będzie mieć ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementów"}`; - } - return `Zbyt duż(y/a/e): oczekiwano, że ${issue2.origin ?? "wartość"} będzie wynosić ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Za mała wartość: oczekiwano, że ${issue2.origin ?? "wartość"} będzie mieć ${adj}${issue2.minimum.toString()} ${sizing.unit ?? "elementów"}`; - } - return `Zbyt mał(y/a/e): oczekiwano, że ${issue2.origin ?? "wartość"} będzie wynosić ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Nieprawidłowy ciąg znaków: musi zaczynać się od "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Nieprawidłowy ciąg znaków: musi kończyć się na "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Nieprawidłowy ciąg znaków: musi zawierać "${_issue.includes}"`; - if (_issue.format === "regex") - return `Nieprawidłowy ciąg znaków: musi odpowiadać wzorcowi ${_issue.pattern}`; - return `Nieprawidłow(y/a/e) ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Nieprawidłowa liczba: musi być wielokrotnością ${issue2.divisor}`; - case "unrecognized_keys": - return `Nierozpoznane klucze${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Nieprawidłowy klucz w ${issue2.origin}`; - case "invalid_union": - return "Nieprawidłowe dane wejściowe"; - case "invalid_element": - return `Nieprawidłowa wartość w ${issue2.origin}`; - default: - return `Nieprawidłowe dane wejściowe`; - } - }; -}; -function pl_default() { - return { - localeError: error33() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/pt.js -var error34 = () => { - const Sizable = { - string: { unit: "caracteres", verb: "ter" }, - file: { unit: "bytes", verb: "ter" }, - array: { unit: "itens", verb: "ter" }, - set: { unit: "itens", verb: "ter" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "padrão", - email: "endereço de e-mail", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data e hora ISO", - date: "data ISO", - time: "hora ISO", - duration: "duração ISO", - ipv4: "endereço IPv4", - ipv6: "endereço IPv6", - cidrv4: "faixa de IPv4", - cidrv6: "faixa de IPv6", - base64: "texto codificado em base64", - base64url: "URL codificada em base64", - json_string: "texto JSON", - e164: "número E.164", - jwt: "JWT", - template_literal: "entrada" - }; - const TypeDictionary = { - nan: "NaN", - number: "número", - null: "nulo" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Tipo inválido: esperado instanceof ${issue2.expected}, recebido ${received}`; - } - return `Tipo inválido: esperado ${expected}, recebido ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Entrada inválida: esperado ${stringifyPrimitive(issue2.values[0])}`; - return `Opção inválida: esperada uma das ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Muito grande: esperado que ${issue2.origin ?? "valor"} tivesse ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementos"}`; - return `Muito grande: esperado que ${issue2.origin ?? "valor"} fosse ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Muito pequeno: esperado que ${issue2.origin} tivesse ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Muito pequeno: esperado que ${issue2.origin} fosse ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Texto inválido: deve começar com "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Texto inválido: deve terminar com "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Texto inválido: deve incluir "${_issue.includes}"`; - if (_issue.format === "regex") - return `Texto inválido: deve corresponder ao padrão ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} inválido`; - } - case "not_multiple_of": - return `Número inválido: deve ser múltiplo de ${issue2.divisor}`; - case "unrecognized_keys": - return `Chave${issue2.keys.length > 1 ? "s" : ""} desconhecida${issue2.keys.length > 1 ? "s" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Chave inválida em ${issue2.origin}`; - case "invalid_union": - return "Entrada inválida"; - case "invalid_element": - return `Valor inválido em ${issue2.origin}`; - default: - return `Campo inválido`; - } - }; -}; -function pt_default() { - return { - localeError: error34() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ru.js -function getRussianPlural(count, one, few, many) { - const absCount = Math.abs(count); - const lastDigit = absCount % 10; - const lastTwoDigits = absCount % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 19) { - return many; - } - if (lastDigit === 1) { - return one; - } - if (lastDigit >= 2 && lastDigit <= 4) { - return few; - } - return many; -} -var error35 = () => { - const Sizable = { - string: { - unit: { - one: "символ", - few: "символа", - many: "символов" - }, - verb: "иметь" - }, - file: { - unit: { - one: "байт", - few: "байта", - many: "байт" - }, - verb: "иметь" - }, - array: { - unit: { - one: "элемент", - few: "элемента", - many: "элементов" - }, - verb: "иметь" - }, - set: { - unit: { - one: "элемент", - few: "элемента", - many: "элементов" - }, - verb: "иметь" - } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ввод", - email: "email адрес", - url: "URL", - emoji: "эмодзи", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO дата и время", - date: "ISO дата", - time: "ISO время", - duration: "ISO длительность", - ipv4: "IPv4 адрес", - ipv6: "IPv6 адрес", - cidrv4: "IPv4 диапазон", - cidrv6: "IPv6 диапазон", - base64: "строка в формате base64", - base64url: "строка в формате base64url", - json_string: "JSON строка", - e164: "номер E.164", - jwt: "JWT", - template_literal: "ввод" - }; - const TypeDictionary = { - nan: "NaN", - number: "число", - array: "массив" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Неверный ввод: ожидалось instanceof ${issue2.expected}, получено ${received}`; - } - return `Неверный ввод: ожидалось ${expected}, получено ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Неверный ввод: ожидалось ${stringifyPrimitive(issue2.values[0])}`; - return `Неверный вариант: ожидалось одно из ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - const maxValue = Number(issue2.maximum); - const unit = getRussianPlural(maxValue, sizing.unit.one, sizing.unit.few, sizing.unit.many); - return `Слишком большое значение: ожидалось, что ${issue2.origin ?? "значение"} будет иметь ${adj}${issue2.maximum.toString()} ${unit}`; - } - return `Слишком большое значение: ожидалось, что ${issue2.origin ?? "значение"} будет ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - const minValue = Number(issue2.minimum); - const unit = getRussianPlural(minValue, sizing.unit.one, sizing.unit.few, sizing.unit.many); - return `Слишком маленькое значение: ожидалось, что ${issue2.origin} будет иметь ${adj}${issue2.minimum.toString()} ${unit}`; - } - return `Слишком маленькое значение: ожидалось, что ${issue2.origin} будет ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Неверная строка: должна начинаться с "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Неверная строка: должна заканчиваться на "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Неверная строка: должна содержать "${_issue.includes}"`; - if (_issue.format === "regex") - return `Неверная строка: должна соответствовать шаблону ${_issue.pattern}`; - return `Неверный ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Неверное число: должно быть кратным ${issue2.divisor}`; - case "unrecognized_keys": - return `Нераспознанн${issue2.keys.length > 1 ? "ые" : "ый"} ключ${issue2.keys.length > 1 ? "и" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Неверный ключ в ${issue2.origin}`; - case "invalid_union": - return "Неверные входные данные"; - case "invalid_element": - return `Неверное значение в ${issue2.origin}`; - default: - return `Неверные входные данные`; - } - }; -}; -function ru_default() { - return { - localeError: error35() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/sl.js -var error36 = () => { - const Sizable = { - string: { unit: "znakov", verb: "imeti" }, - file: { unit: "bajtov", verb: "imeti" }, - array: { unit: "elementov", verb: "imeti" }, - set: { unit: "elementov", verb: "imeti" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "vnos", - email: "e-poštni naslov", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datum in čas", - date: "ISO datum", - time: "ISO čas", - duration: "ISO trajanje", - ipv4: "IPv4 naslov", - ipv6: "IPv6 naslov", - cidrv4: "obseg IPv4", - cidrv6: "obseg IPv6", - base64: "base64 kodiran niz", - base64url: "base64url kodiran niz", - json_string: "JSON niz", - e164: "E.164 številka", - jwt: "JWT", - template_literal: "vnos" - }; - const TypeDictionary = { - nan: "NaN", - number: "število", - array: "tabela" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Neveljaven vnos: pričakovano instanceof ${issue2.expected}, prejeto ${received}`; - } - return `Neveljaven vnos: pričakovano ${expected}, prejeto ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Neveljaven vnos: pričakovano ${stringifyPrimitive(issue2.values[0])}`; - return `Neveljavna možnost: pričakovano eno izmed ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Preveliko: pričakovano, da bo ${issue2.origin ?? "vrednost"} imelo ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "elementov"}`; - return `Preveliko: pričakovano, da bo ${issue2.origin ?? "vrednost"} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Premajhno: pričakovano, da bo ${issue2.origin} imelo ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Premajhno: pričakovano, da bo ${issue2.origin} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Neveljaven niz: mora se začeti z "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Neveljaven niz: mora se končati z "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Neveljaven niz: mora vsebovati "${_issue.includes}"`; - if (_issue.format === "regex") - return `Neveljaven niz: mora ustrezati vzorcu ${_issue.pattern}`; - return `Neveljaven ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Neveljavno število: mora biti večkratnik ${issue2.divisor}`; - case "unrecognized_keys": - return `Neprepoznan${issue2.keys.length > 1 ? "i ključi" : " ključ"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Neveljaven ključ v ${issue2.origin}`; - case "invalid_union": - return "Neveljaven vnos"; - case "invalid_element": - return `Neveljavna vrednost v ${issue2.origin}`; - default: - return "Neveljaven vnos"; - } - }; -}; -function sl_default() { - return { - localeError: error36() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/sv.js -var error37 = () => { - const Sizable = { - string: { unit: "tecken", verb: "att ha" }, - file: { unit: "bytes", verb: "att ha" }, - array: { unit: "objekt", verb: "att innehålla" }, - set: { unit: "objekt", verb: "att innehålla" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "reguljärt uttryck", - email: "e-postadress", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-datum och tid", - date: "ISO-datum", - time: "ISO-tid", - duration: "ISO-varaktighet", - ipv4: "IPv4-intervall", - ipv6: "IPv6-intervall", - cidrv4: "IPv4-spektrum", - cidrv6: "IPv6-spektrum", - base64: "base64-kodad sträng", - base64url: "base64url-kodad sträng", - json_string: "JSON-sträng", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "mall-literal" - }; - const TypeDictionary = { - nan: "NaN", - number: "antal", - array: "lista" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Ogiltig inmatning: förväntat instanceof ${issue2.expected}, fick ${received}`; - } - return `Ogiltig inmatning: förväntat ${expected}, fick ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Ogiltig inmatning: förväntat ${stringifyPrimitive(issue2.values[0])}`; - return `Ogiltigt val: förväntade en av ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `För stor(t): förväntade ${issue2.origin ?? "värdet"} att ha ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "element"}`; - } - return `För stor(t): förväntat ${issue2.origin ?? "värdet"} att ha ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `För lite(t): förväntade ${issue2.origin ?? "värdet"} att ha ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `För lite(t): förväntade ${issue2.origin ?? "värdet"} att ha ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `Ogiltig sträng: måste börja med "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `Ogiltig sträng: måste sluta med "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Ogiltig sträng: måste innehålla "${_issue.includes}"`; - if (_issue.format === "regex") - return `Ogiltig sträng: måste matcha mönstret "${_issue.pattern}"`; - return `Ogiltig(t) ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Ogiltigt tal: måste vara en multipel av ${issue2.divisor}`; - case "unrecognized_keys": - return `${issue2.keys.length > 1 ? "Okända nycklar" : "Okänd nyckel"}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Ogiltig nyckel i ${issue2.origin ?? "värdet"}`; - case "invalid_union": - return "Ogiltig input"; - case "invalid_element": - return `Ogiltigt värde i ${issue2.origin ?? "värdet"}`; - default: - return `Ogiltig input`; - } - }; -}; -function sv_default() { - return { - localeError: error37() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ta.js -var error38 = () => { - const Sizable = { - string: { unit: "எழுத்துக்கள்", verb: "கொண்டிருக்க வேண்டும்" }, - file: { unit: "பைட்டுகள்", verb: "கொண்டிருக்க வேண்டும்" }, - array: { unit: "உறுப்புகள்", verb: "கொண்டிருக்க வேண்டும்" }, - set: { unit: "உறுப்புகள்", verb: "கொண்டிருக்க வேண்டும்" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "உள்ளீடு", - email: "மின்னஞ்சல் முகவரி", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO தேதி நேரம்", - date: "ISO தேதி", - time: "ISO நேரம்", - duration: "ISO கால அளவு", - ipv4: "IPv4 முகவரி", - ipv6: "IPv6 முகவரி", - cidrv4: "IPv4 வரம்பு", - cidrv6: "IPv6 வரம்பு", - base64: "base64-encoded சரம்", - base64url: "base64url-encoded சரம்", - json_string: "JSON சரம்", - e164: "E.164 எண்", - jwt: "JWT", - template_literal: "input" - }; - const TypeDictionary = { - nan: "NaN", - number: "எண்", - array: "அணி", - null: "வெறுமை" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `தவறான உள்ளீடு: எதிர்பார்க்கப்பட்டது instanceof ${issue2.expected}, பெறப்பட்டது ${received}`; - } - return `தவறான உள்ளீடு: எதிர்பார்க்கப்பட்டது ${expected}, பெறப்பட்டது ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `தவறான உள்ளீடு: எதிர்பார்க்கப்பட்டது ${stringifyPrimitive(issue2.values[0])}`; - return `தவறான விருப்பம்: எதிர்பார்க்கப்பட்டது ${joinValues(issue2.values, "|")} இல் ஒன்று`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `மிக பெரியது: எதிர்பார்க்கப்பட்டது ${issue2.origin ?? "மதிப்பு"} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "உறுப்புகள்"} ஆக இருக்க வேண்டும்`; - } - return `மிக பெரியது: எதிர்பார்க்கப்பட்டது ${issue2.origin ?? "மதிப்பு"} ${adj}${issue2.maximum.toString()} ஆக இருக்க வேண்டும்`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `மிகச் சிறியது: எதிர்பார்க்கப்பட்டது ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit} ஆக இருக்க வேண்டும்`; - } - return `மிகச் சிறியது: எதிர்பார்க்கப்பட்டது ${issue2.origin} ${adj}${issue2.minimum.toString()} ஆக இருக்க வேண்டும்`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `தவறான சரம்: "${_issue.prefix}" இல் தொடங்க வேண்டும்`; - if (_issue.format === "ends_with") - return `தவறான சரம்: "${_issue.suffix}" இல் முடிவடைய வேண்டும்`; - if (_issue.format === "includes") - return `தவறான சரம்: "${_issue.includes}" ஐ உள்ளடக்க வேண்டும்`; - if (_issue.format === "regex") - return `தவறான சரம்: ${_issue.pattern} முறைபாட்டுடன் பொருந்த வேண்டும்`; - return `தவறான ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `தவறான எண்: ${issue2.divisor} இன் பலமாக இருக்க வேண்டும்`; - case "unrecognized_keys": - return `அடையாளம் தெரியாத விசை${issue2.keys.length > 1 ? "கள்" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `${issue2.origin} இல் தவறான விசை`; - case "invalid_union": - return "தவறான உள்ளீடு"; - case "invalid_element": - return `${issue2.origin} இல் தவறான மதிப்பு`; - default: - return `தவறான உள்ளீடு`; - } - }; -}; -function ta_default() { - return { - localeError: error38() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/th.js -var error39 = () => { - const Sizable = { - string: { unit: "ตัวอักษร", verb: "ควรมี" }, - file: { unit: "ไบต์", verb: "ควรมี" }, - array: { unit: "รายการ", verb: "ควรมี" }, - set: { unit: "รายการ", verb: "ควรมี" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ข้อมูลที่ป้อน", - email: "ที่อยู่อีเมล", - url: "URL", - emoji: "อิโมจิ", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "วันที่เวลาแบบ ISO", - date: "วันที่แบบ ISO", - time: "เวลาแบบ ISO", - duration: "ช่วงเวลาแบบ ISO", - ipv4: "ที่อยู่ IPv4", - ipv6: "ที่อยู่ IPv6", - cidrv4: "ช่วง IP แบบ IPv4", - cidrv6: "ช่วง IP แบบ IPv6", - base64: "ข้อความแบบ Base64", - base64url: "ข้อความแบบ Base64 สำหรับ URL", - json_string: "ข้อความแบบ JSON", - e164: "เบอร์โทรศัพท์ระหว่างประเทศ (E.164)", - jwt: "โทเคน JWT", - template_literal: "ข้อมูลที่ป้อน" - }; - const TypeDictionary = { - nan: "NaN", - number: "ตัวเลข", - array: "อาร์เรย์ (Array)", - null: "ไม่มีค่า (null)" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `ประเภทข้อมูลไม่ถูกต้อง: ควรเป็น instanceof ${issue2.expected} แต่ได้รับ ${received}`; - } - return `ประเภทข้อมูลไม่ถูกต้อง: ควรเป็น ${expected} แต่ได้รับ ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `ค่าไม่ถูกต้อง: ควรเป็น ${stringifyPrimitive(issue2.values[0])}`; - return `ตัวเลือกไม่ถูกต้อง: ควรเป็นหนึ่งใน ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "ไม่เกิน" : "น้อยกว่า"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `เกินกำหนด: ${issue2.origin ?? "ค่า"} ควรมี${adj} ${issue2.maximum.toString()} ${sizing.unit ?? "รายการ"}`; - return `เกินกำหนด: ${issue2.origin ?? "ค่า"} ควรมี${adj} ${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? "อย่างน้อย" : "มากกว่า"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `น้อยกว่ากำหนด: ${issue2.origin} ควรมี${adj} ${issue2.minimum.toString()} ${sizing.unit}`; - } - return `น้อยกว่ากำหนด: ${issue2.origin} ควรมี${adj} ${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `รูปแบบไม่ถูกต้อง: ข้อความต้องขึ้นต้นด้วย "${_issue.prefix}"`; - } - if (_issue.format === "ends_with") - return `รูปแบบไม่ถูกต้อง: ข้อความต้องลงท้ายด้วย "${_issue.suffix}"`; - if (_issue.format === "includes") - return `รูปแบบไม่ถูกต้อง: ข้อความต้องมี "${_issue.includes}" อยู่ในข้อความ`; - if (_issue.format === "regex") - return `รูปแบบไม่ถูกต้อง: ต้องตรงกับรูปแบบที่กำหนด ${_issue.pattern}`; - return `รูปแบบไม่ถูกต้อง: ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `ตัวเลขไม่ถูกต้อง: ต้องเป็นจำนวนที่หารด้วย ${issue2.divisor} ได้ลงตัว`; - case "unrecognized_keys": - return `พบคีย์ที่ไม่รู้จัก: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `คีย์ไม่ถูกต้องใน ${issue2.origin}`; - case "invalid_union": - return "ข้อมูลไม่ถูกต้อง: ไม่ตรงกับรูปแบบยูเนียนที่กำหนดไว้"; - case "invalid_element": - return `ข้อมูลไม่ถูกต้องใน ${issue2.origin}`; - default: - return `ข้อมูลไม่ถูกต้อง`; - } - }; -}; -function th_default() { - return { - localeError: error39() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/tr.js -var error40 = () => { - const Sizable = { - string: { unit: "karakter", verb: "olmalı" }, - file: { unit: "bayt", verb: "olmalı" }, - array: { unit: "öğe", verb: "olmalı" }, - set: { unit: "öğe", verb: "olmalı" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "girdi", - email: "e-posta adresi", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO tarih ve saat", - date: "ISO tarih", - time: "ISO saat", - duration: "ISO süre", - ipv4: "IPv4 adresi", - ipv6: "IPv6 adresi", - cidrv4: "IPv4 aralığı", - cidrv6: "IPv6 aralığı", - base64: "base64 ile şifrelenmiş metin", - base64url: "base64url ile şifrelenmiş metin", - json_string: "JSON dizesi", - e164: "E.164 sayısı", - jwt: "JWT", - template_literal: "Şablon dizesi" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Geçersiz değer: beklenen instanceof ${issue2.expected}, alınan ${received}`; - } - return `Geçersiz değer: beklenen ${expected}, alınan ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Geçersiz değer: beklenen ${stringifyPrimitive(issue2.values[0])}`; - return `Geçersiz seçenek: aşağıdakilerden biri olmalı: ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Çok büyük: beklenen ${issue2.origin ?? "değer"} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "öğe"}`; - return `Çok büyük: beklenen ${issue2.origin ?? "değer"} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Çok küçük: beklenen ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - return `Çok küçük: beklenen ${issue2.origin} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Geçersiz metin: "${_issue.prefix}" ile başlamalı`; - if (_issue.format === "ends_with") - return `Geçersiz metin: "${_issue.suffix}" ile bitmeli`; - if (_issue.format === "includes") - return `Geçersiz metin: "${_issue.includes}" içermeli`; - if (_issue.format === "regex") - return `Geçersiz metin: ${_issue.pattern} desenine uymalı`; - return `Geçersiz ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Geçersiz sayı: ${issue2.divisor} ile tam bölünebilmeli`; - case "unrecognized_keys": - return `Tanınmayan anahtar${issue2.keys.length > 1 ? "lar" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `${issue2.origin} içinde geçersiz anahtar`; - case "invalid_union": - return "Geçersiz değer"; - case "invalid_element": - return `${issue2.origin} içinde geçersiz değer`; - default: - return `Geçersiz değer`; - } - }; -}; -function tr_default() { - return { - localeError: error40() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/uk.js -var error41 = () => { - const Sizable = { - string: { unit: "символів", verb: "матиме" }, - file: { unit: "байтів", verb: "матиме" }, - array: { unit: "елементів", verb: "матиме" }, - set: { unit: "елементів", verb: "матиме" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "вхідні дані", - email: "адреса електронної пошти", - url: "URL", - emoji: "емодзі", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "дата та час ISO", - date: "дата ISO", - time: "час ISO", - duration: "тривалість ISO", - ipv4: "адреса IPv4", - ipv6: "адреса IPv6", - cidrv4: "діапазон IPv4", - cidrv6: "діапазон IPv6", - base64: "рядок у кодуванні base64", - base64url: "рядок у кодуванні base64url", - json_string: "рядок JSON", - e164: "номер E.164", - jwt: "JWT", - template_literal: "вхідні дані" - }; - const TypeDictionary = { - nan: "NaN", - number: "число", - array: "масив" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Неправильні вхідні дані: очікується instanceof ${issue2.expected}, отримано ${received}`; - } - return `Неправильні вхідні дані: очікується ${expected}, отримано ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Неправильні вхідні дані: очікується ${stringifyPrimitive(issue2.values[0])}`; - return `Неправильна опція: очікується одне з ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Занадто велике: очікується, що ${issue2.origin ?? "значення"} ${sizing.verb} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "елементів"}`; - return `Занадто велике: очікується, що ${issue2.origin ?? "значення"} буде ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Занадто мале: очікується, що ${issue2.origin} ${sizing.verb} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Занадто мале: очікується, що ${issue2.origin} буде ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Неправильний рядок: повинен починатися з "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Неправильний рядок: повинен закінчуватися на "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Неправильний рядок: повинен містити "${_issue.includes}"`; - if (_issue.format === "regex") - return `Неправильний рядок: повинен відповідати шаблону ${_issue.pattern}`; - return `Неправильний ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Неправильне число: повинно бути кратним ${issue2.divisor}`; - case "unrecognized_keys": - return `Нерозпізнаний ключ${issue2.keys.length > 1 ? "і" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Неправильний ключ у ${issue2.origin}`; - case "invalid_union": - return "Неправильні вхідні дані"; - case "invalid_element": - return `Неправильне значення у ${issue2.origin}`; - default: - return `Неправильні вхідні дані`; - } - }; -}; -function uk_default() { - return { - localeError: error41() - }; -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ua.js -function ua_default() { - return uk_default(); -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ur.js -var error42 = () => { - const Sizable = { - string: { unit: "حروف", verb: "ہونا" }, - file: { unit: "بائٹس", verb: "ہونا" }, - array: { unit: "آئٹمز", verb: "ہونا" }, - set: { unit: "آئٹمز", verb: "ہونا" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ان پٹ", - email: "ای میل ایڈریس", - url: "یو آر ایل", - emoji: "ایموجی", - uuid: "یو یو آئی ڈی", - uuidv4: "یو یو آئی ڈی وی 4", - uuidv6: "یو یو آئی ڈی وی 6", - nanoid: "نینو آئی ڈی", - guid: "جی یو آئی ڈی", - cuid: "سی یو آئی ڈی", - cuid2: "سی یو آئی ڈی 2", - ulid: "یو ایل آئی ڈی", - xid: "ایکس آئی ڈی", - ksuid: "کے ایس یو آئی ڈی", - datetime: "آئی ایس او ڈیٹ ٹائم", - date: "آئی ایس او تاریخ", - time: "آئی ایس او وقت", - duration: "آئی ایس او مدت", - ipv4: "آئی پی وی 4 ایڈریس", - ipv6: "آئی پی وی 6 ایڈریس", - cidrv4: "آئی پی وی 4 رینج", - cidrv6: "آئی پی وی 6 رینج", - base64: "بیس 64 ان کوڈڈ سٹرنگ", - base64url: "بیس 64 یو آر ایل ان کوڈڈ سٹرنگ", - json_string: "جے ایس او این سٹرنگ", - e164: "ای 164 نمبر", - jwt: "جے ڈبلیو ٹی", - template_literal: "ان پٹ" - }; - const TypeDictionary = { - nan: "NaN", - number: "نمبر", - array: "آرے", - null: "نل" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `غلط ان پٹ: instanceof ${issue2.expected} متوقع تھا، ${received} موصول ہوا`; - } - return `غلط ان پٹ: ${expected} متوقع تھا، ${received} موصول ہوا`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `غلط ان پٹ: ${stringifyPrimitive(issue2.values[0])} متوقع تھا`; - return `غلط آپشن: ${joinValues(issue2.values, "|")} میں سے ایک متوقع تھا`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `بہت بڑا: ${issue2.origin ?? "ویلیو"} کے ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "عناصر"} ہونے متوقع تھے`; - return `بہت بڑا: ${issue2.origin ?? "ویلیو"} کا ${adj}${issue2.maximum.toString()} ہونا متوقع تھا`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `بہت چھوٹا: ${issue2.origin} کے ${adj}${issue2.minimum.toString()} ${sizing.unit} ہونے متوقع تھے`; - } - return `بہت چھوٹا: ${issue2.origin} کا ${adj}${issue2.minimum.toString()} ہونا متوقع تھا`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `غلط سٹرنگ: "${_issue.prefix}" سے شروع ہونا چاہیے`; - } - if (_issue.format === "ends_with") - return `غلط سٹرنگ: "${_issue.suffix}" پر ختم ہونا چاہیے`; - if (_issue.format === "includes") - return `غلط سٹرنگ: "${_issue.includes}" شامل ہونا چاہیے`; - if (_issue.format === "regex") - return `غلط سٹرنگ: پیٹرن ${_issue.pattern} سے میچ ہونا چاہیے`; - return `غلط ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `غلط نمبر: ${issue2.divisor} کا مضاعف ہونا چاہیے`; - case "unrecognized_keys": - return `غیر تسلیم شدہ کی${issue2.keys.length > 1 ? "ز" : ""}: ${joinValues(issue2.keys, "، ")}`; - case "invalid_key": - return `${issue2.origin} میں غلط کی`; - case "invalid_union": - return "غلط ان پٹ"; - case "invalid_element": - return `${issue2.origin} میں غلط ویلیو`; - default: - return `غلط ان پٹ`; - } - }; -}; -function ur_default() { - return { - localeError: error42() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/uz.js -var error43 = () => { - const Sizable = { - string: { unit: "belgi", verb: "bo‘lishi kerak" }, - file: { unit: "bayt", verb: "bo‘lishi kerak" }, - array: { unit: "element", verb: "bo‘lishi kerak" }, - set: { unit: "element", verb: "bo‘lishi kerak" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "kirish", - email: "elektron pochta manzili", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO sana va vaqti", - date: "ISO sana", - time: "ISO vaqt", - duration: "ISO davomiylik", - ipv4: "IPv4 manzil", - ipv6: "IPv6 manzil", - mac: "MAC manzil", - cidrv4: "IPv4 diapazon", - cidrv6: "IPv6 diapazon", - base64: "base64 kodlangan satr", - base64url: "base64url kodlangan satr", - json_string: "JSON satr", - e164: "E.164 raqam", - jwt: "JWT", - template_literal: "kirish" - }; - const TypeDictionary = { - nan: "NaN", - number: "raqam", - array: "massiv" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Noto‘g‘ri kirish: kutilgan instanceof ${issue2.expected}, qabul qilingan ${received}`; - } - return `Noto‘g‘ri kirish: kutilgan ${expected}, qabul qilingan ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Noto‘g‘ri kirish: kutilgan ${stringifyPrimitive(issue2.values[0])}`; - return `Noto‘g‘ri variant: quyidagilardan biri kutilgan ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Juda katta: kutilgan ${issue2.origin ?? "qiymat"} ${adj}${issue2.maximum.toString()} ${sizing.unit} ${sizing.verb}`; - return `Juda katta: kutilgan ${issue2.origin ?? "qiymat"} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Juda kichik: kutilgan ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit} ${sizing.verb}`; - } - return `Juda kichik: kutilgan ${issue2.origin} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Noto‘g‘ri satr: "${_issue.prefix}" bilan boshlanishi kerak`; - if (_issue.format === "ends_with") - return `Noto‘g‘ri satr: "${_issue.suffix}" bilan tugashi kerak`; - if (_issue.format === "includes") - return `Noto‘g‘ri satr: "${_issue.includes}" ni o‘z ichiga olishi kerak`; - if (_issue.format === "regex") - return `Noto‘g‘ri satr: ${_issue.pattern} shabloniga mos kelishi kerak`; - return `Noto‘g‘ri ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Noto‘g‘ri raqam: ${issue2.divisor} ning karralisi bo‘lishi kerak`; - case "unrecognized_keys": - return `Noma’lum kalit${issue2.keys.length > 1 ? "lar" : ""}: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `${issue2.origin} dagi kalit noto‘g‘ri`; - case "invalid_union": - return "Noto‘g‘ri kirish"; - case "invalid_element": - return `${issue2.origin} da noto‘g‘ri qiymat`; - default: - return `Noto‘g‘ri kirish`; - } - }; -}; -function uz_default() { - return { - localeError: error43() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/vi.js -var error44 = () => { - const Sizable = { - string: { unit: "ký tự", verb: "có" }, - file: { unit: "byte", verb: "có" }, - array: { unit: "phần tử", verb: "có" }, - set: { unit: "phần tử", verb: "có" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "đầu vào", - email: "địa chỉ email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ngày giờ ISO", - date: "ngày ISO", - time: "giờ ISO", - duration: "khoảng thời gian ISO", - ipv4: "địa chỉ IPv4", - ipv6: "địa chỉ IPv6", - cidrv4: "dải IPv4", - cidrv6: "dải IPv6", - base64: "chuỗi mã hóa base64", - base64url: "chuỗi mã hóa base64url", - json_string: "chuỗi JSON", - e164: "số E.164", - jwt: "JWT", - template_literal: "đầu vào" - }; - const TypeDictionary = { - nan: "NaN", - number: "số", - array: "mảng" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Đầu vào không hợp lệ: mong đợi instanceof ${issue2.expected}, nhận được ${received}`; - } - return `Đầu vào không hợp lệ: mong đợi ${expected}, nhận được ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Đầu vào không hợp lệ: mong đợi ${stringifyPrimitive(issue2.values[0])}`; - return `Tùy chọn không hợp lệ: mong đợi một trong các giá trị ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Quá lớn: mong đợi ${issue2.origin ?? "giá trị"} ${sizing.verb} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "phần tử"}`; - return `Quá lớn: mong đợi ${issue2.origin ?? "giá trị"} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `Quá nhỏ: mong đợi ${issue2.origin} ${sizing.verb} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `Quá nhỏ: mong đợi ${issue2.origin} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Chuỗi không hợp lệ: phải bắt đầu bằng "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Chuỗi không hợp lệ: phải kết thúc bằng "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Chuỗi không hợp lệ: phải bao gồm "${_issue.includes}"`; - if (_issue.format === "regex") - return `Chuỗi không hợp lệ: phải khớp với mẫu ${_issue.pattern}`; - return `${FormatDictionary[_issue.format] ?? issue2.format} không hợp lệ`; - } - case "not_multiple_of": - return `Số không hợp lệ: phải là bội số của ${issue2.divisor}`; - case "unrecognized_keys": - return `Khóa không được nhận dạng: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Khóa không hợp lệ trong ${issue2.origin}`; - case "invalid_union": - return "Đầu vào không hợp lệ"; - case "invalid_element": - return `Giá trị không hợp lệ trong ${issue2.origin}`; - default: - return `Đầu vào không hợp lệ`; - } - }; -}; -function vi_default() { - return { - localeError: error44() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/zh-CN.js -var error45 = () => { - const Sizable = { - string: { unit: "字符", verb: "包含" }, - file: { unit: "字节", verb: "包含" }, - array: { unit: "项", verb: "包含" }, - set: { unit: "项", verb: "包含" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "输入", - email: "电子邮件", - url: "URL", - emoji: "表情符号", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO日期时间", - date: "ISO日期", - time: "ISO时间", - duration: "ISO时长", - ipv4: "IPv4地址", - ipv6: "IPv6地址", - cidrv4: "IPv4网段", - cidrv6: "IPv6网段", - base64: "base64编码字符串", - base64url: "base64url编码字符串", - json_string: "JSON字符串", - e164: "E.164号码", - jwt: "JWT", - template_literal: "输入" - }; - const TypeDictionary = { - nan: "NaN", - number: "数字", - array: "数组", - null: "空值(null)" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `无效输入:期望 instanceof ${issue2.expected},实际接收 ${received}`; - } - return `无效输入:期望 ${expected},实际接收 ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `无效输入:期望 ${stringifyPrimitive(issue2.values[0])}`; - return `无效选项:期望以下之一 ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `数值过大:期望 ${issue2.origin ?? "值"} ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "个元素"}`; - return `数值过大:期望 ${issue2.origin ?? "值"} ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `数值过小:期望 ${issue2.origin} ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `数值过小:期望 ${issue2.origin} ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `无效字符串:必须以 "${_issue.prefix}" 开头`; - if (_issue.format === "ends_with") - return `无效字符串:必须以 "${_issue.suffix}" 结尾`; - if (_issue.format === "includes") - return `无效字符串:必须包含 "${_issue.includes}"`; - if (_issue.format === "regex") - return `无效字符串:必须满足正则表达式 ${_issue.pattern}`; - return `无效${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `无效数字:必须是 ${issue2.divisor} 的倍数`; - case "unrecognized_keys": - return `出现未知的键(key): ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `${issue2.origin} 中的键(key)无效`; - case "invalid_union": - return "无效输入"; - case "invalid_element": - return `${issue2.origin} 中包含无效值(value)`; - default: - return `无效输入`; - } - }; -}; -function zh_CN_default() { - return { - localeError: error45() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/zh-TW.js -var error46 = () => { - const Sizable = { - string: { unit: "字元", verb: "擁有" }, - file: { unit: "位元組", verb: "擁有" }, - array: { unit: "項目", verb: "擁有" }, - set: { unit: "項目", verb: "擁有" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "輸入", - email: "郵件地址", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO 日期時間", - date: "ISO 日期", - time: "ISO 時間", - duration: "ISO 期間", - ipv4: "IPv4 位址", - ipv6: "IPv6 位址", - cidrv4: "IPv4 範圍", - cidrv6: "IPv6 範圍", - base64: "base64 編碼字串", - base64url: "base64url 編碼字串", - json_string: "JSON 字串", - e164: "E.164 數值", - jwt: "JWT", - template_literal: "輸入" - }; - const TypeDictionary = { - nan: "NaN" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `無效的輸入值:預期為 instanceof ${issue2.expected},但收到 ${received}`; - } - return `無效的輸入值:預期為 ${expected},但收到 ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `無效的輸入值:預期為 ${stringifyPrimitive(issue2.values[0])}`; - return `無效的選項:預期為以下其中之一 ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `數值過大:預期 ${issue2.origin ?? "值"} 應為 ${adj}${issue2.maximum.toString()} ${sizing.unit ?? "個元素"}`; - return `數值過大:預期 ${issue2.origin ?? "值"} 應為 ${adj}${issue2.maximum.toString()}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) { - return `數值過小:預期 ${issue2.origin} 應為 ${adj}${issue2.minimum.toString()} ${sizing.unit}`; - } - return `數值過小:預期 ${issue2.origin} 應為 ${adj}${issue2.minimum.toString()}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") { - return `無效的字串:必須以 "${_issue.prefix}" 開頭`; - } - if (_issue.format === "ends_with") - return `無效的字串:必須以 "${_issue.suffix}" 結尾`; - if (_issue.format === "includes") - return `無效的字串:必須包含 "${_issue.includes}"`; - if (_issue.format === "regex") - return `無效的字串:必須符合格式 ${_issue.pattern}`; - return `無效的 ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `無效的數字:必須為 ${issue2.divisor} 的倍數`; - case "unrecognized_keys": - return `無法識別的鍵值${issue2.keys.length > 1 ? "們" : ""}:${joinValues(issue2.keys, "、")}`; - case "invalid_key": - return `${issue2.origin} 中有無效的鍵值`; - case "invalid_union": - return "無效的輸入值"; - case "invalid_element": - return `${issue2.origin} 中有無效的值`; - default: - return `無效的輸入值`; - } - }; -}; -function zh_TW_default() { - return { - localeError: error46() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/yo.js -var error47 = () => { - const Sizable = { - string: { unit: "àmi", verb: "ní" }, - file: { unit: "bytes", verb: "ní" }, - array: { unit: "nkan", verb: "ní" }, - set: { unit: "nkan", verb: "ní" } - }; - function getSizing(origin) { - return Sizable[origin] ?? null; - } - const FormatDictionary = { - regex: "ẹ̀rọ ìbáwọlé", - email: "àdírẹ́sì ìmẹ́lì", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "àkókò ISO", - date: "ọjọ́ ISO", - time: "àkókò ISO", - duration: "àkókò tó pé ISO", - ipv4: "àdírẹ́sì IPv4", - ipv6: "àdírẹ́sì IPv6", - cidrv4: "àgbègbè IPv4", - cidrv6: "àgbègbè IPv6", - base64: "ọ̀rọ̀ tí a kọ́ ní base64", - base64url: "ọ̀rọ̀ base64url", - json_string: "ọ̀rọ̀ JSON", - e164: "nọ́mbà E.164", - jwt: "JWT", - template_literal: "ẹ̀rọ ìbáwọlé" - }; - const TypeDictionary = { - nan: "NaN", - number: "nọ́mbà", - array: "akopọ" - }; - return (issue2) => { - switch (issue2.code) { - case "invalid_type": { - const expected = TypeDictionary[issue2.expected] ?? issue2.expected; - const receivedType = parsedType(issue2.input); - const received = TypeDictionary[receivedType] ?? receivedType; - if (/^[A-Z]/.test(issue2.expected)) { - return `Ìbáwọlé aṣìṣe: a ní láti fi instanceof ${issue2.expected}, àmọ̀ a rí ${received}`; - } - return `Ìbáwọlé aṣìṣe: a ní láti fi ${expected}, àmọ̀ a rí ${received}`; - } - case "invalid_value": - if (issue2.values.length === 1) - return `Ìbáwọlé aṣìṣe: a ní láti fi ${stringifyPrimitive(issue2.values[0])}`; - return `Àṣàyàn aṣìṣe: yan ọ̀kan lára ${joinValues(issue2.values, "|")}`; - case "too_big": { - const adj = issue2.inclusive ? "<=" : "<"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Tó pọ̀ jù: a ní láti jẹ́ pé ${issue2.origin ?? "iye"} ${sizing.verb} ${adj}${issue2.maximum} ${sizing.unit}`; - return `Tó pọ̀ jù: a ní láti jẹ́ ${adj}${issue2.maximum}`; - } - case "too_small": { - const adj = issue2.inclusive ? ">=" : ">"; - const sizing = getSizing(issue2.origin); - if (sizing) - return `Kéré ju: a ní láti jẹ́ pé ${issue2.origin} ${sizing.verb} ${adj}${issue2.minimum} ${sizing.unit}`; - return `Kéré ju: a ní láti jẹ́ ${adj}${issue2.minimum}`; - } - case "invalid_format": { - const _issue = issue2; - if (_issue.format === "starts_with") - return `Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ bẹ̀rẹ̀ pẹ̀lú "${_issue.prefix}"`; - if (_issue.format === "ends_with") - return `Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ parí pẹ̀lú "${_issue.suffix}"`; - if (_issue.format === "includes") - return `Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ ní "${_issue.includes}"`; - if (_issue.format === "regex") - return `Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ bá àpẹẹrẹ mu ${_issue.pattern}`; - return `Aṣìṣe: ${FormatDictionary[_issue.format] ?? issue2.format}`; - } - case "not_multiple_of": - return `Nọ́mbà aṣìṣe: gbọ́dọ̀ jẹ́ èyà pípín ti ${issue2.divisor}`; - case "unrecognized_keys": - return `Bọtìnì àìmọ̀: ${joinValues(issue2.keys, ", ")}`; - case "invalid_key": - return `Bọtìnì aṣìṣe nínú ${issue2.origin}`; - case "invalid_union": - return "Ìbáwọlé aṣìṣe"; - case "invalid_element": - return `Iye aṣìṣe nínú ${issue2.origin}`; - default: - return "Ìbáwọlé aṣìṣe"; - } - }; -}; -function yo_default() { - return { - localeError: error47() - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/registries.js -var _a15; -var $output = Symbol("ZodOutput"); -var $input = Symbol("ZodInput"); - -class $ZodRegistry { - constructor() { - this._map = new WeakMap; - this._idmap = new Map; - } - add(schema, ..._meta) { - const meta = _meta[0]; - this._map.set(schema, meta); - if (meta && typeof meta === "object" && "id" in meta) { - this._idmap.set(meta.id, schema); - } - return this; - } - clear() { - this._map = new WeakMap; - this._idmap = new Map; - return this; - } - remove(schema) { - const meta = this._map.get(schema); - if (meta && typeof meta === "object" && "id" in meta) { - this._idmap.delete(meta.id); - } - this._map.delete(schema); - return this; - } - get(schema) { - const p = schema._zod.parent; - if (p) { - const pm = { ...this.get(p) ?? {} }; - delete pm.id; - const f = { ...pm, ...this._map.get(schema) }; - return Object.keys(f).length ? f : undefined; - } - return this._map.get(schema); - } - has(schema) { - return this._map.has(schema); - } -} -function registry() { - return new $ZodRegistry; -} -(_a15 = globalThis).__zod_globalRegistry ?? (_a15.__zod_globalRegistry = registry()); -var globalRegistry = globalThis.__zod_globalRegistry; -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/api.js -function _string(Class2, params) { - return new Class2({ - type: "string", - ...normalizeParams(params) - }); -} -function _coercedString(Class2, params) { - return new Class2({ - type: "string", - coerce: true, - ...normalizeParams(params) - }); -} -function _email(Class2, params) { - return new Class2({ - type: "string", - format: "email", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _guid(Class2, params) { - return new Class2({ - type: "string", - format: "guid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _uuid(Class2, params) { - return new Class2({ - type: "string", - format: "uuid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _uuidv4(Class2, params) { - return new Class2({ - type: "string", - format: "uuid", - check: "string_format", - abort: false, - version: "v4", - ...normalizeParams(params) - }); -} -function _uuidv6(Class2, params) { - return new Class2({ - type: "string", - format: "uuid", - check: "string_format", - abort: false, - version: "v6", - ...normalizeParams(params) - }); -} -function _uuidv7(Class2, params) { - return new Class2({ - type: "string", - format: "uuid", - check: "string_format", - abort: false, - version: "v7", - ...normalizeParams(params) - }); -} -function _url(Class2, params) { - return new Class2({ - type: "string", - format: "url", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _emoji2(Class2, params) { - return new Class2({ - type: "string", - format: "emoji", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _nanoid(Class2, params) { - return new Class2({ - type: "string", - format: "nanoid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _cuid(Class2, params) { - return new Class2({ - type: "string", - format: "cuid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _cuid2(Class2, params) { - return new Class2({ - type: "string", - format: "cuid2", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _ulid(Class2, params) { - return new Class2({ - type: "string", - format: "ulid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _xid(Class2, params) { - return new Class2({ - type: "string", - format: "xid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _ksuid(Class2, params) { - return new Class2({ - type: "string", - format: "ksuid", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _ipv4(Class2, params) { - return new Class2({ - type: "string", - format: "ipv4", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _ipv6(Class2, params) { - return new Class2({ - type: "string", - format: "ipv6", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _mac(Class2, params) { - return new Class2({ - type: "string", - format: "mac", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _cidrv4(Class2, params) { - return new Class2({ - type: "string", - format: "cidrv4", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _cidrv6(Class2, params) { - return new Class2({ - type: "string", - format: "cidrv6", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _base64(Class2, params) { - return new Class2({ - type: "string", - format: "base64", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _base64url(Class2, params) { - return new Class2({ - type: "string", - format: "base64url", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _e164(Class2, params) { - return new Class2({ - type: "string", - format: "e164", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -function _jwt(Class2, params) { - return new Class2({ - type: "string", - format: "jwt", - check: "string_format", - abort: false, - ...normalizeParams(params) - }); -} -var TimePrecision = { - Any: null, - Minute: -1, - Second: 0, - Millisecond: 3, - Microsecond: 6 -}; -function _isoDateTime(Class2, params) { - return new Class2({ - type: "string", - format: "datetime", - check: "string_format", - offset: false, - local: false, - precision: null, - ...normalizeParams(params) - }); -} -function _isoDate(Class2, params) { - return new Class2({ - type: "string", - format: "date", - check: "string_format", - ...normalizeParams(params) - }); -} -function _isoTime(Class2, params) { - return new Class2({ - type: "string", - format: "time", - check: "string_format", - precision: null, - ...normalizeParams(params) - }); -} -function _isoDuration(Class2, params) { - return new Class2({ - type: "string", - format: "duration", - check: "string_format", - ...normalizeParams(params) - }); -} -function _number(Class2, params) { - return new Class2({ - type: "number", - checks: [], - ...normalizeParams(params) - }); -} -function _coercedNumber(Class2, params) { - return new Class2({ - type: "number", - coerce: true, - checks: [], - ...normalizeParams(params) - }); -} -function _int(Class2, params) { - return new Class2({ - type: "number", - check: "number_format", - abort: false, - format: "safeint", - ...normalizeParams(params) - }); -} -function _float32(Class2, params) { - return new Class2({ - type: "number", - check: "number_format", - abort: false, - format: "float32", - ...normalizeParams(params) - }); -} -function _float64(Class2, params) { - return new Class2({ - type: "number", - check: "number_format", - abort: false, - format: "float64", - ...normalizeParams(params) - }); -} -function _int32(Class2, params) { - return new Class2({ - type: "number", - check: "number_format", - abort: false, - format: "int32", - ...normalizeParams(params) - }); -} -function _uint32(Class2, params) { - return new Class2({ - type: "number", - check: "number_format", - abort: false, - format: "uint32", - ...normalizeParams(params) - }); -} -function _boolean(Class2, params) { - return new Class2({ - type: "boolean", - ...normalizeParams(params) - }); -} -function _coercedBoolean(Class2, params) { - return new Class2({ - type: "boolean", - coerce: true, - ...normalizeParams(params) - }); -} -function _bigint(Class2, params) { - return new Class2({ - type: "bigint", - ...normalizeParams(params) - }); -} -function _coercedBigint(Class2, params) { - return new Class2({ - type: "bigint", - coerce: true, - ...normalizeParams(params) - }); -} -function _int64(Class2, params) { - return new Class2({ - type: "bigint", - check: "bigint_format", - abort: false, - format: "int64", - ...normalizeParams(params) - }); -} -function _uint64(Class2, params) { - return new Class2({ - type: "bigint", - check: "bigint_format", - abort: false, - format: "uint64", - ...normalizeParams(params) - }); -} -function _symbol(Class2, params) { - return new Class2({ - type: "symbol", - ...normalizeParams(params) - }); -} -function _undefined2(Class2, params) { - return new Class2({ - type: "undefined", - ...normalizeParams(params) - }); -} -function _null2(Class2, params) { - return new Class2({ - type: "null", - ...normalizeParams(params) - }); -} -function _any(Class2) { - return new Class2({ - type: "any" - }); -} -function _unknown(Class2) { - return new Class2({ - type: "unknown" - }); -} -function _never(Class2, params) { - return new Class2({ - type: "never", - ...normalizeParams(params) - }); -} -function _void(Class2, params) { - return new Class2({ - type: "void", - ...normalizeParams(params) - }); -} -function _date(Class2, params) { - return new Class2({ - type: "date", - ...normalizeParams(params) - }); -} -function _coercedDate(Class2, params) { - return new Class2({ - type: "date", - coerce: true, - ...normalizeParams(params) - }); -} -function _nan(Class2, params) { - return new Class2({ - type: "nan", - ...normalizeParams(params) - }); -} -function _lt(value, params) { - return new $ZodCheckLessThan({ - check: "less_than", - ...normalizeParams(params), - value, - inclusive: false - }); -} -function _lte(value, params) { - return new $ZodCheckLessThan({ - check: "less_than", - ...normalizeParams(params), - value, - inclusive: true - }); -} -function _gt(value, params) { - return new $ZodCheckGreaterThan({ - check: "greater_than", - ...normalizeParams(params), - value, - inclusive: false - }); -} -function _gte(value, params) { - return new $ZodCheckGreaterThan({ - check: "greater_than", - ...normalizeParams(params), - value, - inclusive: true - }); -} -function _positive(params) { - return _gt(0, params); -} -function _negative(params) { - return _lt(0, params); -} -function _nonpositive(params) { - return _lte(0, params); -} -function _nonnegative(params) { - return _gte(0, params); -} -function _multipleOf(value, params) { - return new $ZodCheckMultipleOf({ - check: "multiple_of", - ...normalizeParams(params), - value - }); -} -function _maxSize(maximum, params) { - return new $ZodCheckMaxSize({ - check: "max_size", - ...normalizeParams(params), - maximum - }); -} -function _minSize(minimum, params) { - return new $ZodCheckMinSize({ - check: "min_size", - ...normalizeParams(params), - minimum - }); -} -function _size(size, params) { - return new $ZodCheckSizeEquals({ - check: "size_equals", - ...normalizeParams(params), - size - }); -} -function _maxLength(maximum, params) { - const ch = new $ZodCheckMaxLength({ - check: "max_length", - ...normalizeParams(params), - maximum - }); - return ch; -} -function _minLength(minimum, params) { - return new $ZodCheckMinLength({ - check: "min_length", - ...normalizeParams(params), - minimum - }); -} -function _length(length, params) { - return new $ZodCheckLengthEquals({ - check: "length_equals", - ...normalizeParams(params), - length - }); -} -function _regex(pattern, params) { - return new $ZodCheckRegex({ - check: "string_format", - format: "regex", - ...normalizeParams(params), - pattern - }); -} -function _lowercase(params) { - return new $ZodCheckLowerCase({ - check: "string_format", - format: "lowercase", - ...normalizeParams(params) - }); -} -function _uppercase(params) { - return new $ZodCheckUpperCase({ - check: "string_format", - format: "uppercase", - ...normalizeParams(params) - }); -} -function _includes(includes, params) { - return new $ZodCheckIncludes({ - check: "string_format", - format: "includes", - ...normalizeParams(params), - includes - }); -} -function _startsWith(prefix, params) { - return new $ZodCheckStartsWith({ - check: "string_format", - format: "starts_with", - ...normalizeParams(params), - prefix - }); -} -function _endsWith(suffix, params) { - return new $ZodCheckEndsWith({ - check: "string_format", - format: "ends_with", - ...normalizeParams(params), - suffix - }); -} -function _property(property, schema, params) { - return new $ZodCheckProperty({ - check: "property", - property, - schema, - ...normalizeParams(params) - }); -} -function _mime(types, params) { - return new $ZodCheckMimeType({ - check: "mime_type", - mime: types, - ...normalizeParams(params) - }); -} -function _overwrite(tx) { - return new $ZodCheckOverwrite({ - check: "overwrite", - tx - }); -} -function _normalize(form) { - return _overwrite((input) => input.normalize(form)); -} -function _trim() { - return _overwrite((input) => input.trim()); -} -function _toLowerCase() { - return _overwrite((input) => input.toLowerCase()); -} -function _toUpperCase() { - return _overwrite((input) => input.toUpperCase()); -} -function _slugify() { - return _overwrite((input) => slugify(input)); -} -function _array(Class2, element, params) { - return new Class2({ - type: "array", - element, - ...normalizeParams(params) - }); -} -function _union(Class2, options, params) { - return new Class2({ - type: "union", - options, - ...normalizeParams(params) - }); -} -function _xor(Class2, options, params) { - return new Class2({ - type: "union", - options, - inclusive: false, - ...normalizeParams(params) - }); -} -function _discriminatedUnion(Class2, discriminator, options, params) { - return new Class2({ - type: "union", - options, - discriminator, - ...normalizeParams(params) - }); -} -function _intersection(Class2, left, right) { - return new Class2({ - type: "intersection", - left, - right - }); -} -function _tuple(Class2, items, _paramsOrRest, _params) { - const hasRest = _paramsOrRest instanceof $ZodType; - const params = hasRest ? _params : _paramsOrRest; - const rest = hasRest ? _paramsOrRest : null; - return new Class2({ - type: "tuple", - items, - rest, - ...normalizeParams(params) - }); -} -function _record(Class2, keyType, valueType, params) { - return new Class2({ - type: "record", - keyType, - valueType, - ...normalizeParams(params) - }); -} -function _map(Class2, keyType, valueType, params) { - return new Class2({ - type: "map", - keyType, - valueType, - ...normalizeParams(params) - }); -} -function _set(Class2, valueType, params) { - return new Class2({ - type: "set", - valueType, - ...normalizeParams(params) - }); -} -function _enum(Class2, values, params) { - const entries = Array.isArray(values) ? Object.fromEntries(values.map((v) => [v, v])) : values; - return new Class2({ - type: "enum", - entries, - ...normalizeParams(params) - }); -} -function _nativeEnum(Class2, entries, params) { - return new Class2({ - type: "enum", - entries, - ...normalizeParams(params) - }); -} -function _literal(Class2, value, params) { - return new Class2({ - type: "literal", - values: Array.isArray(value) ? value : [value], - ...normalizeParams(params) - }); -} -function _file(Class2, params) { - return new Class2({ - type: "file", - ...normalizeParams(params) - }); -} -function _transform(Class2, fn) { - return new Class2({ - type: "transform", - transform: fn - }); -} -function _optional(Class2, innerType) { - return new Class2({ - type: "optional", - innerType - }); -} -function _nullable(Class2, innerType) { - return new Class2({ - type: "nullable", - innerType - }); -} -function _default(Class2, innerType, defaultValue) { - return new Class2({ - type: "default", - innerType, - get defaultValue() { - return typeof defaultValue === "function" ? defaultValue() : shallowClone(defaultValue); - } - }); -} -function _nonoptional(Class2, innerType, params) { - return new Class2({ - type: "nonoptional", - innerType, - ...normalizeParams(params) - }); -} -function _success(Class2, innerType) { - return new Class2({ - type: "success", - innerType - }); -} -function _catch(Class2, innerType, catchValue) { - return new Class2({ - type: "catch", - innerType, - catchValue: typeof catchValue === "function" ? catchValue : () => catchValue - }); -} -function _pipe(Class2, in_, out) { - return new Class2({ - type: "pipe", - in: in_, - out - }); -} -function _readonly(Class2, innerType) { - return new Class2({ - type: "readonly", - innerType - }); -} -function _templateLiteral(Class2, parts, params) { - return new Class2({ - type: "template_literal", - parts, - ...normalizeParams(params) - }); -} -function _lazy(Class2, getter) { - return new Class2({ - type: "lazy", - getter - }); -} -function _promise(Class2, innerType) { - return new Class2({ - type: "promise", - innerType - }); -} -function _custom(Class2, fn, _params) { - const norm = normalizeParams(_params); - norm.abort ?? (norm.abort = true); - const schema = new Class2({ - type: "custom", - check: "custom", - fn, - ...norm - }); - return schema; -} -function _refine(Class2, fn, _params) { - const schema = new Class2({ - type: "custom", - check: "custom", - fn, - ...normalizeParams(_params) - }); - return schema; -} -function _superRefine(fn) { - const ch = _check((payload) => { - payload.addIssue = (issue2) => { - if (typeof issue2 === "string") { - payload.issues.push(issue(issue2, payload.value, ch._zod.def)); - } else { - const _issue = issue2; - if (_issue.fatal) - _issue.continue = false; - _issue.code ?? (_issue.code = "custom"); - _issue.input ?? (_issue.input = payload.value); - _issue.inst ?? (_issue.inst = ch); - _issue.continue ?? (_issue.continue = !ch._zod.def.abort); - payload.issues.push(issue(_issue)); - } - }; - return fn(payload.value, payload); - }); - return ch; -} -function _check(fn, params) { - const ch = new $ZodCheck({ - check: "custom", - ...normalizeParams(params) - }); - ch._zod.check = fn; - return ch; -} -function describe(description) { - const ch = new $ZodCheck({ check: "describe" }); - ch._zod.onattach = [ - (inst) => { - const existing = globalRegistry.get(inst) ?? {}; - globalRegistry.add(inst, { ...existing, description }); - } - ]; - ch._zod.check = () => {}; - return ch; -} -function meta(metadata) { - const ch = new $ZodCheck({ check: "meta" }); - ch._zod.onattach = [ - (inst) => { - const existing = globalRegistry.get(inst) ?? {}; - globalRegistry.add(inst, { ...existing, ...metadata }); - } - ]; - ch._zod.check = () => {}; - return ch; -} -function _stringbool(Classes, _params) { - const params = normalizeParams(_params); - let truthyArray = params.truthy ?? ["true", "1", "yes", "on", "y", "enabled"]; - let falsyArray = params.falsy ?? ["false", "0", "no", "off", "n", "disabled"]; - if (params.case !== "sensitive") { - truthyArray = truthyArray.map((v) => typeof v === "string" ? v.toLowerCase() : v); - falsyArray = falsyArray.map((v) => typeof v === "string" ? v.toLowerCase() : v); - } - const truthySet = new Set(truthyArray); - const falsySet = new Set(falsyArray); - const _Codec = Classes.Codec ?? $ZodCodec; - const _Boolean = Classes.Boolean ?? $ZodBoolean; - const _String = Classes.String ?? $ZodString; - const stringSchema = new _String({ type: "string", error: params.error }); - const booleanSchema = new _Boolean({ type: "boolean", error: params.error }); - const codec = new _Codec({ - type: "pipe", - in: stringSchema, - out: booleanSchema, - transform: (input, payload) => { - let data = input; - if (params.case !== "sensitive") - data = data.toLowerCase(); - if (truthySet.has(data)) { - return true; - } else if (falsySet.has(data)) { - return false; - } else { - payload.issues.push({ - code: "invalid_value", - expected: "stringbool", - values: [...truthySet, ...falsySet], - input: payload.value, - inst: codec, - continue: false - }); - return {}; - } - }, - reverseTransform: (input, _payload) => { - if (input === true) { - return truthyArray[0] || "true"; - } else { - return falsyArray[0] || "false"; - } - }, - error: params.error - }); - return codec; -} -function _stringFormat(Class2, format, fnOrRegex, _params = {}) { - const params = normalizeParams(_params); - const def = { - ...normalizeParams(_params), - check: "string_format", - type: "string", - format, - fn: typeof fnOrRegex === "function" ? fnOrRegex : (val) => fnOrRegex.test(val), - ...params - }; - if (fnOrRegex instanceof RegExp) { - def.pattern = fnOrRegex; - } - const inst = new Class2(def); - return inst; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/to-json-schema.js -function initializeContext(params) { - let target = params?.target ?? "draft-2020-12"; - if (target === "draft-4") - target = "draft-04"; - if (target === "draft-7") - target = "draft-07"; - return { - processors: params.processors ?? {}, - metadataRegistry: params?.metadata ?? globalRegistry, - target, - unrepresentable: params?.unrepresentable ?? "throw", - override: params?.override ?? (() => {}), - io: params?.io ?? "output", - counter: 0, - seen: new Map, - cycles: params?.cycles ?? "ref", - reused: params?.reused ?? "inline", - external: params?.external ?? undefined - }; -} -function process2(schema, ctx, _params = { path: [], schemaPath: [] }) { - var _a16; - const def = schema._zod.def; - const seen = ctx.seen.get(schema); - if (seen) { - seen.count++; - const isCycle = _params.schemaPath.includes(schema); - if (isCycle) { - seen.cycle = _params.path; - } - return seen.schema; - } - const result = { schema: {}, count: 1, cycle: undefined, path: _params.path }; - ctx.seen.set(schema, result); - const overrideSchema = schema._zod.toJSONSchema?.(); - if (overrideSchema) { - result.schema = overrideSchema; - } else { - const params = { - ..._params, - schemaPath: [..._params.schemaPath, schema], - path: _params.path - }; - if (schema._zod.processJSONSchema) { - schema._zod.processJSONSchema(ctx, result.schema, params); - } else { - const _json = result.schema; - const processor = ctx.processors[def.type]; - if (!processor) { - throw new Error(`[toJSONSchema]: Non-representable type encountered: ${def.type}`); - } - processor(schema, ctx, _json, params); - } - const parent = schema._zod.parent; - if (parent) { - if (!result.ref) - result.ref = parent; - process2(parent, ctx, params); - ctx.seen.get(parent).isParent = true; - } - } - const meta2 = ctx.metadataRegistry.get(schema); - if (meta2) - Object.assign(result.schema, meta2); - if (ctx.io === "input" && isTransforming(schema)) { - delete result.schema.examples; - delete result.schema.default; - } - if (ctx.io === "input" && result.schema._prefault) - (_a16 = result.schema).default ?? (_a16.default = result.schema._prefault); - delete result.schema._prefault; - const _result = ctx.seen.get(schema); - return _result.schema; -} -function extractDefs(ctx, schema) { - const root = ctx.seen.get(schema); - if (!root) - throw new Error("Unprocessed schema. This is a bug in Zod."); - const idToSchema = new Map; - for (const entry of ctx.seen.entries()) { - const id = ctx.metadataRegistry.get(entry[0])?.id; - if (id) { - const existing = idToSchema.get(id); - if (existing && existing !== entry[0]) { - throw new Error(`Duplicate schema id "${id}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`); - } - idToSchema.set(id, entry[0]); - } - } - const makeURI = (entry) => { - const defsSegment = ctx.target === "draft-2020-12" ? "$defs" : "definitions"; - if (ctx.external) { - const externalId = ctx.external.registry.get(entry[0])?.id; - const uriGenerator = ctx.external.uri ?? ((id2) => id2); - if (externalId) { - return { ref: uriGenerator(externalId) }; - } - const id = entry[1].defId ?? entry[1].schema.id ?? `schema${ctx.counter++}`; - entry[1].defId = id; - return { defId: id, ref: `${uriGenerator("__shared")}#/${defsSegment}/${id}` }; - } - if (entry[1] === root) { - return { ref: "#" }; - } - const uriPrefix = `#`; - const defUriPrefix = `${uriPrefix}/${defsSegment}/`; - const defId = entry[1].schema.id ?? `__schema${ctx.counter++}`; - return { defId, ref: defUriPrefix + defId }; - }; - const extractToDef = (entry) => { - if (entry[1].schema.$ref) { - return; - } - const seen = entry[1]; - const { ref, defId } = makeURI(entry); - seen.def = { ...seen.schema }; - if (defId) - seen.defId = defId; - const schema2 = seen.schema; - for (const key in schema2) { - delete schema2[key]; - } - schema2.$ref = ref; - }; - if (ctx.cycles === "throw") { - for (const entry of ctx.seen.entries()) { - const seen = entry[1]; - if (seen.cycle) { - throw new Error("Cycle detected: " + `#/${seen.cycle?.join("/")}/` + '\n\nSet the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs.'); - } - } - } - for (const entry of ctx.seen.entries()) { - const seen = entry[1]; - if (schema === entry[0]) { - extractToDef(entry); - continue; - } - if (ctx.external) { - const ext = ctx.external.registry.get(entry[0])?.id; - if (schema !== entry[0] && ext) { - extractToDef(entry); - continue; - } - } - const id = ctx.metadataRegistry.get(entry[0])?.id; - if (id) { - extractToDef(entry); - continue; - } - if (seen.cycle) { - extractToDef(entry); - continue; - } - if (seen.count > 1) { - if (ctx.reused === "ref") { - extractToDef(entry); - continue; - } - } - } -} -function finalize(ctx, schema) { - const root = ctx.seen.get(schema); - if (!root) - throw new Error("Unprocessed schema. This is a bug in Zod."); - const flattenRef = (zodSchema) => { - const seen = ctx.seen.get(zodSchema); - if (seen.ref === null) - return; - const schema2 = seen.def ?? seen.schema; - const _cached = { ...schema2 }; - const ref = seen.ref; - seen.ref = null; - if (ref) { - flattenRef(ref); - const refSeen = ctx.seen.get(ref); - const refSchema = refSeen.schema; - if (refSchema.$ref && (ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0")) { - schema2.allOf = schema2.allOf ?? []; - schema2.allOf.push(refSchema); - } else { - Object.assign(schema2, refSchema); - } - Object.assign(schema2, _cached); - const isParentRef = zodSchema._zod.parent === ref; - if (isParentRef) { - for (const key in schema2) { - if (key === "$ref" || key === "allOf") - continue; - if (!(key in _cached)) { - delete schema2[key]; - } - } - } - if (refSchema.$ref && refSeen.def) { - for (const key in schema2) { - if (key === "$ref" || key === "allOf") - continue; - if (key in refSeen.def && JSON.stringify(schema2[key]) === JSON.stringify(refSeen.def[key])) { - delete schema2[key]; - } - } - } - } - const parent = zodSchema._zod.parent; - if (parent && parent !== ref) { - flattenRef(parent); - const parentSeen = ctx.seen.get(parent); - if (parentSeen?.schema.$ref) { - schema2.$ref = parentSeen.schema.$ref; - if (parentSeen.def) { - for (const key in schema2) { - if (key === "$ref" || key === "allOf") - continue; - if (key in parentSeen.def && JSON.stringify(schema2[key]) === JSON.stringify(parentSeen.def[key])) { - delete schema2[key]; - } - } - } - } - } - ctx.override({ - zodSchema, - jsonSchema: schema2, - path: seen.path ?? [] - }); - }; - for (const entry of [...ctx.seen.entries()].reverse()) { - flattenRef(entry[0]); - } - const result = {}; - if (ctx.target === "draft-2020-12") { - result.$schema = "https://json-schema.org/draft/2020-12/schema"; - } else if (ctx.target === "draft-07") { - result.$schema = "http://json-schema.org/draft-07/schema#"; - } else if (ctx.target === "draft-04") { - result.$schema = "http://json-schema.org/draft-04/schema#"; - } else if (ctx.target === "openapi-3.0") {} - if (ctx.external?.uri) { - const id = ctx.external.registry.get(schema)?.id; - if (!id) - throw new Error("Schema is missing an `id` property"); - result.$id = ctx.external.uri(id); - } - Object.assign(result, root.def ?? root.schema); - const defs = ctx.external?.defs ?? {}; - for (const entry of ctx.seen.entries()) { - const seen = entry[1]; - if (seen.def && seen.defId) { - defs[seen.defId] = seen.def; - } - } - if (ctx.external) {} else { - if (Object.keys(defs).length > 0) { - if (ctx.target === "draft-2020-12") { - result.$defs = defs; - } else { - result.definitions = defs; - } - } - } - try { - const finalized = JSON.parse(JSON.stringify(result)); - Object.defineProperty(finalized, "~standard", { - value: { - ...schema["~standard"], - jsonSchema: { - input: createStandardJSONSchemaMethod(schema, "input", ctx.processors), - output: createStandardJSONSchemaMethod(schema, "output", ctx.processors) - } - }, - enumerable: false, - writable: false - }); - return finalized; - } catch (_err) { - throw new Error("Error converting schema to JSON."); - } -} -function isTransforming(_schema, _ctx) { - const ctx = _ctx ?? { seen: new Set }; - if (ctx.seen.has(_schema)) - return false; - ctx.seen.add(_schema); - const def = _schema._zod.def; - if (def.type === "transform") - return true; - if (def.type === "array") - return isTransforming(def.element, ctx); - if (def.type === "set") - return isTransforming(def.valueType, ctx); - if (def.type === "lazy") - return isTransforming(def.getter(), ctx); - if (def.type === "promise" || def.type === "optional" || def.type === "nonoptional" || def.type === "nullable" || def.type === "readonly" || def.type === "default" || def.type === "prefault") { - return isTransforming(def.innerType, ctx); - } - if (def.type === "intersection") { - return isTransforming(def.left, ctx) || isTransforming(def.right, ctx); - } - if (def.type === "record" || def.type === "map") { - return isTransforming(def.keyType, ctx) || isTransforming(def.valueType, ctx); - } - if (def.type === "pipe") { - return isTransforming(def.in, ctx) || isTransforming(def.out, ctx); - } - if (def.type === "object") { - for (const key in def.shape) { - if (isTransforming(def.shape[key], ctx)) - return true; - } - return false; - } - if (def.type === "union") { - for (const option of def.options) { - if (isTransforming(option, ctx)) - return true; - } - return false; - } - if (def.type === "tuple") { - for (const item of def.items) { - if (isTransforming(item, ctx)) - return true; - } - if (def.rest && isTransforming(def.rest, ctx)) - return true; - return false; - } - return false; -} -var createToJSONSchemaMethod = (schema, processors = {}) => (params) => { - const ctx = initializeContext({ ...params, processors }); - process2(schema, ctx); - extractDefs(ctx, schema); - return finalize(ctx, schema); -}; -var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) => { - const { libraryOptions, target } = params ?? {}; - const ctx = initializeContext({ ...libraryOptions ?? {}, target, io, processors }); - process2(schema, ctx); - extractDefs(ctx, schema); - return finalize(ctx, schema); -}; -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema-processors.js -var formatMap = { - guid: "uuid", - url: "uri", - datetime: "date-time", - json_string: "json-string", - regex: "" -}; -var stringProcessor = (schema, ctx, _json, _params) => { - const json = _json; - json.type = "string"; - const { minimum, maximum, format, patterns, contentEncoding } = schema._zod.bag; - if (typeof minimum === "number") - json.minLength = minimum; - if (typeof maximum === "number") - json.maxLength = maximum; - if (format) { - json.format = formatMap[format] ?? format; - if (json.format === "") - delete json.format; - if (format === "time") { - delete json.format; - } - } - if (contentEncoding) - json.contentEncoding = contentEncoding; - if (patterns && patterns.size > 0) { - const regexes = [...patterns]; - if (regexes.length === 1) - json.pattern = regexes[0].source; - else if (regexes.length > 1) { - json.allOf = [ - ...regexes.map((regex) => ({ - ...ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0" ? { type: "string" } : {}, - pattern: regex.source - })) - ]; - } - } -}; -var numberProcessor = (schema, ctx, _json, _params) => { - const json = _json; - const { minimum, maximum, format, multipleOf, exclusiveMaximum, exclusiveMinimum } = schema._zod.bag; - if (typeof format === "string" && format.includes("int")) - json.type = "integer"; - else - json.type = "number"; - if (typeof exclusiveMinimum === "number") { - if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { - json.minimum = exclusiveMinimum; - json.exclusiveMinimum = true; - } else { - json.exclusiveMinimum = exclusiveMinimum; - } - } - if (typeof minimum === "number") { - json.minimum = minimum; - if (typeof exclusiveMinimum === "number" && ctx.target !== "draft-04") { - if (exclusiveMinimum >= minimum) - delete json.minimum; - else - delete json.exclusiveMinimum; - } - } - if (typeof exclusiveMaximum === "number") { - if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { - json.maximum = exclusiveMaximum; - json.exclusiveMaximum = true; - } else { - json.exclusiveMaximum = exclusiveMaximum; - } - } - if (typeof maximum === "number") { - json.maximum = maximum; - if (typeof exclusiveMaximum === "number" && ctx.target !== "draft-04") { - if (exclusiveMaximum <= maximum) - delete json.maximum; - else - delete json.exclusiveMaximum; - } - } - if (typeof multipleOf === "number") - json.multipleOf = multipleOf; -}; -var booleanProcessor = (_schema, _ctx, json, _params) => { - json.type = "boolean"; -}; -var bigintProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("BigInt cannot be represented in JSON Schema"); - } -}; -var symbolProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Symbols cannot be represented in JSON Schema"); - } -}; -var nullProcessor = (_schema, ctx, json, _params) => { - if (ctx.target === "openapi-3.0") { - json.type = "string"; - json.nullable = true; - json.enum = [null]; - } else { - json.type = "null"; - } -}; -var undefinedProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Undefined cannot be represented in JSON Schema"); - } -}; -var voidProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Void cannot be represented in JSON Schema"); - } -}; -var neverProcessor = (_schema, _ctx, json, _params) => { - json.not = {}; -}; -var anyProcessor = (_schema, _ctx, _json, _params) => {}; -var unknownProcessor = (_schema, _ctx, _json, _params) => {}; -var dateProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Date cannot be represented in JSON Schema"); - } -}; -var enumProcessor = (schema, _ctx, json, _params) => { - const def = schema._zod.def; - const values = getEnumValues(def.entries); - if (values.every((v) => typeof v === "number")) - json.type = "number"; - if (values.every((v) => typeof v === "string")) - json.type = "string"; - json.enum = values; -}; -var literalProcessor = (schema, ctx, json, _params) => { - const def = schema._zod.def; - const vals = []; - for (const val of def.values) { - if (val === undefined) { - if (ctx.unrepresentable === "throw") { - throw new Error("Literal `undefined` cannot be represented in JSON Schema"); - } - } else if (typeof val === "bigint") { - if (ctx.unrepresentable === "throw") { - throw new Error("BigInt literals cannot be represented in JSON Schema"); - } else { - vals.push(Number(val)); - } - } else { - vals.push(val); - } - } - if (vals.length === 0) {} else if (vals.length === 1) { - const val = vals[0]; - json.type = val === null ? "null" : typeof val; - if (ctx.target === "draft-04" || ctx.target === "openapi-3.0") { - json.enum = [val]; - } else { - json.const = val; - } - } else { - if (vals.every((v) => typeof v === "number")) - json.type = "number"; - if (vals.every((v) => typeof v === "string")) - json.type = "string"; - if (vals.every((v) => typeof v === "boolean")) - json.type = "boolean"; - if (vals.every((v) => v === null)) - json.type = "null"; - json.enum = vals; - } -}; -var nanProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("NaN cannot be represented in JSON Schema"); - } -}; -var templateLiteralProcessor = (schema, _ctx, json, _params) => { - const _json = json; - const pattern = schema._zod.pattern; - if (!pattern) - throw new Error("Pattern not found in template literal"); - _json.type = "string"; - _json.pattern = pattern.source; -}; -var fileProcessor = (schema, _ctx, json, _params) => { - const _json = json; - const file = { - type: "string", - format: "binary", - contentEncoding: "binary" - }; - const { minimum, maximum, mime } = schema._zod.bag; - if (minimum !== undefined) - file.minLength = minimum; - if (maximum !== undefined) - file.maxLength = maximum; - if (mime) { - if (mime.length === 1) { - file.contentMediaType = mime[0]; - Object.assign(_json, file); - } else { - Object.assign(_json, file); - _json.anyOf = mime.map((m) => ({ contentMediaType: m })); - } - } else { - Object.assign(_json, file); - } -}; -var successProcessor = (_schema, _ctx, json, _params) => { - json.type = "boolean"; -}; -var customProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Custom types cannot be represented in JSON Schema"); - } -}; -var functionProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Function types cannot be represented in JSON Schema"); - } -}; -var transformProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Transforms cannot be represented in JSON Schema"); - } -}; -var mapProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Map cannot be represented in JSON Schema"); - } -}; -var setProcessor = (_schema, ctx, _json, _params) => { - if (ctx.unrepresentable === "throw") { - throw new Error("Set cannot be represented in JSON Schema"); - } -}; -var arrayProcessor = (schema, ctx, _json, params) => { - const json = _json; - const def = schema._zod.def; - const { minimum, maximum } = schema._zod.bag; - if (typeof minimum === "number") - json.minItems = minimum; - if (typeof maximum === "number") - json.maxItems = maximum; - json.type = "array"; - json.items = process2(def.element, ctx, { ...params, path: [...params.path, "items"] }); -}; -var objectProcessor = (schema, ctx, _json, params) => { - const json = _json; - const def = schema._zod.def; - json.type = "object"; - json.properties = {}; - const shape = def.shape; - for (const key in shape) { - json.properties[key] = process2(shape[key], ctx, { - ...params, - path: [...params.path, "properties", key] - }); - } - const allKeys = new Set(Object.keys(shape)); - const requiredKeys = new Set([...allKeys].filter((key) => { - const v = def.shape[key]._zod; - if (ctx.io === "input") { - return v.optin === undefined; - } else { - return v.optout === undefined; - } - })); - if (requiredKeys.size > 0) { - json.required = Array.from(requiredKeys); - } - if (def.catchall?._zod.def.type === "never") { - json.additionalProperties = false; - } else if (!def.catchall) { - if (ctx.io === "output") - json.additionalProperties = false; - } else if (def.catchall) { - json.additionalProperties = process2(def.catchall, ctx, { - ...params, - path: [...params.path, "additionalProperties"] - }); - } -}; -var unionProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - const isExclusive = def.inclusive === false; - const options = def.options.map((x, i) => process2(x, ctx, { - ...params, - path: [...params.path, isExclusive ? "oneOf" : "anyOf", i] - })); - if (isExclusive) { - json.oneOf = options; - } else { - json.anyOf = options; - } -}; -var intersectionProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - const a = process2(def.left, ctx, { - ...params, - path: [...params.path, "allOf", 0] - }); - const b = process2(def.right, ctx, { - ...params, - path: [...params.path, "allOf", 1] - }); - const isSimpleIntersection = (val) => ("allOf" in val) && Object.keys(val).length === 1; - const allOf = [ - ...isSimpleIntersection(a) ? a.allOf : [a], - ...isSimpleIntersection(b) ? b.allOf : [b] - ]; - json.allOf = allOf; -}; -var tupleProcessor = (schema, ctx, _json, params) => { - const json = _json; - const def = schema._zod.def; - json.type = "array"; - const prefixPath = ctx.target === "draft-2020-12" ? "prefixItems" : "items"; - const restPath = ctx.target === "draft-2020-12" ? "items" : ctx.target === "openapi-3.0" ? "items" : "additionalItems"; - const prefixItems = def.items.map((x, i) => process2(x, ctx, { - ...params, - path: [...params.path, prefixPath, i] - })); - const rest = def.rest ? process2(def.rest, ctx, { - ...params, - path: [...params.path, restPath, ...ctx.target === "openapi-3.0" ? [def.items.length] : []] - }) : null; - if (ctx.target === "draft-2020-12") { - json.prefixItems = prefixItems; - if (rest) { - json.items = rest; - } - } else if (ctx.target === "openapi-3.0") { - json.items = { - anyOf: prefixItems - }; - if (rest) { - json.items.anyOf.push(rest); - } - json.minItems = prefixItems.length; - if (!rest) { - json.maxItems = prefixItems.length; - } - } else { - json.items = prefixItems; - if (rest) { - json.additionalItems = rest; - } - } - const { minimum, maximum } = schema._zod.bag; - if (typeof minimum === "number") - json.minItems = minimum; - if (typeof maximum === "number") - json.maxItems = maximum; -}; -var recordProcessor = (schema, ctx, _json, params) => { - const json = _json; - const def = schema._zod.def; - json.type = "object"; - const keyType = def.keyType; - const keyBag = keyType._zod.bag; - const patterns = keyBag?.patterns; - if (def.mode === "loose" && patterns && patterns.size > 0) { - const valueSchema = process2(def.valueType, ctx, { - ...params, - path: [...params.path, "patternProperties", "*"] - }); - json.patternProperties = {}; - for (const pattern of patterns) { - json.patternProperties[pattern.source] = valueSchema; - } - } else { - if (ctx.target === "draft-07" || ctx.target === "draft-2020-12") { - json.propertyNames = process2(def.keyType, ctx, { - ...params, - path: [...params.path, "propertyNames"] - }); - } - json.additionalProperties = process2(def.valueType, ctx, { - ...params, - path: [...params.path, "additionalProperties"] - }); - } - const keyValues = keyType._zod.values; - if (keyValues) { - const validKeyValues = [...keyValues].filter((v) => typeof v === "string" || typeof v === "number"); - if (validKeyValues.length > 0) { - json.required = validKeyValues; - } - } -}; -var nullableProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - const inner = process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - if (ctx.target === "openapi-3.0") { - seen.ref = def.innerType; - json.nullable = true; - } else { - json.anyOf = [inner, { type: "null" }]; - } -}; -var nonoptionalProcessor = (schema, ctx, _json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; -}; -var defaultProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; - json.default = JSON.parse(JSON.stringify(def.defaultValue)); -}; -var prefaultProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; - if (ctx.io === "input") - json._prefault = JSON.parse(JSON.stringify(def.defaultValue)); -}; -var catchProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; - let catchValue; - try { - catchValue = def.catchValue(undefined); - } catch { - throw new Error("Dynamic catch values are not supported in JSON Schema"); - } - json.default = catchValue; -}; -var pipeProcessor = (schema, ctx, _json, params) => { - const def = schema._zod.def; - const innerType = ctx.io === "input" ? def.in._zod.def.type === "transform" ? def.out : def.in : def.out; - process2(innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = innerType; -}; -var readonlyProcessor = (schema, ctx, json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; - json.readOnly = true; -}; -var promiseProcessor = (schema, ctx, _json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; -}; -var optionalProcessor = (schema, ctx, _json, params) => { - const def = schema._zod.def; - process2(def.innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = def.innerType; -}; -var lazyProcessor = (schema, ctx, _json, params) => { - const innerType = schema._zod.innerType; - process2(innerType, ctx, params); - const seen = ctx.seen.get(schema); - seen.ref = innerType; -}; -var allProcessors = { - string: stringProcessor, - number: numberProcessor, - boolean: booleanProcessor, - bigint: bigintProcessor, - symbol: symbolProcessor, - null: nullProcessor, - undefined: undefinedProcessor, - void: voidProcessor, - never: neverProcessor, - any: anyProcessor, - unknown: unknownProcessor, - date: dateProcessor, - enum: enumProcessor, - literal: literalProcessor, - nan: nanProcessor, - template_literal: templateLiteralProcessor, - file: fileProcessor, - success: successProcessor, - custom: customProcessor, - function: functionProcessor, - transform: transformProcessor, - map: mapProcessor, - set: setProcessor, - array: arrayProcessor, - object: objectProcessor, - union: unionProcessor, - intersection: intersectionProcessor, - tuple: tupleProcessor, - record: recordProcessor, - nullable: nullableProcessor, - nonoptional: nonoptionalProcessor, - default: defaultProcessor, - prefault: prefaultProcessor, - catch: catchProcessor, - pipe: pipeProcessor, - readonly: readonlyProcessor, - promise: promiseProcessor, - optional: optionalProcessor, - lazy: lazyProcessor -}; -function toJSONSchema(input, params) { - if ("_idmap" in input) { - const registry2 = input; - const ctx2 = initializeContext({ ...params, processors: allProcessors }); - const defs = {}; - for (const entry of registry2._idmap.entries()) { - const [_, schema] = entry; - process2(schema, ctx2); - } - const schemas = {}; - const external = { - registry: registry2, - uri: params?.uri, - defs - }; - ctx2.external = external; - for (const entry of registry2._idmap.entries()) { - const [key, schema] = entry; - extractDefs(ctx2, schema); - schemas[key] = finalize(ctx2, schema); - } - if (Object.keys(defs).length > 0) { - const defsSegment = ctx2.target === "draft-2020-12" ? "$defs" : "definitions"; - schemas.__shared = { - [defsSegment]: defs - }; - } - return { schemas }; - } - const ctx = initializeContext({ ...params, processors: allProcessors }); - process2(input, ctx); - extractDefs(ctx, input); - return finalize(ctx, input); -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema-generator.js -class JSONSchemaGenerator { - get metadataRegistry() { - return this.ctx.metadataRegistry; - } - get target() { - return this.ctx.target; - } - get unrepresentable() { - return this.ctx.unrepresentable; - } - get override() { - return this.ctx.override; - } - get io() { - return this.ctx.io; - } - get counter() { - return this.ctx.counter; - } - set counter(value) { - this.ctx.counter = value; - } - get seen() { - return this.ctx.seen; - } - constructor(params) { - let normalizedTarget = params?.target ?? "draft-2020-12"; - if (normalizedTarget === "draft-4") - normalizedTarget = "draft-04"; - if (normalizedTarget === "draft-7") - normalizedTarget = "draft-07"; - this.ctx = initializeContext({ - processors: allProcessors, - target: normalizedTarget, - ...params?.metadata && { metadata: params.metadata }, - ...params?.unrepresentable && { unrepresentable: params.unrepresentable }, - ...params?.override && { override: params.override }, - ...params?.io && { io: params.io } - }); - } - process(schema, _params = { path: [], schemaPath: [] }) { - return process2(schema, this.ctx, _params); - } - emit(schema, _params) { - if (_params) { - if (_params.cycles) - this.ctx.cycles = _params.cycles; - if (_params.reused) - this.ctx.reused = _params.reused; - if (_params.external) - this.ctx.external = _params.external; - } - extractDefs(this.ctx, schema); - const result = finalize(this.ctx, schema); - const { "~standard": _, ...plainResult } = result; - return plainResult; - } -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema.js -var exports_json_schema = {}; -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.js -var exports_schemas2 = {}; -__export(exports_schemas2, { - xor: () => xor, - xid: () => xid2, - void: () => _void2, - uuidv7: () => uuidv7, - uuidv6: () => uuidv6, - uuidv4: () => uuidv4, - uuid: () => uuid2, - url: () => url, - unknown: () => unknown, - union: () => union, - undefined: () => _undefined3, - ulid: () => ulid2, - uint64: () => uint64, - uint32: () => uint32, - tuple: () => tuple, - transform: () => transform, - templateLiteral: () => templateLiteral, - symbol: () => symbol15, - superRefine: () => superRefine, - success: () => success, - stringbool: () => stringbool, - stringFormat: () => stringFormat, - string: () => string2, - strictObject: () => strictObject, - set: () => set, - refine: () => refine, - record: () => record, - readonly: () => readonly, - promise: () => promise, - preprocess: () => preprocess, - prefault: () => prefault, - pipe: () => pipe, - partialRecord: () => partialRecord, - optional: () => optional, - object: () => object, - number: () => number2, - nullish: () => nullish2, - nullable: () => nullable, - null: () => _null3, - nonoptional: () => nonoptional, - never: () => never, - nativeEnum: () => nativeEnum, - nanoid: () => nanoid2, - nan: () => nan, - meta: () => meta2, - map: () => map, - mac: () => mac2, - looseRecord: () => looseRecord, - looseObject: () => looseObject, - literal: () => literal, - lazy: () => lazy, - ksuid: () => ksuid2, - keyof: () => keyof, - jwt: () => jwt, - json: () => json, - ipv6: () => ipv62, - ipv4: () => ipv42, - intersection: () => intersection, - int64: () => int64, - int32: () => int32, - int: () => int, - instanceof: () => _instanceof, - httpUrl: () => httpUrl, - hostname: () => hostname2, - hex: () => hex2, - hash: () => hash, - guid: () => guid2, - function: () => _function, - float64: () => float64, - float32: () => float32, - file: () => file, - exactOptional: () => exactOptional, - enum: () => _enum2, - emoji: () => emoji2, - email: () => email2, - e164: () => e1642, - discriminatedUnion: () => discriminatedUnion, - describe: () => describe2, - date: () => date3, - custom: () => custom, - cuid2: () => cuid22, - cuid: () => cuid3, - codec: () => codec, - cidrv6: () => cidrv62, - cidrv4: () => cidrv42, - check: () => check, - catch: () => _catch2, - boolean: () => boolean2, - bigint: () => bigint2, - base64url: () => base64url2, - base64: () => base642, - array: () => array, - any: () => any, - _function: () => _function, - _default: () => _default2, - _ZodString: () => _ZodString, - ZodXor: () => ZodXor, - ZodXID: () => ZodXID, - ZodVoid: () => ZodVoid, - ZodUnknown: () => ZodUnknown, - ZodUnion: () => ZodUnion, - ZodUndefined: () => ZodUndefined, - ZodUUID: () => ZodUUID, - ZodURL: () => ZodURL, - ZodULID: () => ZodULID, - ZodType: () => ZodType, - ZodTuple: () => ZodTuple, - ZodTransform: () => ZodTransform, - ZodTemplateLiteral: () => ZodTemplateLiteral, - ZodSymbol: () => ZodSymbol, - ZodSuccess: () => ZodSuccess, - ZodStringFormat: () => ZodStringFormat, - ZodString: () => ZodString, - ZodSet: () => ZodSet, - ZodRecord: () => ZodRecord, - ZodReadonly: () => ZodReadonly, - ZodPromise: () => ZodPromise, - ZodPrefault: () => ZodPrefault, - ZodPipe: () => ZodPipe, - ZodOptional: () => ZodOptional, - ZodObject: () => ZodObject, - ZodNumberFormat: () => ZodNumberFormat, - ZodNumber: () => ZodNumber, - ZodNullable: () => ZodNullable, - ZodNull: () => ZodNull, - ZodNonOptional: () => ZodNonOptional, - ZodNever: () => ZodNever, - ZodNanoID: () => ZodNanoID, - ZodNaN: () => ZodNaN, - ZodMap: () => ZodMap, - ZodMAC: () => ZodMAC, - ZodLiteral: () => ZodLiteral, - ZodLazy: () => ZodLazy, - ZodKSUID: () => ZodKSUID, - ZodJWT: () => ZodJWT, - ZodIntersection: () => ZodIntersection, - ZodIPv6: () => ZodIPv6, - ZodIPv4: () => ZodIPv4, - ZodGUID: () => ZodGUID, - ZodFunction: () => ZodFunction, - ZodFile: () => ZodFile, - ZodExactOptional: () => ZodExactOptional, - ZodEnum: () => ZodEnum, - ZodEmoji: () => ZodEmoji, - ZodEmail: () => ZodEmail, - ZodE164: () => ZodE164, - ZodDiscriminatedUnion: () => ZodDiscriminatedUnion, - ZodDefault: () => ZodDefault, - ZodDate: () => ZodDate, - ZodCustomStringFormat: () => ZodCustomStringFormat, - ZodCustom: () => ZodCustom, - ZodCodec: () => ZodCodec, - ZodCatch: () => ZodCatch, - ZodCUID2: () => ZodCUID2, - ZodCUID: () => ZodCUID, - ZodCIDRv6: () => ZodCIDRv6, - ZodCIDRv4: () => ZodCIDRv4, - ZodBoolean: () => ZodBoolean, - ZodBigIntFormat: () => ZodBigIntFormat, - ZodBigInt: () => ZodBigInt, - ZodBase64URL: () => ZodBase64URL, - ZodBase64: () => ZodBase64, - ZodArray: () => ZodArray, - ZodAny: () => ZodAny -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/checks.js -var exports_checks2 = {}; -__export(exports_checks2, { - uppercase: () => _uppercase, - trim: () => _trim, - toUpperCase: () => _toUpperCase, - toLowerCase: () => _toLowerCase, - startsWith: () => _startsWith, - slugify: () => _slugify, - size: () => _size, - regex: () => _regex, - property: () => _property, - positive: () => _positive, - overwrite: () => _overwrite, - normalize: () => _normalize, - nonpositive: () => _nonpositive, - nonnegative: () => _nonnegative, - negative: () => _negative, - multipleOf: () => _multipleOf, - minSize: () => _minSize, - minLength: () => _minLength, - mime: () => _mime, - maxSize: () => _maxSize, - maxLength: () => _maxLength, - lte: () => _lte, - lt: () => _lt, - lowercase: () => _lowercase, - length: () => _length, - includes: () => _includes, - gte: () => _gte, - gt: () => _gt, - endsWith: () => _endsWith -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/iso.js -var exports_iso = {}; -__export(exports_iso, { - time: () => time2, - duration: () => duration2, - datetime: () => datetime2, - date: () => date2, - ZodISOTime: () => ZodISOTime, - ZodISODuration: () => ZodISODuration, - ZodISODateTime: () => ZodISODateTime, - ZodISODate: () => ZodISODate -}); -var ZodISODateTime = /* @__PURE__ */ $constructor("ZodISODateTime", (inst, def) => { - $ZodISODateTime.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function datetime2(params) { - return _isoDateTime(ZodISODateTime, params); -} -var ZodISODate = /* @__PURE__ */ $constructor("ZodISODate", (inst, def) => { - $ZodISODate.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function date2(params) { - return _isoDate(ZodISODate, params); -} -var ZodISOTime = /* @__PURE__ */ $constructor("ZodISOTime", (inst, def) => { - $ZodISOTime.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function time2(params) { - return _isoTime(ZodISOTime, params); -} -var ZodISODuration = /* @__PURE__ */ $constructor("ZodISODuration", (inst, def) => { - $ZodISODuration.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function duration2(params) { - return _isoDuration(ZodISODuration, params); -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/errors.js -var initializer2 = (inst, issues) => { - $ZodError.init(inst, issues); - inst.name = "ZodError"; - Object.defineProperties(inst, { - format: { - value: (mapper) => formatError(inst, mapper) - }, - flatten: { - value: (mapper) => flattenError(inst, mapper) - }, - addIssue: { - value: (issue2) => { - inst.issues.push(issue2); - inst.message = JSON.stringify(inst.issues, jsonStringifyReplacer, 2); - } - }, - addIssues: { - value: (issues2) => { - inst.issues.push(...issues2); - inst.message = JSON.stringify(inst.issues, jsonStringifyReplacer, 2); - } - }, - isEmpty: { - get() { - return inst.issues.length === 0; - } - } - }); -}; -var ZodError = $constructor("ZodError", initializer2); -var ZodRealError = $constructor("ZodError", initializer2, { - Parent: Error -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/parse.js -var parse3 = /* @__PURE__ */ _parse(ZodRealError); -var parseAsync2 = /* @__PURE__ */ _parseAsync(ZodRealError); -var safeParse2 = /* @__PURE__ */ _safeParse(ZodRealError); -var safeParseAsync2 = /* @__PURE__ */ _safeParseAsync(ZodRealError); -var encode2 = /* @__PURE__ */ _encode(ZodRealError); -var decode2 = /* @__PURE__ */ _decode(ZodRealError); -var encodeAsync2 = /* @__PURE__ */ _encodeAsync(ZodRealError); -var decodeAsync2 = /* @__PURE__ */ _decodeAsync(ZodRealError); -var safeEncode2 = /* @__PURE__ */ _safeEncode(ZodRealError); -var safeDecode2 = /* @__PURE__ */ _safeDecode(ZodRealError); -var safeEncodeAsync2 = /* @__PURE__ */ _safeEncodeAsync(ZodRealError); -var safeDecodeAsync2 = /* @__PURE__ */ _safeDecodeAsync(ZodRealError); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.js -var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { - $ZodType.init(inst, def); - Object.assign(inst["~standard"], { - jsonSchema: { - input: createStandardJSONSchemaMethod(inst, "input"), - output: createStandardJSONSchemaMethod(inst, "output") - } - }); - inst.toJSONSchema = createToJSONSchemaMethod(inst, {}); - inst.def = def; - inst.type = def.type; - Object.defineProperty(inst, "_def", { value: def }); - inst.check = (...checks2) => { - return inst.clone(exports_util.mergeDefs(def, { - checks: [ - ...def.checks ?? [], - ...checks2.map((ch) => typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch) - ] - }), { - parent: true - }); - }; - inst.with = inst.check; - inst.clone = (def2, params) => clone(inst, def2, params); - inst.brand = () => inst; - inst.register = (reg, meta2) => { - reg.add(inst, meta2); - return inst; - }; - inst.parse = (data, params) => parse3(inst, data, params, { callee: inst.parse }); - inst.safeParse = (data, params) => safeParse2(inst, data, params); - inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync }); - inst.safeParseAsync = async (data, params) => safeParseAsync2(inst, data, params); - inst.spa = inst.safeParseAsync; - inst.encode = (data, params) => encode2(inst, data, params); - inst.decode = (data, params) => decode2(inst, data, params); - inst.encodeAsync = async (data, params) => encodeAsync2(inst, data, params); - inst.decodeAsync = async (data, params) => decodeAsync2(inst, data, params); - inst.safeEncode = (data, params) => safeEncode2(inst, data, params); - inst.safeDecode = (data, params) => safeDecode2(inst, data, params); - inst.safeEncodeAsync = async (data, params) => safeEncodeAsync2(inst, data, params); - inst.safeDecodeAsync = async (data, params) => safeDecodeAsync2(inst, data, params); - inst.refine = (check, params) => inst.check(refine(check, params)); - inst.superRefine = (refinement) => inst.check(superRefine(refinement)); - inst.overwrite = (fn) => inst.check(_overwrite(fn)); - inst.optional = () => optional(inst); - inst.exactOptional = () => exactOptional(inst); - inst.nullable = () => nullable(inst); - inst.nullish = () => optional(nullable(inst)); - inst.nonoptional = (params) => nonoptional(inst, params); - inst.array = () => array(inst); - inst.or = (arg) => union([inst, arg]); - inst.and = (arg) => intersection(inst, arg); - inst.transform = (tx) => pipe(inst, transform(tx)); - inst.default = (def2) => _default2(inst, def2); - inst.prefault = (def2) => prefault(inst, def2); - inst.catch = (params) => _catch2(inst, params); - inst.pipe = (target) => pipe(inst, target); - inst.readonly = () => readonly(inst); - inst.describe = (description) => { - const cl = inst.clone(); - globalRegistry.add(cl, { description }); - return cl; - }; - Object.defineProperty(inst, "description", { - get() { - return globalRegistry.get(inst)?.description; - }, - configurable: true - }); - inst.meta = (...args) => { - if (args.length === 0) { - return globalRegistry.get(inst); - } - const cl = inst.clone(); - globalRegistry.add(cl, args[0]); - return cl; - }; - inst.isOptional = () => inst.safeParse(undefined).success; - inst.isNullable = () => inst.safeParse(null).success; - inst.apply = (fn) => fn(inst); - return inst; -}); -var _ZodString = /* @__PURE__ */ $constructor("_ZodString", (inst, def) => { - $ZodString.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => stringProcessor(inst, ctx, json, params); - const bag = inst._zod.bag; - inst.format = bag.format ?? null; - inst.minLength = bag.minimum ?? null; - inst.maxLength = bag.maximum ?? null; - inst.regex = (...args) => inst.check(_regex(...args)); - inst.includes = (...args) => inst.check(_includes(...args)); - inst.startsWith = (...args) => inst.check(_startsWith(...args)); - inst.endsWith = (...args) => inst.check(_endsWith(...args)); - inst.min = (...args) => inst.check(_minLength(...args)); - inst.max = (...args) => inst.check(_maxLength(...args)); - inst.length = (...args) => inst.check(_length(...args)); - inst.nonempty = (...args) => inst.check(_minLength(1, ...args)); - inst.lowercase = (params) => inst.check(_lowercase(params)); - inst.uppercase = (params) => inst.check(_uppercase(params)); - inst.trim = () => inst.check(_trim()); - inst.normalize = (...args) => inst.check(_normalize(...args)); - inst.toLowerCase = () => inst.check(_toLowerCase()); - inst.toUpperCase = () => inst.check(_toUpperCase()); - inst.slugify = () => inst.check(_slugify()); -}); -var ZodString = /* @__PURE__ */ $constructor("ZodString", (inst, def) => { - $ZodString.init(inst, def); - _ZodString.init(inst, def); - inst.email = (params) => inst.check(_email(ZodEmail, params)); - inst.url = (params) => inst.check(_url(ZodURL, params)); - inst.jwt = (params) => inst.check(_jwt(ZodJWT, params)); - inst.emoji = (params) => inst.check(_emoji2(ZodEmoji, params)); - inst.guid = (params) => inst.check(_guid(ZodGUID, params)); - inst.uuid = (params) => inst.check(_uuid(ZodUUID, params)); - inst.uuidv4 = (params) => inst.check(_uuidv4(ZodUUID, params)); - inst.uuidv6 = (params) => inst.check(_uuidv6(ZodUUID, params)); - inst.uuidv7 = (params) => inst.check(_uuidv7(ZodUUID, params)); - inst.nanoid = (params) => inst.check(_nanoid(ZodNanoID, params)); - inst.guid = (params) => inst.check(_guid(ZodGUID, params)); - inst.cuid = (params) => inst.check(_cuid(ZodCUID, params)); - inst.cuid2 = (params) => inst.check(_cuid2(ZodCUID2, params)); - inst.ulid = (params) => inst.check(_ulid(ZodULID, params)); - inst.base64 = (params) => inst.check(_base64(ZodBase64, params)); - inst.base64url = (params) => inst.check(_base64url(ZodBase64URL, params)); - inst.xid = (params) => inst.check(_xid(ZodXID, params)); - inst.ksuid = (params) => inst.check(_ksuid(ZodKSUID, params)); - inst.ipv4 = (params) => inst.check(_ipv4(ZodIPv4, params)); - inst.ipv6 = (params) => inst.check(_ipv6(ZodIPv6, params)); - inst.cidrv4 = (params) => inst.check(_cidrv4(ZodCIDRv4, params)); - inst.cidrv6 = (params) => inst.check(_cidrv6(ZodCIDRv6, params)); - inst.e164 = (params) => inst.check(_e164(ZodE164, params)); - inst.datetime = (params) => inst.check(datetime2(params)); - inst.date = (params) => inst.check(date2(params)); - inst.time = (params) => inst.check(time2(params)); - inst.duration = (params) => inst.check(duration2(params)); -}); -function string2(params) { - return _string(ZodString, params); -} -var ZodStringFormat = /* @__PURE__ */ $constructor("ZodStringFormat", (inst, def) => { - $ZodStringFormat.init(inst, def); - _ZodString.init(inst, def); -}); -var ZodEmail = /* @__PURE__ */ $constructor("ZodEmail", (inst, def) => { - $ZodEmail.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function email2(params) { - return _email(ZodEmail, params); -} -var ZodGUID = /* @__PURE__ */ $constructor("ZodGUID", (inst, def) => { - $ZodGUID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function guid2(params) { - return _guid(ZodGUID, params); -} -var ZodUUID = /* @__PURE__ */ $constructor("ZodUUID", (inst, def) => { - $ZodUUID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function uuid2(params) { - return _uuid(ZodUUID, params); -} -function uuidv4(params) { - return _uuidv4(ZodUUID, params); -} -function uuidv6(params) { - return _uuidv6(ZodUUID, params); -} -function uuidv7(params) { - return _uuidv7(ZodUUID, params); -} -var ZodURL = /* @__PURE__ */ $constructor("ZodURL", (inst, def) => { - $ZodURL.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function url(params) { - return _url(ZodURL, params); -} -function httpUrl(params) { - return _url(ZodURL, { - protocol: /^https?$/, - hostname: exports_regexes.domain, - ...exports_util.normalizeParams(params) - }); -} -var ZodEmoji = /* @__PURE__ */ $constructor("ZodEmoji", (inst, def) => { - $ZodEmoji.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function emoji2(params) { - return _emoji2(ZodEmoji, params); -} -var ZodNanoID = /* @__PURE__ */ $constructor("ZodNanoID", (inst, def) => { - $ZodNanoID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function nanoid2(params) { - return _nanoid(ZodNanoID, params); -} -var ZodCUID = /* @__PURE__ */ $constructor("ZodCUID", (inst, def) => { - $ZodCUID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function cuid3(params) { - return _cuid(ZodCUID, params); -} -var ZodCUID2 = /* @__PURE__ */ $constructor("ZodCUID2", (inst, def) => { - $ZodCUID2.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function cuid22(params) { - return _cuid2(ZodCUID2, params); -} -var ZodULID = /* @__PURE__ */ $constructor("ZodULID", (inst, def) => { - $ZodULID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function ulid2(params) { - return _ulid(ZodULID, params); -} -var ZodXID = /* @__PURE__ */ $constructor("ZodXID", (inst, def) => { - $ZodXID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function xid2(params) { - return _xid(ZodXID, params); -} -var ZodKSUID = /* @__PURE__ */ $constructor("ZodKSUID", (inst, def) => { - $ZodKSUID.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function ksuid2(params) { - return _ksuid(ZodKSUID, params); -} -var ZodIPv4 = /* @__PURE__ */ $constructor("ZodIPv4", (inst, def) => { - $ZodIPv4.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function ipv42(params) { - return _ipv4(ZodIPv4, params); -} -var ZodMAC = /* @__PURE__ */ $constructor("ZodMAC", (inst, def) => { - $ZodMAC.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function mac2(params) { - return _mac(ZodMAC, params); -} -var ZodIPv6 = /* @__PURE__ */ $constructor("ZodIPv6", (inst, def) => { - $ZodIPv6.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function ipv62(params) { - return _ipv6(ZodIPv6, params); -} -var ZodCIDRv4 = /* @__PURE__ */ $constructor("ZodCIDRv4", (inst, def) => { - $ZodCIDRv4.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function cidrv42(params) { - return _cidrv4(ZodCIDRv4, params); -} -var ZodCIDRv6 = /* @__PURE__ */ $constructor("ZodCIDRv6", (inst, def) => { - $ZodCIDRv6.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function cidrv62(params) { - return _cidrv6(ZodCIDRv6, params); -} -var ZodBase64 = /* @__PURE__ */ $constructor("ZodBase64", (inst, def) => { - $ZodBase64.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function base642(params) { - return _base64(ZodBase64, params); -} -var ZodBase64URL = /* @__PURE__ */ $constructor("ZodBase64URL", (inst, def) => { - $ZodBase64URL.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function base64url2(params) { - return _base64url(ZodBase64URL, params); -} -var ZodE164 = /* @__PURE__ */ $constructor("ZodE164", (inst, def) => { - $ZodE164.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function e1642(params) { - return _e164(ZodE164, params); -} -var ZodJWT = /* @__PURE__ */ $constructor("ZodJWT", (inst, def) => { - $ZodJWT.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function jwt(params) { - return _jwt(ZodJWT, params); -} -var ZodCustomStringFormat = /* @__PURE__ */ $constructor("ZodCustomStringFormat", (inst, def) => { - $ZodCustomStringFormat.init(inst, def); - ZodStringFormat.init(inst, def); -}); -function stringFormat(format, fnOrRegex, _params = {}) { - return _stringFormat(ZodCustomStringFormat, format, fnOrRegex, _params); -} -function hostname2(_params) { - return _stringFormat(ZodCustomStringFormat, "hostname", exports_regexes.hostname, _params); -} -function hex2(_params) { - return _stringFormat(ZodCustomStringFormat, "hex", exports_regexes.hex, _params); -} -function hash(alg, params) { - const enc = params?.enc ?? "hex"; - const format = `${alg}_${enc}`; - const regex = exports_regexes[format]; - if (!regex) - throw new Error(`Unrecognized hash format: ${format}`); - return _stringFormat(ZodCustomStringFormat, format, regex, params); -} -var ZodNumber = /* @__PURE__ */ $constructor("ZodNumber", (inst, def) => { - $ZodNumber.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => numberProcessor(inst, ctx, json, params); - inst.gt = (value, params) => inst.check(_gt(value, params)); - inst.gte = (value, params) => inst.check(_gte(value, params)); - inst.min = (value, params) => inst.check(_gte(value, params)); - inst.lt = (value, params) => inst.check(_lt(value, params)); - inst.lte = (value, params) => inst.check(_lte(value, params)); - inst.max = (value, params) => inst.check(_lte(value, params)); - inst.int = (params) => inst.check(int(params)); - inst.safe = (params) => inst.check(int(params)); - inst.positive = (params) => inst.check(_gt(0, params)); - inst.nonnegative = (params) => inst.check(_gte(0, params)); - inst.negative = (params) => inst.check(_lt(0, params)); - inst.nonpositive = (params) => inst.check(_lte(0, params)); - inst.multipleOf = (value, params) => inst.check(_multipleOf(value, params)); - inst.step = (value, params) => inst.check(_multipleOf(value, params)); - inst.finite = () => inst; - const bag = inst._zod.bag; - inst.minValue = Math.max(bag.minimum ?? Number.NEGATIVE_INFINITY, bag.exclusiveMinimum ?? Number.NEGATIVE_INFINITY) ?? null; - inst.maxValue = Math.min(bag.maximum ?? Number.POSITIVE_INFINITY, bag.exclusiveMaximum ?? Number.POSITIVE_INFINITY) ?? null; - inst.isInt = (bag.format ?? "").includes("int") || Number.isSafeInteger(bag.multipleOf ?? 0.5); - inst.isFinite = true; - inst.format = bag.format ?? null; -}); -function number2(params) { - return _number(ZodNumber, params); -} -var ZodNumberFormat = /* @__PURE__ */ $constructor("ZodNumberFormat", (inst, def) => { - $ZodNumberFormat.init(inst, def); - ZodNumber.init(inst, def); -}); -function int(params) { - return _int(ZodNumberFormat, params); -} -function float32(params) { - return _float32(ZodNumberFormat, params); -} -function float64(params) { - return _float64(ZodNumberFormat, params); -} -function int32(params) { - return _int32(ZodNumberFormat, params); -} -function uint32(params) { - return _uint32(ZodNumberFormat, params); -} -var ZodBoolean = /* @__PURE__ */ $constructor("ZodBoolean", (inst, def) => { - $ZodBoolean.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => booleanProcessor(inst, ctx, json, params); -}); -function boolean2(params) { - return _boolean(ZodBoolean, params); -} -var ZodBigInt = /* @__PURE__ */ $constructor("ZodBigInt", (inst, def) => { - $ZodBigInt.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => bigintProcessor(inst, ctx, json, params); - inst.gte = (value, params) => inst.check(_gte(value, params)); - inst.min = (value, params) => inst.check(_gte(value, params)); - inst.gt = (value, params) => inst.check(_gt(value, params)); - inst.gte = (value, params) => inst.check(_gte(value, params)); - inst.min = (value, params) => inst.check(_gte(value, params)); - inst.lt = (value, params) => inst.check(_lt(value, params)); - inst.lte = (value, params) => inst.check(_lte(value, params)); - inst.max = (value, params) => inst.check(_lte(value, params)); - inst.positive = (params) => inst.check(_gt(BigInt(0), params)); - inst.negative = (params) => inst.check(_lt(BigInt(0), params)); - inst.nonpositive = (params) => inst.check(_lte(BigInt(0), params)); - inst.nonnegative = (params) => inst.check(_gte(BigInt(0), params)); - inst.multipleOf = (value, params) => inst.check(_multipleOf(value, params)); - const bag = inst._zod.bag; - inst.minValue = bag.minimum ?? null; - inst.maxValue = bag.maximum ?? null; - inst.format = bag.format ?? null; -}); -function bigint2(params) { - return _bigint(ZodBigInt, params); -} -var ZodBigIntFormat = /* @__PURE__ */ $constructor("ZodBigIntFormat", (inst, def) => { - $ZodBigIntFormat.init(inst, def); - ZodBigInt.init(inst, def); -}); -function int64(params) { - return _int64(ZodBigIntFormat, params); -} -function uint64(params) { - return _uint64(ZodBigIntFormat, params); -} -var ZodSymbol = /* @__PURE__ */ $constructor("ZodSymbol", (inst, def) => { - $ZodSymbol.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => symbolProcessor(inst, ctx, json, params); -}); -function symbol15(params) { - return _symbol(ZodSymbol, params); -} -var ZodUndefined = /* @__PURE__ */ $constructor("ZodUndefined", (inst, def) => { - $ZodUndefined.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => undefinedProcessor(inst, ctx, json, params); -}); -function _undefined3(params) { - return _undefined2(ZodUndefined, params); -} -var ZodNull = /* @__PURE__ */ $constructor("ZodNull", (inst, def) => { - $ZodNull.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => nullProcessor(inst, ctx, json, params); -}); -function _null3(params) { - return _null2(ZodNull, params); -} -var ZodAny = /* @__PURE__ */ $constructor("ZodAny", (inst, def) => { - $ZodAny.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => anyProcessor(inst, ctx, json, params); -}); -function any() { - return _any(ZodAny); -} -var ZodUnknown = /* @__PURE__ */ $constructor("ZodUnknown", (inst, def) => { - $ZodUnknown.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => unknownProcessor(inst, ctx, json, params); -}); -function unknown() { - return _unknown(ZodUnknown); -} -var ZodNever = /* @__PURE__ */ $constructor("ZodNever", (inst, def) => { - $ZodNever.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => neverProcessor(inst, ctx, json, params); -}); -function never(params) { - return _never(ZodNever, params); -} -var ZodVoid = /* @__PURE__ */ $constructor("ZodVoid", (inst, def) => { - $ZodVoid.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => voidProcessor(inst, ctx, json, params); -}); -function _void2(params) { - return _void(ZodVoid, params); -} -var ZodDate = /* @__PURE__ */ $constructor("ZodDate", (inst, def) => { - $ZodDate.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => dateProcessor(inst, ctx, json, params); - inst.min = (value, params) => inst.check(_gte(value, params)); - inst.max = (value, params) => inst.check(_lte(value, params)); - const c = inst._zod.bag; - inst.minDate = c.minimum ? new Date(c.minimum) : null; - inst.maxDate = c.maximum ? new Date(c.maximum) : null; -}); -function date3(params) { - return _date(ZodDate, params); -} -var ZodArray = /* @__PURE__ */ $constructor("ZodArray", (inst, def) => { - $ZodArray.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => arrayProcessor(inst, ctx, json, params); - inst.element = def.element; - inst.min = (minLength, params) => inst.check(_minLength(minLength, params)); - inst.nonempty = (params) => inst.check(_minLength(1, params)); - inst.max = (maxLength, params) => inst.check(_maxLength(maxLength, params)); - inst.length = (len, params) => inst.check(_length(len, params)); - inst.unwrap = () => inst.element; -}); -function array(element, params) { - return _array(ZodArray, element, params); -} -function keyof(schema) { - const shape = schema._zod.def.shape; - return _enum2(Object.keys(shape)); -} -var ZodObject = /* @__PURE__ */ $constructor("ZodObject", (inst, def) => { - $ZodObjectJIT.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => objectProcessor(inst, ctx, json, params); - exports_util.defineLazy(inst, "shape", () => { - return def.shape; - }); - inst.keyof = () => _enum2(Object.keys(inst._zod.def.shape)); - inst.catchall = (catchall) => inst.clone({ ...inst._zod.def, catchall }); - inst.passthrough = () => inst.clone({ ...inst._zod.def, catchall: unknown() }); - inst.loose = () => inst.clone({ ...inst._zod.def, catchall: unknown() }); - inst.strict = () => inst.clone({ ...inst._zod.def, catchall: never() }); - inst.strip = () => inst.clone({ ...inst._zod.def, catchall: undefined }); - inst.extend = (incoming) => { - return exports_util.extend(inst, incoming); - }; - inst.safeExtend = (incoming) => { - return exports_util.safeExtend(inst, incoming); - }; - inst.merge = (other) => exports_util.merge(inst, other); - inst.pick = (mask) => exports_util.pick(inst, mask); - inst.omit = (mask) => exports_util.omit(inst, mask); - inst.partial = (...args) => exports_util.partial(ZodOptional, inst, args[0]); - inst.required = (...args) => exports_util.required(ZodNonOptional, inst, args[0]); -}); -function object(shape, params) { - const def = { - type: "object", - shape: shape ?? {}, - ...exports_util.normalizeParams(params) - }; - return new ZodObject(def); -} -function strictObject(shape, params) { - return new ZodObject({ - type: "object", - shape, - catchall: never(), - ...exports_util.normalizeParams(params) - }); -} -function looseObject(shape, params) { - return new ZodObject({ - type: "object", - shape, - catchall: unknown(), - ...exports_util.normalizeParams(params) - }); -} -var ZodUnion = /* @__PURE__ */ $constructor("ZodUnion", (inst, def) => { - $ZodUnion.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); - inst.options = def.options; -}); -function union(options, params) { - return new ZodUnion({ - type: "union", - options, - ...exports_util.normalizeParams(params) - }); -} -var ZodXor = /* @__PURE__ */ $constructor("ZodXor", (inst, def) => { - ZodUnion.init(inst, def); - $ZodXor.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params); - inst.options = def.options; -}); -function xor(options, params) { - return new ZodXor({ - type: "union", - options, - inclusive: false, - ...exports_util.normalizeParams(params) - }); -} -var ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("ZodDiscriminatedUnion", (inst, def) => { - ZodUnion.init(inst, def); - $ZodDiscriminatedUnion.init(inst, def); -}); -function discriminatedUnion(discriminator, options, params) { - return new ZodDiscriminatedUnion({ - type: "union", - options, - discriminator, - ...exports_util.normalizeParams(params) - }); -} -var ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def) => { - $ZodIntersection.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => intersectionProcessor(inst, ctx, json, params); -}); -function intersection(left, right) { - return new ZodIntersection({ - type: "intersection", - left, - right - }); -} -var ZodTuple = /* @__PURE__ */ $constructor("ZodTuple", (inst, def) => { - $ZodTuple.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => tupleProcessor(inst, ctx, json, params); - inst.rest = (rest) => inst.clone({ - ...inst._zod.def, - rest - }); -}); -function tuple(items, _paramsOrRest, _params) { - const hasRest = _paramsOrRest instanceof $ZodType; - const params = hasRest ? _params : _paramsOrRest; - const rest = hasRest ? _paramsOrRest : null; - return new ZodTuple({ - type: "tuple", - items, - rest, - ...exports_util.normalizeParams(params) - }); -} -var ZodRecord = /* @__PURE__ */ $constructor("ZodRecord", (inst, def) => { - $ZodRecord.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => recordProcessor(inst, ctx, json, params); - inst.keyType = def.keyType; - inst.valueType = def.valueType; -}); -function record(keyType, valueType, params) { - return new ZodRecord({ - type: "record", - keyType, - valueType, - ...exports_util.normalizeParams(params) - }); -} -function partialRecord(keyType, valueType, params) { - const k = clone(keyType); - k._zod.values = undefined; - return new ZodRecord({ - type: "record", - keyType: k, - valueType, - ...exports_util.normalizeParams(params) - }); -} -function looseRecord(keyType, valueType, params) { - return new ZodRecord({ - type: "record", - keyType, - valueType, - mode: "loose", - ...exports_util.normalizeParams(params) - }); -} -var ZodMap = /* @__PURE__ */ $constructor("ZodMap", (inst, def) => { - $ZodMap.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => mapProcessor(inst, ctx, json, params); - inst.keyType = def.keyType; - inst.valueType = def.valueType; - inst.min = (...args) => inst.check(_minSize(...args)); - inst.nonempty = (params) => inst.check(_minSize(1, params)); - inst.max = (...args) => inst.check(_maxSize(...args)); - inst.size = (...args) => inst.check(_size(...args)); -}); -function map(keyType, valueType, params) { - return new ZodMap({ - type: "map", - keyType, - valueType, - ...exports_util.normalizeParams(params) - }); -} -var ZodSet = /* @__PURE__ */ $constructor("ZodSet", (inst, def) => { - $ZodSet.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => setProcessor(inst, ctx, json, params); - inst.min = (...args) => inst.check(_minSize(...args)); - inst.nonempty = (params) => inst.check(_minSize(1, params)); - inst.max = (...args) => inst.check(_maxSize(...args)); - inst.size = (...args) => inst.check(_size(...args)); -}); -function set(valueType, params) { - return new ZodSet({ - type: "set", - valueType, - ...exports_util.normalizeParams(params) - }); -} -var ZodEnum = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => { - $ZodEnum.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => enumProcessor(inst, ctx, json, params); - inst.enum = def.entries; - inst.options = Object.values(def.entries); - const keys = new Set(Object.keys(def.entries)); - inst.extract = (values, params) => { - const newEntries = {}; - for (const value of values) { - if (keys.has(value)) { - newEntries[value] = def.entries[value]; - } else - throw new Error(`Key ${value} not found in enum`); - } - return new ZodEnum({ - ...def, - checks: [], - ...exports_util.normalizeParams(params), - entries: newEntries - }); - }; - inst.exclude = (values, params) => { - const newEntries = { ...def.entries }; - for (const value of values) { - if (keys.has(value)) { - delete newEntries[value]; - } else - throw new Error(`Key ${value} not found in enum`); - } - return new ZodEnum({ - ...def, - checks: [], - ...exports_util.normalizeParams(params), - entries: newEntries - }); - }; -}); -function _enum2(values, params) { - const entries = Array.isArray(values) ? Object.fromEntries(values.map((v) => [v, v])) : values; - return new ZodEnum({ - type: "enum", - entries, - ...exports_util.normalizeParams(params) - }); -} -function nativeEnum(entries, params) { - return new ZodEnum({ - type: "enum", - entries, - ...exports_util.normalizeParams(params) - }); -} -var ZodLiteral = /* @__PURE__ */ $constructor("ZodLiteral", (inst, def) => { - $ZodLiteral.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => literalProcessor(inst, ctx, json, params); - inst.values = new Set(def.values); - Object.defineProperty(inst, "value", { - get() { - if (def.values.length > 1) { - throw new Error("This schema contains multiple valid literal values. Use `.values` instead."); - } - return def.values[0]; - } - }); -}); -function literal(value, params) { - return new ZodLiteral({ - type: "literal", - values: Array.isArray(value) ? value : [value], - ...exports_util.normalizeParams(params) - }); -} -var ZodFile = /* @__PURE__ */ $constructor("ZodFile", (inst, def) => { - $ZodFile.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => fileProcessor(inst, ctx, json, params); - inst.min = (size, params) => inst.check(_minSize(size, params)); - inst.max = (size, params) => inst.check(_maxSize(size, params)); - inst.mime = (types, params) => inst.check(_mime(Array.isArray(types) ? types : [types], params)); -}); -function file(params) { - return _file(ZodFile, params); -} -var ZodTransform = /* @__PURE__ */ $constructor("ZodTransform", (inst, def) => { - $ZodTransform.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => transformProcessor(inst, ctx, json, params); - inst._zod.parse = (payload, _ctx) => { - if (_ctx.direction === "backward") { - throw new $ZodEncodeError(inst.constructor.name); - } - payload.addIssue = (issue2) => { - if (typeof issue2 === "string") { - payload.issues.push(exports_util.issue(issue2, payload.value, def)); - } else { - const _issue = issue2; - if (_issue.fatal) - _issue.continue = false; - _issue.code ?? (_issue.code = "custom"); - _issue.input ?? (_issue.input = payload.value); - _issue.inst ?? (_issue.inst = inst); - payload.issues.push(exports_util.issue(_issue)); - } - }; - const output = def.transform(payload.value, payload); - if (output instanceof Promise) { - return output.then((output2) => { - payload.value = output2; - return payload; - }); - } - payload.value = output; - return payload; - }; -}); -function transform(fn) { - return new ZodTransform({ - type: "transform", - transform: fn - }); -} -var ZodOptional = /* @__PURE__ */ $constructor("ZodOptional", (inst, def) => { - $ZodOptional.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function optional(innerType) { - return new ZodOptional({ - type: "optional", - innerType - }); -} -var ZodExactOptional = /* @__PURE__ */ $constructor("ZodExactOptional", (inst, def) => { - $ZodExactOptional.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => optionalProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function exactOptional(innerType) { - return new ZodExactOptional({ - type: "optional", - innerType - }); -} -var ZodNullable = /* @__PURE__ */ $constructor("ZodNullable", (inst, def) => { - $ZodNullable.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => nullableProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function nullable(innerType) { - return new ZodNullable({ - type: "nullable", - innerType - }); -} -function nullish2(innerType) { - return optional(nullable(innerType)); -} -var ZodDefault = /* @__PURE__ */ $constructor("ZodDefault", (inst, def) => { - $ZodDefault.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => defaultProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; - inst.removeDefault = inst.unwrap; -}); -function _default2(innerType, defaultValue) { - return new ZodDefault({ - type: "default", - innerType, - get defaultValue() { - return typeof defaultValue === "function" ? defaultValue() : exports_util.shallowClone(defaultValue); - } - }); -} -var ZodPrefault = /* @__PURE__ */ $constructor("ZodPrefault", (inst, def) => { - $ZodPrefault.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => prefaultProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function prefault(innerType, defaultValue) { - return new ZodPrefault({ - type: "prefault", - innerType, - get defaultValue() { - return typeof defaultValue === "function" ? defaultValue() : exports_util.shallowClone(defaultValue); - } - }); -} -var ZodNonOptional = /* @__PURE__ */ $constructor("ZodNonOptional", (inst, def) => { - $ZodNonOptional.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => nonoptionalProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function nonoptional(innerType, params) { - return new ZodNonOptional({ - type: "nonoptional", - innerType, - ...exports_util.normalizeParams(params) - }); -} -var ZodSuccess = /* @__PURE__ */ $constructor("ZodSuccess", (inst, def) => { - $ZodSuccess.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => successProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function success(innerType) { - return new ZodSuccess({ - type: "success", - innerType - }); -} -var ZodCatch = /* @__PURE__ */ $constructor("ZodCatch", (inst, def) => { - $ZodCatch.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => catchProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; - inst.removeCatch = inst.unwrap; -}); -function _catch2(innerType, catchValue) { - return new ZodCatch({ - type: "catch", - innerType, - catchValue: typeof catchValue === "function" ? catchValue : () => catchValue - }); -} -var ZodNaN = /* @__PURE__ */ $constructor("ZodNaN", (inst, def) => { - $ZodNaN.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => nanProcessor(inst, ctx, json, params); -}); -function nan(params) { - return _nan(ZodNaN, params); -} -var ZodPipe = /* @__PURE__ */ $constructor("ZodPipe", (inst, def) => { - $ZodPipe.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => pipeProcessor(inst, ctx, json, params); - inst.in = def.in; - inst.out = def.out; -}); -function pipe(in_, out) { - return new ZodPipe({ - type: "pipe", - in: in_, - out - }); -} -var ZodCodec = /* @__PURE__ */ $constructor("ZodCodec", (inst, def) => { - ZodPipe.init(inst, def); - $ZodCodec.init(inst, def); -}); -function codec(in_, out, params) { - return new ZodCodec({ - type: "pipe", - in: in_, - out, - transform: params.decode, - reverseTransform: params.encode - }); -} -var ZodReadonly = /* @__PURE__ */ $constructor("ZodReadonly", (inst, def) => { - $ZodReadonly.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => readonlyProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function readonly(innerType) { - return new ZodReadonly({ - type: "readonly", - innerType - }); -} -var ZodTemplateLiteral = /* @__PURE__ */ $constructor("ZodTemplateLiteral", (inst, def) => { - $ZodTemplateLiteral.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => templateLiteralProcessor(inst, ctx, json, params); -}); -function templateLiteral(parts, params) { - return new ZodTemplateLiteral({ - type: "template_literal", - parts, - ...exports_util.normalizeParams(params) - }); -} -var ZodLazy = /* @__PURE__ */ $constructor("ZodLazy", (inst, def) => { - $ZodLazy.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => lazyProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.getter(); -}); -function lazy(getter) { - return new ZodLazy({ - type: "lazy", - getter - }); -} -var ZodPromise = /* @__PURE__ */ $constructor("ZodPromise", (inst, def) => { - $ZodPromise.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => promiseProcessor(inst, ctx, json, params); - inst.unwrap = () => inst._zod.def.innerType; -}); -function promise(innerType) { - return new ZodPromise({ - type: "promise", - innerType - }); -} -var ZodFunction = /* @__PURE__ */ $constructor("ZodFunction", (inst, def) => { - $ZodFunction.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => functionProcessor(inst, ctx, json, params); -}); -function _function(params) { - return new ZodFunction({ - type: "function", - input: Array.isArray(params?.input) ? tuple(params?.input) : params?.input ?? array(unknown()), - output: params?.output ?? unknown() - }); -} -var ZodCustom = /* @__PURE__ */ $constructor("ZodCustom", (inst, def) => { - $ZodCustom.init(inst, def); - ZodType.init(inst, def); - inst._zod.processJSONSchema = (ctx, json, params) => customProcessor(inst, ctx, json, params); -}); -function check(fn) { - const ch = new $ZodCheck({ - check: "custom" - }); - ch._zod.check = fn; - return ch; -} -function custom(fn, _params) { - return _custom(ZodCustom, fn ?? (() => true), _params); -} -function refine(fn, _params = {}) { - return _refine(ZodCustom, fn, _params); -} -function superRefine(fn) { - return _superRefine(fn); -} -var describe2 = describe; -var meta2 = meta; -function _instanceof(cls, params = {}) { - const inst = new ZodCustom({ - type: "custom", - check: "custom", - fn: (data) => data instanceof cls, - abort: true, - ...exports_util.normalizeParams(params) - }); - inst._zod.bag.Class = cls; - inst._zod.check = (payload) => { - if (!(payload.value instanceof cls)) { - payload.issues.push({ - code: "invalid_type", - expected: cls.name, - input: payload.value, - inst, - path: [...inst._zod.def.path ?? []] - }); - } - }; - return inst; -} -var stringbool = (...args) => _stringbool({ - Codec: ZodCodec, - Boolean: ZodBoolean, - String: ZodString -}, ...args); -function json(params) { - const jsonSchema = lazy(() => { - return union([string2(params), number2(), boolean2(), _null3(), array(jsonSchema), record(string2(), jsonSchema)]); - }); - return jsonSchema; -} -function preprocess(fn, schema) { - return pipe(transform(fn), schema); -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/compat.js -var ZodIssueCode = { - invalid_type: "invalid_type", - too_big: "too_big", - too_small: "too_small", - invalid_format: "invalid_format", - not_multiple_of: "not_multiple_of", - unrecognized_keys: "unrecognized_keys", - invalid_union: "invalid_union", - invalid_key: "invalid_key", - invalid_element: "invalid_element", - invalid_value: "invalid_value", - custom: "custom" -}; -function setErrorMap(map2) { - config({ - customError: map2 - }); -} -function getErrorMap() { - return config().customError; -} -var ZodFirstPartyTypeKind; -(function(ZodFirstPartyTypeKind2) {})(ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {})); -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/from-json-schema.js -var z = { - ...exports_schemas2, - ...exports_checks2, - iso: exports_iso -}; -var RECOGNIZED_KEYS = new Set([ - "$schema", - "$ref", - "$defs", - "definitions", - "$id", - "id", - "$comment", - "$anchor", - "$vocabulary", - "$dynamicRef", - "$dynamicAnchor", - "type", - "enum", - "const", - "anyOf", - "oneOf", - "allOf", - "not", - "properties", - "required", - "additionalProperties", - "patternProperties", - "propertyNames", - "minProperties", - "maxProperties", - "items", - "prefixItems", - "additionalItems", - "minItems", - "maxItems", - "uniqueItems", - "contains", - "minContains", - "maxContains", - "minLength", - "maxLength", - "pattern", - "format", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "multipleOf", - "description", - "default", - "contentEncoding", - "contentMediaType", - "contentSchema", - "unevaluatedItems", - "unevaluatedProperties", - "if", - "then", - "else", - "dependentSchemas", - "dependentRequired", - "nullable", - "readOnly" -]); -function detectVersion(schema, defaultTarget) { - const $schema = schema.$schema; - if ($schema === "https://json-schema.org/draft/2020-12/schema") { - return "draft-2020-12"; - } - if ($schema === "http://json-schema.org/draft-07/schema#") { - return "draft-7"; - } - if ($schema === "http://json-schema.org/draft-04/schema#") { - return "draft-4"; - } - return defaultTarget ?? "draft-2020-12"; -} -function resolveRef(ref, ctx) { - if (!ref.startsWith("#")) { - throw new Error("External $ref is not supported, only local refs (#/...) are allowed"); - } - const path = ref.slice(1).split("/").filter(Boolean); - if (path.length === 0) { - return ctx.rootSchema; - } - const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions"; - if (path[0] === defsKey) { - const key = path[1]; - if (!key || !ctx.defs[key]) { - throw new Error(`Reference not found: ${ref}`); - } - return ctx.defs[key]; - } - throw new Error(`Reference not found: ${ref}`); -} -function convertBaseSchema(schema, ctx) { - if (schema.not !== undefined) { - if (typeof schema.not === "object" && Object.keys(schema.not).length === 0) { - return z.never(); - } - throw new Error("not is not supported in Zod (except { not: {} } for never)"); - } - if (schema.unevaluatedItems !== undefined) { - throw new Error("unevaluatedItems is not supported"); - } - if (schema.unevaluatedProperties !== undefined) { - throw new Error("unevaluatedProperties is not supported"); - } - if (schema.if !== undefined || schema.then !== undefined || schema.else !== undefined) { - throw new Error("Conditional schemas (if/then/else) are not supported"); - } - if (schema.dependentSchemas !== undefined || schema.dependentRequired !== undefined) { - throw new Error("dependentSchemas and dependentRequired are not supported"); - } - if (schema.$ref) { - const refPath = schema.$ref; - if (ctx.refs.has(refPath)) { - return ctx.refs.get(refPath); - } - if (ctx.processing.has(refPath)) { - return z.lazy(() => { - if (!ctx.refs.has(refPath)) { - throw new Error(`Circular reference not resolved: ${refPath}`); - } - return ctx.refs.get(refPath); - }); - } - ctx.processing.add(refPath); - const resolved = resolveRef(refPath, ctx); - const zodSchema2 = convertSchema(resolved, ctx); - ctx.refs.set(refPath, zodSchema2); - ctx.processing.delete(refPath); - return zodSchema2; - } - if (schema.enum !== undefined) { - const enumValues = schema.enum; - if (ctx.version === "openapi-3.0" && schema.nullable === true && enumValues.length === 1 && enumValues[0] === null) { - return z.null(); - } - if (enumValues.length === 0) { - return z.never(); - } - if (enumValues.length === 1) { - return z.literal(enumValues[0]); - } - if (enumValues.every((v) => typeof v === "string")) { - return z.enum(enumValues); - } - const literalSchemas = enumValues.map((v) => z.literal(v)); - if (literalSchemas.length < 2) { - return literalSchemas[0]; - } - return z.union([literalSchemas[0], literalSchemas[1], ...literalSchemas.slice(2)]); - } - if (schema.const !== undefined) { - return z.literal(schema.const); - } - const type = schema.type; - if (Array.isArray(type)) { - const typeSchemas = type.map((t) => { - const typeSchema = { ...schema, type: t }; - return convertBaseSchema(typeSchema, ctx); - }); - if (typeSchemas.length === 0) { - return z.never(); - } - if (typeSchemas.length === 1) { - return typeSchemas[0]; - } - return z.union(typeSchemas); - } - if (!type) { - return z.any(); - } - let zodSchema; - switch (type) { - case "string": { - let stringSchema = z.string(); - if (schema.format) { - const format = schema.format; - if (format === "email") { - stringSchema = stringSchema.check(z.email()); - } else if (format === "uri" || format === "uri-reference") { - stringSchema = stringSchema.check(z.url()); - } else if (format === "uuid" || format === "guid") { - stringSchema = stringSchema.check(z.uuid()); - } else if (format === "date-time") { - stringSchema = stringSchema.check(z.iso.datetime()); - } else if (format === "date") { - stringSchema = stringSchema.check(z.iso.date()); - } else if (format === "time") { - stringSchema = stringSchema.check(z.iso.time()); - } else if (format === "duration") { - stringSchema = stringSchema.check(z.iso.duration()); - } else if (format === "ipv4") { - stringSchema = stringSchema.check(z.ipv4()); - } else if (format === "ipv6") { - stringSchema = stringSchema.check(z.ipv6()); - } else if (format === "mac") { - stringSchema = stringSchema.check(z.mac()); - } else if (format === "cidr") { - stringSchema = stringSchema.check(z.cidrv4()); - } else if (format === "cidr-v6") { - stringSchema = stringSchema.check(z.cidrv6()); - } else if (format === "base64") { - stringSchema = stringSchema.check(z.base64()); - } else if (format === "base64url") { - stringSchema = stringSchema.check(z.base64url()); - } else if (format === "e164") { - stringSchema = stringSchema.check(z.e164()); - } else if (format === "jwt") { - stringSchema = stringSchema.check(z.jwt()); - } else if (format === "emoji") { - stringSchema = stringSchema.check(z.emoji()); - } else if (format === "nanoid") { - stringSchema = stringSchema.check(z.nanoid()); - } else if (format === "cuid") { - stringSchema = stringSchema.check(z.cuid()); - } else if (format === "cuid2") { - stringSchema = stringSchema.check(z.cuid2()); - } else if (format === "ulid") { - stringSchema = stringSchema.check(z.ulid()); - } else if (format === "xid") { - stringSchema = stringSchema.check(z.xid()); - } else if (format === "ksuid") { - stringSchema = stringSchema.check(z.ksuid()); - } - } - if (typeof schema.minLength === "number") { - stringSchema = stringSchema.min(schema.minLength); - } - if (typeof schema.maxLength === "number") { - stringSchema = stringSchema.max(schema.maxLength); - } - if (schema.pattern) { - stringSchema = stringSchema.regex(new RegExp(schema.pattern)); - } - zodSchema = stringSchema; - break; - } - case "number": - case "integer": { - let numberSchema = type === "integer" ? z.number().int() : z.number(); - if (typeof schema.minimum === "number") { - numberSchema = numberSchema.min(schema.minimum); - } - if (typeof schema.maximum === "number") { - numberSchema = numberSchema.max(schema.maximum); - } - if (typeof schema.exclusiveMinimum === "number") { - numberSchema = numberSchema.gt(schema.exclusiveMinimum); - } else if (schema.exclusiveMinimum === true && typeof schema.minimum === "number") { - numberSchema = numberSchema.gt(schema.minimum); - } - if (typeof schema.exclusiveMaximum === "number") { - numberSchema = numberSchema.lt(schema.exclusiveMaximum); - } else if (schema.exclusiveMaximum === true && typeof schema.maximum === "number") { - numberSchema = numberSchema.lt(schema.maximum); - } - if (typeof schema.multipleOf === "number") { - numberSchema = numberSchema.multipleOf(schema.multipleOf); - } - zodSchema = numberSchema; - break; - } - case "boolean": { - zodSchema = z.boolean(); - break; - } - case "null": { - zodSchema = z.null(); - break; - } - case "object": { - const shape = {}; - const properties = schema.properties || {}; - const requiredSet = new Set(schema.required || []); - for (const [key, propSchema] of Object.entries(properties)) { - const propZodSchema = convertSchema(propSchema, ctx); - shape[key] = requiredSet.has(key) ? propZodSchema : propZodSchema.optional(); - } - if (schema.propertyNames) { - const keySchema = convertSchema(schema.propertyNames, ctx); - const valueSchema = schema.additionalProperties && typeof schema.additionalProperties === "object" ? convertSchema(schema.additionalProperties, ctx) : z.any(); - if (Object.keys(shape).length === 0) { - zodSchema = z.record(keySchema, valueSchema); - break; - } - const objectSchema2 = z.object(shape).passthrough(); - const recordSchema = z.looseRecord(keySchema, valueSchema); - zodSchema = z.intersection(objectSchema2, recordSchema); - break; - } - if (schema.patternProperties) { - const patternProps = schema.patternProperties; - const patternKeys = Object.keys(patternProps); - const looseRecords = []; - for (const pattern of patternKeys) { - const patternValue = convertSchema(patternProps[pattern], ctx); - const keySchema = z.string().regex(new RegExp(pattern)); - looseRecords.push(z.looseRecord(keySchema, patternValue)); - } - const schemasToIntersect = []; - if (Object.keys(shape).length > 0) { - schemasToIntersect.push(z.object(shape).passthrough()); - } - schemasToIntersect.push(...looseRecords); - if (schemasToIntersect.length === 0) { - zodSchema = z.object({}).passthrough(); - } else if (schemasToIntersect.length === 1) { - zodSchema = schemasToIntersect[0]; - } else { - let result = z.intersection(schemasToIntersect[0], schemasToIntersect[1]); - for (let i = 2;i < schemasToIntersect.length; i++) { - result = z.intersection(result, schemasToIntersect[i]); - } - zodSchema = result; - } - break; - } - const objectSchema = z.object(shape); - if (schema.additionalProperties === false) { - zodSchema = objectSchema.strict(); - } else if (typeof schema.additionalProperties === "object") { - zodSchema = objectSchema.catchall(convertSchema(schema.additionalProperties, ctx)); - } else { - zodSchema = objectSchema.passthrough(); - } - break; - } - case "array": { - const prefixItems = schema.prefixItems; - const items = schema.items; - if (prefixItems && Array.isArray(prefixItems)) { - const tupleItems = prefixItems.map((item) => convertSchema(item, ctx)); - const rest = items && typeof items === "object" && !Array.isArray(items) ? convertSchema(items, ctx) : undefined; - if (rest) { - zodSchema = z.tuple(tupleItems).rest(rest); - } else { - zodSchema = z.tuple(tupleItems); - } - if (typeof schema.minItems === "number") { - zodSchema = zodSchema.check(z.minLength(schema.minItems)); - } - if (typeof schema.maxItems === "number") { - zodSchema = zodSchema.check(z.maxLength(schema.maxItems)); - } - } else if (Array.isArray(items)) { - const tupleItems = items.map((item) => convertSchema(item, ctx)); - const rest = schema.additionalItems && typeof schema.additionalItems === "object" ? convertSchema(schema.additionalItems, ctx) : undefined; - if (rest) { - zodSchema = z.tuple(tupleItems).rest(rest); - } else { - zodSchema = z.tuple(tupleItems); - } - if (typeof schema.minItems === "number") { - zodSchema = zodSchema.check(z.minLength(schema.minItems)); - } - if (typeof schema.maxItems === "number") { - zodSchema = zodSchema.check(z.maxLength(schema.maxItems)); - } - } else if (items !== undefined) { - const element = convertSchema(items, ctx); - let arraySchema = z.array(element); - if (typeof schema.minItems === "number") { - arraySchema = arraySchema.min(schema.minItems); - } - if (typeof schema.maxItems === "number") { - arraySchema = arraySchema.max(schema.maxItems); - } - zodSchema = arraySchema; - } else { - zodSchema = z.array(z.any()); - } - break; - } - default: - throw new Error(`Unsupported type: ${type}`); - } - if (schema.description) { - zodSchema = zodSchema.describe(schema.description); - } - if (schema.default !== undefined) { - zodSchema = zodSchema.default(schema.default); - } - return zodSchema; -} -function convertSchema(schema, ctx) { - if (typeof schema === "boolean") { - return schema ? z.any() : z.never(); - } - let baseSchema = convertBaseSchema(schema, ctx); - const hasExplicitType = schema.type || schema.enum !== undefined || schema.const !== undefined; - if (schema.anyOf && Array.isArray(schema.anyOf)) { - const options = schema.anyOf.map((s) => convertSchema(s, ctx)); - const anyOfUnion = z.union(options); - baseSchema = hasExplicitType ? z.intersection(baseSchema, anyOfUnion) : anyOfUnion; - } - if (schema.oneOf && Array.isArray(schema.oneOf)) { - const options = schema.oneOf.map((s) => convertSchema(s, ctx)); - const oneOfUnion = z.xor(options); - baseSchema = hasExplicitType ? z.intersection(baseSchema, oneOfUnion) : oneOfUnion; - } - if (schema.allOf && Array.isArray(schema.allOf)) { - if (schema.allOf.length === 0) { - baseSchema = hasExplicitType ? baseSchema : z.any(); - } else { - let result = hasExplicitType ? baseSchema : convertSchema(schema.allOf[0], ctx); - const startIdx = hasExplicitType ? 0 : 1; - for (let i = startIdx;i < schema.allOf.length; i++) { - result = z.intersection(result, convertSchema(schema.allOf[i], ctx)); - } - baseSchema = result; - } - } - if (schema.nullable === true && ctx.version === "openapi-3.0") { - baseSchema = z.nullable(baseSchema); - } - if (schema.readOnly === true) { - baseSchema = z.readonly(baseSchema); - } - const extraMeta = {}; - const coreMetadataKeys = ["$id", "id", "$comment", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor"]; - for (const key of coreMetadataKeys) { - if (key in schema) { - extraMeta[key] = schema[key]; - } - } - const contentMetadataKeys = ["contentEncoding", "contentMediaType", "contentSchema"]; - for (const key of contentMetadataKeys) { - if (key in schema) { - extraMeta[key] = schema[key]; - } - } - for (const key of Object.keys(schema)) { - if (!RECOGNIZED_KEYS.has(key)) { - extraMeta[key] = schema[key]; - } - } - if (Object.keys(extraMeta).length > 0) { - ctx.registry.add(baseSchema, extraMeta); - } - return baseSchema; -} -function fromJSONSchema(schema, params) { - if (typeof schema === "boolean") { - return schema ? z.any() : z.never(); - } - const version2 = detectVersion(schema, params?.defaultTarget); - const defs = schema.$defs || schema.definitions || {}; - const ctx = { - version: version2, - defs, - refs: new Map, - processing: new Set, - rootSchema: schema, - registry: params?.registry ?? globalRegistry - }; - return convertSchema(schema, ctx); -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/coerce.js -var exports_coerce = {}; -__export(exports_coerce, { - string: () => string3, - number: () => number3, - date: () => date4, - boolean: () => boolean3, - bigint: () => bigint3 -}); -function string3(params) { - return _coercedString(ZodString, params); -} -function number3(params) { - return _coercedNumber(ZodNumber, params); -} -function boolean3(params) { - return _coercedBoolean(ZodBoolean, params); -} -function bigint3(params) { - return _coercedBigint(ZodBigInt, params); -} -function date4(params) { - return _coercedDate(ZodDate, params); -} - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.js -config(en_default()); -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/external.js -var exports_external2 = {}; -__export(exports_external2, { - void: () => voidType, - util: () => util, - unknown: () => unknownType, - union: () => unionType, - undefined: () => undefinedType, - tuple: () => tupleType, - transformer: () => effectsType, - symbol: () => symbolType, - string: () => stringType, - strictObject: () => strictObjectType, - setErrorMap: () => setErrorMap2, - set: () => setType, - record: () => recordType, - quotelessJson: () => quotelessJson, - promise: () => promiseType, - preprocess: () => preprocessType, - pipeline: () => pipelineType, - ostring: () => ostring, - optional: () => optionalType, - onumber: () => onumber, - oboolean: () => oboolean, - objectUtil: () => objectUtil, - object: () => objectType, - number: () => numberType, - nullable: () => nullableType, - null: () => nullType, - never: () => neverType, - nativeEnum: () => nativeEnumType, - nan: () => nanType, - map: () => mapType, - makeIssue: () => makeIssue, - literal: () => literalType, - lazy: () => lazyType, - late: () => late, - isValid: () => isValid, - isDirty: () => isDirty, - isAsync: () => isAsync, - isAborted: () => isAborted, - intersection: () => intersectionType, - instanceof: () => instanceOfType, - getParsedType: () => getParsedType2, - getErrorMap: () => getErrorMap2, - function: () => functionType, - enum: () => enumType, - effect: () => effectsType, - discriminatedUnion: () => discriminatedUnionType, - defaultErrorMap: () => en_default2, - datetimeRegex: () => datetimeRegex, - date: () => dateType, - custom: () => custom2, - coerce: () => coerce, - boolean: () => booleanType, - bigint: () => bigIntType, - array: () => arrayType, - any: () => anyType, - addIssueToContext: () => addIssueToContext, - ZodVoid: () => ZodVoid2, - ZodUnknown: () => ZodUnknown2, - ZodUnion: () => ZodUnion2, - ZodUndefined: () => ZodUndefined2, - ZodType: () => ZodType2, - ZodTuple: () => ZodTuple2, - ZodTransformer: () => ZodEffects, - ZodSymbol: () => ZodSymbol2, - ZodString: () => ZodString2, - ZodSet: () => ZodSet2, - ZodSchema: () => ZodType2, - ZodRecord: () => ZodRecord2, - ZodReadonly: () => ZodReadonly2, - ZodPromise: () => ZodPromise2, - ZodPipeline: () => ZodPipeline, - ZodParsedType: () => ZodParsedType, - ZodOptional: () => ZodOptional2, - ZodObject: () => ZodObject2, - ZodNumber: () => ZodNumber2, - ZodNullable: () => ZodNullable2, - ZodNull: () => ZodNull2, - ZodNever: () => ZodNever2, - ZodNativeEnum: () => ZodNativeEnum, - ZodNaN: () => ZodNaN2, - ZodMap: () => ZodMap2, - ZodLiteral: () => ZodLiteral2, - ZodLazy: () => ZodLazy2, - ZodIssueCode: () => ZodIssueCode2, - ZodIntersection: () => ZodIntersection2, - ZodFunction: () => ZodFunction2, - ZodFirstPartyTypeKind: () => ZodFirstPartyTypeKind2, - ZodError: () => ZodError2, - ZodEnum: () => ZodEnum2, - ZodEffects: () => ZodEffects, - ZodDiscriminatedUnion: () => ZodDiscriminatedUnion2, - ZodDefault: () => ZodDefault2, - ZodDate: () => ZodDate2, - ZodCatch: () => ZodCatch2, - ZodBranded: () => ZodBranded, - ZodBoolean: () => ZodBoolean2, - ZodBigInt: () => ZodBigInt2, - ZodArray: () => ZodArray2, - ZodAny: () => ZodAny2, - Schema: () => ZodType2, - ParseStatus: () => ParseStatus, - OK: () => OK, - NEVER: () => NEVER2, - INVALID: () => INVALID, - EMPTY_PATH: () => EMPTY_PATH, - DIRTY: () => DIRTY, - BRAND: () => BRAND -}); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/helpers/util.js -var util; -(function(util2) { - util2.assertEqual = (_) => {}; - function assertIs2(_arg) {} - util2.assertIs = assertIs2; - function assertNever2(_x) { - throw new Error; - } - util2.assertNever = assertNever2; - util2.arrayToEnum = (items) => { - const obj = {}; - for (const item of items) { - obj[item] = item; - } - return obj; - }; - util2.getValidEnumValues = (obj) => { - const validKeys = util2.objectKeys(obj).filter((k) => typeof obj[obj[k]] !== "number"); - const filtered = {}; - for (const k of validKeys) { - filtered[k] = obj[k]; - } - return util2.objectValues(filtered); - }; - util2.objectValues = (obj) => { - return util2.objectKeys(obj).map(function(e) { - return obj[e]; - }); - }; - util2.objectKeys = typeof Object.keys === "function" ? (obj) => Object.keys(obj) : (object2) => { - const keys = []; - for (const key in object2) { - if (Object.prototype.hasOwnProperty.call(object2, key)) { - keys.push(key); - } - } - return keys; - }; - util2.find = (arr, checker) => { - for (const item of arr) { - if (checker(item)) - return item; - } - return; - }; - util2.isInteger = typeof Number.isInteger === "function" ? (val) => Number.isInteger(val) : (val) => typeof val === "number" && Number.isFinite(val) && Math.floor(val) === val; - function joinValues2(array2, separator = " | ") { - return array2.map((val) => typeof val === "string" ? `'${val}'` : val).join(separator); - } - util2.joinValues = joinValues2; - util2.jsonStringifyReplacer = (_, value) => { - if (typeof value === "bigint") { - return value.toString(); - } - return value; - }; -})(util || (util = {})); -var objectUtil; -(function(objectUtil2) { - objectUtil2.mergeShapes = (first, second) => { - return { - ...first, - ...second - }; - }; -})(objectUtil || (objectUtil = {})); -var ZodParsedType = util.arrayToEnum([ - "string", - "nan", - "number", - "integer", - "float", - "boolean", - "date", - "bigint", - "symbol", - "function", - "undefined", - "null", - "array", - "object", - "unknown", - "promise", - "void", - "never", - "map", - "set" -]); -var getParsedType2 = (data) => { - const t = typeof data; - switch (t) { - case "undefined": - return ZodParsedType.undefined; - case "string": - return ZodParsedType.string; - case "number": - return Number.isNaN(data) ? ZodParsedType.nan : ZodParsedType.number; - case "boolean": - return ZodParsedType.boolean; - case "function": - return ZodParsedType.function; - case "bigint": - return ZodParsedType.bigint; - case "symbol": - return ZodParsedType.symbol; - case "object": - if (Array.isArray(data)) { - return ZodParsedType.array; - } - if (data === null) { - return ZodParsedType.null; - } - if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") { - return ZodParsedType.promise; - } - if (typeof Map !== "undefined" && data instanceof Map) { - return ZodParsedType.map; - } - if (typeof Set !== "undefined" && data instanceof Set) { - return ZodParsedType.set; - } - if (typeof Date !== "undefined" && data instanceof Date) { - return ZodParsedType.date; - } - return ZodParsedType.object; - default: - return ZodParsedType.unknown; - } -}; - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/ZodError.js -var ZodIssueCode2 = util.arrayToEnum([ - "invalid_type", - "invalid_literal", - "custom", - "invalid_union", - "invalid_union_discriminator", - "invalid_enum_value", - "unrecognized_keys", - "invalid_arguments", - "invalid_return_type", - "invalid_date", - "invalid_string", - "too_small", - "too_big", - "invalid_intersection_types", - "not_multiple_of", - "not_finite" -]); -var quotelessJson = (obj) => { - const json2 = JSON.stringify(obj, null, 2); - return json2.replace(/"([^"]+)":/g, "$1:"); -}; - -class ZodError2 extends Error { - get errors() { - return this.issues; - } - constructor(issues) { - super(); - this.issues = []; - this.addIssue = (sub) => { - this.issues = [...this.issues, sub]; - }; - this.addIssues = (subs = []) => { - this.issues = [...this.issues, ...subs]; - }; - const actualProto = new.target.prototype; - if (Object.setPrototypeOf) { - Object.setPrototypeOf(this, actualProto); - } else { - this.__proto__ = actualProto; - } - this.name = "ZodError"; - this.issues = issues; - } - format(_mapper) { - const mapper = _mapper || function(issue2) { - return issue2.message; - }; - const fieldErrors = { _errors: [] }; - const processError = (error48) => { - for (const issue2 of error48.issues) { - if (issue2.code === "invalid_union") { - issue2.unionErrors.map(processError); - } else if (issue2.code === "invalid_return_type") { - processError(issue2.returnTypeError); - } else if (issue2.code === "invalid_arguments") { - processError(issue2.argumentsError); - } else if (issue2.path.length === 0) { - fieldErrors._errors.push(mapper(issue2)); - } else { - let curr = fieldErrors; - let i = 0; - while (i < issue2.path.length) { - const el = issue2.path[i]; - const terminal = i === issue2.path.length - 1; - if (!terminal) { - curr[el] = curr[el] || { _errors: [] }; - } else { - curr[el] = curr[el] || { _errors: [] }; - curr[el]._errors.push(mapper(issue2)); - } - curr = curr[el]; - i++; - } - } - } - }; - processError(this); - return fieldErrors; - } - static assert(value) { - if (!(value instanceof ZodError2)) { - throw new Error(`Not a ZodError: ${value}`); - } - } - toString() { - return this.message; - } - get message() { - return JSON.stringify(this.issues, util.jsonStringifyReplacer, 2); - } - get isEmpty() { - return this.issues.length === 0; - } - flatten(mapper = (issue2) => issue2.message) { - const fieldErrors = Object.create(null); - const formErrors = []; - for (const sub of this.issues) { - if (sub.path.length > 0) { - const firstEl = sub.path[0]; - fieldErrors[firstEl] = fieldErrors[firstEl] || []; - fieldErrors[firstEl].push(mapper(sub)); - } else { - formErrors.push(mapper(sub)); - } - } - return { formErrors, fieldErrors }; - } - get formErrors() { - return this.flatten(); - } -} -ZodError2.create = (issues) => { - const error48 = new ZodError2(issues); - return error48; -}; - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/locales/en.js -var errorMap = (issue2, _ctx) => { - let message; - switch (issue2.code) { - case ZodIssueCode2.invalid_type: - if (issue2.received === ZodParsedType.undefined) { - message = "Required"; - } else { - message = `Expected ${issue2.expected}, received ${issue2.received}`; - } - break; - case ZodIssueCode2.invalid_literal: - message = `Invalid literal value, expected ${JSON.stringify(issue2.expected, util.jsonStringifyReplacer)}`; - break; - case ZodIssueCode2.unrecognized_keys: - message = `Unrecognized key(s) in object: ${util.joinValues(issue2.keys, ", ")}`; - break; - case ZodIssueCode2.invalid_union: - message = `Invalid input`; - break; - case ZodIssueCode2.invalid_union_discriminator: - message = `Invalid discriminator value. Expected ${util.joinValues(issue2.options)}`; - break; - case ZodIssueCode2.invalid_enum_value: - message = `Invalid enum value. Expected ${util.joinValues(issue2.options)}, received '${issue2.received}'`; - break; - case ZodIssueCode2.invalid_arguments: - message = `Invalid function arguments`; - break; - case ZodIssueCode2.invalid_return_type: - message = `Invalid function return type`; - break; - case ZodIssueCode2.invalid_date: - message = `Invalid date`; - break; - case ZodIssueCode2.invalid_string: - if (typeof issue2.validation === "object") { - if ("includes" in issue2.validation) { - message = `Invalid input: must include "${issue2.validation.includes}"`; - if (typeof issue2.validation.position === "number") { - message = `${message} at one or more positions greater than or equal to ${issue2.validation.position}`; - } - } else if ("startsWith" in issue2.validation) { - message = `Invalid input: must start with "${issue2.validation.startsWith}"`; - } else if ("endsWith" in issue2.validation) { - message = `Invalid input: must end with "${issue2.validation.endsWith}"`; - } else { - util.assertNever(issue2.validation); - } - } else if (issue2.validation !== "regex") { - message = `Invalid ${issue2.validation}`; - } else { - message = "Invalid"; - } - break; - case ZodIssueCode2.too_small: - if (issue2.type === "array") - message = `Array must contain ${issue2.exact ? "exactly" : issue2.inclusive ? `at least` : `more than`} ${issue2.minimum} element(s)`; - else if (issue2.type === "string") - message = `String must contain ${issue2.exact ? "exactly" : issue2.inclusive ? `at least` : `over`} ${issue2.minimum} character(s)`; - else if (issue2.type === "number") - message = `Number must be ${issue2.exact ? `exactly equal to ` : issue2.inclusive ? `greater than or equal to ` : `greater than `}${issue2.minimum}`; - else if (issue2.type === "bigint") - message = `Number must be ${issue2.exact ? `exactly equal to ` : issue2.inclusive ? `greater than or equal to ` : `greater than `}${issue2.minimum}`; - else if (issue2.type === "date") - message = `Date must be ${issue2.exact ? `exactly equal to ` : issue2.inclusive ? `greater than or equal to ` : `greater than `}${new Date(Number(issue2.minimum))}`; - else - message = "Invalid input"; - break; - case ZodIssueCode2.too_big: - if (issue2.type === "array") - message = `Array must contain ${issue2.exact ? `exactly` : issue2.inclusive ? `at most` : `less than`} ${issue2.maximum} element(s)`; - else if (issue2.type === "string") - message = `String must contain ${issue2.exact ? `exactly` : issue2.inclusive ? `at most` : `under`} ${issue2.maximum} character(s)`; - else if (issue2.type === "number") - message = `Number must be ${issue2.exact ? `exactly` : issue2.inclusive ? `less than or equal to` : `less than`} ${issue2.maximum}`; - else if (issue2.type === "bigint") - message = `BigInt must be ${issue2.exact ? `exactly` : issue2.inclusive ? `less than or equal to` : `less than`} ${issue2.maximum}`; - else if (issue2.type === "date") - message = `Date must be ${issue2.exact ? `exactly` : issue2.inclusive ? `smaller than or equal to` : `smaller than`} ${new Date(Number(issue2.maximum))}`; - else - message = "Invalid input"; - break; - case ZodIssueCode2.custom: - message = `Invalid input`; - break; - case ZodIssueCode2.invalid_intersection_types: - message = `Intersection results could not be merged`; - break; - case ZodIssueCode2.not_multiple_of: - message = `Number must be a multiple of ${issue2.multipleOf}`; - break; - case ZodIssueCode2.not_finite: - message = "Number must be finite"; - break; - default: - message = _ctx.defaultError; - util.assertNever(issue2); - } - return { message }; -}; -var en_default2 = errorMap; - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/errors.js -var overrideErrorMap = en_default2; -function setErrorMap2(map2) { - overrideErrorMap = map2; -} -function getErrorMap2() { - return overrideErrorMap; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/helpers/parseUtil.js -var makeIssue = (params) => { - const { data, path, errorMaps, issueData } = params; - const fullPath = [...path, ...issueData.path || []]; - const fullIssue = { - ...issueData, - path: fullPath - }; - if (issueData.message !== undefined) { - return { - ...issueData, - path: fullPath, - message: issueData.message - }; - } - let errorMessage = ""; - const maps = errorMaps.filter((m) => !!m).slice().reverse(); - for (const map2 of maps) { - errorMessage = map2(fullIssue, { data, defaultError: errorMessage }).message; - } - return { - ...issueData, - path: fullPath, - message: errorMessage - }; -}; -var EMPTY_PATH = []; -function addIssueToContext(ctx, issueData) { - const overrideMap = getErrorMap2(); - const issue2 = makeIssue({ - issueData, - data: ctx.data, - path: ctx.path, - errorMaps: [ - ctx.common.contextualErrorMap, - ctx.schemaErrorMap, - overrideMap, - overrideMap === en_default2 ? undefined : en_default2 - ].filter((x) => !!x) - }); - ctx.common.issues.push(issue2); -} - -class ParseStatus { - constructor() { - this.value = "valid"; - } - dirty() { - if (this.value === "valid") - this.value = "dirty"; - } - abort() { - if (this.value !== "aborted") - this.value = "aborted"; - } - static mergeArray(status, results) { - const arrayValue = []; - for (const s of results) { - if (s.status === "aborted") - return INVALID; - if (s.status === "dirty") - status.dirty(); - arrayValue.push(s.value); - } - return { status: status.value, value: arrayValue }; - } - static async mergeObjectAsync(status, pairs) { - const syncPairs = []; - for (const pair of pairs) { - const key = await pair.key; - const value = await pair.value; - syncPairs.push({ - key, - value - }); - } - return ParseStatus.mergeObjectSync(status, syncPairs); - } - static mergeObjectSync(status, pairs) { - const finalObject = {}; - for (const pair of pairs) { - const { key, value } = pair; - if (key.status === "aborted") - return INVALID; - if (value.status === "aborted") - return INVALID; - if (key.status === "dirty") - status.dirty(); - if (value.status === "dirty") - status.dirty(); - if (key.value !== "__proto__" && (typeof value.value !== "undefined" || pair.alwaysSet)) { - finalObject[key.value] = value.value; - } - } - return { status: status.value, value: finalObject }; - } -} -var INVALID = Object.freeze({ - status: "aborted" -}); -var DIRTY = (value) => ({ status: "dirty", value }); -var OK = (value) => ({ status: "valid", value }); -var isAborted = (x) => x.status === "aborted"; -var isDirty = (x) => x.status === "dirty"; -var isValid = (x) => x.status === "valid"; -var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise; -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/helpers/errorUtil.js -var errorUtil; -(function(errorUtil2) { - errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {}; - errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message; -})(errorUtil || (errorUtil = {})); - -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v3/types.js -class ParseInputLazyPath { - constructor(parent, value, path, key) { - this._cachedPath = []; - this.parent = parent; - this.data = value; - this._path = path; - this._key = key; - } - get path() { - if (!this._cachedPath.length) { - if (Array.isArray(this._key)) { - this._cachedPath.push(...this._path, ...this._key); - } else { - this._cachedPath.push(...this._path, this._key); - } - } - return this._cachedPath; - } -} -var handleResult = (ctx, result) => { - if (isValid(result)) { - return { success: true, data: result.value }; - } else { - if (!ctx.common.issues.length) { - throw new Error("Validation failed but no issues detected."); - } - return { - success: false, - get error() { - if (this._error) - return this._error; - const error48 = new ZodError2(ctx.common.issues); - this._error = error48; - return this._error; - } - }; - } -}; -function processCreateParams(params) { - if (!params) - return {}; - const { errorMap: errorMap2, invalid_type_error, required_error, description } = params; - if (errorMap2 && (invalid_type_error || required_error)) { - throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`); - } - if (errorMap2) - return { errorMap: errorMap2, description }; - const customMap = (iss, ctx) => { - const { message } = params; - if (iss.code === "invalid_enum_value") { - return { message: message ?? ctx.defaultError }; - } - if (typeof ctx.data === "undefined") { - return { message: message ?? required_error ?? ctx.defaultError }; - } - if (iss.code !== "invalid_type") - return { message: ctx.defaultError }; - return { message: message ?? invalid_type_error ?? ctx.defaultError }; - }; - return { errorMap: customMap, description }; -} - -class ZodType2 { - get description() { - return this._def.description; - } - _getType(input) { - return getParsedType2(input.data); - } - _getOrReturnCtx(input, ctx) { - return ctx || { - common: input.parent.common, - data: input.data, - parsedType: getParsedType2(input.data), - schemaErrorMap: this._def.errorMap, - path: input.path, - parent: input.parent - }; - } - _processInputParams(input) { - return { - status: new ParseStatus, - ctx: { - common: input.parent.common, - data: input.data, - parsedType: getParsedType2(input.data), - schemaErrorMap: this._def.errorMap, - path: input.path, - parent: input.parent - } - }; - } - _parseSync(input) { - const result = this._parse(input); - if (isAsync(result)) { - throw new Error("Synchronous parse encountered promise."); - } - return result; - } - _parseAsync(input) { - const result = this._parse(input); - return Promise.resolve(result); - } - parse(data, params) { - const result = this.safeParse(data, params); - if (result.success) - return result.data; - throw result.error; - } - safeParse(data, params) { - const ctx = { - common: { - issues: [], - async: params?.async ?? false, - contextualErrorMap: params?.errorMap - }, - path: params?.path || [], - schemaErrorMap: this._def.errorMap, - parent: null, - data, - parsedType: getParsedType2(data) - }; - const result = this._parseSync({ data, path: ctx.path, parent: ctx }); - return handleResult(ctx, result); - } - "~validate"(data) { - const ctx = { - common: { - issues: [], - async: !!this["~standard"].async - }, - path: [], - schemaErrorMap: this._def.errorMap, - parent: null, - data, - parsedType: getParsedType2(data) - }; - if (!this["~standard"].async) { - try { - const result = this._parseSync({ data, path: [], parent: ctx }); - return isValid(result) ? { - value: result.value - } : { - issues: ctx.common.issues - }; - } catch (err) { - if (err?.message?.toLowerCase()?.includes("encountered")) { - this["~standard"].async = true; - } - ctx.common = { - issues: [], - async: true - }; - } - } - return this._parseAsync({ data, path: [], parent: ctx }).then((result) => isValid(result) ? { - value: result.value - } : { - issues: ctx.common.issues - }); - } - async parseAsync(data, params) { - const result = await this.safeParseAsync(data, params); - if (result.success) - return result.data; - throw result.error; - } - async safeParseAsync(data, params) { - const ctx = { - common: { - issues: [], - contextualErrorMap: params?.errorMap, - async: true - }, - path: params?.path || [], - schemaErrorMap: this._def.errorMap, - parent: null, - data, - parsedType: getParsedType2(data) - }; - const maybeAsyncResult = this._parse({ data, path: ctx.path, parent: ctx }); - const result = await (isAsync(maybeAsyncResult) ? maybeAsyncResult : Promise.resolve(maybeAsyncResult)); - return handleResult(ctx, result); - } - refine(check2, message) { - const getIssueProperties = (val) => { - if (typeof message === "string" || typeof message === "undefined") { - return { message }; - } else if (typeof message === "function") { - return message(val); - } else { - return message; - } - }; - return this._refinement((val, ctx) => { - const result = check2(val); - const setError = () => ctx.addIssue({ - code: ZodIssueCode2.custom, - ...getIssueProperties(val) - }); - if (typeof Promise !== "undefined" && result instanceof Promise) { - return result.then((data) => { - if (!data) { - setError(); - return false; - } else { - return true; - } - }); - } - if (!result) { - setError(); - return false; - } else { - return true; - } - }); - } - refinement(check2, refinementData) { - return this._refinement((val, ctx) => { - if (!check2(val)) { - ctx.addIssue(typeof refinementData === "function" ? refinementData(val, ctx) : refinementData); - return false; - } else { - return true; - } - }); - } - _refinement(refinement) { - return new ZodEffects({ - schema: this, - typeName: ZodFirstPartyTypeKind2.ZodEffects, - effect: { type: "refinement", refinement } - }); - } - superRefine(refinement) { - return this._refinement(refinement); - } - constructor(def) { - this.spa = this.safeParseAsync; - this._def = def; - this.parse = this.parse.bind(this); - this.safeParse = this.safeParse.bind(this); - this.parseAsync = this.parseAsync.bind(this); - this.safeParseAsync = this.safeParseAsync.bind(this); - this.spa = this.spa.bind(this); - this.refine = this.refine.bind(this); - this.refinement = this.refinement.bind(this); - this.superRefine = this.superRefine.bind(this); - this.optional = this.optional.bind(this); - this.nullable = this.nullable.bind(this); - this.nullish = this.nullish.bind(this); - this.array = this.array.bind(this); - this.promise = this.promise.bind(this); - this.or = this.or.bind(this); - this.and = this.and.bind(this); - this.transform = this.transform.bind(this); - this.brand = this.brand.bind(this); - this.default = this.default.bind(this); - this.catch = this.catch.bind(this); - this.describe = this.describe.bind(this); - this.pipe = this.pipe.bind(this); - this.readonly = this.readonly.bind(this); - this.isNullable = this.isNullable.bind(this); - this.isOptional = this.isOptional.bind(this); - this["~standard"] = { - version: 1, - vendor: "zod", - validate: (data) => this["~validate"](data) - }; - } - optional() { - return ZodOptional2.create(this, this._def); - } - nullable() { - return ZodNullable2.create(this, this._def); - } - nullish() { - return this.nullable().optional(); - } - array() { - return ZodArray2.create(this); - } - promise() { - return ZodPromise2.create(this, this._def); - } - or(option) { - return ZodUnion2.create([this, option], this._def); - } - and(incoming) { - return ZodIntersection2.create(this, incoming, this._def); - } - transform(transform2) { - return new ZodEffects({ - ...processCreateParams(this._def), - schema: this, - typeName: ZodFirstPartyTypeKind2.ZodEffects, - effect: { type: "transform", transform: transform2 } - }); - } - default(def) { - const defaultValueFunc = typeof def === "function" ? def : () => def; - return new ZodDefault2({ - ...processCreateParams(this._def), - innerType: this, - defaultValue: defaultValueFunc, - typeName: ZodFirstPartyTypeKind2.ZodDefault - }); - } - brand() { - return new ZodBranded({ - typeName: ZodFirstPartyTypeKind2.ZodBranded, - type: this, - ...processCreateParams(this._def) - }); - } - catch(def) { - const catchValueFunc = typeof def === "function" ? def : () => def; - return new ZodCatch2({ - ...processCreateParams(this._def), - innerType: this, - catchValue: catchValueFunc, - typeName: ZodFirstPartyTypeKind2.ZodCatch - }); - } - describe(description) { - const This = this.constructor; - return new This({ - ...this._def, - description - }); - } - pipe(target) { - return ZodPipeline.create(this, target); - } - readonly() { - return ZodReadonly2.create(this); - } - isOptional() { - return this.safeParse(undefined).success; - } - isNullable() { - return this.safeParse(null).success; - } -} -var cuidRegex = /^c[^\s-]{8,}$/i; -var cuid2Regex = /^[0-9a-z]+$/; -var ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; -var uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; -var nanoidRegex = /^[a-z0-9_-]{21}$/i; -var jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/; -var durationRegex = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; -var emailRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; -var _emojiRegex = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; -var emojiRegex; -var ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -var ipv4CidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; -var ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -var ipv6CidrRegex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; -var base64Regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; -var base64urlRegex = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/; -var dateRegexSource = `((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))`; -var dateRegex = new RegExp(`^${dateRegexSource}$`); -function timeRegexSource(args) { - let secondsRegexSource = `[0-5]\\d`; - if (args.precision) { - secondsRegexSource = `${secondsRegexSource}\\.\\d{${args.precision}}`; - } else if (args.precision == null) { - secondsRegexSource = `${secondsRegexSource}(\\.\\d+)?`; - } - const secondsQuantifier = args.precision ? "+" : "?"; - return `([01]\\d|2[0-3]):[0-5]\\d(:${secondsRegexSource})${secondsQuantifier}`; -} -function timeRegex(args) { - return new RegExp(`^${timeRegexSource(args)}$`); -} -function datetimeRegex(args) { - let regex = `${dateRegexSource}T${timeRegexSource(args)}`; - const opts = []; - opts.push(args.local ? `Z?` : `Z`); - if (args.offset) - opts.push(`([+-]\\d{2}:?\\d{2})`); - regex = `${regex}(${opts.join("|")})`; - return new RegExp(`^${regex}$`); -} -function isValidIP(ip, version2) { - if ((version2 === "v4" || !version2) && ipv4Regex.test(ip)) { - return true; - } - if ((version2 === "v6" || !version2) && ipv6Regex.test(ip)) { - return true; - } - return false; -} -function isValidJWT2(jwt2, alg) { - if (!jwtRegex.test(jwt2)) - return false; - try { - const [header] = jwt2.split("."); - if (!header) - return false; - const base643 = header.replace(/-/g, "+").replace(/_/g, "/").padEnd(header.length + (4 - header.length % 4) % 4, "="); - const decoded = JSON.parse(atob(base643)); - if (typeof decoded !== "object" || decoded === null) - return false; - if ("typ" in decoded && decoded?.typ !== "JWT") - return false; - if (!decoded.alg) - return false; - if (alg && decoded.alg !== alg) - return false; - return true; - } catch { - return false; - } -} -function isValidCidr(ip, version2) { - if ((version2 === "v4" || !version2) && ipv4CidrRegex.test(ip)) { - return true; - } - if ((version2 === "v6" || !version2) && ipv6CidrRegex.test(ip)) { - return true; - } - return false; -} - -class ZodString2 extends ZodType2 { - _parse(input) { - if (this._def.coerce) { - input.data = String(input.data); - } - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.string) { - const ctx2 = this._getOrReturnCtx(input); - addIssueToContext(ctx2, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.string, - received: ctx2.parsedType - }); - return INVALID; - } - const status = new ParseStatus; - let ctx = undefined; - for (const check2 of this._def.checks) { - if (check2.kind === "min") { - if (input.data.length < check2.value) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - minimum: check2.value, - type: "string", - inclusive: true, - exact: false, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "max") { - if (input.data.length > check2.value) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - maximum: check2.value, - type: "string", - inclusive: true, - exact: false, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "length") { - const tooBig = input.data.length > check2.value; - const tooSmall = input.data.length < check2.value; - if (tooBig || tooSmall) { - ctx = this._getOrReturnCtx(input, ctx); - if (tooBig) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - maximum: check2.value, - type: "string", - inclusive: true, - exact: true, - message: check2.message - }); - } else if (tooSmall) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - minimum: check2.value, - type: "string", - inclusive: true, - exact: true, - message: check2.message - }); - } - status.dirty(); - } - } else if (check2.kind === "email") { - if (!emailRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "email", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "emoji") { - if (!emojiRegex) { - emojiRegex = new RegExp(_emojiRegex, "u"); - } - if (!emojiRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "emoji", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "uuid") { - if (!uuidRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "uuid", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "nanoid") { - if (!nanoidRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "nanoid", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "cuid") { - if (!cuidRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "cuid", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "cuid2") { - if (!cuid2Regex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "cuid2", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "ulid") { - if (!ulidRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "ulid", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "url") { - try { - new URL(input.data); - } catch { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "url", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "regex") { - check2.regex.lastIndex = 0; - const testResult = check2.regex.test(input.data); - if (!testResult) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "regex", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "trim") { - input.data = input.data.trim(); - } else if (check2.kind === "includes") { - if (!input.data.includes(check2.value, check2.position)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_string, - validation: { includes: check2.value, position: check2.position }, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "toLowerCase") { - input.data = input.data.toLowerCase(); - } else if (check2.kind === "toUpperCase") { - input.data = input.data.toUpperCase(); - } else if (check2.kind === "startsWith") { - if (!input.data.startsWith(check2.value)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_string, - validation: { startsWith: check2.value }, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "endsWith") { - if (!input.data.endsWith(check2.value)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_string, - validation: { endsWith: check2.value }, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "datetime") { - const regex = datetimeRegex(check2); - if (!regex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_string, - validation: "datetime", - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "date") { - const regex = dateRegex; - if (!regex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_string, - validation: "date", - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "time") { - const regex = timeRegex(check2); - if (!regex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_string, - validation: "time", - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "duration") { - if (!durationRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "duration", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "ip") { - if (!isValidIP(input.data, check2.version)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "ip", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "jwt") { - if (!isValidJWT2(input.data, check2.alg)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "jwt", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "cidr") { - if (!isValidCidr(input.data, check2.version)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "cidr", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "base64") { - if (!base64Regex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "base64", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "base64url") { - if (!base64urlRegex.test(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - validation: "base64url", - code: ZodIssueCode2.invalid_string, - message: check2.message - }); - status.dirty(); - } - } else { - util.assertNever(check2); - } - } - return { status: status.value, value: input.data }; - } - _regex(regex, validation, message) { - return this.refinement((data) => regex.test(data), { - validation, - code: ZodIssueCode2.invalid_string, - ...errorUtil.errToObj(message) - }); - } - _addCheck(check2) { - return new ZodString2({ - ...this._def, - checks: [...this._def.checks, check2] - }); - } - email(message) { - return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) }); - } - url(message) { - return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) }); - } - emoji(message) { - return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) }); - } - uuid(message) { - return this._addCheck({ kind: "uuid", ...errorUtil.errToObj(message) }); - } - nanoid(message) { - return this._addCheck({ kind: "nanoid", ...errorUtil.errToObj(message) }); - } - cuid(message) { - return this._addCheck({ kind: "cuid", ...errorUtil.errToObj(message) }); - } - cuid2(message) { - return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); - } - ulid(message) { - return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) }); - } - base64(message) { - return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); - } - base64url(message) { - return this._addCheck({ - kind: "base64url", - ...errorUtil.errToObj(message) - }); - } - jwt(options) { - return this._addCheck({ kind: "jwt", ...errorUtil.errToObj(options) }); - } - ip(options) { - return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); - } - cidr(options) { - return this._addCheck({ kind: "cidr", ...errorUtil.errToObj(options) }); - } - datetime(options) { - if (typeof options === "string") { - return this._addCheck({ - kind: "datetime", - precision: null, - offset: false, - local: false, - message: options - }); - } - return this._addCheck({ - kind: "datetime", - precision: typeof options?.precision === "undefined" ? null : options?.precision, - offset: options?.offset ?? false, - local: options?.local ?? false, - ...errorUtil.errToObj(options?.message) - }); - } - date(message) { - return this._addCheck({ kind: "date", message }); - } - time(options) { - if (typeof options === "string") { - return this._addCheck({ - kind: "time", - precision: null, - message: options - }); - } - return this._addCheck({ - kind: "time", - precision: typeof options?.precision === "undefined" ? null : options?.precision, - ...errorUtil.errToObj(options?.message) - }); - } - duration(message) { - return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) }); - } - regex(regex, message) { - return this._addCheck({ - kind: "regex", - regex, - ...errorUtil.errToObj(message) - }); - } - includes(value, options) { - return this._addCheck({ - kind: "includes", - value, - position: options?.position, - ...errorUtil.errToObj(options?.message) - }); - } - startsWith(value, message) { - return this._addCheck({ - kind: "startsWith", - value, - ...errorUtil.errToObj(message) - }); - } - endsWith(value, message) { - return this._addCheck({ - kind: "endsWith", - value, - ...errorUtil.errToObj(message) - }); - } - min(minLength, message) { - return this._addCheck({ - kind: "min", - value: minLength, - ...errorUtil.errToObj(message) - }); - } - max(maxLength, message) { - return this._addCheck({ - kind: "max", - value: maxLength, - ...errorUtil.errToObj(message) - }); - } - length(len, message) { - return this._addCheck({ - kind: "length", - value: len, - ...errorUtil.errToObj(message) - }); - } - nonempty(message) { - return this.min(1, errorUtil.errToObj(message)); - } - trim() { - return new ZodString2({ - ...this._def, - checks: [...this._def.checks, { kind: "trim" }] - }); - } - toLowerCase() { - return new ZodString2({ - ...this._def, - checks: [...this._def.checks, { kind: "toLowerCase" }] - }); - } - toUpperCase() { - return new ZodString2({ - ...this._def, - checks: [...this._def.checks, { kind: "toUpperCase" }] - }); - } - get isDatetime() { - return !!this._def.checks.find((ch) => ch.kind === "datetime"); - } - get isDate() { - return !!this._def.checks.find((ch) => ch.kind === "date"); - } - get isTime() { - return !!this._def.checks.find((ch) => ch.kind === "time"); - } - get isDuration() { - return !!this._def.checks.find((ch) => ch.kind === "duration"); - } - get isEmail() { - return !!this._def.checks.find((ch) => ch.kind === "email"); - } - get isURL() { - return !!this._def.checks.find((ch) => ch.kind === "url"); - } - get isEmoji() { - return !!this._def.checks.find((ch) => ch.kind === "emoji"); - } - get isUUID() { - return !!this._def.checks.find((ch) => ch.kind === "uuid"); - } - get isNANOID() { - return !!this._def.checks.find((ch) => ch.kind === "nanoid"); - } - get isCUID() { - return !!this._def.checks.find((ch) => ch.kind === "cuid"); - } - get isCUID2() { - return !!this._def.checks.find((ch) => ch.kind === "cuid2"); - } - get isULID() { - return !!this._def.checks.find((ch) => ch.kind === "ulid"); - } - get isIP() { - return !!this._def.checks.find((ch) => ch.kind === "ip"); - } - get isCIDR() { - return !!this._def.checks.find((ch) => ch.kind === "cidr"); - } - get isBase64() { - return !!this._def.checks.find((ch) => ch.kind === "base64"); - } - get isBase64url() { - return !!this._def.checks.find((ch) => ch.kind === "base64url"); - } - get minLength() { - let min = null; - for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; - } - } - return min; - } - get maxLength() { - let max = null; - for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; - } - } - return max; - } -} -ZodString2.create = (params) => { - return new ZodString2({ - checks: [], - typeName: ZodFirstPartyTypeKind2.ZodString, - coerce: params?.coerce ?? false, - ...processCreateParams(params) - }); -}; -function floatSafeRemainder2(val, step) { - const valDecCount = (val.toString().split(".")[1] || "").length; - const stepDecCount = (step.toString().split(".")[1] || "").length; - const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount; - const valInt = Number.parseInt(val.toFixed(decCount).replace(".", "")); - const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", "")); - return valInt % stepInt / 10 ** decCount; -} - -class ZodNumber2 extends ZodType2 { - constructor() { - super(...arguments); - this.min = this.gte; - this.max = this.lte; - this.step = this.multipleOf; - } - _parse(input) { - if (this._def.coerce) { - input.data = Number(input.data); - } - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.number) { - const ctx2 = this._getOrReturnCtx(input); - addIssueToContext(ctx2, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.number, - received: ctx2.parsedType - }); - return INVALID; - } - let ctx = undefined; - const status = new ParseStatus; - for (const check2 of this._def.checks) { - if (check2.kind === "int") { - if (!util.isInteger(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: "integer", - received: "float", - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "min") { - const tooSmall = check2.inclusive ? input.data < check2.value : input.data <= check2.value; - if (tooSmall) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - minimum: check2.value, - type: "number", - inclusive: check2.inclusive, - exact: false, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "max") { - const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value; - if (tooBig) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - maximum: check2.value, - type: "number", - inclusive: check2.inclusive, - exact: false, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "multipleOf") { - if (floatSafeRemainder2(input.data, check2.value) !== 0) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.not_multiple_of, - multipleOf: check2.value, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "finite") { - if (!Number.isFinite(input.data)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.not_finite, - message: check2.message - }); - status.dirty(); - } - } else { - util.assertNever(check2); - } - } - return { status: status.value, value: input.data }; - } - gte(value, message) { - return this.setLimit("min", value, true, errorUtil.toString(message)); - } - gt(value, message) { - return this.setLimit("min", value, false, errorUtil.toString(message)); - } - lte(value, message) { - return this.setLimit("max", value, true, errorUtil.toString(message)); - } - lt(value, message) { - return this.setLimit("max", value, false, errorUtil.toString(message)); - } - setLimit(kind, value, inclusive, message) { - return new ZodNumber2({ - ...this._def, - checks: [ - ...this._def.checks, - { - kind, - value, - inclusive, - message: errorUtil.toString(message) - } - ] - }); - } - _addCheck(check2) { - return new ZodNumber2({ - ...this._def, - checks: [...this._def.checks, check2] - }); - } - int(message) { - return this._addCheck({ - kind: "int", - message: errorUtil.toString(message) - }); - } - positive(message) { - return this._addCheck({ - kind: "min", - value: 0, - inclusive: false, - message: errorUtil.toString(message) - }); - } - negative(message) { - return this._addCheck({ - kind: "max", - value: 0, - inclusive: false, - message: errorUtil.toString(message) - }); - } - nonpositive(message) { - return this._addCheck({ - kind: "max", - value: 0, - inclusive: true, - message: errorUtil.toString(message) - }); - } - nonnegative(message) { - return this._addCheck({ - kind: "min", - value: 0, - inclusive: true, - message: errorUtil.toString(message) - }); - } - multipleOf(value, message) { - return this._addCheck({ - kind: "multipleOf", - value, - message: errorUtil.toString(message) - }); - } - finite(message) { - return this._addCheck({ - kind: "finite", - message: errorUtil.toString(message) - }); - } - safe(message) { - return this._addCheck({ - kind: "min", - inclusive: true, - value: Number.MIN_SAFE_INTEGER, - message: errorUtil.toString(message) - })._addCheck({ - kind: "max", - inclusive: true, - value: Number.MAX_SAFE_INTEGER, - message: errorUtil.toString(message) - }); - } - get minValue() { - let min = null; - for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; - } - } - return min; - } - get maxValue() { - let max = null; - for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; - } - } - return max; - } - get isInt() { - return !!this._def.checks.find((ch) => ch.kind === "int" || ch.kind === "multipleOf" && util.isInteger(ch.value)); - } - get isFinite() { - let max = null; - let min = null; - for (const ch of this._def.checks) { - if (ch.kind === "finite" || ch.kind === "int" || ch.kind === "multipleOf") { - return true; - } else if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; - } else if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; - } - } - return Number.isFinite(min) && Number.isFinite(max); - } -} -ZodNumber2.create = (params) => { - return new ZodNumber2({ - checks: [], - typeName: ZodFirstPartyTypeKind2.ZodNumber, - coerce: params?.coerce || false, - ...processCreateParams(params) - }); -}; - -class ZodBigInt2 extends ZodType2 { - constructor() { - super(...arguments); - this.min = this.gte; - this.max = this.lte; - } - _parse(input) { - if (this._def.coerce) { - try { - input.data = BigInt(input.data); - } catch { - return this._getInvalidInput(input); - } - } - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.bigint) { - return this._getInvalidInput(input); - } - let ctx = undefined; - const status = new ParseStatus; - for (const check2 of this._def.checks) { - if (check2.kind === "min") { - const tooSmall = check2.inclusive ? input.data < check2.value : input.data <= check2.value; - if (tooSmall) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - type: "bigint", - minimum: check2.value, - inclusive: check2.inclusive, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "max") { - const tooBig = check2.inclusive ? input.data > check2.value : input.data >= check2.value; - if (tooBig) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - type: "bigint", - maximum: check2.value, - inclusive: check2.inclusive, - message: check2.message - }); - status.dirty(); - } - } else if (check2.kind === "multipleOf") { - if (input.data % check2.value !== BigInt(0)) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.not_multiple_of, - multipleOf: check2.value, - message: check2.message - }); - status.dirty(); - } - } else { - util.assertNever(check2); - } - } - return { status: status.value, value: input.data }; - } - _getInvalidInput(input) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.bigint, - received: ctx.parsedType - }); - return INVALID; - } - gte(value, message) { - return this.setLimit("min", value, true, errorUtil.toString(message)); - } - gt(value, message) { - return this.setLimit("min", value, false, errorUtil.toString(message)); - } - lte(value, message) { - return this.setLimit("max", value, true, errorUtil.toString(message)); - } - lt(value, message) { - return this.setLimit("max", value, false, errorUtil.toString(message)); - } - setLimit(kind, value, inclusive, message) { - return new ZodBigInt2({ - ...this._def, - checks: [ - ...this._def.checks, - { - kind, - value, - inclusive, - message: errorUtil.toString(message) - } - ] - }); - } - _addCheck(check2) { - return new ZodBigInt2({ - ...this._def, - checks: [...this._def.checks, check2] - }); - } - positive(message) { - return this._addCheck({ - kind: "min", - value: BigInt(0), - inclusive: false, - message: errorUtil.toString(message) - }); - } - negative(message) { - return this._addCheck({ - kind: "max", - value: BigInt(0), - inclusive: false, - message: errorUtil.toString(message) - }); - } - nonpositive(message) { - return this._addCheck({ - kind: "max", - value: BigInt(0), - inclusive: true, - message: errorUtil.toString(message) - }); - } - nonnegative(message) { - return this._addCheck({ - kind: "min", - value: BigInt(0), - inclusive: true, - message: errorUtil.toString(message) - }); - } - multipleOf(value, message) { - return this._addCheck({ - kind: "multipleOf", - value, - message: errorUtil.toString(message) - }); - } - get minValue() { - let min = null; - for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; - } - } - return min; - } - get maxValue() { - let max = null; - for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; - } - } - return max; - } -} -ZodBigInt2.create = (params) => { - return new ZodBigInt2({ - checks: [], - typeName: ZodFirstPartyTypeKind2.ZodBigInt, - coerce: params?.coerce ?? false, - ...processCreateParams(params) - }); -}; - -class ZodBoolean2 extends ZodType2 { - _parse(input) { - if (this._def.coerce) { - input.data = Boolean(input.data); - } - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.boolean) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.boolean, - received: ctx.parsedType - }); - return INVALID; - } - return OK(input.data); - } -} -ZodBoolean2.create = (params) => { - return new ZodBoolean2({ - typeName: ZodFirstPartyTypeKind2.ZodBoolean, - coerce: params?.coerce || false, - ...processCreateParams(params) - }); -}; - -class ZodDate2 extends ZodType2 { - _parse(input) { - if (this._def.coerce) { - input.data = new Date(input.data); - } - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.date) { - const ctx2 = this._getOrReturnCtx(input); - addIssueToContext(ctx2, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.date, - received: ctx2.parsedType - }); - return INVALID; - } - if (Number.isNaN(input.data.getTime())) { - const ctx2 = this._getOrReturnCtx(input); - addIssueToContext(ctx2, { - code: ZodIssueCode2.invalid_date - }); - return INVALID; - } - const status = new ParseStatus; - let ctx = undefined; - for (const check2 of this._def.checks) { - if (check2.kind === "min") { - if (input.data.getTime() < check2.value) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - message: check2.message, - inclusive: true, - exact: false, - minimum: check2.value, - type: "date" - }); - status.dirty(); - } - } else if (check2.kind === "max") { - if (input.data.getTime() > check2.value) { - ctx = this._getOrReturnCtx(input, ctx); - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - message: check2.message, - inclusive: true, - exact: false, - maximum: check2.value, - type: "date" - }); - status.dirty(); - } - } else { - util.assertNever(check2); - } - } - return { - status: status.value, - value: new Date(input.data.getTime()) - }; - } - _addCheck(check2) { - return new ZodDate2({ - ...this._def, - checks: [...this._def.checks, check2] - }); - } - min(minDate, message) { - return this._addCheck({ - kind: "min", - value: minDate.getTime(), - message: errorUtil.toString(message) - }); - } - max(maxDate, message) { - return this._addCheck({ - kind: "max", - value: maxDate.getTime(), - message: errorUtil.toString(message) - }); - } - get minDate() { - let min = null; - for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; - } - } - return min != null ? new Date(min) : null; - } - get maxDate() { - let max = null; - for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; - } - } - return max != null ? new Date(max) : null; - } -} -ZodDate2.create = (params) => { - return new ZodDate2({ - checks: [], - coerce: params?.coerce || false, - typeName: ZodFirstPartyTypeKind2.ZodDate, - ...processCreateParams(params) - }); -}; - -class ZodSymbol2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.symbol) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.symbol, - received: ctx.parsedType - }); - return INVALID; - } - return OK(input.data); - } -} -ZodSymbol2.create = (params) => { - return new ZodSymbol2({ - typeName: ZodFirstPartyTypeKind2.ZodSymbol, - ...processCreateParams(params) - }); -}; - -class ZodUndefined2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.undefined) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.undefined, - received: ctx.parsedType - }); - return INVALID; - } - return OK(input.data); - } -} -ZodUndefined2.create = (params) => { - return new ZodUndefined2({ - typeName: ZodFirstPartyTypeKind2.ZodUndefined, - ...processCreateParams(params) - }); -}; - -class ZodNull2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.null) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.null, - received: ctx.parsedType - }); - return INVALID; - } - return OK(input.data); - } -} -ZodNull2.create = (params) => { - return new ZodNull2({ - typeName: ZodFirstPartyTypeKind2.ZodNull, - ...processCreateParams(params) - }); -}; - -class ZodAny2 extends ZodType2 { - constructor() { - super(...arguments); - this._any = true; - } - _parse(input) { - return OK(input.data); - } -} -ZodAny2.create = (params) => { - return new ZodAny2({ - typeName: ZodFirstPartyTypeKind2.ZodAny, - ...processCreateParams(params) - }); -}; - -class ZodUnknown2 extends ZodType2 { - constructor() { - super(...arguments); - this._unknown = true; - } - _parse(input) { - return OK(input.data); - } -} -ZodUnknown2.create = (params) => { - return new ZodUnknown2({ - typeName: ZodFirstPartyTypeKind2.ZodUnknown, - ...processCreateParams(params) - }); -}; - -class ZodNever2 extends ZodType2 { - _parse(input) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.never, - received: ctx.parsedType - }); - return INVALID; - } -} -ZodNever2.create = (params) => { - return new ZodNever2({ - typeName: ZodFirstPartyTypeKind2.ZodNever, - ...processCreateParams(params) - }); -}; - -class ZodVoid2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.undefined) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.void, - received: ctx.parsedType - }); - return INVALID; - } - return OK(input.data); - } -} -ZodVoid2.create = (params) => { - return new ZodVoid2({ - typeName: ZodFirstPartyTypeKind2.ZodVoid, - ...processCreateParams(params) - }); -}; - -class ZodArray2 extends ZodType2 { - _parse(input) { - const { ctx, status } = this._processInputParams(input); - const def = this._def; - if (ctx.parsedType !== ZodParsedType.array) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.array, - received: ctx.parsedType - }); - return INVALID; - } - if (def.exactLength !== null) { - const tooBig = ctx.data.length > def.exactLength.value; - const tooSmall = ctx.data.length < def.exactLength.value; - if (tooBig || tooSmall) { - addIssueToContext(ctx, { - code: tooBig ? ZodIssueCode2.too_big : ZodIssueCode2.too_small, - minimum: tooSmall ? def.exactLength.value : undefined, - maximum: tooBig ? def.exactLength.value : undefined, - type: "array", - inclusive: true, - exact: true, - message: def.exactLength.message - }); - status.dirty(); - } - } - if (def.minLength !== null) { - if (ctx.data.length < def.minLength.value) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - minimum: def.minLength.value, - type: "array", - inclusive: true, - exact: false, - message: def.minLength.message - }); - status.dirty(); - } - } - if (def.maxLength !== null) { - if (ctx.data.length > def.maxLength.value) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - maximum: def.maxLength.value, - type: "array", - inclusive: true, - exact: false, - message: def.maxLength.message - }); - status.dirty(); - } - } - if (ctx.common.async) { - return Promise.all([...ctx.data].map((item, i) => { - return def.type._parseAsync(new ParseInputLazyPath(ctx, item, ctx.path, i)); - })).then((result2) => { - return ParseStatus.mergeArray(status, result2); - }); - } - const result = [...ctx.data].map((item, i) => { - return def.type._parseSync(new ParseInputLazyPath(ctx, item, ctx.path, i)); - }); - return ParseStatus.mergeArray(status, result); - } - get element() { - return this._def.type; - } - min(minLength, message) { - return new ZodArray2({ - ...this._def, - minLength: { value: minLength, message: errorUtil.toString(message) } - }); - } - max(maxLength, message) { - return new ZodArray2({ - ...this._def, - maxLength: { value: maxLength, message: errorUtil.toString(message) } - }); - } - length(len, message) { - return new ZodArray2({ - ...this._def, - exactLength: { value: len, message: errorUtil.toString(message) } - }); - } - nonempty(message) { - return this.min(1, message); - } -} -ZodArray2.create = (schema, params) => { - return new ZodArray2({ - type: schema, - minLength: null, - maxLength: null, - exactLength: null, - typeName: ZodFirstPartyTypeKind2.ZodArray, - ...processCreateParams(params) - }); -}; -function deepPartialify(schema) { - if (schema instanceof ZodObject2) { - const newShape = {}; - for (const key in schema.shape) { - const fieldSchema = schema.shape[key]; - newShape[key] = ZodOptional2.create(deepPartialify(fieldSchema)); - } - return new ZodObject2({ - ...schema._def, - shape: () => newShape - }); - } else if (schema instanceof ZodArray2) { - return new ZodArray2({ - ...schema._def, - type: deepPartialify(schema.element) - }); - } else if (schema instanceof ZodOptional2) { - return ZodOptional2.create(deepPartialify(schema.unwrap())); - } else if (schema instanceof ZodNullable2) { - return ZodNullable2.create(deepPartialify(schema.unwrap())); - } else if (schema instanceof ZodTuple2) { - return ZodTuple2.create(schema.items.map((item) => deepPartialify(item))); - } else { - return schema; - } -} - -class ZodObject2 extends ZodType2 { - constructor() { - super(...arguments); - this._cached = null; - this.nonstrict = this.passthrough; - this.augment = this.extend; - } - _getCached() { - if (this._cached !== null) - return this._cached; - const shape = this._def.shape(); - const keys = util.objectKeys(shape); - this._cached = { shape, keys }; - return this._cached; - } - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.object) { - const ctx2 = this._getOrReturnCtx(input); - addIssueToContext(ctx2, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.object, - received: ctx2.parsedType - }); - return INVALID; - } - const { status, ctx } = this._processInputParams(input); - const { shape, keys: shapeKeys } = this._getCached(); - const extraKeys = []; - if (!(this._def.catchall instanceof ZodNever2 && this._def.unknownKeys === "strip")) { - for (const key in ctx.data) { - if (!shapeKeys.includes(key)) { - extraKeys.push(key); - } - } - } - const pairs = []; - for (const key of shapeKeys) { - const keyValidator = shape[key]; - const value = ctx.data[key]; - pairs.push({ - key: { status: "valid", value: key }, - value: keyValidator._parse(new ParseInputLazyPath(ctx, value, ctx.path, key)), - alwaysSet: key in ctx.data - }); - } - if (this._def.catchall instanceof ZodNever2) { - const unknownKeys = this._def.unknownKeys; - if (unknownKeys === "passthrough") { - for (const key of extraKeys) { - pairs.push({ - key: { status: "valid", value: key }, - value: { status: "valid", value: ctx.data[key] } - }); - } - } else if (unknownKeys === "strict") { - if (extraKeys.length > 0) { - addIssueToContext(ctx, { - code: ZodIssueCode2.unrecognized_keys, - keys: extraKeys - }); - status.dirty(); - } - } else if (unknownKeys === "strip") {} else { - throw new Error(`Internal ZodObject error: invalid unknownKeys value.`); - } - } else { - const catchall = this._def.catchall; - for (const key of extraKeys) { - const value = ctx.data[key]; - pairs.push({ - key: { status: "valid", value: key }, - value: catchall._parse(new ParseInputLazyPath(ctx, value, ctx.path, key)), - alwaysSet: key in ctx.data - }); - } - } - if (ctx.common.async) { - return Promise.resolve().then(async () => { - const syncPairs = []; - for (const pair of pairs) { - const key = await pair.key; - const value = await pair.value; - syncPairs.push({ - key, - value, - alwaysSet: pair.alwaysSet - }); - } - return syncPairs; - }).then((syncPairs) => { - return ParseStatus.mergeObjectSync(status, syncPairs); - }); - } else { - return ParseStatus.mergeObjectSync(status, pairs); - } - } - get shape() { - return this._def.shape(); - } - strict(message) { - errorUtil.errToObj; - return new ZodObject2({ - ...this._def, - unknownKeys: "strict", - ...message !== undefined ? { - errorMap: (issue2, ctx) => { - const defaultError = this._def.errorMap?.(issue2, ctx).message ?? ctx.defaultError; - if (issue2.code === "unrecognized_keys") - return { - message: errorUtil.errToObj(message).message ?? defaultError - }; - return { - message: defaultError - }; - } - } : {} - }); - } - strip() { - return new ZodObject2({ - ...this._def, - unknownKeys: "strip" - }); - } - passthrough() { - return new ZodObject2({ - ...this._def, - unknownKeys: "passthrough" - }); - } - extend(augmentation) { - return new ZodObject2({ - ...this._def, - shape: () => ({ - ...this._def.shape(), - ...augmentation - }) - }); - } - merge(merging) { - const merged = new ZodObject2({ - unknownKeys: merging._def.unknownKeys, - catchall: merging._def.catchall, - shape: () => ({ - ...this._def.shape(), - ...merging._def.shape() - }), - typeName: ZodFirstPartyTypeKind2.ZodObject - }); - return merged; - } - setKey(key, schema) { - return this.augment({ [key]: schema }); - } - catchall(index) { - return new ZodObject2({ - ...this._def, - catchall: index - }); - } - pick(mask) { - const shape = {}; - for (const key of util.objectKeys(mask)) { - if (mask[key] && this.shape[key]) { - shape[key] = this.shape[key]; - } - } - return new ZodObject2({ - ...this._def, - shape: () => shape - }); - } - omit(mask) { - const shape = {}; - for (const key of util.objectKeys(this.shape)) { - if (!mask[key]) { - shape[key] = this.shape[key]; - } - } - return new ZodObject2({ - ...this._def, - shape: () => shape - }); - } - deepPartial() { - return deepPartialify(this); - } - partial(mask) { - const newShape = {}; - for (const key of util.objectKeys(this.shape)) { - const fieldSchema = this.shape[key]; - if (mask && !mask[key]) { - newShape[key] = fieldSchema; - } else { - newShape[key] = fieldSchema.optional(); - } - } - return new ZodObject2({ - ...this._def, - shape: () => newShape - }); - } - required(mask) { - const newShape = {}; - for (const key of util.objectKeys(this.shape)) { - if (mask && !mask[key]) { - newShape[key] = this.shape[key]; - } else { - const fieldSchema = this.shape[key]; - let newField = fieldSchema; - while (newField instanceof ZodOptional2) { - newField = newField._def.innerType; - } - newShape[key] = newField; - } - } - return new ZodObject2({ - ...this._def, - shape: () => newShape - }); - } - keyof() { - return createZodEnum(util.objectKeys(this.shape)); - } -} -ZodObject2.create = (shape, params) => { - return new ZodObject2({ - shape: () => shape, - unknownKeys: "strip", - catchall: ZodNever2.create(), - typeName: ZodFirstPartyTypeKind2.ZodObject, - ...processCreateParams(params) - }); -}; -ZodObject2.strictCreate = (shape, params) => { - return new ZodObject2({ - shape: () => shape, - unknownKeys: "strict", - catchall: ZodNever2.create(), - typeName: ZodFirstPartyTypeKind2.ZodObject, - ...processCreateParams(params) - }); -}; -ZodObject2.lazycreate = (shape, params) => { - return new ZodObject2({ - shape, - unknownKeys: "strip", - catchall: ZodNever2.create(), - typeName: ZodFirstPartyTypeKind2.ZodObject, - ...processCreateParams(params) - }); -}; - -class ZodUnion2 extends ZodType2 { - _parse(input) { - const { ctx } = this._processInputParams(input); - const options = this._def.options; - function handleResults(results) { - for (const result of results) { - if (result.result.status === "valid") { - return result.result; - } - } - for (const result of results) { - if (result.result.status === "dirty") { - ctx.common.issues.push(...result.ctx.common.issues); - return result.result; - } - } - const unionErrors = results.map((result) => new ZodError2(result.ctx.common.issues)); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_union, - unionErrors - }); - return INVALID; - } - if (ctx.common.async) { - return Promise.all(options.map(async (option) => { - const childCtx = { - ...ctx, - common: { - ...ctx.common, - issues: [] - }, - parent: null - }; - return { - result: await option._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: childCtx - }), - ctx: childCtx - }; - })).then(handleResults); - } else { - let dirty = undefined; - const issues = []; - for (const option of options) { - const childCtx = { - ...ctx, - common: { - ...ctx.common, - issues: [] - }, - parent: null - }; - const result = option._parseSync({ - data: ctx.data, - path: ctx.path, - parent: childCtx - }); - if (result.status === "valid") { - return result; - } else if (result.status === "dirty" && !dirty) { - dirty = { result, ctx: childCtx }; - } - if (childCtx.common.issues.length) { - issues.push(childCtx.common.issues); - } - } - if (dirty) { - ctx.common.issues.push(...dirty.ctx.common.issues); - return dirty.result; - } - const unionErrors = issues.map((issues2) => new ZodError2(issues2)); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_union, - unionErrors - }); - return INVALID; - } - } - get options() { - return this._def.options; - } -} -ZodUnion2.create = (types, params) => { - return new ZodUnion2({ - options: types, - typeName: ZodFirstPartyTypeKind2.ZodUnion, - ...processCreateParams(params) - }); -}; -var getDiscriminator = (type) => { - if (type instanceof ZodLazy2) { - return getDiscriminator(type.schema); - } else if (type instanceof ZodEffects) { - return getDiscriminator(type.innerType()); - } else if (type instanceof ZodLiteral2) { - return [type.value]; - } else if (type instanceof ZodEnum2) { - return type.options; - } else if (type instanceof ZodNativeEnum) { - return util.objectValues(type.enum); - } else if (type instanceof ZodDefault2) { - return getDiscriminator(type._def.innerType); - } else if (type instanceof ZodUndefined2) { - return [undefined]; - } else if (type instanceof ZodNull2) { - return [null]; - } else if (type instanceof ZodOptional2) { - return [undefined, ...getDiscriminator(type.unwrap())]; - } else if (type instanceof ZodNullable2) { - return [null, ...getDiscriminator(type.unwrap())]; - } else if (type instanceof ZodBranded) { - return getDiscriminator(type.unwrap()); - } else if (type instanceof ZodReadonly2) { - return getDiscriminator(type.unwrap()); - } else if (type instanceof ZodCatch2) { - return getDiscriminator(type._def.innerType); - } else { - return []; - } -}; - -class ZodDiscriminatedUnion2 extends ZodType2 { - _parse(input) { - const { ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.object) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.object, - received: ctx.parsedType - }); - return INVALID; - } - const discriminator = this.discriminator; - const discriminatorValue = ctx.data[discriminator]; - const option = this.optionsMap.get(discriminatorValue); - if (!option) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_union_discriminator, - options: Array.from(this.optionsMap.keys()), - path: [discriminator] - }); - return INVALID; - } - if (ctx.common.async) { - return option._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }); - } else { - return option._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }); - } - } - get discriminator() { - return this._def.discriminator; - } - get options() { - return this._def.options; - } - get optionsMap() { - return this._def.optionsMap; - } - static create(discriminator, options, params) { - const optionsMap = new Map; - for (const type of options) { - const discriminatorValues = getDiscriminator(type.shape[discriminator]); - if (!discriminatorValues.length) { - throw new Error(`A discriminator value for key \`${discriminator}\` could not be extracted from all schema options`); - } - for (const value of discriminatorValues) { - if (optionsMap.has(value)) { - throw new Error(`Discriminator property ${String(discriminator)} has duplicate value ${String(value)}`); - } - optionsMap.set(value, type); - } - } - return new ZodDiscriminatedUnion2({ - typeName: ZodFirstPartyTypeKind2.ZodDiscriminatedUnion, - discriminator, - options, - optionsMap, - ...processCreateParams(params) - }); - } -} -function mergeValues2(a, b) { - const aType = getParsedType2(a); - const bType = getParsedType2(b); - if (a === b) { - return { valid: true, data: a }; - } else if (aType === ZodParsedType.object && bType === ZodParsedType.object) { - const bKeys = util.objectKeys(b); - const sharedKeys = util.objectKeys(a).filter((key) => bKeys.indexOf(key) !== -1); - const newObj = { ...a, ...b }; - for (const key of sharedKeys) { - const sharedValue = mergeValues2(a[key], b[key]); - if (!sharedValue.valid) { - return { valid: false }; - } - newObj[key] = sharedValue.data; - } - return { valid: true, data: newObj }; - } else if (aType === ZodParsedType.array && bType === ZodParsedType.array) { - if (a.length !== b.length) { - return { valid: false }; - } - const newArray = []; - for (let index = 0;index < a.length; index++) { - const itemA = a[index]; - const itemB = b[index]; - const sharedValue = mergeValues2(itemA, itemB); - if (!sharedValue.valid) { - return { valid: false }; - } - newArray.push(sharedValue.data); - } - return { valid: true, data: newArray }; - } else if (aType === ZodParsedType.date && bType === ZodParsedType.date && +a === +b) { - return { valid: true, data: a }; - } else { - return { valid: false }; - } -} - -class ZodIntersection2 extends ZodType2 { - _parse(input) { - const { status, ctx } = this._processInputParams(input); - const handleParsed = (parsedLeft, parsedRight) => { - if (isAborted(parsedLeft) || isAborted(parsedRight)) { - return INVALID; - } - const merged = mergeValues2(parsedLeft.value, parsedRight.value); - if (!merged.valid) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_intersection_types - }); - return INVALID; - } - if (isDirty(parsedLeft) || isDirty(parsedRight)) { - status.dirty(); - } - return { status: status.value, value: merged.data }; - }; - if (ctx.common.async) { - return Promise.all([ - this._def.left._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }), - this._def.right._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }) - ]).then(([left, right]) => handleParsed(left, right)); - } else { - return handleParsed(this._def.left._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }), this._def.right._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - })); - } - } -} -ZodIntersection2.create = (left, right, params) => { - return new ZodIntersection2({ - left, - right, - typeName: ZodFirstPartyTypeKind2.ZodIntersection, - ...processCreateParams(params) - }); -}; - -class ZodTuple2 extends ZodType2 { - _parse(input) { - const { status, ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.array) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.array, - received: ctx.parsedType - }); - return INVALID; - } - if (ctx.data.length < this._def.items.length) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - minimum: this._def.items.length, - inclusive: true, - exact: false, - type: "array" - }); - return INVALID; - } - const rest = this._def.rest; - if (!rest && ctx.data.length > this._def.items.length) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - maximum: this._def.items.length, - inclusive: true, - exact: false, - type: "array" - }); - status.dirty(); - } - const items = [...ctx.data].map((item, itemIndex) => { - const schema = this._def.items[itemIndex] || this._def.rest; - if (!schema) - return null; - return schema._parse(new ParseInputLazyPath(ctx, item, ctx.path, itemIndex)); - }).filter((x) => !!x); - if (ctx.common.async) { - return Promise.all(items).then((results) => { - return ParseStatus.mergeArray(status, results); - }); - } else { - return ParseStatus.mergeArray(status, items); - } - } - get items() { - return this._def.items; - } - rest(rest) { - return new ZodTuple2({ - ...this._def, - rest - }); - } -} -ZodTuple2.create = (schemas3, params) => { - if (!Array.isArray(schemas3)) { - throw new Error("You must pass an array of schemas to z.tuple([ ... ])"); - } - return new ZodTuple2({ - items: schemas3, - typeName: ZodFirstPartyTypeKind2.ZodTuple, - rest: null, - ...processCreateParams(params) - }); -}; - -class ZodRecord2 extends ZodType2 { - get keySchema() { - return this._def.keyType; - } - get valueSchema() { - return this._def.valueType; - } - _parse(input) { - const { status, ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.object) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.object, - received: ctx.parsedType - }); - return INVALID; - } - const pairs = []; - const keyType = this._def.keyType; - const valueType = this._def.valueType; - for (const key in ctx.data) { - pairs.push({ - key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, key)), - value: valueType._parse(new ParseInputLazyPath(ctx, ctx.data[key], ctx.path, key)), - alwaysSet: key in ctx.data - }); - } - if (ctx.common.async) { - return ParseStatus.mergeObjectAsync(status, pairs); - } else { - return ParseStatus.mergeObjectSync(status, pairs); - } - } - get element() { - return this._def.valueType; - } - static create(first, second, third) { - if (second instanceof ZodType2) { - return new ZodRecord2({ - keyType: first, - valueType: second, - typeName: ZodFirstPartyTypeKind2.ZodRecord, - ...processCreateParams(third) - }); - } - return new ZodRecord2({ - keyType: ZodString2.create(), - valueType: first, - typeName: ZodFirstPartyTypeKind2.ZodRecord, - ...processCreateParams(second) - }); - } -} - -class ZodMap2 extends ZodType2 { - get keySchema() { - return this._def.keyType; - } - get valueSchema() { - return this._def.valueType; - } - _parse(input) { - const { status, ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.map) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.map, - received: ctx.parsedType - }); - return INVALID; - } - const keyType = this._def.keyType; - const valueType = this._def.valueType; - const pairs = [...ctx.data.entries()].map(([key, value], index) => { - return { - key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, [index, "key"])), - value: valueType._parse(new ParseInputLazyPath(ctx, value, ctx.path, [index, "value"])) - }; - }); - if (ctx.common.async) { - const finalMap = new Map; - return Promise.resolve().then(async () => { - for (const pair of pairs) { - const key = await pair.key; - const value = await pair.value; - if (key.status === "aborted" || value.status === "aborted") { - return INVALID; - } - if (key.status === "dirty" || value.status === "dirty") { - status.dirty(); - } - finalMap.set(key.value, value.value); - } - return { status: status.value, value: finalMap }; - }); - } else { - const finalMap = new Map; - for (const pair of pairs) { - const key = pair.key; - const value = pair.value; - if (key.status === "aborted" || value.status === "aborted") { - return INVALID; - } - if (key.status === "dirty" || value.status === "dirty") { - status.dirty(); - } - finalMap.set(key.value, value.value); - } - return { status: status.value, value: finalMap }; - } - } -} -ZodMap2.create = (keyType, valueType, params) => { - return new ZodMap2({ - valueType, - keyType, - typeName: ZodFirstPartyTypeKind2.ZodMap, - ...processCreateParams(params) - }); -}; - -class ZodSet2 extends ZodType2 { - _parse(input) { - const { status, ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.set) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.set, - received: ctx.parsedType - }); - return INVALID; - } - const def = this._def; - if (def.minSize !== null) { - if (ctx.data.size < def.minSize.value) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_small, - minimum: def.minSize.value, - type: "set", - inclusive: true, - exact: false, - message: def.minSize.message - }); - status.dirty(); - } - } - if (def.maxSize !== null) { - if (ctx.data.size > def.maxSize.value) { - addIssueToContext(ctx, { - code: ZodIssueCode2.too_big, - maximum: def.maxSize.value, - type: "set", - inclusive: true, - exact: false, - message: def.maxSize.message - }); - status.dirty(); - } - } - const valueType = this._def.valueType; - function finalizeSet(elements2) { - const parsedSet = new Set; - for (const element of elements2) { - if (element.status === "aborted") - return INVALID; - if (element.status === "dirty") - status.dirty(); - parsedSet.add(element.value); - } - return { status: status.value, value: parsedSet }; - } - const elements = [...ctx.data.values()].map((item, i) => valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i))); - if (ctx.common.async) { - return Promise.all(elements).then((elements2) => finalizeSet(elements2)); - } else { - return finalizeSet(elements); - } - } - min(minSize, message) { - return new ZodSet2({ - ...this._def, - minSize: { value: minSize, message: errorUtil.toString(message) } - }); - } - max(maxSize, message) { - return new ZodSet2({ - ...this._def, - maxSize: { value: maxSize, message: errorUtil.toString(message) } - }); - } - size(size, message) { - return this.min(size, message).max(size, message); - } - nonempty(message) { - return this.min(1, message); - } -} -ZodSet2.create = (valueType, params) => { - return new ZodSet2({ - valueType, - minSize: null, - maxSize: null, - typeName: ZodFirstPartyTypeKind2.ZodSet, - ...processCreateParams(params) - }); -}; - -class ZodFunction2 extends ZodType2 { - constructor() { - super(...arguments); - this.validate = this.implement; - } - _parse(input) { - const { ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.function) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.function, - received: ctx.parsedType - }); - return INVALID; - } - function makeArgsIssue(args, error48) { - return makeIssue({ - data: args, - path: ctx.path, - errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap2(), en_default2].filter((x) => !!x), - issueData: { - code: ZodIssueCode2.invalid_arguments, - argumentsError: error48 - } - }); - } - function makeReturnsIssue(returns, error48) { - return makeIssue({ - data: returns, - path: ctx.path, - errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap2(), en_default2].filter((x) => !!x), - issueData: { - code: ZodIssueCode2.invalid_return_type, - returnTypeError: error48 - } - }); - } - const params = { errorMap: ctx.common.contextualErrorMap }; - const fn = ctx.data; - if (this._def.returns instanceof ZodPromise2) { - const me = this; - return OK(async function(...args) { - const error48 = new ZodError2([]); - const parsedArgs = await me._def.args.parseAsync(args, params).catch((e) => { - error48.addIssue(makeArgsIssue(args, e)); - throw error48; - }); - const result = await Reflect.apply(fn, this, parsedArgs); - const parsedReturns = await me._def.returns._def.type.parseAsync(result, params).catch((e) => { - error48.addIssue(makeReturnsIssue(result, e)); - throw error48; - }); - return parsedReturns; - }); - } else { - const me = this; - return OK(function(...args) { - const parsedArgs = me._def.args.safeParse(args, params); - if (!parsedArgs.success) { - throw new ZodError2([makeArgsIssue(args, parsedArgs.error)]); - } - const result = Reflect.apply(fn, this, parsedArgs.data); - const parsedReturns = me._def.returns.safeParse(result, params); - if (!parsedReturns.success) { - throw new ZodError2([makeReturnsIssue(result, parsedReturns.error)]); - } - return parsedReturns.data; - }); - } - } - parameters() { - return this._def.args; - } - returnType() { - return this._def.returns; - } - args(...items) { - return new ZodFunction2({ - ...this._def, - args: ZodTuple2.create(items).rest(ZodUnknown2.create()) - }); - } - returns(returnType) { - return new ZodFunction2({ - ...this._def, - returns: returnType - }); - } - implement(func) { - const validatedFunc = this.parse(func); - return validatedFunc; - } - strictImplement(func) { - const validatedFunc = this.parse(func); - return validatedFunc; - } - static create(args, returns, params) { - return new ZodFunction2({ - args: args ? args : ZodTuple2.create([]).rest(ZodUnknown2.create()), - returns: returns || ZodUnknown2.create(), - typeName: ZodFirstPartyTypeKind2.ZodFunction, - ...processCreateParams(params) - }); - } -} - -class ZodLazy2 extends ZodType2 { - get schema() { - return this._def.getter(); - } - _parse(input) { - const { ctx } = this._processInputParams(input); - const lazySchema = this._def.getter(); - return lazySchema._parse({ data: ctx.data, path: ctx.path, parent: ctx }); - } -} -ZodLazy2.create = (getter, params) => { - return new ZodLazy2({ - getter, - typeName: ZodFirstPartyTypeKind2.ZodLazy, - ...processCreateParams(params) - }); -}; - -class ZodLiteral2 extends ZodType2 { - _parse(input) { - if (input.data !== this._def.value) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - received: ctx.data, - code: ZodIssueCode2.invalid_literal, - expected: this._def.value - }); - return INVALID; - } - return { status: "valid", value: input.data }; - } - get value() { - return this._def.value; - } -} -ZodLiteral2.create = (value, params) => { - return new ZodLiteral2({ - value, - typeName: ZodFirstPartyTypeKind2.ZodLiteral, - ...processCreateParams(params) - }); -}; -function createZodEnum(values, params) { - return new ZodEnum2({ - values, - typeName: ZodFirstPartyTypeKind2.ZodEnum, - ...processCreateParams(params) - }); -} - -class ZodEnum2 extends ZodType2 { - _parse(input) { - if (typeof input.data !== "string") { - const ctx = this._getOrReturnCtx(input); - const expectedValues = this._def.values; - addIssueToContext(ctx, { - expected: util.joinValues(expectedValues), - received: ctx.parsedType, - code: ZodIssueCode2.invalid_type - }); - return INVALID; - } - if (!this._cache) { - this._cache = new Set(this._def.values); - } - if (!this._cache.has(input.data)) { - const ctx = this._getOrReturnCtx(input); - const expectedValues = this._def.values; - addIssueToContext(ctx, { - received: ctx.data, - code: ZodIssueCode2.invalid_enum_value, - options: expectedValues - }); - return INVALID; - } - return OK(input.data); - } - get options() { - return this._def.values; - } - get enum() { - const enumValues = {}; - for (const val of this._def.values) { - enumValues[val] = val; - } - return enumValues; - } - get Values() { - const enumValues = {}; - for (const val of this._def.values) { - enumValues[val] = val; - } - return enumValues; - } - get Enum() { - const enumValues = {}; - for (const val of this._def.values) { - enumValues[val] = val; - } - return enumValues; - } - extract(values, newDef = this._def) { - return ZodEnum2.create(values, { - ...this._def, - ...newDef - }); - } - exclude(values, newDef = this._def) { - return ZodEnum2.create(this.options.filter((opt) => !values.includes(opt)), { - ...this._def, - ...newDef - }); - } -} -ZodEnum2.create = createZodEnum; - -class ZodNativeEnum extends ZodType2 { - _parse(input) { - const nativeEnumValues = util.getValidEnumValues(this._def.values); - const ctx = this._getOrReturnCtx(input); - if (ctx.parsedType !== ZodParsedType.string && ctx.parsedType !== ZodParsedType.number) { - const expectedValues = util.objectValues(nativeEnumValues); - addIssueToContext(ctx, { - expected: util.joinValues(expectedValues), - received: ctx.parsedType, - code: ZodIssueCode2.invalid_type - }); - return INVALID; - } - if (!this._cache) { - this._cache = new Set(util.getValidEnumValues(this._def.values)); - } - if (!this._cache.has(input.data)) { - const expectedValues = util.objectValues(nativeEnumValues); - addIssueToContext(ctx, { - received: ctx.data, - code: ZodIssueCode2.invalid_enum_value, - options: expectedValues - }); - return INVALID; - } - return OK(input.data); - } - get enum() { - return this._def.values; - } -} -ZodNativeEnum.create = (values, params) => { - return new ZodNativeEnum({ - values, - typeName: ZodFirstPartyTypeKind2.ZodNativeEnum, - ...processCreateParams(params) - }); -}; - -class ZodPromise2 extends ZodType2 { - unwrap() { - return this._def.type; - } - _parse(input) { - const { ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.promise && ctx.common.async === false) { - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.promise, - received: ctx.parsedType - }); - return INVALID; - } - const promisified = ctx.parsedType === ZodParsedType.promise ? ctx.data : Promise.resolve(ctx.data); - return OK(promisified.then((data) => { - return this._def.type.parseAsync(data, { - path: ctx.path, - errorMap: ctx.common.contextualErrorMap - }); - })); - } -} -ZodPromise2.create = (schema, params) => { - return new ZodPromise2({ - type: schema, - typeName: ZodFirstPartyTypeKind2.ZodPromise, - ...processCreateParams(params) - }); -}; - -class ZodEffects extends ZodType2 { - innerType() { - return this._def.schema; - } - sourceType() { - return this._def.schema._def.typeName === ZodFirstPartyTypeKind2.ZodEffects ? this._def.schema.sourceType() : this._def.schema; - } - _parse(input) { - const { status, ctx } = this._processInputParams(input); - const effect = this._def.effect || null; - const checkCtx = { - addIssue: (arg) => { - addIssueToContext(ctx, arg); - if (arg.fatal) { - status.abort(); - } else { - status.dirty(); - } - }, - get path() { - return ctx.path; - } - }; - checkCtx.addIssue = checkCtx.addIssue.bind(checkCtx); - if (effect.type === "preprocess") { - const processed = effect.transform(ctx.data, checkCtx); - if (ctx.common.async) { - return Promise.resolve(processed).then(async (processed2) => { - if (status.value === "aborted") - return INVALID; - const result = await this._def.schema._parseAsync({ - data: processed2, - path: ctx.path, - parent: ctx - }); - if (result.status === "aborted") - return INVALID; - if (result.status === "dirty") - return DIRTY(result.value); - if (status.value === "dirty") - return DIRTY(result.value); - return result; - }); - } else { - if (status.value === "aborted") - return INVALID; - const result = this._def.schema._parseSync({ - data: processed, - path: ctx.path, - parent: ctx - }); - if (result.status === "aborted") - return INVALID; - if (result.status === "dirty") - return DIRTY(result.value); - if (status.value === "dirty") - return DIRTY(result.value); - return result; - } - } - if (effect.type === "refinement") { - const executeRefinement = (acc) => { - const result = effect.refinement(acc, checkCtx); - if (ctx.common.async) { - return Promise.resolve(result); - } - if (result instanceof Promise) { - throw new Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead."); - } - return acc; - }; - if (ctx.common.async === false) { - const inner = this._def.schema._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }); - if (inner.status === "aborted") - return INVALID; - if (inner.status === "dirty") - status.dirty(); - executeRefinement(inner.value); - return { status: status.value, value: inner.value }; - } else { - return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((inner) => { - if (inner.status === "aborted") - return INVALID; - if (inner.status === "dirty") - status.dirty(); - return executeRefinement(inner.value).then(() => { - return { status: status.value, value: inner.value }; - }); - }); - } - } - if (effect.type === "transform") { - if (ctx.common.async === false) { - const base = this._def.schema._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }); - if (!isValid(base)) - return INVALID; - const result = effect.transform(base.value, checkCtx); - if (result instanceof Promise) { - throw new Error(`Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.`); - } - return { status: status.value, value: result }; - } else { - return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((base) => { - if (!isValid(base)) - return INVALID; - return Promise.resolve(effect.transform(base.value, checkCtx)).then((result) => ({ - status: status.value, - value: result - })); - }); - } - } - util.assertNever(effect); - } -} -ZodEffects.create = (schema, effect, params) => { - return new ZodEffects({ - schema, - typeName: ZodFirstPartyTypeKind2.ZodEffects, - effect, - ...processCreateParams(params) - }); -}; -ZodEffects.createWithPreprocess = (preprocess2, schema, params) => { - return new ZodEffects({ - schema, - effect: { type: "preprocess", transform: preprocess2 }, - typeName: ZodFirstPartyTypeKind2.ZodEffects, - ...processCreateParams(params) - }); -}; -class ZodOptional2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 === ZodParsedType.undefined) { - return OK(undefined); - } - return this._def.innerType._parse(input); - } - unwrap() { - return this._def.innerType; - } -} -ZodOptional2.create = (type, params) => { - return new ZodOptional2({ - innerType: type, - typeName: ZodFirstPartyTypeKind2.ZodOptional, - ...processCreateParams(params) - }); -}; - -class ZodNullable2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 === ZodParsedType.null) { - return OK(null); - } - return this._def.innerType._parse(input); - } - unwrap() { - return this._def.innerType; - } -} -ZodNullable2.create = (type, params) => { - return new ZodNullable2({ - innerType: type, - typeName: ZodFirstPartyTypeKind2.ZodNullable, - ...processCreateParams(params) - }); -}; - -class ZodDefault2 extends ZodType2 { - _parse(input) { - const { ctx } = this._processInputParams(input); - let data = ctx.data; - if (ctx.parsedType === ZodParsedType.undefined) { - data = this._def.defaultValue(); - } - return this._def.innerType._parse({ - data, - path: ctx.path, - parent: ctx - }); - } - removeDefault() { - return this._def.innerType; - } -} -ZodDefault2.create = (type, params) => { - return new ZodDefault2({ - innerType: type, - typeName: ZodFirstPartyTypeKind2.ZodDefault, - defaultValue: typeof params.default === "function" ? params.default : () => params.default, - ...processCreateParams(params) - }); -}; - -class ZodCatch2 extends ZodType2 { - _parse(input) { - const { ctx } = this._processInputParams(input); - const newCtx = { - ...ctx, - common: { - ...ctx.common, - issues: [] - } - }; - const result = this._def.innerType._parse({ - data: newCtx.data, - path: newCtx.path, - parent: { - ...newCtx - } - }); - if (isAsync(result)) { - return result.then((result2) => { - return { - status: "valid", - value: result2.status === "valid" ? result2.value : this._def.catchValue({ - get error() { - return new ZodError2(newCtx.common.issues); - }, - input: newCtx.data - }) - }; - }); - } else { - return { - status: "valid", - value: result.status === "valid" ? result.value : this._def.catchValue({ - get error() { - return new ZodError2(newCtx.common.issues); - }, - input: newCtx.data - }) - }; - } - } - removeCatch() { - return this._def.innerType; - } -} -ZodCatch2.create = (type, params) => { - return new ZodCatch2({ - innerType: type, - typeName: ZodFirstPartyTypeKind2.ZodCatch, - catchValue: typeof params.catch === "function" ? params.catch : () => params.catch, - ...processCreateParams(params) - }); -}; - -class ZodNaN2 extends ZodType2 { - _parse(input) { - const parsedType2 = this._getType(input); - if (parsedType2 !== ZodParsedType.nan) { - const ctx = this._getOrReturnCtx(input); - addIssueToContext(ctx, { - code: ZodIssueCode2.invalid_type, - expected: ZodParsedType.nan, - received: ctx.parsedType - }); - return INVALID; - } - return { status: "valid", value: input.data }; - } -} -ZodNaN2.create = (params) => { - return new ZodNaN2({ - typeName: ZodFirstPartyTypeKind2.ZodNaN, - ...processCreateParams(params) - }); -}; -var BRAND = Symbol("zod_brand"); - -class ZodBranded extends ZodType2 { - _parse(input) { - const { ctx } = this._processInputParams(input); - const data = ctx.data; - return this._def.type._parse({ - data, - path: ctx.path, - parent: ctx - }); - } - unwrap() { - return this._def.type; - } -} - -class ZodPipeline extends ZodType2 { - _parse(input) { - const { status, ctx } = this._processInputParams(input); - if (ctx.common.async) { - const handleAsync = async () => { - const inResult = await this._def.in._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }); - if (inResult.status === "aborted") - return INVALID; - if (inResult.status === "dirty") { - status.dirty(); - return DIRTY(inResult.value); - } else { - return this._def.out._parseAsync({ - data: inResult.value, - path: ctx.path, - parent: ctx - }); - } - }; - return handleAsync(); - } else { - const inResult = this._def.in._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }); - if (inResult.status === "aborted") - return INVALID; - if (inResult.status === "dirty") { - status.dirty(); - return { - status: "dirty", - value: inResult.value - }; - } else { - return this._def.out._parseSync({ - data: inResult.value, - path: ctx.path, - parent: ctx - }); - } - } - } - static create(a, b) { - return new ZodPipeline({ - in: a, - out: b, - typeName: ZodFirstPartyTypeKind2.ZodPipeline - }); - } -} - -class ZodReadonly2 extends ZodType2 { - _parse(input) { - const result = this._def.innerType._parse(input); - const freeze = (data) => { - if (isValid(data)) { - data.value = Object.freeze(data.value); - } - return data; - }; - return isAsync(result) ? result.then((data) => freeze(data)) : freeze(result); - } - unwrap() { - return this._def.innerType; - } -} -ZodReadonly2.create = (type, params) => { - return new ZodReadonly2({ - innerType: type, - typeName: ZodFirstPartyTypeKind2.ZodReadonly, - ...processCreateParams(params) - }); -}; -function cleanParams(params, data) { - const p = typeof params === "function" ? params(data) : typeof params === "string" ? { message: params } : params; - const p2 = typeof p === "string" ? { message: p } : p; - return p2; -} -function custom2(check2, _params = {}, fatal) { - if (check2) - return ZodAny2.create().superRefine((data, ctx) => { - const r = check2(data); - if (r instanceof Promise) { - return r.then((r2) => { - if (!r2) { - const params = cleanParams(_params, data); - const _fatal = params.fatal ?? fatal ?? true; - ctx.addIssue({ code: "custom", ...params, fatal: _fatal }); - } - }); - } - if (!r) { - const params = cleanParams(_params, data); - const _fatal = params.fatal ?? fatal ?? true; - ctx.addIssue({ code: "custom", ...params, fatal: _fatal }); - } - return; - }); - return ZodAny2.create(); -} -var late = { - object: ZodObject2.lazycreate -}; -var ZodFirstPartyTypeKind2; -(function(ZodFirstPartyTypeKind3) { - ZodFirstPartyTypeKind3["ZodString"] = "ZodString"; - ZodFirstPartyTypeKind3["ZodNumber"] = "ZodNumber"; - ZodFirstPartyTypeKind3["ZodNaN"] = "ZodNaN"; - ZodFirstPartyTypeKind3["ZodBigInt"] = "ZodBigInt"; - ZodFirstPartyTypeKind3["ZodBoolean"] = "ZodBoolean"; - ZodFirstPartyTypeKind3["ZodDate"] = "ZodDate"; - ZodFirstPartyTypeKind3["ZodSymbol"] = "ZodSymbol"; - ZodFirstPartyTypeKind3["ZodUndefined"] = "ZodUndefined"; - ZodFirstPartyTypeKind3["ZodNull"] = "ZodNull"; - ZodFirstPartyTypeKind3["ZodAny"] = "ZodAny"; - ZodFirstPartyTypeKind3["ZodUnknown"] = "ZodUnknown"; - ZodFirstPartyTypeKind3["ZodNever"] = "ZodNever"; - ZodFirstPartyTypeKind3["ZodVoid"] = "ZodVoid"; - ZodFirstPartyTypeKind3["ZodArray"] = "ZodArray"; - ZodFirstPartyTypeKind3["ZodObject"] = "ZodObject"; - ZodFirstPartyTypeKind3["ZodUnion"] = "ZodUnion"; - ZodFirstPartyTypeKind3["ZodDiscriminatedUnion"] = "ZodDiscriminatedUnion"; - ZodFirstPartyTypeKind3["ZodIntersection"] = "ZodIntersection"; - ZodFirstPartyTypeKind3["ZodTuple"] = "ZodTuple"; - ZodFirstPartyTypeKind3["ZodRecord"] = "ZodRecord"; - ZodFirstPartyTypeKind3["ZodMap"] = "ZodMap"; - ZodFirstPartyTypeKind3["ZodSet"] = "ZodSet"; - ZodFirstPartyTypeKind3["ZodFunction"] = "ZodFunction"; - ZodFirstPartyTypeKind3["ZodLazy"] = "ZodLazy"; - ZodFirstPartyTypeKind3["ZodLiteral"] = "ZodLiteral"; - ZodFirstPartyTypeKind3["ZodEnum"] = "ZodEnum"; - ZodFirstPartyTypeKind3["ZodEffects"] = "ZodEffects"; - ZodFirstPartyTypeKind3["ZodNativeEnum"] = "ZodNativeEnum"; - ZodFirstPartyTypeKind3["ZodOptional"] = "ZodOptional"; - ZodFirstPartyTypeKind3["ZodNullable"] = "ZodNullable"; - ZodFirstPartyTypeKind3["ZodDefault"] = "ZodDefault"; - ZodFirstPartyTypeKind3["ZodCatch"] = "ZodCatch"; - ZodFirstPartyTypeKind3["ZodPromise"] = "ZodPromise"; - ZodFirstPartyTypeKind3["ZodBranded"] = "ZodBranded"; - ZodFirstPartyTypeKind3["ZodPipeline"] = "ZodPipeline"; - ZodFirstPartyTypeKind3["ZodReadonly"] = "ZodReadonly"; -})(ZodFirstPartyTypeKind2 || (ZodFirstPartyTypeKind2 = {})); -var instanceOfType = (cls, params = { - message: `Input not instance of ${cls.name}` -}) => custom2((data) => data instanceof cls, params); -var stringType = ZodString2.create; -var numberType = ZodNumber2.create; -var nanType = ZodNaN2.create; -var bigIntType = ZodBigInt2.create; -var booleanType = ZodBoolean2.create; -var dateType = ZodDate2.create; -var symbolType = ZodSymbol2.create; -var undefinedType = ZodUndefined2.create; -var nullType = ZodNull2.create; -var anyType = ZodAny2.create; -var unknownType = ZodUnknown2.create; -var neverType = ZodNever2.create; -var voidType = ZodVoid2.create; -var arrayType = ZodArray2.create; -var objectType = ZodObject2.create; -var strictObjectType = ZodObject2.strictCreate; -var unionType = ZodUnion2.create; -var discriminatedUnionType = ZodDiscriminatedUnion2.create; -var intersectionType = ZodIntersection2.create; -var tupleType = ZodTuple2.create; -var recordType = ZodRecord2.create; -var mapType = ZodMap2.create; -var setType = ZodSet2.create; -var functionType = ZodFunction2.create; -var lazyType = ZodLazy2.create; -var literalType = ZodLiteral2.create; -var enumType = ZodEnum2.create; -var nativeEnumType = ZodNativeEnum.create; -var promiseType = ZodPromise2.create; -var effectsType = ZodEffects.create; -var optionalType = ZodOptional2.create; -var nullableType = ZodNullable2.create; -var preprocessType = ZodEffects.createWithPreprocess; -var pipelineType = ZodPipeline.create; -var ostring = () => stringType().optional(); -var onumber = () => numberType().optional(); -var oboolean = () => booleanType().optional(); -var coerce = { - string: (arg) => ZodString2.create({ ...arg, coerce: true }), - number: (arg) => ZodNumber2.create({ ...arg, coerce: true }), - boolean: (arg) => ZodBoolean2.create({ - ...arg, - coerce: true - }), - bigint: (arg) => ZodBigInt2.create({ ...arg, coerce: true }), - date: (arg) => ZodDate2.create({ ...arg, coerce: true }) -}; -var NEVER2 = INVALID; -// ../../node_modules/.pnpm/eventsource-parser@3.0.6/node_modules/eventsource-parser/dist/index.js -class ParseError extends Error { - constructor(message, options) { - super(message), this.name = "ParseError", this.type = options.type, this.field = options.field, this.value = options.value, this.line = options.line; - } -} -function noop(_arg) {} -function createParser(callbacks) { - if (typeof callbacks == "function") - throw new TypeError("`callbacks` must be an object, got a function instead. Did you mean `{onEvent: fn}`?"); - const { onEvent = noop, onError = noop, onRetry = noop, onComment } = callbacks; - let incompleteLine = "", isFirstChunk = true, id, data = "", eventType = ""; - function feed(newChunk) { - const chunk = isFirstChunk ? newChunk.replace(/^\xEF\xBB\xBF/, "") : newChunk, [complete, incomplete] = splitLines(`${incompleteLine}${chunk}`); - for (const line of complete) - parseLine(line); - incompleteLine = incomplete, isFirstChunk = false; - } - function parseLine(line) { - if (line === "") { - dispatchEvent(); - return; - } - if (line.startsWith(":")) { - onComment && onComment(line.slice(line.startsWith(": ") ? 2 : 1)); - return; - } - const fieldSeparatorIndex = line.indexOf(":"); - if (fieldSeparatorIndex !== -1) { - const field = line.slice(0, fieldSeparatorIndex), offset = line[fieldSeparatorIndex + 1] === " " ? 2 : 1, value = line.slice(fieldSeparatorIndex + offset); - processField(field, value, line); - return; - } - processField(line, "", line); - } - function processField(field, value, line) { - switch (field) { - case "event": - eventType = value; - break; - case "data": - data = `${data}${value} -`; - break; - case "id": - id = value.includes("\x00") ? undefined : value; - break; - case "retry": - /^\d+$/.test(value) ? onRetry(parseInt(value, 10)) : onError(new ParseError(`Invalid \`retry\` value: "${value}"`, { - type: "invalid-retry", - value, - line - })); - break; - default: - onError(new ParseError(`Unknown field "${field.length > 20 ? `${field.slice(0, 20)}…` : field}"`, { type: "unknown-field", field, value, line })); - break; - } - } - function dispatchEvent() { - data.length > 0 && onEvent({ - id, - event: eventType || undefined, - data: data.endsWith(` -`) ? data.slice(0, -1) : data - }), id = undefined, data = "", eventType = ""; - } - function reset(options = {}) { - incompleteLine && options.consume && parseLine(incompleteLine), isFirstChunk = true, id = undefined, data = "", eventType = "", incompleteLine = ""; - } - return { feed, reset }; -} -function splitLines(chunk) { - const lines = []; - let incompleteLine = "", searchIndex = 0; - for (;searchIndex < chunk.length; ) { - const crIndex = chunk.indexOf("\r", searchIndex), lfIndex = chunk.indexOf(` -`, searchIndex); - let lineEnd = -1; - if (crIndex !== -1 && lfIndex !== -1 ? lineEnd = Math.min(crIndex, lfIndex) : crIndex !== -1 ? crIndex === chunk.length - 1 ? lineEnd = -1 : lineEnd = crIndex : lfIndex !== -1 && (lineEnd = lfIndex), lineEnd === -1) { - incompleteLine = chunk.slice(searchIndex); - break; - } else { - const line = chunk.slice(searchIndex, lineEnd); - lines.push(line), searchIndex = lineEnd + 1, chunk[searchIndex - 1] === "\r" && chunk[searchIndex] === ` -` && searchIndex++; - } - } - return [lines, incompleteLine]; -} - -// ../../node_modules/.pnpm/eventsource-parser@3.0.6/node_modules/eventsource-parser/dist/stream.js -class EventSourceParserStream extends TransformStream { - constructor({ onError, onRetry, onComment } = {}) { - let parser; - super({ - start(controller) { - parser = createParser({ - onEvent: (event) => { - controller.enqueue(event); - }, - onError(error48) { - onError === "terminate" ? controller.error(error48) : typeof onError == "function" && onError(error48); - }, - onRetry, - onComment - }); - }, - transform(chunk) { - parser.feed(chunk); - } - }); - } -} - -// ../../node_modules/.pnpm/@ai-sdk+provider-utils@4.0.15_zod@4.3.6/node_modules/@ai-sdk/provider-utils/dist/index.mjs -function combineHeaders(...headers) { - return headers.reduce((combinedHeaders, currentHeaders) => ({ - ...combinedHeaders, - ...currentHeaders != null ? currentHeaders : {} - }), {}); -} -async function delay(delayInMs, options) { - if (delayInMs == null) { - return Promise.resolve(); - } - const signal = options == null ? undefined : options.abortSignal; - return new Promise((resolve2, reject) => { - if (signal == null ? undefined : signal.aborted) { - reject(createAbortError()); - return; - } - const timeoutId = setTimeout(() => { - cleanup(); - resolve2(); - }, delayInMs); - const cleanup = () => { - clearTimeout(timeoutId); - signal == null || signal.removeEventListener("abort", onAbort); - }; - const onAbort = () => { - cleanup(); - reject(createAbortError()); - }; - signal == null || signal.addEventListener("abort", onAbort); - }); -} -function createAbortError() { - return new DOMException("Delay was aborted", "AbortError"); -} -var DelayedPromise = class { - constructor() { - this.status = { type: "pending" }; - this._resolve = undefined; - this._reject = undefined; - } - get promise() { - if (this._promise) { - return this._promise; - } - this._promise = new Promise((resolve2, reject) => { - if (this.status.type === "resolved") { - resolve2(this.status.value); - } else if (this.status.type === "rejected") { - reject(this.status.error); - } - this._resolve = resolve2; - this._reject = reject; - }); - return this._promise; - } - resolve(value) { - var _a22; - this.status = { type: "resolved", value }; - if (this._promise) { - (_a22 = this._resolve) == null || _a22.call(this, value); - } - } - reject(error48) { - var _a22; - this.status = { type: "rejected", error: error48 }; - if (this._promise) { - (_a22 = this._reject) == null || _a22.call(this, error48); - } - } - isResolved() { - return this.status.type === "resolved"; - } - isRejected() { - return this.status.type === "rejected"; - } - isPending() { - return this.status.type === "pending"; - } -}; -function extractResponseHeaders(response) { - return Object.fromEntries([...response.headers]); -} -var { btoa: btoa2, atob: atob2 } = globalThis; -function convertBase64ToUint8Array(base64String) { - const base64Url = base64String.replace(/-/g, "+").replace(/_/g, "/"); - const latin1string = atob2(base64Url); - return Uint8Array.from(latin1string, (byte) => byte.codePointAt(0)); -} -function convertUint8ArrayToBase64(array2) { - let latin1string = ""; - for (let i = 0;i < array2.length; i++) { - latin1string += String.fromCodePoint(array2[i]); - } - return btoa2(latin1string); -} -function convertToBase64(value) { - return value instanceof Uint8Array ? convertUint8ArrayToBase64(value) : value; -} -function convertToFormData(input, options = {}) { - const { useArrayBrackets = true } = options; - const formData = new FormData; - for (const [key, value] of Object.entries(input)) { - if (value == null) { - continue; - } - if (Array.isArray(value)) { - if (value.length === 1) { - formData.append(key, value[0]); - continue; - } - const arrayKey = useArrayBrackets ? `${key}[]` : key; - for (const item of value) { - formData.append(arrayKey, item); - } - continue; - } - formData.append(key, value); - } - return formData; -} -var name14 = "AI_DownloadError"; -var marker15 = `vercel.ai.error.${name14}`; -var symbol17 = Symbol.for(marker15); -var _a17; -var _b15; -var DownloadError = class extends (_b15 = AISDKError, _a17 = symbol17, _b15) { - constructor({ - url: url2, - statusCode, - statusText, - cause, - message = cause == null ? `Failed to download ${url2}: ${statusCode} ${statusText}` : `Failed to download ${url2}: ${cause}` - }) { - super({ name: name14, message, cause }); - this[_a17] = true; - this.url = url2; - this.statusCode = statusCode; - this.statusText = statusText; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker15); - } -}; -var DEFAULT_MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024; -async function readResponseWithSizeLimit({ - response, - url: url2, - maxBytes = DEFAULT_MAX_DOWNLOAD_SIZE -}) { - const contentLength = response.headers.get("content-length"); - if (contentLength != null) { - const length = parseInt(contentLength, 10); - if (!isNaN(length) && length > maxBytes) { - throw new DownloadError({ - url: url2, - message: `Download of ${url2} exceeded maximum size of ${maxBytes} bytes (Content-Length: ${length}).` - }); - } - } - const body = response.body; - if (body == null) { - return new Uint8Array(0); - } - const reader = body.getReader(); - const chunks = []; - let totalBytes = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - totalBytes += value.length; - if (totalBytes > maxBytes) { - throw new DownloadError({ - url: url2, - message: `Download of ${url2} exceeded maximum size of ${maxBytes} bytes.` - }); - } - chunks.push(value); - } - } finally { - try { - await reader.cancel(); - } finally { - reader.releaseLock(); - } - } - const result = new Uint8Array(totalBytes); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; -} -async function downloadBlob(url2, options) { - var _a22, _b22; - try { - const response = await fetch(url2, { - signal: options == null ? undefined : options.abortSignal - }); - if (!response.ok) { - throw new DownloadError({ - url: url2, - statusCode: response.status, - statusText: response.statusText - }); - } - const data = await readResponseWithSizeLimit({ - response, - url: url2, - maxBytes: (_a22 = options == null ? undefined : options.maxBytes) != null ? _a22 : DEFAULT_MAX_DOWNLOAD_SIZE - }); - const contentType = (_b22 = response.headers.get("content-type")) != null ? _b22 : undefined; - return new Blob([data], contentType ? { type: contentType } : undefined); - } catch (error48) { - if (DownloadError.isInstance(error48)) { - throw error48; - } - throw new DownloadError({ url: url2, cause: error48 }); - } -} -var createIdGenerator = ({ - prefix, - size = 16, - alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", - separator = "-" -} = {}) => { - const generator = () => { - const alphabetLength = alphabet.length; - const chars = new Array(size); - for (let i = 0;i < size; i++) { - chars[i] = alphabet[Math.random() * alphabetLength | 0]; - } - return chars.join(""); - }; - if (prefix == null) { - return generator; - } - if (alphabet.includes(separator)) { - throw new InvalidArgumentError({ - argument: "separator", - message: `The separator "${separator}" must not be part of the alphabet "${alphabet}".` - }); - } - return () => `${prefix}${separator}${generator()}`; -}; -var generateId = createIdGenerator(); -function getErrorMessage2(error48) { - if (error48 == null) { - return "unknown error"; - } - if (typeof error48 === "string") { - return error48; - } - if (error48 instanceof Error) { - return error48.message; - } - return JSON.stringify(error48); -} -function isAbortError(error48) { - return (error48 instanceof Error || error48 instanceof DOMException) && (error48.name === "AbortError" || error48.name === "ResponseAborted" || error48.name === "TimeoutError"); -} -var FETCH_FAILED_ERROR_MESSAGES = ["fetch failed", "failed to fetch"]; -var BUN_ERROR_CODES = [ - "ConnectionRefused", - "ConnectionClosed", - "FailedToOpenSocket", - "ECONNRESET", - "ECONNREFUSED", - "ETIMEDOUT", - "EPIPE" -]; -function isBunNetworkError(error48) { - if (!(error48 instanceof Error)) { - return false; - } - const code = error48.code; - if (typeof code === "string" && BUN_ERROR_CODES.includes(code)) { - return true; - } - return false; -} -function handleFetchError({ - error: error48, - url: url2, - requestBodyValues -}) { - if (isAbortError(error48)) { - return error48; - } - if (error48 instanceof TypeError && FETCH_FAILED_ERROR_MESSAGES.includes(error48.message.toLowerCase())) { - const cause = error48.cause; - if (cause != null) { - return new APICallError({ - message: `Cannot connect to API: ${cause.message}`, - cause, - url: url2, - requestBodyValues, - isRetryable: true - }); - } - } - if (isBunNetworkError(error48)) { - return new APICallError({ - message: `Cannot connect to API: ${error48.message}`, - cause: error48, - url: url2, - requestBodyValues, - isRetryable: true - }); - } - return error48; -} -function getRuntimeEnvironmentUserAgent(globalThisAny = globalThis) { - var _a22, _b22, _c; - if (globalThisAny.window) { - return `runtime/browser`; - } - if ((_a22 = globalThisAny.navigator) == null ? undefined : _a22.userAgent) { - return `runtime/${globalThisAny.navigator.userAgent.toLowerCase()}`; - } - if ((_c = (_b22 = globalThisAny.process) == null ? undefined : _b22.versions) == null ? undefined : _c.node) { - return `runtime/node.js/${globalThisAny.process.version.substring(0)}`; - } - if (globalThisAny.EdgeRuntime) { - return `runtime/vercel-edge`; - } - return "runtime/unknown"; -} -function normalizeHeaders(headers) { - if (headers == null) { - return {}; - } - const normalized = {}; - if (headers instanceof Headers) { - headers.forEach((value, key) => { - normalized[key.toLowerCase()] = value; - }); - } else { - if (!Array.isArray(headers)) { - headers = Object.entries(headers); - } - for (const [key, value] of headers) { - if (value != null) { - normalized[key.toLowerCase()] = value; - } - } - } - return normalized; -} -function withUserAgentSuffix(headers, ...userAgentSuffixParts) { - const normalizedHeaders = new Headers(normalizeHeaders(headers)); - const currentUserAgentHeader = normalizedHeaders.get("user-agent") || ""; - normalizedHeaders.set("user-agent", [currentUserAgentHeader, ...userAgentSuffixParts].filter(Boolean).join(" ")); - return Object.fromEntries(normalizedHeaders.entries()); -} -var VERSION = "4.0.15"; -var getOriginalFetch = () => globalThis.fetch; -var getFromApi = async ({ - url: url2, - headers = {}, - successfulResponseHandler, - failedResponseHandler, - abortSignal, - fetch: fetch2 = getOriginalFetch() -}) => { - try { - const response = await fetch2(url2, { - method: "GET", - headers: withUserAgentSuffix(headers, `ai-sdk/provider-utils/${VERSION}`, getRuntimeEnvironmentUserAgent()), - signal: abortSignal - }); - const responseHeaders = extractResponseHeaders(response); - if (!response.ok) { - let errorInformation; - try { - errorInformation = await failedResponseHandler({ - response, - url: url2, - requestBodyValues: {} - }); - } catch (error48) { - if (isAbortError(error48) || APICallError.isInstance(error48)) { - throw error48; - } - throw new APICallError({ - message: "Failed to process error response", - cause: error48, - statusCode: response.status, - url: url2, - responseHeaders, - requestBodyValues: {} - }); - } - throw errorInformation.value; - } - try { - return await successfulResponseHandler({ - response, - url: url2, - requestBodyValues: {} - }); - } catch (error48) { - if (error48 instanceof Error) { - if (isAbortError(error48) || APICallError.isInstance(error48)) { - throw error48; - } - } - throw new APICallError({ - message: "Failed to process successful response", - cause: error48, - statusCode: response.status, - url: url2, - responseHeaders, - requestBodyValues: {} - }); - } - } catch (error48) { - throw handleFetchError({ error: error48, url: url2, requestBodyValues: {} }); - } -}; -function isNonNullable(value) { - return value != null; -} -function isUrlSupported({ - mediaType, - url: url2, - supportedUrls -}) { - url2 = url2.toLowerCase(); - mediaType = mediaType.toLowerCase(); - return Object.entries(supportedUrls).map(([key, value]) => { - const mediaType2 = key.toLowerCase(); - return mediaType2 === "*" || mediaType2 === "*/*" ? { mediaTypePrefix: "", regexes: value } : { mediaTypePrefix: mediaType2.replace(/\*/, ""), regexes: value }; - }).filter(({ mediaTypePrefix }) => mediaType.startsWith(mediaTypePrefix)).flatMap(({ regexes }) => regexes).some((pattern) => pattern.test(url2)); -} -function loadOptionalSetting({ - settingValue, - environmentVariableName -}) { - if (typeof settingValue === "string") { - return settingValue; - } - if (settingValue != null || typeof process === "undefined") { - return; - } - settingValue = process.env[environmentVariableName]; - if (settingValue == null || typeof settingValue !== "string") { - return; - } - return settingValue; -} -var suspectProtoRx = /"__proto__"\s*:/; -var suspectConstructorRx = /"constructor"\s*:/; -function _parse2(text) { - const obj = JSON.parse(text); - if (obj === null || typeof obj !== "object") { - return obj; - } - if (suspectProtoRx.test(text) === false && suspectConstructorRx.test(text) === false) { - return obj; - } - return filter(obj); -} -function filter(obj) { - let next = [obj]; - while (next.length) { - const nodes = next; - next = []; - for (const node of nodes) { - if (Object.prototype.hasOwnProperty.call(node, "__proto__")) { - throw new SyntaxError("Object contains forbidden prototype property"); - } - if (Object.prototype.hasOwnProperty.call(node, "constructor") && Object.prototype.hasOwnProperty.call(node.constructor, "prototype")) { - throw new SyntaxError("Object contains forbidden prototype property"); - } - for (const key in node) { - const value = node[key]; - if (value && typeof value === "object") { - next.push(value); - } - } - } - } - return obj; -} -function secureJsonParse(text) { - const { stackTraceLimit } = Error; - try { - Error.stackTraceLimit = 0; - } catch (e) { - return _parse2(text); - } - try { - return _parse2(text); - } finally { - Error.stackTraceLimit = stackTraceLimit; - } -} -function addAdditionalPropertiesToJsonSchema(jsonSchema2) { - if (jsonSchema2.type === "object" || Array.isArray(jsonSchema2.type) && jsonSchema2.type.includes("object")) { - jsonSchema2.additionalProperties = false; - const { properties } = jsonSchema2; - if (properties != null) { - for (const key of Object.keys(properties)) { - properties[key] = visit(properties[key]); - } - } - } - if (jsonSchema2.items != null) { - jsonSchema2.items = Array.isArray(jsonSchema2.items) ? jsonSchema2.items.map(visit) : visit(jsonSchema2.items); - } - if (jsonSchema2.anyOf != null) { - jsonSchema2.anyOf = jsonSchema2.anyOf.map(visit); - } - if (jsonSchema2.allOf != null) { - jsonSchema2.allOf = jsonSchema2.allOf.map(visit); - } - if (jsonSchema2.oneOf != null) { - jsonSchema2.oneOf = jsonSchema2.oneOf.map(visit); - } - const { definitions } = jsonSchema2; - if (definitions != null) { - for (const key of Object.keys(definitions)) { - definitions[key] = visit(definitions[key]); - } - } - return jsonSchema2; -} -function visit(def) { - if (typeof def === "boolean") - return def; - return addAdditionalPropertiesToJsonSchema(def); -} -var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use"); -var defaultOptions = { - name: undefined, - $refStrategy: "root", - basePath: ["#"], - effectStrategy: "input", - pipeStrategy: "all", - dateStrategy: "format:date-time", - mapStrategy: "entries", - removeAdditionalStrategy: "passthrough", - allowedAdditionalProperties: true, - rejectedAdditionalProperties: false, - definitionPath: "definitions", - strictUnions: false, - definitions: {}, - errorMessages: false, - patternStrategy: "escape", - applyRegexFlags: false, - emailStrategy: "format:email", - base64Strategy: "contentEncoding:base64", - nameStrategy: "ref" -}; -var getDefaultOptions = (options) => typeof options === "string" ? { - ...defaultOptions, - name: options -} : { - ...defaultOptions, - ...options -}; -function parseAnyDef() { - return {}; -} -function parseArrayDef(def, refs) { - var _a22, _b22, _c; - const res = { - type: "array" - }; - if (((_a22 = def.type) == null ? undefined : _a22._def) && ((_c = (_b22 = def.type) == null ? undefined : _b22._def) == null ? undefined : _c.typeName) !== ZodFirstPartyTypeKind2.ZodAny) { - res.items = parseDef(def.type._def, { - ...refs, - currentPath: [...refs.currentPath, "items"] - }); - } - if (def.minLength) { - res.minItems = def.minLength.value; - } - if (def.maxLength) { - res.maxItems = def.maxLength.value; - } - if (def.exactLength) { - res.minItems = def.exactLength.value; - res.maxItems = def.exactLength.value; - } - return res; -} -function parseBigintDef(def) { - const res = { - type: "integer", - format: "int64" - }; - if (!def.checks) - return res; - for (const check2 of def.checks) { - switch (check2.kind) { - case "min": - if (check2.inclusive) { - res.minimum = check2.value; - } else { - res.exclusiveMinimum = check2.value; - } - break; - case "max": - if (check2.inclusive) { - res.maximum = check2.value; - } else { - res.exclusiveMaximum = check2.value; - } - break; - case "multipleOf": - res.multipleOf = check2.value; - break; - } - } - return res; -} -function parseBooleanDef() { - return { type: "boolean" }; -} -function parseBrandedDef(_def, refs) { - return parseDef(_def.type._def, refs); -} -var parseCatchDef = (def, refs) => { - return parseDef(def.innerType._def, refs); -}; -function parseDateDef(def, refs, overrideDateStrategy) { - const strategy = overrideDateStrategy != null ? overrideDateStrategy : refs.dateStrategy; - if (Array.isArray(strategy)) { - return { - anyOf: strategy.map((item, i) => parseDateDef(def, refs, item)) - }; - } - switch (strategy) { - case "string": - case "format:date-time": - return { - type: "string", - format: "date-time" - }; - case "format:date": - return { - type: "string", - format: "date" - }; - case "integer": - return integerDateParser(def); - } -} -var integerDateParser = (def) => { - const res = { - type: "integer", - format: "unix-time" - }; - for (const check2 of def.checks) { - switch (check2.kind) { - case "min": - res.minimum = check2.value; - break; - case "max": - res.maximum = check2.value; - break; - } - } - return res; -}; -function parseDefaultDef(_def, refs) { - return { - ...parseDef(_def.innerType._def, refs), - default: _def.defaultValue() - }; -} -function parseEffectsDef(_def, refs) { - return refs.effectStrategy === "input" ? parseDef(_def.schema._def, refs) : parseAnyDef(); -} -function parseEnumDef(def) { - return { - type: "string", - enum: Array.from(def.values) - }; -} -var isJsonSchema7AllOfType = (type) => { - if ("type" in type && type.type === "string") - return false; - return "allOf" in type; -}; -function parseIntersectionDef(def, refs) { - const allOf = [ - parseDef(def.left._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", "0"] - }), - parseDef(def.right._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", "1"] - }) - ].filter((x) => !!x); - const mergedAllOf = []; - allOf.forEach((schema) => { - if (isJsonSchema7AllOfType(schema)) { - mergedAllOf.push(...schema.allOf); - } else { - let nestedSchema = schema; - if ("additionalProperties" in schema && schema.additionalProperties === false) { - const { additionalProperties, ...rest } = schema; - nestedSchema = rest; - } - mergedAllOf.push(nestedSchema); - } - }); - return mergedAllOf.length ? { allOf: mergedAllOf } : undefined; -} -function parseLiteralDef(def) { - const parsedType2 = typeof def.value; - if (parsedType2 !== "bigint" && parsedType2 !== "number" && parsedType2 !== "boolean" && parsedType2 !== "string") { - return { - type: Array.isArray(def.value) ? "array" : "object" - }; - } - return { - type: parsedType2 === "bigint" ? "integer" : parsedType2, - const: def.value - }; -} -var emojiRegex2 = undefined; -var zodPatterns = { - cuid: /^[cC][^\s-]{8,}$/, - cuid2: /^[0-9a-z]+$/, - ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/, - email: /^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/, - emoji: () => { - if (emojiRegex2 === undefined) { - emojiRegex2 = RegExp("^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$", "u"); - } - return emojiRegex2; - }, - uuid: /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/, - ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, - ipv4Cidr: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/, - ipv6: /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/, - ipv6Cidr: /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/, - base64: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, - base64url: /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/, - nanoid: /^[a-zA-Z0-9_-]{21}$/, - jwt: /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/ -}; -function parseStringDef(def, refs) { - const res = { - type: "string" - }; - if (def.checks) { - for (const check2 of def.checks) { - switch (check2.kind) { - case "min": - res.minLength = typeof res.minLength === "number" ? Math.max(res.minLength, check2.value) : check2.value; - break; - case "max": - res.maxLength = typeof res.maxLength === "number" ? Math.min(res.maxLength, check2.value) : check2.value; - break; - case "email": - switch (refs.emailStrategy) { - case "format:email": - addFormat(res, "email", check2.message, refs); - break; - case "format:idn-email": - addFormat(res, "idn-email", check2.message, refs); - break; - case "pattern:zod": - addPattern(res, zodPatterns.email, check2.message, refs); - break; - } - break; - case "url": - addFormat(res, "uri", check2.message, refs); - break; - case "uuid": - addFormat(res, "uuid", check2.message, refs); - break; - case "regex": - addPattern(res, check2.regex, check2.message, refs); - break; - case "cuid": - addPattern(res, zodPatterns.cuid, check2.message, refs); - break; - case "cuid2": - addPattern(res, zodPatterns.cuid2, check2.message, refs); - break; - case "startsWith": - addPattern(res, RegExp(`^${escapeLiteralCheckValue(check2.value, refs)}`), check2.message, refs); - break; - case "endsWith": - addPattern(res, RegExp(`${escapeLiteralCheckValue(check2.value, refs)}$`), check2.message, refs); - break; - case "datetime": - addFormat(res, "date-time", check2.message, refs); - break; - case "date": - addFormat(res, "date", check2.message, refs); - break; - case "time": - addFormat(res, "time", check2.message, refs); - break; - case "duration": - addFormat(res, "duration", check2.message, refs); - break; - case "length": - res.minLength = typeof res.minLength === "number" ? Math.max(res.minLength, check2.value) : check2.value; - res.maxLength = typeof res.maxLength === "number" ? Math.min(res.maxLength, check2.value) : check2.value; - break; - case "includes": { - addPattern(res, RegExp(escapeLiteralCheckValue(check2.value, refs)), check2.message, refs); - break; - } - case "ip": { - if (check2.version !== "v6") { - addFormat(res, "ipv4", check2.message, refs); - } - if (check2.version !== "v4") { - addFormat(res, "ipv6", check2.message, refs); - } - break; - } - case "base64url": - addPattern(res, zodPatterns.base64url, check2.message, refs); - break; - case "jwt": - addPattern(res, zodPatterns.jwt, check2.message, refs); - break; - case "cidr": { - if (check2.version !== "v6") { - addPattern(res, zodPatterns.ipv4Cidr, check2.message, refs); - } - if (check2.version !== "v4") { - addPattern(res, zodPatterns.ipv6Cidr, check2.message, refs); - } - break; - } - case "emoji": - addPattern(res, zodPatterns.emoji(), check2.message, refs); - break; - case "ulid": { - addPattern(res, zodPatterns.ulid, check2.message, refs); - break; - } - case "base64": { - switch (refs.base64Strategy) { - case "format:binary": { - addFormat(res, "binary", check2.message, refs); - break; - } - case "contentEncoding:base64": { - res.contentEncoding = "base64"; - break; - } - case "pattern:zod": { - addPattern(res, zodPatterns.base64, check2.message, refs); - break; - } - } - break; - } - case "nanoid": { - addPattern(res, zodPatterns.nanoid, check2.message, refs); - } - case "toLowerCase": - case "toUpperCase": - case "trim": - break; - default: - } - } - } - return res; -} -function escapeLiteralCheckValue(literal2, refs) { - return refs.patternStrategy === "escape" ? escapeNonAlphaNumeric(literal2) : literal2; -} -var ALPHA_NUMERIC = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789"); -function escapeNonAlphaNumeric(source) { - let result = ""; - for (let i = 0;i < source.length; i++) { - if (!ALPHA_NUMERIC.has(source[i])) { - result += "\\"; - } - result += source[i]; - } - return result; -} -function addFormat(schema, value, message, refs) { - var _a22; - if (schema.format || ((_a22 = schema.anyOf) == null ? undefined : _a22.some((x) => x.format))) { - if (!schema.anyOf) { - schema.anyOf = []; - } - if (schema.format) { - schema.anyOf.push({ - format: schema.format - }); - delete schema.format; - } - schema.anyOf.push({ - format: value, - ...message && refs.errorMessages && { errorMessage: { format: message } } - }); - } else { - schema.format = value; - } -} -function addPattern(schema, regex, message, refs) { - var _a22; - if (schema.pattern || ((_a22 = schema.allOf) == null ? undefined : _a22.some((x) => x.pattern))) { - if (!schema.allOf) { - schema.allOf = []; - } - if (schema.pattern) { - schema.allOf.push({ - pattern: schema.pattern - }); - delete schema.pattern; - } - schema.allOf.push({ - pattern: stringifyRegExpWithFlags(regex, refs), - ...message && refs.errorMessages && { errorMessage: { pattern: message } } - }); - } else { - schema.pattern = stringifyRegExpWithFlags(regex, refs); - } -} -function stringifyRegExpWithFlags(regex, refs) { - var _a22; - if (!refs.applyRegexFlags || !regex.flags) { - return regex.source; - } - const flags = { - i: regex.flags.includes("i"), - m: regex.flags.includes("m"), - s: regex.flags.includes("s") - }; - const source = flags.i ? regex.source.toLowerCase() : regex.source; - let pattern = ""; - let isEscaped = false; - let inCharGroup = false; - let inCharRange = false; - for (let i = 0;i < source.length; i++) { - if (isEscaped) { - pattern += source[i]; - isEscaped = false; - continue; - } - if (flags.i) { - if (inCharGroup) { - if (source[i].match(/[a-z]/)) { - if (inCharRange) { - pattern += source[i]; - pattern += `${source[i - 2]}-${source[i]}`.toUpperCase(); - inCharRange = false; - } else if (source[i + 1] === "-" && ((_a22 = source[i + 2]) == null ? undefined : _a22.match(/[a-z]/))) { - pattern += source[i]; - inCharRange = true; - } else { - pattern += `${source[i]}${source[i].toUpperCase()}`; - } - continue; - } - } else if (source[i].match(/[a-z]/)) { - pattern += `[${source[i]}${source[i].toUpperCase()}]`; - continue; - } - } - if (flags.m) { - if (source[i] === "^") { - pattern += `(^|(?<=[\r -]))`; - continue; - } else if (source[i] === "$") { - pattern += `($|(?=[\r -]))`; - continue; - } - } - if (flags.s && source[i] === ".") { - pattern += inCharGroup ? `${source[i]}\r -` : `[${source[i]}\r -]`; - continue; - } - pattern += source[i]; - if (source[i] === "\\") { - isEscaped = true; - } else if (inCharGroup && source[i] === "]") { - inCharGroup = false; - } else if (!inCharGroup && source[i] === "[") { - inCharGroup = true; - } - } - try { - new RegExp(pattern); - } catch (e) { - console.warn(`Could not convert regex pattern at ${refs.currentPath.join("/")} to a flag-independent form! Falling back to the flag-ignorant source`); - return regex.source; - } - return pattern; -} -function parseRecordDef(def, refs) { - var _a22, _b22, _c, _d, _e, _f; - const schema = { - type: "object", - additionalProperties: (_a22 = parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "additionalProperties"] - })) != null ? _a22 : refs.allowedAdditionalProperties - }; - if (((_b22 = def.keyType) == null ? undefined : _b22._def.typeName) === ZodFirstPartyTypeKind2.ZodString && ((_c = def.keyType._def.checks) == null ? undefined : _c.length)) { - const { type, ...keyType } = parseStringDef(def.keyType._def, refs); - return { - ...schema, - propertyNames: keyType - }; - } else if (((_d = def.keyType) == null ? undefined : _d._def.typeName) === ZodFirstPartyTypeKind2.ZodEnum) { - return { - ...schema, - propertyNames: { - enum: def.keyType._def.values - } - }; - } else if (((_e = def.keyType) == null ? undefined : _e._def.typeName) === ZodFirstPartyTypeKind2.ZodBranded && def.keyType._def.type._def.typeName === ZodFirstPartyTypeKind2.ZodString && ((_f = def.keyType._def.type._def.checks) == null ? undefined : _f.length)) { - const { type, ...keyType } = parseBrandedDef(def.keyType._def, refs); - return { - ...schema, - propertyNames: keyType - }; - } - return schema; -} -function parseMapDef(def, refs) { - if (refs.mapStrategy === "record") { - return parseRecordDef(def, refs); - } - const keys = parseDef(def.keyType._def, { - ...refs, - currentPath: [...refs.currentPath, "items", "items", "0"] - }) || parseAnyDef(); - const values = parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "items", "items", "1"] - }) || parseAnyDef(); - return { - type: "array", - maxItems: 125, - items: { - type: "array", - items: [keys, values], - minItems: 2, - maxItems: 2 - } - }; -} -function parseNativeEnumDef(def) { - const object2 = def.values; - const actualKeys = Object.keys(def.values).filter((key) => { - return typeof object2[object2[key]] !== "number"; - }); - const actualValues = actualKeys.map((key) => object2[key]); - const parsedTypes = Array.from(new Set(actualValues.map((values) => typeof values))); - return { - type: parsedTypes.length === 1 ? parsedTypes[0] === "string" ? "string" : "number" : ["string", "number"], - enum: actualValues - }; -} -function parseNeverDef() { - return { not: parseAnyDef() }; -} -function parseNullDef() { - return { - type: "null" - }; -} -var primitiveMappings = { - ZodString: "string", - ZodNumber: "number", - ZodBigInt: "integer", - ZodBoolean: "boolean", - ZodNull: "null" -}; -function parseUnionDef(def, refs) { - const options = def.options instanceof Map ? Array.from(def.options.values()) : def.options; - if (options.every((x) => (x._def.typeName in primitiveMappings) && (!x._def.checks || !x._def.checks.length))) { - const types2 = options.reduce((types22, x) => { - const type = primitiveMappings[x._def.typeName]; - return type && !types22.includes(type) ? [...types22, type] : types22; - }, []); - return { - type: types2.length > 1 ? types2 : types2[0] - }; - } else if (options.every((x) => x._def.typeName === "ZodLiteral" && !x.description)) { - const types2 = options.reduce((acc, x) => { - const type = typeof x._def.value; - switch (type) { - case "string": - case "number": - case "boolean": - return [...acc, type]; - case "bigint": - return [...acc, "integer"]; - case "object": - if (x._def.value === null) - return [...acc, "null"]; - case "symbol": - case "undefined": - case "function": - default: - return acc; - } - }, []); - if (types2.length === options.length) { - const uniqueTypes = types2.filter((x, i, a) => a.indexOf(x) === i); - return { - type: uniqueTypes.length > 1 ? uniqueTypes : uniqueTypes[0], - enum: options.reduce((acc, x) => { - return acc.includes(x._def.value) ? acc : [...acc, x._def.value]; - }, []) - }; - } - } else if (options.every((x) => x._def.typeName === "ZodEnum")) { - return { - type: "string", - enum: options.reduce((acc, x) => [ - ...acc, - ...x._def.values.filter((x2) => !acc.includes(x2)) - ], []) - }; - } - return asAnyOf(def, refs); -} -var asAnyOf = (def, refs) => { - const anyOf = (def.options instanceof Map ? Array.from(def.options.values()) : def.options).map((x, i) => parseDef(x._def, { - ...refs, - currentPath: [...refs.currentPath, "anyOf", `${i}`] - })).filter((x) => !!x && (!refs.strictUnions || typeof x === "object" && Object.keys(x).length > 0)); - return anyOf.length ? { anyOf } : undefined; -}; -function parseNullableDef(def, refs) { - if (["ZodString", "ZodNumber", "ZodBigInt", "ZodBoolean", "ZodNull"].includes(def.innerType._def.typeName) && (!def.innerType._def.checks || !def.innerType._def.checks.length)) { - return { - type: [ - primitiveMappings[def.innerType._def.typeName], - "null" - ] - }; - } - const base = parseDef(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath, "anyOf", "0"] - }); - return base && { anyOf: [base, { type: "null" }] }; -} -function parseNumberDef(def) { - const res = { - type: "number" - }; - if (!def.checks) - return res; - for (const check2 of def.checks) { - switch (check2.kind) { - case "int": - res.type = "integer"; - break; - case "min": - if (check2.inclusive) { - res.minimum = check2.value; - } else { - res.exclusiveMinimum = check2.value; - } - break; - case "max": - if (check2.inclusive) { - res.maximum = check2.value; - } else { - res.exclusiveMaximum = check2.value; - } - break; - case "multipleOf": - res.multipleOf = check2.value; - break; - } - } - return res; -} -function parseObjectDef(def, refs) { - const result = { - type: "object", - properties: {} - }; - const required2 = []; - const shape = def.shape(); - for (const propName in shape) { - let propDef = shape[propName]; - if (propDef === undefined || propDef._def === undefined) { - continue; - } - const propOptional = safeIsOptional(propDef); - const parsedDef = parseDef(propDef._def, { - ...refs, - currentPath: [...refs.currentPath, "properties", propName], - propertyPath: [...refs.currentPath, "properties", propName] - }); - if (parsedDef === undefined) { - continue; - } - result.properties[propName] = parsedDef; - if (!propOptional) { - required2.push(propName); - } - } - if (required2.length) { - result.required = required2; - } - const additionalProperties = decideAdditionalProperties(def, refs); - if (additionalProperties !== undefined) { - result.additionalProperties = additionalProperties; - } - return result; -} -function decideAdditionalProperties(def, refs) { - if (def.catchall._def.typeName !== "ZodNever") { - return parseDef(def.catchall._def, { - ...refs, - currentPath: [...refs.currentPath, "additionalProperties"] - }); - } - switch (def.unknownKeys) { - case "passthrough": - return refs.allowedAdditionalProperties; - case "strict": - return refs.rejectedAdditionalProperties; - case "strip": - return refs.removeAdditionalStrategy === "strict" ? refs.allowedAdditionalProperties : refs.rejectedAdditionalProperties; - } -} -function safeIsOptional(schema) { - try { - return schema.isOptional(); - } catch (e) { - return true; - } -} -var parseOptionalDef = (def, refs) => { - var _a22; - if (refs.currentPath.toString() === ((_a22 = refs.propertyPath) == null ? undefined : _a22.toString())) { - return parseDef(def.innerType._def, refs); - } - const innerSchema = parseDef(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath, "anyOf", "1"] - }); - return innerSchema ? { anyOf: [{ not: parseAnyDef() }, innerSchema] } : parseAnyDef(); -}; -var parsePipelineDef = (def, refs) => { - if (refs.pipeStrategy === "input") { - return parseDef(def.in._def, refs); - } else if (refs.pipeStrategy === "output") { - return parseDef(def.out._def, refs); - } - const a = parseDef(def.in._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", "0"] - }); - const b = parseDef(def.out._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", a ? "1" : "0"] - }); - return { - allOf: [a, b].filter((x) => x !== undefined) - }; -}; -function parsePromiseDef(def, refs) { - return parseDef(def.type._def, refs); -} -function parseSetDef(def, refs) { - const items = parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "items"] - }); - const schema = { - type: "array", - uniqueItems: true, - items - }; - if (def.minSize) { - schema.minItems = def.minSize.value; - } - if (def.maxSize) { - schema.maxItems = def.maxSize.value; - } - return schema; -} -function parseTupleDef(def, refs) { - if (def.rest) { - return { - type: "array", - minItems: def.items.length, - items: def.items.map((x, i) => parseDef(x._def, { - ...refs, - currentPath: [...refs.currentPath, "items", `${i}`] - })).reduce((acc, x) => x === undefined ? acc : [...acc, x], []), - additionalItems: parseDef(def.rest._def, { - ...refs, - currentPath: [...refs.currentPath, "additionalItems"] - }) - }; - } else { - return { - type: "array", - minItems: def.items.length, - maxItems: def.items.length, - items: def.items.map((x, i) => parseDef(x._def, { - ...refs, - currentPath: [...refs.currentPath, "items", `${i}`] - })).reduce((acc, x) => x === undefined ? acc : [...acc, x], []) - }; - } -} -function parseUndefinedDef() { - return { - not: parseAnyDef() - }; -} -function parseUnknownDef() { - return parseAnyDef(); -} -var parseReadonlyDef = (def, refs) => { - return parseDef(def.innerType._def, refs); -}; -var selectParser = (def, typeName, refs) => { - switch (typeName) { - case ZodFirstPartyTypeKind2.ZodString: - return parseStringDef(def, refs); - case ZodFirstPartyTypeKind2.ZodNumber: - return parseNumberDef(def); - case ZodFirstPartyTypeKind2.ZodObject: - return parseObjectDef(def, refs); - case ZodFirstPartyTypeKind2.ZodBigInt: - return parseBigintDef(def); - case ZodFirstPartyTypeKind2.ZodBoolean: - return parseBooleanDef(); - case ZodFirstPartyTypeKind2.ZodDate: - return parseDateDef(def, refs); - case ZodFirstPartyTypeKind2.ZodUndefined: - return parseUndefinedDef(); - case ZodFirstPartyTypeKind2.ZodNull: - return parseNullDef(); - case ZodFirstPartyTypeKind2.ZodArray: - return parseArrayDef(def, refs); - case ZodFirstPartyTypeKind2.ZodUnion: - case ZodFirstPartyTypeKind2.ZodDiscriminatedUnion: - return parseUnionDef(def, refs); - case ZodFirstPartyTypeKind2.ZodIntersection: - return parseIntersectionDef(def, refs); - case ZodFirstPartyTypeKind2.ZodTuple: - return parseTupleDef(def, refs); - case ZodFirstPartyTypeKind2.ZodRecord: - return parseRecordDef(def, refs); - case ZodFirstPartyTypeKind2.ZodLiteral: - return parseLiteralDef(def); - case ZodFirstPartyTypeKind2.ZodEnum: - return parseEnumDef(def); - case ZodFirstPartyTypeKind2.ZodNativeEnum: - return parseNativeEnumDef(def); - case ZodFirstPartyTypeKind2.ZodNullable: - return parseNullableDef(def, refs); - case ZodFirstPartyTypeKind2.ZodOptional: - return parseOptionalDef(def, refs); - case ZodFirstPartyTypeKind2.ZodMap: - return parseMapDef(def, refs); - case ZodFirstPartyTypeKind2.ZodSet: - return parseSetDef(def, refs); - case ZodFirstPartyTypeKind2.ZodLazy: - return () => def.getter()._def; - case ZodFirstPartyTypeKind2.ZodPromise: - return parsePromiseDef(def, refs); - case ZodFirstPartyTypeKind2.ZodNaN: - case ZodFirstPartyTypeKind2.ZodNever: - return parseNeverDef(); - case ZodFirstPartyTypeKind2.ZodEffects: - return parseEffectsDef(def, refs); - case ZodFirstPartyTypeKind2.ZodAny: - return parseAnyDef(); - case ZodFirstPartyTypeKind2.ZodUnknown: - return parseUnknownDef(); - case ZodFirstPartyTypeKind2.ZodDefault: - return parseDefaultDef(def, refs); - case ZodFirstPartyTypeKind2.ZodBranded: - return parseBrandedDef(def, refs); - case ZodFirstPartyTypeKind2.ZodReadonly: - return parseReadonlyDef(def, refs); - case ZodFirstPartyTypeKind2.ZodCatch: - return parseCatchDef(def, refs); - case ZodFirstPartyTypeKind2.ZodPipeline: - return parsePipelineDef(def, refs); - case ZodFirstPartyTypeKind2.ZodFunction: - case ZodFirstPartyTypeKind2.ZodVoid: - case ZodFirstPartyTypeKind2.ZodSymbol: - return; - default: - return /* @__PURE__ */ ((_) => { - return; - })(typeName); - } -}; -var getRelativePath = (pathA, pathB) => { - let i = 0; - for (;i < pathA.length && i < pathB.length; i++) { - if (pathA[i] !== pathB[i]) - break; - } - return [(pathA.length - i).toString(), ...pathB.slice(i)].join("/"); -}; -function parseDef(def, refs, forceResolution = false) { - var _a22; - const seenItem = refs.seen.get(def); - if (refs.override) { - const overrideResult = (_a22 = refs.override) == null ? undefined : _a22.call(refs, def, refs, seenItem, forceResolution); - if (overrideResult !== ignoreOverride) { - return overrideResult; - } - } - if (seenItem && !forceResolution) { - const seenSchema = get$ref(seenItem, refs); - if (seenSchema !== undefined) { - return seenSchema; - } - } - const newItem = { def, path: refs.currentPath, jsonSchema: undefined }; - refs.seen.set(def, newItem); - const jsonSchemaOrGetter = selectParser(def, def.typeName, refs); - const jsonSchema2 = typeof jsonSchemaOrGetter === "function" ? parseDef(jsonSchemaOrGetter(), refs) : jsonSchemaOrGetter; - if (jsonSchema2) { - addMeta(def, refs, jsonSchema2); - } - if (refs.postProcess) { - const postProcessResult = refs.postProcess(jsonSchema2, def, refs); - newItem.jsonSchema = jsonSchema2; - return postProcessResult; - } - newItem.jsonSchema = jsonSchema2; - return jsonSchema2; -} -var get$ref = (item, refs) => { - switch (refs.$refStrategy) { - case "root": - return { $ref: item.path.join("/") }; - case "relative": - return { $ref: getRelativePath(refs.currentPath, item.path) }; - case "none": - case "seen": { - if (item.path.length < refs.currentPath.length && item.path.every((value, index) => refs.currentPath[index] === value)) { - console.warn(`Recursive reference detected at ${refs.currentPath.join("/")}! Defaulting to any`); - return parseAnyDef(); - } - return refs.$refStrategy === "seen" ? parseAnyDef() : undefined; - } - } -}; -var addMeta = (def, refs, jsonSchema2) => { - if (def.description) { - jsonSchema2.description = def.description; - } - return jsonSchema2; -}; -var getRefs = (options) => { - const _options = getDefaultOptions(options); - const currentPath = _options.name !== undefined ? [..._options.basePath, _options.definitionPath, _options.name] : _options.basePath; - return { - ..._options, - currentPath, - propertyPath: undefined, - seen: new Map(Object.entries(_options.definitions).map(([name22, def]) => [ - def._def, - { - def: def._def, - path: [..._options.basePath, _options.definitionPath, name22], - jsonSchema: undefined - } - ])) - }; -}; -var zod3ToJsonSchema = (schema, options) => { - var _a22; - const refs = getRefs(options); - let definitions = typeof options === "object" && options.definitions ? Object.entries(options.definitions).reduce((acc, [name32, schema2]) => { - var _a32; - return { - ...acc, - [name32]: (_a32 = parseDef(schema2._def, { - ...refs, - currentPath: [...refs.basePath, refs.definitionPath, name32] - }, true)) != null ? _a32 : parseAnyDef() - }; - }, {}) : undefined; - const name22 = typeof options === "string" ? options : (options == null ? undefined : options.nameStrategy) === "title" ? undefined : options == null ? undefined : options.name; - const main = (_a22 = parseDef(schema._def, name22 === undefined ? refs : { - ...refs, - currentPath: [...refs.basePath, refs.definitionPath, name22] - }, false)) != null ? _a22 : parseAnyDef(); - const title = typeof options === "object" && options.name !== undefined && options.nameStrategy === "title" ? options.name : undefined; - if (title !== undefined) { - main.title = title; - } - const combined = name22 === undefined ? definitions ? { - ...main, - [refs.definitionPath]: definitions - } : main : { - $ref: [ - ...refs.$refStrategy === "relative" ? [] : refs.basePath, - refs.definitionPath, - name22 - ].join("/"), - [refs.definitionPath]: { - ...definitions, - [name22]: main - } - }; - combined.$schema = "http://json-schema.org/draft-07/schema#"; - return combined; -}; -var schemaSymbol = Symbol.for("vercel.ai.schema"); -function lazySchema(createSchema) { - let schema; - return () => { - if (schema == null) { - schema = createSchema(); - } - return schema; - }; -} -function jsonSchema(jsonSchema2, { - validate -} = {}) { - return { - [schemaSymbol]: true, - _type: undefined, - get jsonSchema() { - if (typeof jsonSchema2 === "function") { - jsonSchema2 = jsonSchema2(); - } - return jsonSchema2; - }, - validate - }; -} -function isSchema(value) { - return typeof value === "object" && value !== null && schemaSymbol in value && value[schemaSymbol] === true && "jsonSchema" in value && "validate" in value; -} -function asSchema(schema) { - return schema == null ? jsonSchema({ properties: {}, additionalProperties: false }) : isSchema(schema) ? schema : ("~standard" in schema) ? schema["~standard"].vendor === "zod" ? zodSchema(schema) : standardSchema(schema) : schema(); -} -function standardSchema(standardSchema2) { - return jsonSchema(() => addAdditionalPropertiesToJsonSchema(standardSchema2["~standard"].jsonSchema.input({ - target: "draft-07" - })), { - validate: async (value) => { - const result = await standardSchema2["~standard"].validate(value); - return "value" in result ? { success: true, value: result.value } : { - success: false, - error: new TypeValidationError({ - value, - cause: result.issues - }) - }; - } - }); -} -function zod3Schema(zodSchema2, options) { - var _a22; - const useReferences = (_a22 = options == null ? undefined : options.useReferences) != null ? _a22 : false; - return jsonSchema(() => zod3ToJsonSchema(zodSchema2, { - $refStrategy: useReferences ? "root" : "none" - }), { - validate: async (value) => { - const result = await zodSchema2.safeParseAsync(value); - return result.success ? { success: true, value: result.data } : { success: false, error: result.error }; - } - }); -} -function zod4Schema(zodSchema2, options) { - var _a22; - const useReferences = (_a22 = options == null ? undefined : options.useReferences) != null ? _a22 : false; - return jsonSchema(() => addAdditionalPropertiesToJsonSchema(toJSONSchema(zodSchema2, { - target: "draft-7", - io: "input", - reused: useReferences ? "ref" : "inline" - })), { - validate: async (value) => { - const result = await safeParseAsync2(zodSchema2, value); - return result.success ? { success: true, value: result.data } : { success: false, error: result.error }; - } - }); -} -function isZod4Schema(zodSchema2) { - return "_zod" in zodSchema2; -} -function zodSchema(zodSchema2, options) { - if (isZod4Schema(zodSchema2)) { - return zod4Schema(zodSchema2, options); - } else { - return zod3Schema(zodSchema2, options); - } -} -async function validateTypes({ - value, - schema, - context -}) { - const result = await safeValidateTypes({ value, schema, context }); - if (!result.success) { - throw TypeValidationError.wrap({ value, cause: result.error, context }); - } - return result.value; -} -async function safeValidateTypes({ - value, - schema, - context -}) { - const actualSchema = asSchema(schema); - try { - if (actualSchema.validate == null) { - return { success: true, value, rawValue: value }; - } - const result = await actualSchema.validate(value); - if (result.success) { - return { success: true, value: result.value, rawValue: value }; - } - return { - success: false, - error: TypeValidationError.wrap({ value, cause: result.error, context }), - rawValue: value - }; - } catch (error48) { - return { - success: false, - error: TypeValidationError.wrap({ value, cause: error48, context }), - rawValue: value - }; - } -} -async function parseJSON({ - text, - schema -}) { - try { - const value = secureJsonParse(text); - if (schema == null) { - return value; - } - return validateTypes({ value, schema }); - } catch (error48) { - if (JSONParseError.isInstance(error48) || TypeValidationError.isInstance(error48)) { - throw error48; - } - throw new JSONParseError({ text, cause: error48 }); - } -} -async function safeParseJSON({ - text, - schema -}) { - try { - const value = secureJsonParse(text); - if (schema == null) { - return { success: true, value, rawValue: value }; - } - return await safeValidateTypes({ value, schema }); - } catch (error48) { - return { - success: false, - error: JSONParseError.isInstance(error48) ? error48 : new JSONParseError({ text, cause: error48 }), - rawValue: undefined - }; - } -} -function isParsableJson(input) { - try { - secureJsonParse(input); - return true; - } catch (e) { - return false; - } -} -function parseJsonEventStream({ - stream, - schema -}) { - return stream.pipeThrough(new TextDecoderStream).pipeThrough(new EventSourceParserStream).pipeThrough(new TransformStream({ - async transform({ data }, controller) { - if (data === "[DONE]") { - return; - } - controller.enqueue(await safeParseJSON({ text: data, schema })); - } - })); -} -async function parseProviderOptions({ - provider, - providerOptions, - schema -}) { - if ((providerOptions == null ? undefined : providerOptions[provider]) == null) { - return; - } - const parsedProviderOptions = await safeValidateTypes({ - value: providerOptions[provider], - schema - }); - if (!parsedProviderOptions.success) { - throw new InvalidArgumentError({ - argument: "providerOptions", - message: `invalid ${provider} provider options`, - cause: parsedProviderOptions.error - }); - } - return parsedProviderOptions.value; -} -var getOriginalFetch2 = () => globalThis.fetch; -var postJsonToApi = async ({ - url: url2, - headers, - body, - failedResponseHandler, - successfulResponseHandler, - abortSignal, - fetch: fetch2 -}) => postToApi({ - url: url2, - headers: { - "Content-Type": "application/json", - ...headers - }, - body: { - content: JSON.stringify(body), - values: body - }, - failedResponseHandler, - successfulResponseHandler, - abortSignal, - fetch: fetch2 -}); -var postFormDataToApi = async ({ - url: url2, - headers, - formData, - failedResponseHandler, - successfulResponseHandler, - abortSignal, - fetch: fetch2 -}) => postToApi({ - url: url2, - headers, - body: { - content: formData, - values: Object.fromEntries(formData.entries()) - }, - failedResponseHandler, - successfulResponseHandler, - abortSignal, - fetch: fetch2 -}); -var postToApi = async ({ - url: url2, - headers = {}, - body, - successfulResponseHandler, - failedResponseHandler, - abortSignal, - fetch: fetch2 = getOriginalFetch2() -}) => { - try { - const response = await fetch2(url2, { - method: "POST", - headers: withUserAgentSuffix(headers, `ai-sdk/provider-utils/${VERSION}`, getRuntimeEnvironmentUserAgent()), - body: body.content, - signal: abortSignal - }); - const responseHeaders = extractResponseHeaders(response); - if (!response.ok) { - let errorInformation; - try { - errorInformation = await failedResponseHandler({ - response, - url: url2, - requestBodyValues: body.values - }); - } catch (error48) { - if (isAbortError(error48) || APICallError.isInstance(error48)) { - throw error48; - } - throw new APICallError({ - message: "Failed to process error response", - cause: error48, - statusCode: response.status, - url: url2, - responseHeaders, - requestBodyValues: body.values - }); - } - throw errorInformation.value; - } - try { - return await successfulResponseHandler({ - response, - url: url2, - requestBodyValues: body.values - }); - } catch (error48) { - if (error48 instanceof Error) { - if (isAbortError(error48) || APICallError.isInstance(error48)) { - throw error48; - } - } - throw new APICallError({ - message: "Failed to process successful response", - cause: error48, - statusCode: response.status, - url: url2, - responseHeaders, - requestBodyValues: body.values - }); - } - } catch (error48) { - throw handleFetchError({ error: error48, url: url2, requestBodyValues: body.values }); - } -}; -function tool(tool2) { - return tool2; -} -function createProviderToolFactoryWithOutputSchema({ - id, - inputSchema, - outputSchema, - supportsDeferredResults -}) { - return ({ - execute, - needsApproval, - toModelOutput, - onInputStart, - onInputDelta, - onInputAvailable, - ...args - }) => tool({ - type: "provider", - id, - args, - inputSchema, - outputSchema, - execute, - needsApproval, - toModelOutput, - onInputStart, - onInputDelta, - onInputAvailable, - supportsDeferredResults - }); -} -async function resolve(value) { - if (typeof value === "function") { - value = value(); - } - return Promise.resolve(value); -} -var createJsonErrorResponseHandler = ({ - errorSchema, - errorToMessage, - isRetryable -}) => async ({ response, url: url2, requestBodyValues }) => { - const responseBody = await response.text(); - const responseHeaders = extractResponseHeaders(response); - if (responseBody.trim() === "") { - return { - responseHeaders, - value: new APICallError({ - message: response.statusText, - url: url2, - requestBodyValues, - statusCode: response.status, - responseHeaders, - responseBody, - isRetryable: isRetryable == null ? undefined : isRetryable(response) - }) - }; - } - try { - const parsedError = await parseJSON({ - text: responseBody, - schema: errorSchema - }); - return { - responseHeaders, - value: new APICallError({ - message: errorToMessage(parsedError), - url: url2, - requestBodyValues, - statusCode: response.status, - responseHeaders, - responseBody, - data: parsedError, - isRetryable: isRetryable == null ? undefined : isRetryable(response, parsedError) - }) - }; - } catch (parseError) { - return { - responseHeaders, - value: new APICallError({ - message: response.statusText, - url: url2, - requestBodyValues, - statusCode: response.status, - responseHeaders, - responseBody, - isRetryable: isRetryable == null ? undefined : isRetryable(response) - }) - }; - } -}; -var createEventSourceResponseHandler = (chunkSchema) => async ({ response }) => { - const responseHeaders = extractResponseHeaders(response); - if (response.body == null) { - throw new EmptyResponseBodyError({}); - } - return { - responseHeaders, - value: parseJsonEventStream({ - stream: response.body, - schema: chunkSchema - }) - }; -}; -var createJsonResponseHandler = (responseSchema) => async ({ response, url: url2, requestBodyValues }) => { - const responseBody = await response.text(); - const parsedResult = await safeParseJSON({ - text: responseBody, - schema: responseSchema - }); - const responseHeaders = extractResponseHeaders(response); - if (!parsedResult.success) { - throw new APICallError({ - message: "Invalid JSON response", - cause: parsedResult.error, - statusCode: response.status, - responseHeaders, - responseBody, - url: url2, - requestBodyValues - }); - } - return { - responseHeaders, - value: parsedResult.value, - rawValue: parsedResult.rawValue - }; -}; -function withoutTrailingSlash(url2) { - return url2 == null ? undefined : url2.replace(/\/$/, ""); -} -function isAsyncIterable(obj) { - return obj != null && typeof obj[Symbol.asyncIterator] === "function"; -} -async function* executeTool({ - execute, - input, - options -}) { - const result = execute(input, options); - if (isAsyncIterable(result)) { - let lastOutput; - for await (const output of result) { - lastOutput = output; - yield { type: "preliminary", output }; - } - yield { type: "final", output: lastOutput }; - } else { - yield { type: "final", output: await result }; - } -} -// ../../node_modules/.pnpm/@ai-sdk+gateway@3.0.54_zod@4.3.6/node_modules/@ai-sdk/gateway/dist/index.mjs -var import_oidc = __toESM(require_dist(), 1); -var import_oidc2 = __toESM(require_dist(), 1); -var marker17 = "vercel.ai.gateway.error"; -var symbol18 = Symbol.for(marker17); -var _a18; -var _b17; -var GatewayError = class _GatewayError extends (_b17 = Error, _a18 = symbol18, _b17) { - constructor({ - message, - statusCode = 500, - cause, - generationId - }) { - super(generationId ? `${message} [${generationId}]` : message); - this[_a18] = true; - this.statusCode = statusCode; - this.cause = cause; - this.generationId = generationId; - } - static isInstance(error48) { - return _GatewayError.hasMarker(error48); - } - static hasMarker(error48) { - return typeof error48 === "object" && error48 !== null && symbol18 in error48 && error48[symbol18] === true; - } -}; -var name16 = "GatewayAuthenticationError"; -var marker22 = `vercel.ai.gateway.error.${name16}`; -var symbol22 = Symbol.for(marker22); -var _a22; -var _b22; -var GatewayAuthenticationError = class _GatewayAuthenticationError extends (_b22 = GatewayError, _a22 = symbol22, _b22) { - constructor({ - message = "Authentication failed", - statusCode = 401, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a22] = true; - this.name = name16; - this.type = "authentication_error"; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol22 in error48; - } - static createContextualError({ - apiKeyProvided, - oidcTokenProvided, - message = "Authentication failed", - statusCode = 401, - cause, - generationId - }) { - let contextualMessage; - if (apiKeyProvided) { - contextualMessage = `AI Gateway authentication failed: Invalid API key. - -Create a new API key: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys - -Provide via 'apiKey' option or 'AI_GATEWAY_API_KEY' environment variable.`; - } else if (oidcTokenProvided) { - contextualMessage = `AI Gateway authentication failed: Invalid OIDC token. - -Run 'npx vercel link' to link your project, then 'vc env pull' to fetch the token. - -Alternatively, use an API key: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys`; - } else { - contextualMessage = `AI Gateway authentication failed: No authentication provided. - -Option 1 - API key: -Create an API key: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys -Provide via 'apiKey' option or 'AI_GATEWAY_API_KEY' environment variable. - -Option 2 - OIDC token: -Run 'npx vercel link' to link your project, then 'vc env pull' to fetch the token.`; - } - return new _GatewayAuthenticationError({ - message: contextualMessage, - statusCode, - cause, - generationId - }); - } -}; -var name22 = "GatewayInvalidRequestError"; -var marker32 = `vercel.ai.gateway.error.${name22}`; -var symbol32 = Symbol.for(marker32); -var _a32; -var _b32; -var GatewayInvalidRequestError = class extends (_b32 = GatewayError, _a32 = symbol32, _b32) { - constructor({ - message = "Invalid request", - statusCode = 400, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a32] = true; - this.name = name22; - this.type = "invalid_request_error"; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol32 in error48; - } -}; -var name32 = "GatewayRateLimitError"; -var marker42 = `vercel.ai.gateway.error.${name32}`; -var symbol42 = Symbol.for(marker42); -var _a42; -var _b42; -var GatewayRateLimitError = class extends (_b42 = GatewayError, _a42 = symbol42, _b42) { - constructor({ - message = "Rate limit exceeded", - statusCode = 429, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a42] = true; - this.name = name32; - this.type = "rate_limit_exceeded"; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol42 in error48; - } -}; -var name42 = "GatewayModelNotFoundError"; -var marker52 = `vercel.ai.gateway.error.${name42}`; -var symbol52 = Symbol.for(marker52); -var modelNotFoundParamSchema = lazySchema(() => zodSchema(exports_external.object({ - modelId: exports_external.string() -}))); -var _a52; -var _b52; -var GatewayModelNotFoundError = class extends (_b52 = GatewayError, _a52 = symbol52, _b52) { - constructor({ - message = "Model not found", - statusCode = 404, - modelId, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a52] = true; - this.name = name42; - this.type = "model_not_found"; - this.modelId = modelId; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol52 in error48; - } -}; -var name52 = "GatewayInternalServerError"; -var marker62 = `vercel.ai.gateway.error.${name52}`; -var symbol62 = Symbol.for(marker62); -var _a62; -var _b62; -var GatewayInternalServerError = class extends (_b62 = GatewayError, _a62 = symbol62, _b62) { - constructor({ - message = "Internal server error", - statusCode = 500, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a62] = true; - this.name = name52; - this.type = "internal_server_error"; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol62 in error48; - } -}; -var name62 = "GatewayResponseError"; -var marker72 = `vercel.ai.gateway.error.${name62}`; -var symbol72 = Symbol.for(marker72); -var _a72; -var _b72; -var GatewayResponseError = class extends (_b72 = GatewayError, _a72 = symbol72, _b72) { - constructor({ - message = "Invalid response from Gateway", - statusCode = 502, - response, - validationError, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a72] = true; - this.name = name62; - this.type = "response_error"; - this.response = response; - this.validationError = validationError; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol72 in error48; - } -}; -async function createGatewayErrorFromResponse({ - response, - statusCode, - defaultMessage = "Gateway request failed", - cause, - authMethod -}) { - var _a92; - const parseResult = await safeValidateTypes({ - value: response, - schema: gatewayErrorResponseSchema - }); - if (!parseResult.success) { - const rawGenerationId = typeof response === "object" && response !== null && "generationId" in response ? response.generationId : undefined; - return new GatewayResponseError({ - message: `Invalid error response format: ${defaultMessage}`, - statusCode, - response, - validationError: parseResult.error, - cause, - generationId: rawGenerationId - }); - } - const validatedResponse = parseResult.value; - const errorType = validatedResponse.error.type; - const message = validatedResponse.error.message; - const generationId = (_a92 = validatedResponse.generationId) != null ? _a92 : undefined; - switch (errorType) { - case "authentication_error": - return GatewayAuthenticationError.createContextualError({ - apiKeyProvided: authMethod === "api-key", - oidcTokenProvided: authMethod === "oidc", - statusCode, - cause, - generationId - }); - case "invalid_request_error": - return new GatewayInvalidRequestError({ - message, - statusCode, - cause, - generationId - }); - case "rate_limit_exceeded": - return new GatewayRateLimitError({ - message, - statusCode, - cause, - generationId - }); - case "model_not_found": { - const modelResult = await safeValidateTypes({ - value: validatedResponse.error.param, - schema: modelNotFoundParamSchema - }); - return new GatewayModelNotFoundError({ - message, - statusCode, - modelId: modelResult.success ? modelResult.value.modelId : undefined, - cause, - generationId - }); - } - case "internal_server_error": - return new GatewayInternalServerError({ - message, - statusCode, - cause, - generationId - }); - default: - return new GatewayInternalServerError({ - message, - statusCode, - cause, - generationId - }); - } -} -var gatewayErrorResponseSchema = lazySchema(() => zodSchema(exports_external.object({ - error: exports_external.object({ - message: exports_external.string(), - type: exports_external.string().nullish(), - param: exports_external.unknown().nullish(), - code: exports_external.union([exports_external.string(), exports_external.number()]).nullish() - }), - generationId: exports_external.string().nullish() -}))); -var name72 = "GatewayTimeoutError"; -var marker82 = `vercel.ai.gateway.error.${name72}`; -var symbol82 = Symbol.for(marker82); -var _a82; -var _b82; -var GatewayTimeoutError = class _GatewayTimeoutError extends (_b82 = GatewayError, _a82 = symbol82, _b82) { - constructor({ - message = "Request timed out", - statusCode = 408, - cause, - generationId - } = {}) { - super({ message, statusCode, cause, generationId }); - this[_a82] = true; - this.name = name72; - this.type = "timeout_error"; - } - static isInstance(error48) { - return GatewayError.hasMarker(error48) && symbol82 in error48; - } - static createTimeoutError({ - originalMessage, - statusCode = 408, - cause, - generationId - }) { - const message = `Gateway request timed out: ${originalMessage} - - This is a client-side timeout. To resolve this, increase your timeout configuration: https://vercel.com/docs/ai-gateway/capabilities/video-generation#extending-timeouts-for-node.js`; - return new _GatewayTimeoutError({ - message, - statusCode, - cause, - generationId - }); - } -}; -function isTimeoutError(error48) { - if (!(error48 instanceof Error)) { - return false; - } - const errorCode = error48.code; - if (typeof errorCode === "string") { - const undiciTimeoutCodes = [ - "UND_ERR_HEADERS_TIMEOUT", - "UND_ERR_BODY_TIMEOUT", - "UND_ERR_CONNECT_TIMEOUT" - ]; - return undiciTimeoutCodes.includes(errorCode); - } - return false; -} -async function asGatewayError(error48, authMethod) { - var _a92; - if (GatewayError.isInstance(error48)) { - return error48; - } - if (isTimeoutError(error48)) { - return GatewayTimeoutError.createTimeoutError({ - originalMessage: error48 instanceof Error ? error48.message : "Unknown error", - cause: error48 - }); - } - if (APICallError.isInstance(error48)) { - if (error48.cause && isTimeoutError(error48.cause)) { - return GatewayTimeoutError.createTimeoutError({ - originalMessage: error48.message, - cause: error48 - }); - } - return await createGatewayErrorFromResponse({ - response: extractApiCallResponse(error48), - statusCode: (_a92 = error48.statusCode) != null ? _a92 : 500, - defaultMessage: "Gateway request failed", - cause: error48, - authMethod - }); - } - return await createGatewayErrorFromResponse({ - response: {}, - statusCode: 500, - defaultMessage: error48 instanceof Error ? `Gateway request failed: ${error48.message}` : "Unknown Gateway error", - cause: error48, - authMethod - }); -} -function extractApiCallResponse(error48) { - if (error48.data !== undefined) { - return error48.data; - } - if (error48.responseBody != null) { - try { - return JSON.parse(error48.responseBody); - } catch (e) { - return error48.responseBody; - } - } - return {}; -} -var GATEWAY_AUTH_METHOD_HEADER = "ai-gateway-auth-method"; -async function parseAuthMethod(headers) { - const result = await safeValidateTypes({ - value: headers[GATEWAY_AUTH_METHOD_HEADER], - schema: gatewayAuthMethodSchema - }); - return result.success ? result.value : undefined; -} -var gatewayAuthMethodSchema = lazySchema(() => zodSchema(exports_external.union([exports_external.literal("api-key"), exports_external.literal("oidc")]))); -var GatewayFetchMetadata = class { - constructor(config2) { - this.config = config2; - } - async getAvailableModels() { - try { - const { value } = await getFromApi({ - url: `${this.config.baseURL}/config`, - headers: await resolve(this.config.headers()), - successfulResponseHandler: createJsonResponseHandler(gatewayAvailableModelsResponseSchema), - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - fetch: this.config.fetch - }); - return value; - } catch (error48) { - throw await asGatewayError(error48); - } - } - async getCredits() { - try { - const baseUrl = new URL(this.config.baseURL); - const { value } = await getFromApi({ - url: `${baseUrl.origin}/v1/credits`, - headers: await resolve(this.config.headers()), - successfulResponseHandler: createJsonResponseHandler(gatewayCreditsResponseSchema), - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - fetch: this.config.fetch - }); - return value; - } catch (error48) { - throw await asGatewayError(error48); - } - } -}; -var gatewayAvailableModelsResponseSchema = lazySchema(() => zodSchema(exports_external.object({ - models: exports_external.array(exports_external.object({ - id: exports_external.string(), - name: exports_external.string(), - description: exports_external.string().nullish(), - pricing: exports_external.object({ - input: exports_external.string(), - output: exports_external.string(), - input_cache_read: exports_external.string().nullish(), - input_cache_write: exports_external.string().nullish() - }).transform(({ input, output, input_cache_read, input_cache_write }) => ({ - input, - output, - ...input_cache_read ? { cachedInputTokens: input_cache_read } : {}, - ...input_cache_write ? { cacheCreationInputTokens: input_cache_write } : {} - })).nullish(), - specification: exports_external.object({ - specificationVersion: exports_external.literal("v3"), - provider: exports_external.string(), - modelId: exports_external.string() - }), - modelType: exports_external.enum(["embedding", "image", "language", "video"]).nullish() - })) -}))); -var gatewayCreditsResponseSchema = lazySchema(() => zodSchema(exports_external.object({ - balance: exports_external.string(), - total_used: exports_external.string() -}).transform(({ balance, total_used }) => ({ - balance, - totalUsed: total_used -})))); -var GatewayLanguageModel = class { - constructor(modelId, config2) { - this.modelId = modelId; - this.config = config2; - this.specificationVersion = "v3"; - this.supportedUrls = { "*/*": [/.*/] }; - } - get provider() { - return this.config.provider; - } - async getArgs(options) { - const { abortSignal: _abortSignal, ...optionsWithoutSignal } = options; - return { - args: this.maybeEncodeFileParts(optionsWithoutSignal), - warnings: [] - }; - } - async doGenerate(options) { - const { args, warnings } = await this.getArgs(options); - const { abortSignal } = options; - const resolvedHeaders = await resolve(this.config.headers()); - try { - const { - responseHeaders, - value: responseBody, - rawValue: rawResponse - } = await postJsonToApi({ - url: this.getUrl(), - headers: combineHeaders(resolvedHeaders, options.headers, this.getModelConfigHeaders(this.modelId, false), await resolve(this.config.o11yHeaders)), - body: args, - successfulResponseHandler: createJsonResponseHandler(exports_external.any()), - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - ...abortSignal && { abortSignal }, - fetch: this.config.fetch - }); - return { - ...responseBody, - request: { body: args }, - response: { headers: responseHeaders, body: rawResponse }, - warnings - }; - } catch (error48) { - throw await asGatewayError(error48, await parseAuthMethod(resolvedHeaders)); - } - } - async doStream(options) { - const { args, warnings } = await this.getArgs(options); - const { abortSignal } = options; - const resolvedHeaders = await resolve(this.config.headers()); - try { - const { value: response, responseHeaders } = await postJsonToApi({ - url: this.getUrl(), - headers: combineHeaders(resolvedHeaders, options.headers, this.getModelConfigHeaders(this.modelId, true), await resolve(this.config.o11yHeaders)), - body: args, - successfulResponseHandler: createEventSourceResponseHandler(exports_external.any()), - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - ...abortSignal && { abortSignal }, - fetch: this.config.fetch - }); - return { - stream: response.pipeThrough(new TransformStream({ - start(controller) { - if (warnings.length > 0) { - controller.enqueue({ type: "stream-start", warnings }); - } - }, - transform(chunk, controller) { - if (chunk.success) { - const streamPart = chunk.value; - if (streamPart.type === "raw" && !options.includeRawChunks) { - return; - } - if (streamPart.type === "response-metadata" && streamPart.timestamp && typeof streamPart.timestamp === "string") { - streamPart.timestamp = new Date(streamPart.timestamp); - } - controller.enqueue(streamPart); - } else { - controller.error(chunk.error); - } - } - })), - request: { body: args }, - response: { headers: responseHeaders } - }; - } catch (error48) { - throw await asGatewayError(error48, await parseAuthMethod(resolvedHeaders)); - } - } - isFilePart(part) { - return part && typeof part === "object" && "type" in part && part.type === "file"; - } - maybeEncodeFileParts(options) { - for (const message of options.prompt) { - for (const part of message.content) { - if (this.isFilePart(part)) { - const filePart = part; - if (filePart.data instanceof Uint8Array) { - const buffer = Uint8Array.from(filePart.data); - const base64Data = Buffer.from(buffer).toString("base64"); - filePart.data = new URL(`data:${filePart.mediaType || "application/octet-stream"};base64,${base64Data}`); - } - } - } - } - return options; - } - getUrl() { - return `${this.config.baseURL}/language-model`; - } - getModelConfigHeaders(modelId, streaming) { - return { - "ai-language-model-specification-version": "3", - "ai-language-model-id": modelId, - "ai-language-model-streaming": String(streaming) - }; - } -}; -var GatewayEmbeddingModel = class { - constructor(modelId, config2) { - this.modelId = modelId; - this.config = config2; - this.specificationVersion = "v3"; - this.maxEmbeddingsPerCall = 2048; - this.supportsParallelCalls = true; - } - get provider() { - return this.config.provider; - } - async doEmbed({ - values, - headers, - abortSignal, - providerOptions - }) { - var _a92; - const resolvedHeaders = await resolve(this.config.headers()); - try { - const { - responseHeaders, - value: responseBody, - rawValue - } = await postJsonToApi({ - url: this.getUrl(), - headers: combineHeaders(resolvedHeaders, headers != null ? headers : {}, this.getModelConfigHeaders(), await resolve(this.config.o11yHeaders)), - body: { - values, - ...providerOptions ? { providerOptions } : {} - }, - successfulResponseHandler: createJsonResponseHandler(gatewayEmbeddingResponseSchema), - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - ...abortSignal && { abortSignal }, - fetch: this.config.fetch - }); - return { - embeddings: responseBody.embeddings, - usage: (_a92 = responseBody.usage) != null ? _a92 : undefined, - providerMetadata: responseBody.providerMetadata, - response: { headers: responseHeaders, body: rawValue }, - warnings: [] - }; - } catch (error48) { - throw await asGatewayError(error48, await parseAuthMethod(resolvedHeaders)); - } - } - getUrl() { - return `${this.config.baseURL}/embedding-model`; - } - getModelConfigHeaders() { - return { - "ai-embedding-model-specification-version": "3", - "ai-model-id": this.modelId - }; - } -}; -var gatewayEmbeddingResponseSchema = lazySchema(() => zodSchema(exports_external.object({ - embeddings: exports_external.array(exports_external.array(exports_external.number())), - usage: exports_external.object({ tokens: exports_external.number() }).nullish(), - providerMetadata: exports_external.record(exports_external.string(), exports_external.record(exports_external.string(), exports_external.unknown())).optional() -}))); -var GatewayImageModel = class { - constructor(modelId, config2) { - this.modelId = modelId; - this.config = config2; - this.specificationVersion = "v3"; - this.maxImagesPerCall = Number.MAX_SAFE_INTEGER; - } - get provider() { - return this.config.provider; - } - async doGenerate({ - prompt, - n, - size, - aspectRatio, - seed, - files, - mask, - providerOptions, - headers, - abortSignal - }) { - var _a92, _b92, _c, _d; - const resolvedHeaders = await resolve(this.config.headers()); - try { - const { - responseHeaders, - value: responseBody, - rawValue - } = await postJsonToApi({ - url: this.getUrl(), - headers: combineHeaders(resolvedHeaders, headers != null ? headers : {}, this.getModelConfigHeaders(), await resolve(this.config.o11yHeaders)), - body: { - prompt, - n, - ...size && { size }, - ...aspectRatio && { aspectRatio }, - ...seed && { seed }, - ...providerOptions && { providerOptions }, - ...files && { - files: files.map((file2) => maybeEncodeImageFile(file2)) - }, - ...mask && { mask: maybeEncodeImageFile(mask) } - }, - successfulResponseHandler: createJsonResponseHandler(gatewayImageResponseSchema), - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - ...abortSignal && { abortSignal }, - fetch: this.config.fetch - }); - return { - images: responseBody.images, - warnings: (_a92 = responseBody.warnings) != null ? _a92 : [], - providerMetadata: responseBody.providerMetadata, - response: { - timestamp: /* @__PURE__ */ new Date, - modelId: this.modelId, - headers: responseHeaders - }, - ...responseBody.usage != null && { - usage: { - inputTokens: (_b92 = responseBody.usage.inputTokens) != null ? _b92 : undefined, - outputTokens: (_c = responseBody.usage.outputTokens) != null ? _c : undefined, - totalTokens: (_d = responseBody.usage.totalTokens) != null ? _d : undefined - } - } - }; - } catch (error48) { - throw await asGatewayError(error48, await parseAuthMethod(resolvedHeaders)); - } - } - getUrl() { - return `${this.config.baseURL}/image-model`; - } - getModelConfigHeaders() { - return { - "ai-image-model-specification-version": "3", - "ai-model-id": this.modelId - }; - } -}; -function maybeEncodeImageFile(file2) { - if (file2.type === "file" && file2.data instanceof Uint8Array) { - return { - ...file2, - data: convertUint8ArrayToBase64(file2.data) - }; - } - return file2; -} -var providerMetadataEntrySchema = exports_external.object({ - images: exports_external.array(exports_external.unknown()).optional() -}).catchall(exports_external.unknown()); -var gatewayImageWarningSchema = exports_external.discriminatedUnion("type", [ - exports_external.object({ - type: exports_external.literal("unsupported"), - feature: exports_external.string(), - details: exports_external.string().optional() - }), - exports_external.object({ - type: exports_external.literal("compatibility"), - feature: exports_external.string(), - details: exports_external.string().optional() - }), - exports_external.object({ - type: exports_external.literal("other"), - message: exports_external.string() - }) -]); -var gatewayImageUsageSchema = exports_external.object({ - inputTokens: exports_external.number().nullish(), - outputTokens: exports_external.number().nullish(), - totalTokens: exports_external.number().nullish() -}); -var gatewayImageResponseSchema = exports_external.object({ - images: exports_external.array(exports_external.string()), - warnings: exports_external.array(gatewayImageWarningSchema).optional(), - providerMetadata: exports_external.record(exports_external.string(), providerMetadataEntrySchema).optional(), - usage: gatewayImageUsageSchema.optional() -}); -var GatewayVideoModel = class { - constructor(modelId, config2) { - this.modelId = modelId; - this.config = config2; - this.specificationVersion = "v3"; - this.maxVideosPerCall = Number.MAX_SAFE_INTEGER; - } - get provider() { - return this.config.provider; - } - async doGenerate({ - prompt, - n, - aspectRatio, - resolution, - duration: duration3, - fps, - seed, - image, - providerOptions, - headers, - abortSignal - }) { - var _a92; - const resolvedHeaders = await resolve(this.config.headers()); - try { - const { responseHeaders, value: responseBody } = await postJsonToApi({ - url: this.getUrl(), - headers: combineHeaders(resolvedHeaders, headers != null ? headers : {}, this.getModelConfigHeaders(), await resolve(this.config.o11yHeaders), { accept: "text/event-stream" }), - body: { - prompt, - n, - ...aspectRatio && { aspectRatio }, - ...resolution && { resolution }, - ...duration3 && { duration: duration3 }, - ...fps && { fps }, - ...seed && { seed }, - ...providerOptions && { providerOptions }, - ...image && { image: maybeEncodeVideoFile(image) } - }, - successfulResponseHandler: async ({ - response, - url: url2, - requestBodyValues - }) => { - if (response.body == null) { - throw new APICallError({ - message: "SSE response body is empty", - url: url2, - requestBodyValues, - statusCode: response.status - }); - } - const eventStream = parseJsonEventStream({ - stream: response.body, - schema: gatewayVideoEventSchema - }); - const reader = eventStream.getReader(); - const { done, value: parseResult } = await reader.read(); - reader.releaseLock(); - if (done || !parseResult) { - throw new APICallError({ - message: "SSE stream ended without a data event", - url: url2, - requestBodyValues, - statusCode: response.status - }); - } - if (!parseResult.success) { - throw new APICallError({ - message: "Failed to parse video SSE event", - cause: parseResult.error, - url: url2, - requestBodyValues, - statusCode: response.status - }); - } - const event = parseResult.value; - if (event.type === "error") { - throw new APICallError({ - message: event.message, - statusCode: event.statusCode, - url: url2, - requestBodyValues, - responseHeaders: Object.fromEntries([...response.headers]), - responseBody: JSON.stringify(event), - data: { - error: { - message: event.message, - type: event.errorType, - param: event.param - } - } - }); - } - return { - value: { - videos: event.videos, - warnings: event.warnings, - providerMetadata: event.providerMetadata - }, - responseHeaders: Object.fromEntries([...response.headers]) - }; - }, - failedResponseHandler: createJsonErrorResponseHandler({ - errorSchema: exports_external.any(), - errorToMessage: (data) => data - }), - ...abortSignal && { abortSignal }, - fetch: this.config.fetch - }); - return { - videos: responseBody.videos, - warnings: (_a92 = responseBody.warnings) != null ? _a92 : [], - providerMetadata: responseBody.providerMetadata, - response: { - timestamp: /* @__PURE__ */ new Date, - modelId: this.modelId, - headers: responseHeaders - } - }; - } catch (error48) { - throw await asGatewayError(error48, await parseAuthMethod(resolvedHeaders)); - } - } - getUrl() { - return `${this.config.baseURL}/video-model`; - } - getModelConfigHeaders() { - return { - "ai-video-model-specification-version": "3", - "ai-model-id": this.modelId - }; - } -}; -function maybeEncodeVideoFile(file2) { - if (file2.type === "file" && file2.data instanceof Uint8Array) { - return { - ...file2, - data: convertUint8ArrayToBase64(file2.data) - }; - } - return file2; -} -var providerMetadataEntrySchema2 = exports_external.object({ - videos: exports_external.array(exports_external.unknown()).optional() -}).catchall(exports_external.unknown()); -var gatewayVideoDataSchema = exports_external.union([ - exports_external.object({ - type: exports_external.literal("url"), - url: exports_external.string(), - mediaType: exports_external.string() - }), - exports_external.object({ - type: exports_external.literal("base64"), - data: exports_external.string(), - mediaType: exports_external.string() - }) -]); -var gatewayVideoWarningSchema = exports_external.discriminatedUnion("type", [ - exports_external.object({ - type: exports_external.literal("unsupported"), - feature: exports_external.string(), - details: exports_external.string().optional() - }), - exports_external.object({ - type: exports_external.literal("compatibility"), - feature: exports_external.string(), - details: exports_external.string().optional() - }), - exports_external.object({ - type: exports_external.literal("other"), - message: exports_external.string() - }) -]); -var gatewayVideoEventSchema = exports_external.discriminatedUnion("type", [ - exports_external.object({ - type: exports_external.literal("result"), - videos: exports_external.array(gatewayVideoDataSchema), - warnings: exports_external.array(gatewayVideoWarningSchema).optional(), - providerMetadata: exports_external.record(exports_external.string(), providerMetadataEntrySchema2).optional() - }), - exports_external.object({ - type: exports_external.literal("error"), - message: exports_external.string(), - errorType: exports_external.string(), - statusCode: exports_external.number(), - param: exports_external.unknown().nullable() - }) -]); -var parallelSearchInputSchema = lazySchema(() => zodSchema(exports_external.object({ - objective: exports_external.string().describe("Natural-language description of the web research goal, including source or freshness guidance and broader context from the task. Maximum 5000 characters."), - search_queries: exports_external.array(exports_external.string()).optional().describe("Optional search queries to supplement the objective. Maximum 200 characters per query."), - mode: exports_external.enum(["one-shot", "agentic"]).optional().describe('Mode preset: "one-shot" for comprehensive results with longer excerpts (default), "agentic" for concise, token-efficient results for multi-step workflows.'), - max_results: exports_external.number().optional().describe("Maximum number of results to return (1-20). Defaults to 10 if not specified."), - source_policy: exports_external.object({ - include_domains: exports_external.array(exports_external.string()).optional().describe("List of domains to include in search results."), - exclude_domains: exports_external.array(exports_external.string()).optional().describe("List of domains to exclude from search results."), - after_date: exports_external.string().optional().describe("Only include results published after this date (ISO 8601 format).") - }).optional().describe("Source policy for controlling which domains to include/exclude and freshness."), - excerpts: exports_external.object({ - max_chars_per_result: exports_external.number().optional().describe("Maximum characters per result."), - max_chars_total: exports_external.number().optional().describe("Maximum total characters across all results.") - }).optional().describe("Excerpt configuration for controlling result length."), - fetch_policy: exports_external.object({ - max_age_seconds: exports_external.number().optional().describe("Maximum age in seconds for cached content. Set to 0 to always fetch fresh content.") - }).optional().describe("Fetch policy for controlling content freshness.") -}))); -var parallelSearchOutputSchema = lazySchema(() => zodSchema(exports_external.union([ - exports_external.object({ - searchId: exports_external.string(), - results: exports_external.array(exports_external.object({ - url: exports_external.string(), - title: exports_external.string(), - excerpt: exports_external.string(), - publishDate: exports_external.string().nullable().optional(), - relevanceScore: exports_external.number().optional() - })) - }), - exports_external.object({ - error: exports_external.enum([ - "api_error", - "rate_limit", - "timeout", - "invalid_input", - "configuration_error", - "unknown" - ]), - statusCode: exports_external.number().optional(), - message: exports_external.string() - }) -]))); -var parallelSearchToolFactory = createProviderToolFactoryWithOutputSchema({ - id: "gateway.parallel_search", - inputSchema: parallelSearchInputSchema, - outputSchema: parallelSearchOutputSchema -}); -var parallelSearch = (config2 = {}) => parallelSearchToolFactory(config2); -var perplexitySearchInputSchema = lazySchema(() => zodSchema(exports_external.object({ - query: exports_external.union([exports_external.string(), exports_external.array(exports_external.string())]).describe("Search query (string) or multiple queries (array of up to 5 strings). Multi-query searches return combined results from all queries."), - max_results: exports_external.number().optional().describe("Maximum number of search results to return (1-20, default: 10)"), - max_tokens_per_page: exports_external.number().optional().describe("Maximum number of tokens to extract per search result page (256-2048, default: 2048)"), - max_tokens: exports_external.number().optional().describe("Maximum total tokens across all search results (default: 25000, max: 1000000)"), - country: exports_external.string().optional().describe("Two-letter ISO 3166-1 alpha-2 country code for regional search results (e.g., 'US', 'GB', 'FR')"), - search_domain_filter: exports_external.array(exports_external.string()).optional().describe("List of domains to include or exclude from search results (max 20). To include: ['nature.com', 'science.org']. To exclude: ['-example.com', '-spam.net']"), - search_language_filter: exports_external.array(exports_external.string()).optional().describe("List of ISO 639-1 language codes to filter results (max 10, lowercase). Examples: ['en', 'fr', 'de']"), - search_after_date: exports_external.string().optional().describe("Include only results published after this date. Format: 'MM/DD/YYYY' (e.g., '3/1/2025'). Cannot be used with search_recency_filter."), - search_before_date: exports_external.string().optional().describe("Include only results published before this date. Format: 'MM/DD/YYYY' (e.g., '3/15/2025'). Cannot be used with search_recency_filter."), - last_updated_after_filter: exports_external.string().optional().describe("Include only results last updated after this date. Format: 'MM/DD/YYYY' (e.g., '3/1/2025'). Cannot be used with search_recency_filter."), - last_updated_before_filter: exports_external.string().optional().describe("Include only results last updated before this date. Format: 'MM/DD/YYYY' (e.g., '3/15/2025'). Cannot be used with search_recency_filter."), - search_recency_filter: exports_external.enum(["day", "week", "month", "year"]).optional().describe("Filter results by relative time period. Cannot be used with search_after_date or search_before_date.") -}))); -var perplexitySearchOutputSchema = lazySchema(() => zodSchema(exports_external.union([ - exports_external.object({ - results: exports_external.array(exports_external.object({ - title: exports_external.string(), - url: exports_external.string(), - snippet: exports_external.string(), - date: exports_external.string().optional(), - lastUpdated: exports_external.string().optional() - })), - id: exports_external.string() - }), - exports_external.object({ - error: exports_external.enum([ - "api_error", - "rate_limit", - "timeout", - "invalid_input", - "unknown" - ]), - statusCode: exports_external.number().optional(), - message: exports_external.string() - }) -]))); -var perplexitySearchToolFactory = createProviderToolFactoryWithOutputSchema({ - id: "gateway.perplexity_search", - inputSchema: perplexitySearchInputSchema, - outputSchema: perplexitySearchOutputSchema -}); -var perplexitySearch = (config2 = {}) => perplexitySearchToolFactory(config2); -var gatewayTools = { - parallelSearch, - perplexitySearch -}; -async function getVercelRequestId() { - var _a92; - return (_a92 = import_oidc.getContext().headers) == null ? undefined : _a92["x-vercel-id"]; -} -var VERSION2 = "3.0.54"; -var AI_GATEWAY_PROTOCOL_VERSION = "0.0.1"; -function createGatewayProvider(options = {}) { - var _a92, _b92; - let pendingMetadata = null; - let metadataCache = null; - const cacheRefreshMillis = (_a92 = options.metadataCacheRefreshMillis) != null ? _a92 : 1000 * 60 * 5; - let lastFetchTime = 0; - const baseURL = (_b92 = withoutTrailingSlash(options.baseURL)) != null ? _b92 : "https://ai-gateway.vercel.sh/v3/ai"; - const getHeaders = async () => { - try { - const auth = await getGatewayAuthToken(options); - return withUserAgentSuffix({ - Authorization: `Bearer ${auth.token}`, - "ai-gateway-protocol-version": AI_GATEWAY_PROTOCOL_VERSION, - [GATEWAY_AUTH_METHOD_HEADER]: auth.authMethod, - ...options.headers - }, `ai-sdk/gateway/${VERSION2}`); - } catch (error48) { - throw GatewayAuthenticationError.createContextualError({ - apiKeyProvided: false, - oidcTokenProvided: false, - statusCode: 401, - cause: error48 - }); - } - }; - const createO11yHeaders = () => { - const deploymentId = loadOptionalSetting({ - settingValue: undefined, - environmentVariableName: "VERCEL_DEPLOYMENT_ID" - }); - const environment = loadOptionalSetting({ - settingValue: undefined, - environmentVariableName: "VERCEL_ENV" - }); - const region = loadOptionalSetting({ - settingValue: undefined, - environmentVariableName: "VERCEL_REGION" - }); - return async () => { - const requestId = await getVercelRequestId(); - return { - ...deploymentId && { "ai-o11y-deployment-id": deploymentId }, - ...environment && { "ai-o11y-environment": environment }, - ...region && { "ai-o11y-region": region }, - ...requestId && { "ai-o11y-request-id": requestId } - }; - }; - }; - const createLanguageModel = (modelId) => { - return new GatewayLanguageModel(modelId, { - provider: "gateway", - baseURL, - headers: getHeaders, - fetch: options.fetch, - o11yHeaders: createO11yHeaders() - }); - }; - const getAvailableModels = async () => { - var _a102, _b102, _c; - const now = (_c = (_b102 = (_a102 = options._internal) == null ? undefined : _a102.currentDate) == null ? undefined : _b102.call(_a102).getTime()) != null ? _c : Date.now(); - if (!pendingMetadata || now - lastFetchTime > cacheRefreshMillis) { - lastFetchTime = now; - pendingMetadata = new GatewayFetchMetadata({ - baseURL, - headers: getHeaders, - fetch: options.fetch - }).getAvailableModels().then((metadata) => { - metadataCache = metadata; - return metadata; - }).catch(async (error48) => { - throw await asGatewayError(error48, await parseAuthMethod(await getHeaders())); - }); - } - return metadataCache ? Promise.resolve(metadataCache) : pendingMetadata; - }; - const getCredits = async () => { - return new GatewayFetchMetadata({ - baseURL, - headers: getHeaders, - fetch: options.fetch - }).getCredits().catch(async (error48) => { - throw await asGatewayError(error48, await parseAuthMethod(await getHeaders())); - }); - }; - const provider = function(modelId) { - if (new.target) { - throw new Error("The Gateway Provider model function cannot be called with the new keyword."); - } - return createLanguageModel(modelId); - }; - provider.specificationVersion = "v3"; - provider.getAvailableModels = getAvailableModels; - provider.getCredits = getCredits; - provider.imageModel = (modelId) => { - return new GatewayImageModel(modelId, { - provider: "gateway", - baseURL, - headers: getHeaders, - fetch: options.fetch, - o11yHeaders: createO11yHeaders() - }); - }; - provider.languageModel = createLanguageModel; - const createEmbeddingModel = (modelId) => { - return new GatewayEmbeddingModel(modelId, { - provider: "gateway", - baseURL, - headers: getHeaders, - fetch: options.fetch, - o11yHeaders: createO11yHeaders() - }); - }; - provider.embeddingModel = createEmbeddingModel; - provider.textEmbeddingModel = createEmbeddingModel; - provider.videoModel = (modelId) => { - return new GatewayVideoModel(modelId, { - provider: "gateway", - baseURL, - headers: getHeaders, - fetch: options.fetch, - o11yHeaders: createO11yHeaders() - }); - }; - provider.chat = provider.languageModel; - provider.embedding = provider.embeddingModel; - provider.image = provider.imageModel; - provider.video = provider.videoModel; - provider.tools = gatewayTools; - return provider; -} -var gateway = createGatewayProvider(); -async function getGatewayAuthToken(options) { - const apiKey = loadOptionalSetting({ - settingValue: options.apiKey, - environmentVariableName: "AI_GATEWAY_API_KEY" - }); - if (apiKey) { - return { - token: apiKey, - authMethod: "api-key" - }; - } - const oidcToken = await import_oidc2.getVercelOidcToken(); - return { - token: oidcToken, - authMethod: "oidc" - }; -} - -// ../../node_modules/.pnpm/ai@6.0.98_zod@4.3.6/node_modules/ai/dist/index.mjs -var import_api = __toESM(require_src(), 1); -var import_api2 = __toESM(require_src(), 1); -var __defProp2 = Object.defineProperty; -var __export2 = (target, all) => { - for (var name21 in all) - __defProp2(target, name21, { get: all[name21], enumerable: true }); -}; -var name17 = "AI_InvalidArgumentError"; -var marker18 = `vercel.ai.error.${name17}`; -var symbol19 = Symbol.for(marker18); -var _a19; -var InvalidArgumentError2 = class extends AISDKError { - constructor({ - parameter, - value, - message - }) { - super({ - name: name17, - message: `Invalid argument for parameter ${parameter}: ${message}` - }); - this[_a19] = true; - this.parameter = parameter; - this.value = value; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker18); - } -}; -_a19 = symbol19; -var name23 = "AI_InvalidStreamPartError"; -var marker23 = `vercel.ai.error.${name23}`; -var symbol23 = Symbol.for(marker23); -var _a23; -_a23 = symbol23; -var name33 = "AI_InvalidToolApprovalError"; -var marker33 = `vercel.ai.error.${name33}`; -var symbol33 = Symbol.for(marker33); -var _a33; -var InvalidToolApprovalError = class extends AISDKError { - constructor({ approvalId }) { - super({ - name: name33, - message: `Tool approval response references unknown approvalId: "${approvalId}". No matching tool-approval-request found in message history.` - }); - this[_a33] = true; - this.approvalId = approvalId; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker33); - } -}; -_a33 = symbol33; -var name43 = "AI_InvalidToolInputError"; -var marker43 = `vercel.ai.error.${name43}`; -var symbol43 = Symbol.for(marker43); -var _a43; -var InvalidToolInputError = class extends AISDKError { - constructor({ - toolInput, - toolName, - cause, - message = `Invalid input for tool ${toolName}: ${getErrorMessage(cause)}` - }) { - super({ name: name43, message, cause }); - this[_a43] = true; - this.toolInput = toolInput; - this.toolName = toolName; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker43); - } -}; -_a43 = symbol43; -var name53 = "AI_ToolCallNotFoundForApprovalError"; -var marker53 = `vercel.ai.error.${name53}`; -var symbol53 = Symbol.for(marker53); -var _a53; -var ToolCallNotFoundForApprovalError = class extends AISDKError { - constructor({ - toolCallId, - approvalId - }) { - super({ - name: name53, - message: `Tool call "${toolCallId}" not found for approval request "${approvalId}".` - }); - this[_a53] = true; - this.toolCallId = toolCallId; - this.approvalId = approvalId; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker53); - } -}; -_a53 = symbol53; -var name63 = "AI_MissingToolResultsError"; -var marker63 = `vercel.ai.error.${name63}`; -var symbol63 = Symbol.for(marker63); -var _a63; -var MissingToolResultsError = class extends AISDKError { - constructor({ toolCallIds }) { - super({ - name: name63, - message: `Tool result${toolCallIds.length > 1 ? "s are" : " is"} missing for tool call${toolCallIds.length > 1 ? "s" : ""} ${toolCallIds.join(", ")}.` - }); - this[_a63] = true; - this.toolCallIds = toolCallIds; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker63); - } -}; -_a63 = symbol63; -var name73 = "AI_NoImageGeneratedError"; -var marker73 = `vercel.ai.error.${name73}`; -var symbol73 = Symbol.for(marker73); -var _a73; -_a73 = symbol73; -var name82 = "AI_NoObjectGeneratedError"; -var marker83 = `vercel.ai.error.${name82}`; -var symbol83 = Symbol.for(marker83); -var _a83; -var NoObjectGeneratedError = class extends AISDKError { - constructor({ - message = "No object generated.", - cause, - text: text2, - response, - usage, - finishReason - }) { - super({ name: name82, message, cause }); - this[_a83] = true; - this.text = text2; - this.response = response; - this.usage = usage; - this.finishReason = finishReason; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker83); - } -}; -_a83 = symbol83; -var name92 = "AI_NoOutputGeneratedError"; -var marker92 = `vercel.ai.error.${name92}`; -var symbol92 = Symbol.for(marker92); -var _a92; -var NoOutputGeneratedError = class extends AISDKError { - constructor({ - message = "No output generated.", - cause - } = {}) { - super({ name: name92, message, cause }); - this[_a92] = true; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker92); - } -}; -_a92 = symbol92; -var name102 = "AI_NoSpeechGeneratedError"; -var marker102 = `vercel.ai.error.${name102}`; -var symbol102 = Symbol.for(marker102); -var _a102; -_a102 = symbol102; -var name112 = "AI_NoTranscriptGeneratedError"; -var marker112 = `vercel.ai.error.${name112}`; -var symbol112 = Symbol.for(marker112); -var _a112; -_a112 = symbol112; -var name122 = "AI_NoVideoGeneratedError"; -var marker122 = `vercel.ai.error.${name122}`; -var symbol122 = Symbol.for(marker122); -var _a122; -_a122 = symbol122; -var name132 = "AI_NoSuchToolError"; -var marker132 = `vercel.ai.error.${name132}`; -var symbol132 = Symbol.for(marker132); -var _a132; -var NoSuchToolError = class extends AISDKError { - constructor({ - toolName, - availableTools = undefined, - message = `Model tried to call unavailable tool '${toolName}'. ${availableTools === undefined ? "No tools are available." : `Available tools: ${availableTools.join(", ")}.`}` - }) { - super({ name: name132, message }); - this[_a132] = true; - this.toolName = toolName; - this.availableTools = availableTools; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker132); - } -}; -_a132 = symbol132; -var name142 = "AI_ToolCallRepairError"; -var marker142 = `vercel.ai.error.${name142}`; -var symbol142 = Symbol.for(marker142); -var _a142; -var ToolCallRepairError = class extends AISDKError { - constructor({ - cause, - originalError, - message = `Error repairing tool call: ${getErrorMessage(cause)}` - }) { - super({ name: name142, message, cause }); - this[_a142] = true; - this.originalError = originalError; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker142); - } -}; -_a142 = symbol142; -var UnsupportedModelVersionError = class extends AISDKError { - constructor(options) { - super({ - name: "AI_UnsupportedModelVersionError", - message: `Unsupported model version ${options.version} for provider "${options.provider}" and model "${options.modelId}". AI SDK 5 only supports models that implement specification version "v2".` - }); - this.version = options.version; - this.provider = options.provider; - this.modelId = options.modelId; - } -}; -var name15 = "AI_UIMessageStreamError"; -var marker152 = `vercel.ai.error.${name15}`; -var symbol152 = Symbol.for(marker152); -var _a152; -var UIMessageStreamError = class extends AISDKError { - constructor({ - chunkType, - chunkId, - message - }) { - super({ name: name15, message }); - this[_a152] = true; - this.chunkType = chunkType; - this.chunkId = chunkId; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker152); - } -}; -_a152 = symbol152; -var name162 = "AI_InvalidDataContentError"; -var marker16 = `vercel.ai.error.${name162}`; -var symbol16 = Symbol.for(marker16); -var _a16; -_a16 = symbol16; -var name172 = "AI_InvalidMessageRoleError"; -var marker172 = `vercel.ai.error.${name172}`; -var symbol172 = Symbol.for(marker172); -var _a172; -var InvalidMessageRoleError = class extends AISDKError { - constructor({ - role, - message = `Invalid message role: '${role}'. Must be one of: "system", "user", "assistant", "tool".` - }) { - super({ name: name172, message }); - this[_a172] = true; - this.role = role; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker172); - } -}; -_a172 = symbol172; -var name18 = "AI_MessageConversionError"; -var marker182 = `vercel.ai.error.${name18}`; -var symbol182 = Symbol.for(marker182); -var _a182; -var MessageConversionError = class extends AISDKError { - constructor({ - originalMessage, - message - }) { - super({ name: name18, message }); - this[_a182] = true; - this.originalMessage = originalMessage; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker182); - } -}; -_a182 = symbol182; -var name19 = "AI_RetryError"; -var marker19 = `vercel.ai.error.${name19}`; -var symbol192 = Symbol.for(marker19); -var _a192; -var RetryError = class extends AISDKError { - constructor({ - message, - reason, - errors: errors4 - }) { - super({ name: name19, message }); - this[_a192] = true; - this.reason = reason; - this.errors = errors4; - this.lastError = errors4[errors4.length - 1]; - } - static isInstance(error48) { - return AISDKError.hasMarker(error48, marker19); - } -}; -_a192 = symbol192; -function formatWarning({ - warning, - provider, - model -}) { - const prefix = `AI SDK Warning (${provider} / ${model}):`; - switch (warning.type) { - case "unsupported": { - let message = `${prefix} The feature "${warning.feature}" is not supported.`; - if (warning.details) { - message += ` ${warning.details}`; - } - return message; - } - case "compatibility": { - let message = `${prefix} The feature "${warning.feature}" is used in a compatibility mode.`; - if (warning.details) { - message += ` ${warning.details}`; - } - return message; - } - case "other": { - return `${prefix} ${warning.message}`; - } - default: { - return `${prefix} ${JSON.stringify(warning, null, 2)}`; - } - } -} -var FIRST_WARNING_INFO_MESSAGE = "AI SDK Warning System: To turn off warning logging, set the AI_SDK_LOG_WARNINGS global to false."; -var hasLoggedBefore = false; -var logWarnings = (options) => { - if (options.warnings.length === 0) { - return; - } - const logger = globalThis.AI_SDK_LOG_WARNINGS; - if (logger === false) { - return; - } - if (typeof logger === "function") { - logger(options); - return; - } - if (!hasLoggedBefore) { - hasLoggedBefore = true; - console.info(FIRST_WARNING_INFO_MESSAGE); - } - for (const warning of options.warnings) { - console.warn(formatWarning({ - warning, - provider: options.provider, - model: options.model - })); - } -}; -function logV2CompatibilityWarning({ - provider, - modelId -}) { - logWarnings({ - warnings: [ - { - type: "compatibility", - feature: "specificationVersion", - details: `Using v2 specification compatibility mode. Some features may not be available.` - } - ], - provider, - model: modelId - }); -} -function asLanguageModelV3(model) { - if (model.specificationVersion === "v3") { - return model; - } - logV2CompatibilityWarning({ - provider: model.provider, - modelId: model.modelId - }); - return new Proxy(model, { - get(target, prop) { - switch (prop) { - case "specificationVersion": - return "v3"; - case "doGenerate": - return async (...args) => { - const result = await target.doGenerate(...args); - return { - ...result, - finishReason: convertV2FinishReasonToV3(result.finishReason), - usage: convertV2UsageToV3(result.usage) - }; - }; - case "doStream": - return async (...args) => { - const result = await target.doStream(...args); - return { - ...result, - stream: convertV2StreamToV3(result.stream) - }; - }; - default: - return target[prop]; - } - } - }); -} -function convertV2StreamToV3(stream) { - return stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - switch (chunk.type) { - case "finish": - controller.enqueue({ - ...chunk, - finishReason: convertV2FinishReasonToV3(chunk.finishReason), - usage: convertV2UsageToV3(chunk.usage) - }); - break; - default: - controller.enqueue(chunk); - break; - } - } - })); -} -function convertV2FinishReasonToV3(finishReason) { - return { - unified: finishReason === "unknown" ? "other" : finishReason, - raw: undefined - }; -} -function convertV2UsageToV3(usage) { - return { - inputTokens: { - total: usage.inputTokens, - noCache: undefined, - cacheRead: usage.cachedInputTokens, - cacheWrite: undefined - }, - outputTokens: { - total: usage.outputTokens, - text: undefined, - reasoning: usage.reasoningTokens - } - }; -} -function resolveLanguageModel(model) { - if (typeof model !== "string") { - if (model.specificationVersion !== "v3" && model.specificationVersion !== "v2") { - const unsupportedModel = model; - throw new UnsupportedModelVersionError({ - version: unsupportedModel.specificationVersion, - provider: unsupportedModel.provider, - modelId: unsupportedModel.modelId - }); - } - return asLanguageModelV3(model); - } - return getGlobalProvider().languageModel(model); -} -function getGlobalProvider() { - var _a21; - return (_a21 = globalThis.AI_SDK_DEFAULT_PROVIDER) != null ? _a21 : gateway; -} -function getTotalTimeoutMs(timeout) { - if (timeout == null) { - return; - } - if (typeof timeout === "number") { - return timeout; - } - return timeout.totalMs; -} -function getStepTimeoutMs(timeout) { - if (timeout == null || typeof timeout === "number") { - return; - } - return timeout.stepMs; -} -function getChunkTimeoutMs(timeout) { - if (timeout == null || typeof timeout === "number") { - return; - } - return timeout.chunkMs; -} -var imageMediaTypeSignatures = [ - { - mediaType: "image/gif", - bytesPrefix: [71, 73, 70] - }, - { - mediaType: "image/png", - bytesPrefix: [137, 80, 78, 71] - }, - { - mediaType: "image/jpeg", - bytesPrefix: [255, 216] - }, - { - mediaType: "image/webp", - bytesPrefix: [ - 82, - 73, - 70, - 70, - null, - null, - null, - null, - 87, - 69, - 66, - 80 - ] - }, - { - mediaType: "image/bmp", - bytesPrefix: [66, 77] - }, - { - mediaType: "image/tiff", - bytesPrefix: [73, 73, 42, 0] - }, - { - mediaType: "image/tiff", - bytesPrefix: [77, 77, 0, 42] - }, - { - mediaType: "image/avif", - bytesPrefix: [ - 0, - 0, - 0, - 32, - 102, - 116, - 121, - 112, - 97, - 118, - 105, - 102 - ] - }, - { - mediaType: "image/heic", - bytesPrefix: [ - 0, - 0, - 0, - 32, - 102, - 116, - 121, - 112, - 104, - 101, - 105, - 99 - ] - } -]; -var stripID3 = (data) => { - const bytes = typeof data === "string" ? convertBase64ToUint8Array(data) : data; - const id3Size = (bytes[6] & 127) << 21 | (bytes[7] & 127) << 14 | (bytes[8] & 127) << 7 | bytes[9] & 127; - return bytes.slice(id3Size + 10); -}; -function stripID3TagsIfPresent(data) { - const hasId3 = typeof data === "string" && data.startsWith("SUQz") || typeof data !== "string" && data.length > 10 && data[0] === 73 && data[1] === 68 && data[2] === 51; - return hasId3 ? stripID3(data) : data; -} -function detectMediaType({ - data, - signatures -}) { - const processedData = stripID3TagsIfPresent(data); - const bytes = typeof processedData === "string" ? convertBase64ToUint8Array(processedData.substring(0, Math.min(processedData.length, 24))) : processedData; - for (const signature of signatures) { - if (bytes.length >= signature.bytesPrefix.length && signature.bytesPrefix.every((byte, index) => byte === null || bytes[index] === byte)) { - return signature.mediaType; - } - } - return; -} -var VERSION3 = "6.0.98"; -var download = async ({ - url: url2, - maxBytes, - abortSignal -}) => { - var _a21; - const urlText = url2.toString(); - try { - const response = await fetch(urlText, { - headers: withUserAgentSuffix({}, `ai-sdk/${VERSION3}`, getRuntimeEnvironmentUserAgent()), - signal: abortSignal - }); - if (!response.ok) { - throw new DownloadError({ - url: urlText, - statusCode: response.status, - statusText: response.statusText - }); - } - const data = await readResponseWithSizeLimit({ - response, - url: urlText, - maxBytes: maxBytes != null ? maxBytes : DEFAULT_MAX_DOWNLOAD_SIZE - }); - return { - data, - mediaType: (_a21 = response.headers.get("content-type")) != null ? _a21 : undefined - }; - } catch (error48) { - if (DownloadError.isInstance(error48)) { - throw error48; - } - throw new DownloadError({ url: urlText, cause: error48 }); - } -}; -var createDefaultDownloadFunction = (download2 = download) => (requestedDownloads) => Promise.all(requestedDownloads.map(async (requestedDownload) => requestedDownload.isUrlSupportedByModel ? null : download2(requestedDownload))); -function splitDataUrl(dataUrl) { - try { - const [header, base64Content] = dataUrl.split(","); - return { - mediaType: header.split(";")[0].split(":")[1], - base64Content - }; - } catch (error48) { - return { - mediaType: undefined, - base64Content: undefined - }; - } -} -var dataContentSchema = exports_external.union([ - exports_external.string(), - exports_external.instanceof(Uint8Array), - exports_external.instanceof(ArrayBuffer), - exports_external.custom((value) => { - var _a21, _b16; - return (_b16 = (_a21 = globalThis.Buffer) == null ? undefined : _a21.isBuffer(value)) != null ? _b16 : false; - }, { message: "Must be a Buffer" }) -]); -function convertToLanguageModelV3DataContent(content) { - if (content instanceof Uint8Array) { - return { data: content, mediaType: undefined }; - } - if (content instanceof ArrayBuffer) { - return { data: new Uint8Array(content), mediaType: undefined }; - } - if (typeof content === "string") { - try { - content = new URL(content); - } catch (error48) {} - } - if (content instanceof URL && content.protocol === "data:") { - const { mediaType: dataUrlMediaType, base64Content } = splitDataUrl(content.toString()); - if (dataUrlMediaType == null || base64Content == null) { - throw new AISDKError({ - name: "InvalidDataContentError", - message: `Invalid data URL format in content ${content.toString()}` - }); - } - return { data: base64Content, mediaType: dataUrlMediaType }; - } - return { data: content, mediaType: undefined }; -} -function convertDataContentToBase64String(content) { - if (typeof content === "string") { - return content; - } - if (content instanceof ArrayBuffer) { - return convertUint8ArrayToBase64(new Uint8Array(content)); - } - return convertUint8ArrayToBase64(content); -} -function asArray(value) { - return value === undefined ? [] : Array.isArray(value) ? value : [value]; -} -async function convertToLanguageModelPrompt({ - prompt, - supportedUrls, - download: download2 = createDefaultDownloadFunction() -}) { - const downloadedAssets = await downloadAssets(prompt.messages, download2, supportedUrls); - const approvalIdToToolCallId = /* @__PURE__ */ new Map; - for (const message of prompt.messages) { - if (message.role === "assistant" && Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === "tool-approval-request" && "approvalId" in part && "toolCallId" in part) { - approvalIdToToolCallId.set(part.approvalId, part.toolCallId); - } - } - } - } - const approvedToolCallIds = /* @__PURE__ */ new Set; - for (const message of prompt.messages) { - if (message.role === "tool") { - for (const part of message.content) { - if (part.type === "tool-approval-response") { - const toolCallId = approvalIdToToolCallId.get(part.approvalId); - if (toolCallId) { - approvedToolCallIds.add(toolCallId); - } - } - } - } - } - const messages = [ - ...prompt.system != null ? typeof prompt.system === "string" ? [{ role: "system", content: prompt.system }] : asArray(prompt.system).map((message) => ({ - role: "system", - content: message.content, - providerOptions: message.providerOptions - })) : [], - ...prompt.messages.map((message) => convertToLanguageModelMessage({ message, downloadedAssets })) - ]; - const combinedMessages = []; - for (const message of messages) { - if (message.role !== "tool") { - combinedMessages.push(message); - continue; - } - const lastCombinedMessage = combinedMessages.at(-1); - if ((lastCombinedMessage == null ? undefined : lastCombinedMessage.role) === "tool") { - lastCombinedMessage.content.push(...message.content); - } else { - combinedMessages.push(message); - } - } - const toolCallIds = /* @__PURE__ */ new Set; - for (const message of combinedMessages) { - switch (message.role) { - case "assistant": { - for (const content of message.content) { - if (content.type === "tool-call" && !content.providerExecuted) { - toolCallIds.add(content.toolCallId); - } - } - break; - } - case "tool": { - for (const content of message.content) { - if (content.type === "tool-result") { - toolCallIds.delete(content.toolCallId); - } - } - break; - } - case "user": - case "system": - for (const id of approvedToolCallIds) { - toolCallIds.delete(id); - } - if (toolCallIds.size > 0) { - throw new MissingToolResultsError({ - toolCallIds: Array.from(toolCallIds) - }); - } - break; - } - } - for (const id of approvedToolCallIds) { - toolCallIds.delete(id); - } - if (toolCallIds.size > 0) { - throw new MissingToolResultsError({ toolCallIds: Array.from(toolCallIds) }); - } - return combinedMessages.filter((message) => message.role !== "tool" || message.content.length > 0); -} -function convertToLanguageModelMessage({ - message, - downloadedAssets -}) { - const role = message.role; - switch (role) { - case "system": { - return { - role: "system", - content: message.content, - providerOptions: message.providerOptions - }; - } - case "user": { - if (typeof message.content === "string") { - return { - role: "user", - content: [{ type: "text", text: message.content }], - providerOptions: message.providerOptions - }; - } - return { - role: "user", - content: message.content.map((part) => convertPartToLanguageModelPart(part, downloadedAssets)).filter((part) => part.type !== "text" || part.text !== ""), - providerOptions: message.providerOptions - }; - } - case "assistant": { - if (typeof message.content === "string") { - return { - role: "assistant", - content: [{ type: "text", text: message.content }], - providerOptions: message.providerOptions - }; - } - return { - role: "assistant", - content: message.content.filter((part) => part.type !== "text" || part.text !== "" || part.providerOptions != null).filter((part) => part.type !== "tool-approval-request").map((part) => { - const providerOptions = part.providerOptions; - switch (part.type) { - case "file": { - const { data, mediaType } = convertToLanguageModelV3DataContent(part.data); - return { - type: "file", - data, - filename: part.filename, - mediaType: mediaType != null ? mediaType : part.mediaType, - providerOptions - }; - } - case "reasoning": { - return { - type: "reasoning", - text: part.text, - providerOptions - }; - } - case "text": { - return { - type: "text", - text: part.text, - providerOptions - }; - } - case "tool-call": { - return { - type: "tool-call", - toolCallId: part.toolCallId, - toolName: part.toolName, - input: part.input, - providerExecuted: part.providerExecuted, - providerOptions - }; - } - case "tool-result": { - return { - type: "tool-result", - toolCallId: part.toolCallId, - toolName: part.toolName, - output: mapToolResultOutput(part.output), - providerOptions - }; - } - } - }), - providerOptions: message.providerOptions - }; - } - case "tool": { - return { - role: "tool", - content: message.content.filter((part) => part.type !== "tool-approval-response" || part.providerExecuted).map((part) => { - switch (part.type) { - case "tool-result": { - return { - type: "tool-result", - toolCallId: part.toolCallId, - toolName: part.toolName, - output: mapToolResultOutput(part.output), - providerOptions: part.providerOptions - }; - } - case "tool-approval-response": { - return { - type: "tool-approval-response", - approvalId: part.approvalId, - approved: part.approved, - reason: part.reason - }; - } - } - }), - providerOptions: message.providerOptions - }; - } - default: { - const _exhaustiveCheck = role; - throw new InvalidMessageRoleError({ role: _exhaustiveCheck }); - } - } -} -async function downloadAssets(messages, download2, supportedUrls) { - const plannedDownloads = messages.filter((message) => message.role === "user").map((message) => message.content).filter((content) => Array.isArray(content)).flat().filter((part) => part.type === "image" || part.type === "file").map((part) => { - var _a21; - const mediaType = (_a21 = part.mediaType) != null ? _a21 : part.type === "image" ? "image/*" : undefined; - let data = part.type === "image" ? part.image : part.data; - if (typeof data === "string") { - try { - data = new URL(data); - } catch (ignored) {} - } - return { mediaType, data }; - }).filter((part) => part.data instanceof URL).map((part) => ({ - url: part.data, - isUrlSupportedByModel: part.mediaType != null && isUrlSupported({ - url: part.data.toString(), - mediaType: part.mediaType, - supportedUrls - }) - })); - const downloadedFiles = await download2(plannedDownloads); - return Object.fromEntries(downloadedFiles.map((file2, index) => file2 == null ? null : [ - plannedDownloads[index].url.toString(), - { data: file2.data, mediaType: file2.mediaType } - ]).filter((file2) => file2 != null)); -} -function convertPartToLanguageModelPart(part, downloadedAssets) { - var _a21; - if (part.type === "text") { - return { - type: "text", - text: part.text, - providerOptions: part.providerOptions - }; - } - let originalData; - const type = part.type; - switch (type) { - case "image": - originalData = part.image; - break; - case "file": - originalData = part.data; - break; - default: - throw new Error(`Unsupported part type: ${type}`); - } - const { data: convertedData, mediaType: convertedMediaType } = convertToLanguageModelV3DataContent(originalData); - let mediaType = convertedMediaType != null ? convertedMediaType : part.mediaType; - let data = convertedData; - if (data instanceof URL) { - const downloadedFile = downloadedAssets[data.toString()]; - if (downloadedFile) { - data = downloadedFile.data; - mediaType != null || (mediaType = downloadedFile.mediaType); - } - } - switch (type) { - case "image": { - if (data instanceof Uint8Array || typeof data === "string") { - mediaType = (_a21 = detectMediaType({ data, signatures: imageMediaTypeSignatures })) != null ? _a21 : mediaType; - } - return { - type: "file", - mediaType: mediaType != null ? mediaType : "image/*", - filename: undefined, - data, - providerOptions: part.providerOptions - }; - } - case "file": { - if (mediaType == null) { - throw new Error(`Media type is missing for file part`); - } - return { - type: "file", - mediaType, - filename: part.filename, - data, - providerOptions: part.providerOptions - }; - } - } -} -function mapToolResultOutput(output) { - if (output.type !== "content") { - return output; - } - return { - type: "content", - value: output.value.map((item) => { - if (item.type !== "media") { - return item; - } - if (item.mediaType.startsWith("image/")) { - return { - type: "image-data", - data: item.data, - mediaType: item.mediaType - }; - } - return { - type: "file-data", - data: item.data, - mediaType: item.mediaType - }; - }) - }; -} -async function createToolModelOutput({ - toolCallId, - input, - output, - tool: tool2, - errorMode -}) { - if (errorMode === "text") { - return { type: "error-text", value: getErrorMessage(output) }; - } else if (errorMode === "json") { - return { type: "error-json", value: toJSONValue(output) }; - } - if (tool2 == null ? undefined : tool2.toModelOutput) { - return await tool2.toModelOutput({ toolCallId, input, output }); - } - return typeof output === "string" ? { type: "text", value: output } : { type: "json", value: toJSONValue(output) }; -} -function toJSONValue(value) { - return value === undefined ? null : value; -} -function prepareCallSettings({ - maxOutputTokens, - temperature, - topP, - topK, - presencePenalty, - frequencyPenalty, - seed, - stopSequences -}) { - if (maxOutputTokens != null) { - if (!Number.isInteger(maxOutputTokens)) { - throw new InvalidArgumentError2({ - parameter: "maxOutputTokens", - value: maxOutputTokens, - message: "maxOutputTokens must be an integer" - }); - } - if (maxOutputTokens < 1) { - throw new InvalidArgumentError2({ - parameter: "maxOutputTokens", - value: maxOutputTokens, - message: "maxOutputTokens must be >= 1" - }); - } - } - if (temperature != null) { - if (typeof temperature !== "number") { - throw new InvalidArgumentError2({ - parameter: "temperature", - value: temperature, - message: "temperature must be a number" - }); - } - } - if (topP != null) { - if (typeof topP !== "number") { - throw new InvalidArgumentError2({ - parameter: "topP", - value: topP, - message: "topP must be a number" - }); - } - } - if (topK != null) { - if (typeof topK !== "number") { - throw new InvalidArgumentError2({ - parameter: "topK", - value: topK, - message: "topK must be a number" - }); - } - } - if (presencePenalty != null) { - if (typeof presencePenalty !== "number") { - throw new InvalidArgumentError2({ - parameter: "presencePenalty", - value: presencePenalty, - message: "presencePenalty must be a number" - }); - } - } - if (frequencyPenalty != null) { - if (typeof frequencyPenalty !== "number") { - throw new InvalidArgumentError2({ - parameter: "frequencyPenalty", - value: frequencyPenalty, - message: "frequencyPenalty must be a number" - }); - } - } - if (seed != null) { - if (!Number.isInteger(seed)) { - throw new InvalidArgumentError2({ - parameter: "seed", - value: seed, - message: "seed must be an integer" - }); - } - } - return { - maxOutputTokens, - temperature, - topP, - topK, - presencePenalty, - frequencyPenalty, - stopSequences, - seed - }; -} -function isNonEmptyObject(object2) { - return object2 != null && Object.keys(object2).length > 0; -} -async function prepareToolsAndToolChoice({ - tools, - toolChoice, - activeTools -}) { - if (!isNonEmptyObject(tools)) { - return { - tools: undefined, - toolChoice: undefined - }; - } - const filteredTools = activeTools != null ? Object.entries(tools).filter(([name21]) => activeTools.includes(name21)) : Object.entries(tools); - const languageModelTools = []; - for (const [name21, tool2] of filteredTools) { - const toolType = tool2.type; - switch (toolType) { - case undefined: - case "dynamic": - case "function": - languageModelTools.push({ - type: "function", - name: name21, - description: tool2.description, - inputSchema: await asSchema(tool2.inputSchema).jsonSchema, - ...tool2.inputExamples != null ? { inputExamples: tool2.inputExamples } : {}, - providerOptions: tool2.providerOptions, - ...tool2.strict != null ? { strict: tool2.strict } : {} - }); - break; - case "provider": - languageModelTools.push({ - type: "provider", - name: name21, - id: tool2.id, - args: tool2.args - }); - break; - default: { - const exhaustiveCheck = toolType; - throw new Error(`Unsupported tool type: ${exhaustiveCheck}`); - } - } - } - return { - tools: languageModelTools, - toolChoice: toolChoice == null ? { type: "auto" } : typeof toolChoice === "string" ? { type: toolChoice } : { type: "tool", toolName: toolChoice.toolName } - }; -} -var jsonValueSchema = exports_external.lazy(() => exports_external.union([ - exports_external.null(), - exports_external.string(), - exports_external.number(), - exports_external.boolean(), - exports_external.record(exports_external.string(), jsonValueSchema.optional()), - exports_external.array(jsonValueSchema) -])); -var providerMetadataSchema = exports_external.record(exports_external.string(), exports_external.record(exports_external.string(), jsonValueSchema.optional())); -var textPartSchema = exports_external.object({ - type: exports_external.literal("text"), - text: exports_external.string(), - providerOptions: providerMetadataSchema.optional() -}); -var imagePartSchema = exports_external.object({ - type: exports_external.literal("image"), - image: exports_external.union([dataContentSchema, exports_external.instanceof(URL)]), - mediaType: exports_external.string().optional(), - providerOptions: providerMetadataSchema.optional() -}); -var filePartSchema = exports_external.object({ - type: exports_external.literal("file"), - data: exports_external.union([dataContentSchema, exports_external.instanceof(URL)]), - filename: exports_external.string().optional(), - mediaType: exports_external.string(), - providerOptions: providerMetadataSchema.optional() -}); -var reasoningPartSchema = exports_external.object({ - type: exports_external.literal("reasoning"), - text: exports_external.string(), - providerOptions: providerMetadataSchema.optional() -}); -var toolCallPartSchema = exports_external.object({ - type: exports_external.literal("tool-call"), - toolCallId: exports_external.string(), - toolName: exports_external.string(), - input: exports_external.unknown(), - providerOptions: providerMetadataSchema.optional(), - providerExecuted: exports_external.boolean().optional() -}); -var outputSchema = exports_external.discriminatedUnion("type", [ - exports_external.object({ - type: exports_external.literal("text"), - value: exports_external.string(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("json"), - value: jsonValueSchema, - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("execution-denied"), - reason: exports_external.string().optional(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("error-text"), - value: exports_external.string(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("error-json"), - value: jsonValueSchema, - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("content"), - value: exports_external.array(exports_external.union([ - exports_external.object({ - type: exports_external.literal("text"), - text: exports_external.string(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("media"), - data: exports_external.string(), - mediaType: exports_external.string() - }), - exports_external.object({ - type: exports_external.literal("file-data"), - data: exports_external.string(), - mediaType: exports_external.string(), - filename: exports_external.string().optional(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("file-url"), - url: exports_external.string(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("file-id"), - fileId: exports_external.union([exports_external.string(), exports_external.record(exports_external.string(), exports_external.string())]), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("image-data"), - data: exports_external.string(), - mediaType: exports_external.string(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("image-url"), - url: exports_external.string(), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("image-file-id"), - fileId: exports_external.union([exports_external.string(), exports_external.record(exports_external.string(), exports_external.string())]), - providerOptions: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("custom"), - providerOptions: providerMetadataSchema.optional() - }) - ])) - }) -]); -var toolResultPartSchema = exports_external.object({ - type: exports_external.literal("tool-result"), - toolCallId: exports_external.string(), - toolName: exports_external.string(), - output: outputSchema, - providerOptions: providerMetadataSchema.optional() -}); -var toolApprovalRequestSchema = exports_external.object({ - type: exports_external.literal("tool-approval-request"), - approvalId: exports_external.string(), - toolCallId: exports_external.string() -}); -var toolApprovalResponseSchema = exports_external.object({ - type: exports_external.literal("tool-approval-response"), - approvalId: exports_external.string(), - approved: exports_external.boolean(), - reason: exports_external.string().optional() -}); -var systemModelMessageSchema = exports_external.object({ - role: exports_external.literal("system"), - content: exports_external.string(), - providerOptions: providerMetadataSchema.optional() -}); -var userModelMessageSchema = exports_external.object({ - role: exports_external.literal("user"), - content: exports_external.union([ - exports_external.string(), - exports_external.array(exports_external.union([textPartSchema, imagePartSchema, filePartSchema])) - ]), - providerOptions: providerMetadataSchema.optional() -}); -var assistantModelMessageSchema = exports_external.object({ - role: exports_external.literal("assistant"), - content: exports_external.union([ - exports_external.string(), - exports_external.array(exports_external.union([ - textPartSchema, - filePartSchema, - reasoningPartSchema, - toolCallPartSchema, - toolResultPartSchema, - toolApprovalRequestSchema - ])) - ]), - providerOptions: providerMetadataSchema.optional() -}); -var toolModelMessageSchema = exports_external.object({ - role: exports_external.literal("tool"), - content: exports_external.array(exports_external.union([toolResultPartSchema, toolApprovalResponseSchema])), - providerOptions: providerMetadataSchema.optional() -}); -var modelMessageSchema = exports_external.union([ - systemModelMessageSchema, - userModelMessageSchema, - assistantModelMessageSchema, - toolModelMessageSchema -]); -async function standardizePrompt(prompt) { - if (prompt.prompt == null && prompt.messages == null) { - throw new InvalidPromptError({ - prompt, - message: "prompt or messages must be defined" - }); - } - if (prompt.prompt != null && prompt.messages != null) { - throw new InvalidPromptError({ - prompt, - message: "prompt and messages cannot be defined at the same time" - }); - } - if (prompt.system != null && typeof prompt.system !== "string" && !asArray(prompt.system).every((message) => typeof message === "object" && message !== null && ("role" in message) && message.role === "system")) { - throw new InvalidPromptError({ - prompt, - message: "system must be a string, SystemModelMessage, or array of SystemModelMessage" - }); - } - let messages; - if (prompt.prompt != null && typeof prompt.prompt === "string") { - messages = [{ role: "user", content: prompt.prompt }]; - } else if (prompt.prompt != null && Array.isArray(prompt.prompt)) { - messages = prompt.prompt; - } else if (prompt.messages != null) { - messages = prompt.messages; - } else { - throw new InvalidPromptError({ - prompt, - message: "prompt or messages must be defined" - }); - } - if (messages.length === 0) { - throw new InvalidPromptError({ - prompt, - message: "messages must not be empty" - }); - } - const validationResult = await safeValidateTypes({ - value: messages, - schema: exports_external.array(modelMessageSchema) - }); - if (!validationResult.success) { - throw new InvalidPromptError({ - prompt, - message: "The messages do not match the ModelMessage[] schema.", - cause: validationResult.error - }); - } - return { - messages, - system: prompt.system - }; -} -function wrapGatewayError(error48) { - if (!GatewayAuthenticationError.isInstance(error48)) - return error48; - const isProductionEnv = (process == null ? undefined : "development") === "production"; - const moreInfoURL = "https://ai-sdk.dev/unauthenticated-ai-gateway"; - if (isProductionEnv) { - return new AISDKError({ - name: "GatewayError", - message: `Unauthenticated. Configure AI_GATEWAY_API_KEY or use a provider module. Learn more: ${moreInfoURL}` - }); - } - return Object.assign(new Error(`\x1B[1m\x1B[31mUnauthenticated request to AI Gateway.\x1B[0m - -To authenticate, set the \x1B[33mAI_GATEWAY_API_KEY\x1B[0m environment variable with your API key. - -Alternatively, you can use a provider module instead of the AI Gateway. - -Learn more: \x1B[34m${moreInfoURL}\x1B[0m - -`), { name: "GatewayAuthenticationError" }); -} -function assembleOperationName({ - operationId, - telemetry -}) { - return { - "operation.name": `${operationId}${(telemetry == null ? undefined : telemetry.functionId) != null ? ` ${telemetry.functionId}` : ""}`, - "resource.name": telemetry == null ? undefined : telemetry.functionId, - "ai.operationId": operationId, - "ai.telemetry.functionId": telemetry == null ? undefined : telemetry.functionId - }; -} -function getBaseTelemetryAttributes({ - model, - settings, - telemetry, - headers -}) { - var _a21; - return { - "ai.model.provider": model.provider, - "ai.model.id": model.modelId, - ...Object.entries(settings).reduce((attributes, [key, value]) => { - if (key === "timeout") { - const totalTimeoutMs = getTotalTimeoutMs(value); - if (totalTimeoutMs != null) { - attributes[`ai.settings.${key}`] = totalTimeoutMs; - } - } else { - attributes[`ai.settings.${key}`] = value; - } - return attributes; - }, {}), - ...Object.entries((_a21 = telemetry == null ? undefined : telemetry.metadata) != null ? _a21 : {}).reduce((attributes, [key, value]) => { - attributes[`ai.telemetry.metadata.${key}`] = value; - return attributes; - }, {}), - ...Object.entries(headers != null ? headers : {}).reduce((attributes, [key, value]) => { - if (value !== undefined) { - attributes[`ai.request.headers.${key}`] = value; - } - return attributes; - }, {}) - }; -} -var noopTracer = { - startSpan() { - return noopSpan; - }, - startActiveSpan(name21, arg1, arg2, arg3) { - if (typeof arg1 === "function") { - return arg1(noopSpan); - } - if (typeof arg2 === "function") { - return arg2(noopSpan); - } - if (typeof arg3 === "function") { - return arg3(noopSpan); - } - } -}; -var noopSpan = { - spanContext() { - return noopSpanContext; - }, - setAttribute() { - return this; - }, - setAttributes() { - return this; - }, - addEvent() { - return this; - }, - addLink() { - return this; - }, - addLinks() { - return this; - }, - setStatus() { - return this; - }, - updateName() { - return this; - }, - end() { - return this; - }, - isRecording() { - return false; - }, - recordException() { - return this; - } -}; -var noopSpanContext = { - traceId: "", - spanId: "", - traceFlags: 0 -}; -function getTracer({ - isEnabled = false, - tracer -} = {}) { - if (!isEnabled) { - return noopTracer; - } - if (tracer) { - return tracer; - } - return import_api.trace.getTracer("ai"); -} -async function recordSpan({ - name: name21, - tracer, - attributes, - fn, - endWhenDone = true -}) { - return tracer.startActiveSpan(name21, { attributes: await attributes }, async (span) => { - const ctx = import_api2.context.active(); - try { - const result = await import_api2.context.with(ctx, () => fn(span)); - if (endWhenDone) { - span.end(); - } - return result; - } catch (error48) { - try { - recordErrorOnSpan(span, error48); - } finally { - span.end(); - } - throw error48; - } - }); -} -function recordErrorOnSpan(span, error48) { - if (error48 instanceof Error) { - span.recordException({ - name: error48.name, - message: error48.message, - stack: error48.stack - }); - span.setStatus({ - code: import_api2.SpanStatusCode.ERROR, - message: error48.message - }); - } else { - span.setStatus({ code: import_api2.SpanStatusCode.ERROR }); - } -} -async function selectTelemetryAttributes({ - telemetry, - attributes -}) { - if ((telemetry == null ? undefined : telemetry.isEnabled) !== true) { - return {}; - } - const resultAttributes = {}; - for (const [key, value] of Object.entries(attributes)) { - if (value == null) { - continue; - } - if (typeof value === "object" && "input" in value && typeof value.input === "function") { - if ((telemetry == null ? undefined : telemetry.recordInputs) === false) { - continue; - } - const result = await value.input(); - if (result != null) { - resultAttributes[key] = result; - } - continue; - } - if (typeof value === "object" && "output" in value && typeof value.output === "function") { - if ((telemetry == null ? undefined : telemetry.recordOutputs) === false) { - continue; - } - const result = await value.output(); - if (result != null) { - resultAttributes[key] = result; - } - continue; - } - resultAttributes[key] = value; - } - return resultAttributes; -} -function stringifyForTelemetry(prompt) { - return JSON.stringify(prompt.map((message) => ({ - ...message, - content: typeof message.content === "string" ? message.content : message.content.map((part) => part.type === "file" ? { - ...part, - data: part.data instanceof Uint8Array ? convertDataContentToBase64String(part.data) : part.data - } : part) - }))); -} -function asLanguageModelUsage(usage) { - return { - inputTokens: usage.inputTokens.total, - inputTokenDetails: { - noCacheTokens: usage.inputTokens.noCache, - cacheReadTokens: usage.inputTokens.cacheRead, - cacheWriteTokens: usage.inputTokens.cacheWrite - }, - outputTokens: usage.outputTokens.total, - outputTokenDetails: { - textTokens: usage.outputTokens.text, - reasoningTokens: usage.outputTokens.reasoning - }, - totalTokens: addTokenCounts(usage.inputTokens.total, usage.outputTokens.total), - raw: usage.raw, - reasoningTokens: usage.outputTokens.reasoning, - cachedInputTokens: usage.inputTokens.cacheRead - }; -} -function createNullLanguageModelUsage() { - return { - inputTokens: undefined, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined - }, - outputTokens: undefined, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined - }, - totalTokens: undefined, - raw: undefined - }; -} -function addLanguageModelUsage(usage1, usage2) { - var _a21, _b16, _c, _d, _e, _f, _g, _h, _i, _j; - return { - inputTokens: addTokenCounts(usage1.inputTokens, usage2.inputTokens), - inputTokenDetails: { - noCacheTokens: addTokenCounts((_a21 = usage1.inputTokenDetails) == null ? undefined : _a21.noCacheTokens, (_b16 = usage2.inputTokenDetails) == null ? undefined : _b16.noCacheTokens), - cacheReadTokens: addTokenCounts((_c = usage1.inputTokenDetails) == null ? undefined : _c.cacheReadTokens, (_d = usage2.inputTokenDetails) == null ? undefined : _d.cacheReadTokens), - cacheWriteTokens: addTokenCounts((_e = usage1.inputTokenDetails) == null ? undefined : _e.cacheWriteTokens, (_f = usage2.inputTokenDetails) == null ? undefined : _f.cacheWriteTokens) - }, - outputTokens: addTokenCounts(usage1.outputTokens, usage2.outputTokens), - outputTokenDetails: { - textTokens: addTokenCounts((_g = usage1.outputTokenDetails) == null ? undefined : _g.textTokens, (_h = usage2.outputTokenDetails) == null ? undefined : _h.textTokens), - reasoningTokens: addTokenCounts((_i = usage1.outputTokenDetails) == null ? undefined : _i.reasoningTokens, (_j = usage2.outputTokenDetails) == null ? undefined : _j.reasoningTokens) - }, - totalTokens: addTokenCounts(usage1.totalTokens, usage2.totalTokens), - reasoningTokens: addTokenCounts(usage1.reasoningTokens, usage2.reasoningTokens), - cachedInputTokens: addTokenCounts(usage1.cachedInputTokens, usage2.cachedInputTokens) - }; -} -function addTokenCounts(tokenCount1, tokenCount2) { - return tokenCount1 == null && tokenCount2 == null ? undefined : (tokenCount1 != null ? tokenCount1 : 0) + (tokenCount2 != null ? tokenCount2 : 0); -} -function mergeObjects(base, overrides) { - if (base === undefined && overrides === undefined) { - return; - } - if (base === undefined) { - return overrides; - } - if (overrides === undefined) { - return base; - } - const result = { ...base }; - for (const key in overrides) { - if (Object.prototype.hasOwnProperty.call(overrides, key)) { - const overridesValue = overrides[key]; - if (overridesValue === undefined) - continue; - const baseValue = key in base ? base[key] : undefined; - const isSourceObject = overridesValue !== null && typeof overridesValue === "object" && !Array.isArray(overridesValue) && !(overridesValue instanceof Date) && !(overridesValue instanceof RegExp); - const isTargetObject = baseValue !== null && baseValue !== undefined && typeof baseValue === "object" && !Array.isArray(baseValue) && !(baseValue instanceof Date) && !(baseValue instanceof RegExp); - if (isSourceObject && isTargetObject) { - result[key] = mergeObjects(baseValue, overridesValue); - } else { - result[key] = overridesValue; - } - } - } - return result; -} -function getRetryDelayInMs({ - error: error48, - exponentialBackoffDelay -}) { - const headers = error48.responseHeaders; - if (!headers) - return exponentialBackoffDelay; - let ms; - const retryAfterMs = headers["retry-after-ms"]; - if (retryAfterMs) { - const timeoutMs = parseFloat(retryAfterMs); - if (!Number.isNaN(timeoutMs)) { - ms = timeoutMs; - } - } - const retryAfter = headers["retry-after"]; - if (retryAfter && ms === undefined) { - const timeoutSeconds = parseFloat(retryAfter); - if (!Number.isNaN(timeoutSeconds)) { - ms = timeoutSeconds * 1000; - } else { - ms = Date.parse(retryAfter) - Date.now(); - } - } - if (ms != null && !Number.isNaN(ms) && 0 <= ms && (ms < 60 * 1000 || ms < exponentialBackoffDelay)) { - return ms; - } - return exponentialBackoffDelay; -} -var retryWithExponentialBackoffRespectingRetryHeaders = ({ - maxRetries = 2, - initialDelayInMs = 2000, - backoffFactor = 2, - abortSignal -} = {}) => async (f) => _retryWithExponentialBackoff(f, { - maxRetries, - delayInMs: initialDelayInMs, - backoffFactor, - abortSignal -}); -async function _retryWithExponentialBackoff(f, { - maxRetries, - delayInMs, - backoffFactor, - abortSignal -}, errors4 = []) { - try { - return await f(); - } catch (error48) { - if (isAbortError(error48)) { - throw error48; - } - if (maxRetries === 0) { - throw error48; - } - const errorMessage = getErrorMessage2(error48); - const newErrors = [...errors4, error48]; - const tryNumber = newErrors.length; - if (tryNumber > maxRetries) { - throw new RetryError({ - message: `Failed after ${tryNumber} attempts. Last error: ${errorMessage}`, - reason: "maxRetriesExceeded", - errors: newErrors - }); - } - if (error48 instanceof Error && APICallError.isInstance(error48) && error48.isRetryable === true && tryNumber <= maxRetries) { - await delay(getRetryDelayInMs({ - error: error48, - exponentialBackoffDelay: delayInMs - }), { abortSignal }); - return _retryWithExponentialBackoff(f, { - maxRetries, - delayInMs: backoffFactor * delayInMs, - backoffFactor, - abortSignal - }, newErrors); - } - if (tryNumber === 1) { - throw error48; - } - throw new RetryError({ - message: `Failed after ${tryNumber} attempts with non-retryable error: '${errorMessage}'`, - reason: "errorNotRetryable", - errors: newErrors - }); - } -} -function prepareRetries({ - maxRetries, - abortSignal -}) { - if (maxRetries != null) { - if (!Number.isInteger(maxRetries)) { - throw new InvalidArgumentError2({ - parameter: "maxRetries", - value: maxRetries, - message: "maxRetries must be an integer" - }); - } - if (maxRetries < 0) { - throw new InvalidArgumentError2({ - parameter: "maxRetries", - value: maxRetries, - message: "maxRetries must be >= 0" - }); - } - } - const maxRetriesResult = maxRetries != null ? maxRetries : 2; - return { - maxRetries: maxRetriesResult, - retry: retryWithExponentialBackoffRespectingRetryHeaders({ - maxRetries: maxRetriesResult, - abortSignal - }) - }; -} -function collectToolApprovals({ - messages -}) { - const lastMessage = messages.at(-1); - if ((lastMessage == null ? undefined : lastMessage.role) != "tool") { - return { - approvedToolApprovals: [], - deniedToolApprovals: [] - }; - } - const toolCallsByToolCallId = {}; - for (const message of messages) { - if (message.role === "assistant" && typeof message.content !== "string") { - const content = message.content; - for (const part of content) { - if (part.type === "tool-call") { - toolCallsByToolCallId[part.toolCallId] = part; - } - } - } - } - const toolApprovalRequestsByApprovalId = {}; - for (const message of messages) { - if (message.role === "assistant" && typeof message.content !== "string") { - const content = message.content; - for (const part of content) { - if (part.type === "tool-approval-request") { - toolApprovalRequestsByApprovalId[part.approvalId] = part; - } - } - } - } - const toolResults = {}; - for (const part of lastMessage.content) { - if (part.type === "tool-result") { - toolResults[part.toolCallId] = part; - } - } - const approvedToolApprovals = []; - const deniedToolApprovals = []; - const approvalResponses = lastMessage.content.filter((part) => part.type === "tool-approval-response"); - for (const approvalResponse of approvalResponses) { - const approvalRequest = toolApprovalRequestsByApprovalId[approvalResponse.approvalId]; - if (approvalRequest == null) { - throw new InvalidToolApprovalError({ - approvalId: approvalResponse.approvalId - }); - } - if (toolResults[approvalRequest.toolCallId] != null) { - continue; - } - const toolCall = toolCallsByToolCallId[approvalRequest.toolCallId]; - if (toolCall == null) { - throw new ToolCallNotFoundForApprovalError({ - toolCallId: approvalRequest.toolCallId, - approvalId: approvalRequest.approvalId - }); - } - const approval = { - approvalRequest, - approvalResponse, - toolCall - }; - if (approvalResponse.approved) { - approvedToolApprovals.push(approval); - } else { - deniedToolApprovals.push(approval); - } - } - return { approvedToolApprovals, deniedToolApprovals }; -} -function now() { - var _a21, _b16; - return (_b16 = (_a21 = globalThis == null ? undefined : globalThis.performance) == null ? undefined : _a21.now()) != null ? _b16 : Date.now(); -} -async function executeToolCall({ - toolCall, - tools, - tracer, - telemetry, - messages, - abortSignal, - experimental_context, - stepNumber, - model, - onPreliminaryToolResult, - onToolCallStart, - onToolCallFinish -}) { - const { toolName, toolCallId, input } = toolCall; - const tool2 = tools == null ? undefined : tools[toolName]; - if ((tool2 == null ? undefined : tool2.execute) == null) { - return; - } - const baseCallbackEvent = { - stepNumber, - model, - toolCall, - messages, - abortSignal, - functionId: telemetry == null ? undefined : telemetry.functionId, - metadata: telemetry == null ? undefined : telemetry.metadata, - experimental_context - }; - return recordSpan({ - name: "ai.toolCall", - attributes: selectTelemetryAttributes({ - telemetry, - attributes: { - ...assembleOperationName({ - operationId: "ai.toolCall", - telemetry - }), - "ai.toolCall.name": toolName, - "ai.toolCall.id": toolCallId, - "ai.toolCall.args": { - output: () => JSON.stringify(input) - } - } - }), - tracer, - fn: async (span) => { - let output; - try { - await (onToolCallStart == null ? undefined : onToolCallStart(baseCallbackEvent)); - } catch (_ignored) {} - const startTime = now(); - try { - const stream = executeTool({ - execute: tool2.execute.bind(tool2), - input, - options: { - toolCallId, - messages, - abortSignal, - experimental_context - } - }); - for await (const part of stream) { - if (part.type === "preliminary") { - onPreliminaryToolResult == null || onPreliminaryToolResult({ - ...toolCall, - type: "tool-result", - output: part.output, - preliminary: true - }); - } else { - output = part.output; - } - } - } catch (error48) { - const durationMs2 = now() - startTime; - try { - await (onToolCallFinish == null ? undefined : onToolCallFinish({ - ...baseCallbackEvent, - success: false, - error: error48, - durationMs: durationMs2 - })); - } catch (_ignored) {} - recordErrorOnSpan(span, error48); - return { - type: "tool-error", - toolCallId, - toolName, - input, - error: error48, - dynamic: tool2.type === "dynamic", - ...toolCall.providerMetadata != null ? { providerMetadata: toolCall.providerMetadata } : {} - }; - } - const durationMs = now() - startTime; - try { - await (onToolCallFinish == null ? undefined : onToolCallFinish({ - ...baseCallbackEvent, - success: true, - output, - durationMs - })); - } catch (_ignored) {} - try { - span.setAttributes(await selectTelemetryAttributes({ - telemetry, - attributes: { - "ai.toolCall.result": { - output: () => JSON.stringify(output) - } - } - })); - } catch (ignored) {} - return { - type: "tool-result", - toolCallId, - toolName, - input, - output, - dynamic: tool2.type === "dynamic", - ...toolCall.providerMetadata != null ? { providerMetadata: toolCall.providerMetadata } : {} - }; - } - }); -} -var DefaultGeneratedFile = class { - constructor({ - data, - mediaType - }) { - const isUint8Array = data instanceof Uint8Array; - this.base64Data = isUint8Array ? undefined : data; - this.uint8ArrayData = isUint8Array ? data : undefined; - this.mediaType = mediaType; - } - get base64() { - if (this.base64Data == null) { - this.base64Data = convertUint8ArrayToBase64(this.uint8ArrayData); - } - return this.base64Data; - } - get uint8Array() { - if (this.uint8ArrayData == null) { - this.uint8ArrayData = convertBase64ToUint8Array(this.base64Data); - } - return this.uint8ArrayData; - } -}; -var DefaultGeneratedFileWithType = class extends DefaultGeneratedFile { - constructor(options) { - super(options); - this.type = "file"; - } -}; -async function isApprovalNeeded({ - tool: tool2, - toolCall, - messages, - experimental_context -}) { - if (tool2.needsApproval == null) { - return false; - } - if (typeof tool2.needsApproval === "boolean") { - return tool2.needsApproval; - } - return await tool2.needsApproval(toolCall.input, { - toolCallId: toolCall.toolCallId, - messages, - experimental_context - }); -} -var output_exports = {}; -__export2(output_exports, { - array: () => array2, - choice: () => choice, - json: () => json2, - object: () => object2, - text: () => text -}); -function fixJson(input) { - const stack = ["ROOT"]; - let lastValidIndex = -1; - let literalStart = null; - function processValueStart(char, i, swapState) { - { - switch (char) { - case '"': { - lastValidIndex = i; - stack.pop(); - stack.push(swapState); - stack.push("INSIDE_STRING"); - break; - } - case "f": - case "t": - case "n": { - lastValidIndex = i; - literalStart = i; - stack.pop(); - stack.push(swapState); - stack.push("INSIDE_LITERAL"); - break; - } - case "-": { - stack.pop(); - stack.push(swapState); - stack.push("INSIDE_NUMBER"); - break; - } - case "0": - case "1": - case "2": - case "3": - case "4": - case "5": - case "6": - case "7": - case "8": - case "9": { - lastValidIndex = i; - stack.pop(); - stack.push(swapState); - stack.push("INSIDE_NUMBER"); - break; - } - case "{": { - lastValidIndex = i; - stack.pop(); - stack.push(swapState); - stack.push("INSIDE_OBJECT_START"); - break; - } - case "[": { - lastValidIndex = i; - stack.pop(); - stack.push(swapState); - stack.push("INSIDE_ARRAY_START"); - break; - } - } - } - } - function processAfterObjectValue(char, i) { - switch (char) { - case ",": { - stack.pop(); - stack.push("INSIDE_OBJECT_AFTER_COMMA"); - break; - } - case "}": { - lastValidIndex = i; - stack.pop(); - break; - } - } - } - function processAfterArrayValue(char, i) { - switch (char) { - case ",": { - stack.pop(); - stack.push("INSIDE_ARRAY_AFTER_COMMA"); - break; - } - case "]": { - lastValidIndex = i; - stack.pop(); - break; - } - } - } - for (let i = 0;i < input.length; i++) { - const char = input[i]; - const currentState = stack[stack.length - 1]; - switch (currentState) { - case "ROOT": - processValueStart(char, i, "FINISH"); - break; - case "INSIDE_OBJECT_START": { - switch (char) { - case '"': { - stack.pop(); - stack.push("INSIDE_OBJECT_KEY"); - break; - } - case "}": { - lastValidIndex = i; - stack.pop(); - break; - } - } - break; - } - case "INSIDE_OBJECT_AFTER_COMMA": { - switch (char) { - case '"': { - stack.pop(); - stack.push("INSIDE_OBJECT_KEY"); - break; - } - } - break; - } - case "INSIDE_OBJECT_KEY": { - switch (char) { - case '"': { - stack.pop(); - stack.push("INSIDE_OBJECT_AFTER_KEY"); - break; - } - } - break; - } - case "INSIDE_OBJECT_AFTER_KEY": { - switch (char) { - case ":": { - stack.pop(); - stack.push("INSIDE_OBJECT_BEFORE_VALUE"); - break; - } - } - break; - } - case "INSIDE_OBJECT_BEFORE_VALUE": { - processValueStart(char, i, "INSIDE_OBJECT_AFTER_VALUE"); - break; - } - case "INSIDE_OBJECT_AFTER_VALUE": { - processAfterObjectValue(char, i); - break; - } - case "INSIDE_STRING": { - switch (char) { - case '"': { - stack.pop(); - lastValidIndex = i; - break; - } - case "\\": { - stack.push("INSIDE_STRING_ESCAPE"); - break; - } - default: { - lastValidIndex = i; - } - } - break; - } - case "INSIDE_ARRAY_START": { - switch (char) { - case "]": { - lastValidIndex = i; - stack.pop(); - break; - } - default: { - lastValidIndex = i; - processValueStart(char, i, "INSIDE_ARRAY_AFTER_VALUE"); - break; - } - } - break; - } - case "INSIDE_ARRAY_AFTER_VALUE": { - switch (char) { - case ",": { - stack.pop(); - stack.push("INSIDE_ARRAY_AFTER_COMMA"); - break; - } - case "]": { - lastValidIndex = i; - stack.pop(); - break; - } - default: { - lastValidIndex = i; - break; - } - } - break; - } - case "INSIDE_ARRAY_AFTER_COMMA": { - processValueStart(char, i, "INSIDE_ARRAY_AFTER_VALUE"); - break; - } - case "INSIDE_STRING_ESCAPE": { - stack.pop(); - lastValidIndex = i; - break; - } - case "INSIDE_NUMBER": { - switch (char) { - case "0": - case "1": - case "2": - case "3": - case "4": - case "5": - case "6": - case "7": - case "8": - case "9": { - lastValidIndex = i; - break; - } - case "e": - case "E": - case "-": - case ".": { - break; - } - case ",": { - stack.pop(); - if (stack[stack.length - 1] === "INSIDE_ARRAY_AFTER_VALUE") { - processAfterArrayValue(char, i); - } - if (stack[stack.length - 1] === "INSIDE_OBJECT_AFTER_VALUE") { - processAfterObjectValue(char, i); - } - break; - } - case "}": { - stack.pop(); - if (stack[stack.length - 1] === "INSIDE_OBJECT_AFTER_VALUE") { - processAfterObjectValue(char, i); - } - break; - } - case "]": { - stack.pop(); - if (stack[stack.length - 1] === "INSIDE_ARRAY_AFTER_VALUE") { - processAfterArrayValue(char, i); - } - break; - } - default: { - stack.pop(); - break; - } - } - break; - } - case "INSIDE_LITERAL": { - const partialLiteral = input.substring(literalStart, i + 1); - if (!"false".startsWith(partialLiteral) && !"true".startsWith(partialLiteral) && !"null".startsWith(partialLiteral)) { - stack.pop(); - if (stack[stack.length - 1] === "INSIDE_OBJECT_AFTER_VALUE") { - processAfterObjectValue(char, i); - } else if (stack[stack.length - 1] === "INSIDE_ARRAY_AFTER_VALUE") { - processAfterArrayValue(char, i); - } - } else { - lastValidIndex = i; - } - break; - } - } - } - let result = input.slice(0, lastValidIndex + 1); - for (let i = stack.length - 1;i >= 0; i--) { - const state = stack[i]; - switch (state) { - case "INSIDE_STRING": { - result += '"'; - break; - } - case "INSIDE_OBJECT_KEY": - case "INSIDE_OBJECT_AFTER_KEY": - case "INSIDE_OBJECT_AFTER_COMMA": - case "INSIDE_OBJECT_START": - case "INSIDE_OBJECT_BEFORE_VALUE": - case "INSIDE_OBJECT_AFTER_VALUE": { - result += "}"; - break; - } - case "INSIDE_ARRAY_START": - case "INSIDE_ARRAY_AFTER_COMMA": - case "INSIDE_ARRAY_AFTER_VALUE": { - result += "]"; - break; - } - case "INSIDE_LITERAL": { - const partialLiteral = input.substring(literalStart, input.length); - if ("true".startsWith(partialLiteral)) { - result += "true".slice(partialLiteral.length); - } else if ("false".startsWith(partialLiteral)) { - result += "false".slice(partialLiteral.length); - } else if ("null".startsWith(partialLiteral)) { - result += "null".slice(partialLiteral.length); - } - } - } - } - return result; -} -async function parsePartialJson(jsonText) { - if (jsonText === undefined) { - return { value: undefined, state: "undefined-input" }; - } - let result = await safeParseJSON({ text: jsonText }); - if (result.success) { - return { value: result.value, state: "successful-parse" }; - } - result = await safeParseJSON({ text: fixJson(jsonText) }); - if (result.success) { - return { value: result.value, state: "repaired-parse" }; - } - return { value: undefined, state: "failed-parse" }; -} -var text = () => ({ - name: "text", - responseFormat: Promise.resolve({ type: "text" }), - async parseCompleteOutput({ text: text2 }) { - return text2; - }, - async parsePartialOutput({ text: text2 }) { - return { partial: text2 }; - }, - createElementStreamTransform() { - return; - } -}); -var object2 = ({ - schema: inputSchema, - name: name21, - description -}) => { - const schema = asSchema(inputSchema); - return { - name: "object", - responseFormat: resolve(schema.jsonSchema).then((jsonSchema2) => ({ - type: "json", - schema: jsonSchema2, - ...name21 != null && { name: name21 }, - ...description != null && { description } - })), - async parseCompleteOutput({ text: text2 }, context2) { - const parseResult = await safeParseJSON({ text: text2 }); - if (!parseResult.success) { - throw new NoObjectGeneratedError({ - message: "No object generated: could not parse the response.", - cause: parseResult.error, - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - const validationResult = await safeValidateTypes({ - value: parseResult.value, - schema - }); - if (!validationResult.success) { - throw new NoObjectGeneratedError({ - message: "No object generated: response did not match schema.", - cause: validationResult.error, - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - return validationResult.value; - }, - async parsePartialOutput({ text: text2 }) { - const result = await parsePartialJson(text2); - switch (result.state) { - case "failed-parse": - case "undefined-input": { - return; - } - case "repaired-parse": - case "successful-parse": { - return { - partial: result.value - }; - } - } - }, - createElementStreamTransform() { - return; - } - }; -}; -var array2 = ({ - element: inputElementSchema, - name: name21, - description -}) => { - const elementSchema = asSchema(inputElementSchema); - return { - name: "array", - responseFormat: resolve(elementSchema.jsonSchema).then((jsonSchema2) => { - const { $schema, ...itemSchema } = jsonSchema2; - return { - type: "json", - schema: { - $schema: "http://json-schema.org/draft-07/schema#", - type: "object", - properties: { - elements: { type: "array", items: itemSchema } - }, - required: ["elements"], - additionalProperties: false - }, - ...name21 != null && { name: name21 }, - ...description != null && { description } - }; - }), - async parseCompleteOutput({ text: text2 }, context2) { - const parseResult = await safeParseJSON({ text: text2 }); - if (!parseResult.success) { - throw new NoObjectGeneratedError({ - message: "No object generated: could not parse the response.", - cause: parseResult.error, - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - const outerValue = parseResult.value; - if (outerValue == null || typeof outerValue !== "object" || !("elements" in outerValue) || !Array.isArray(outerValue.elements)) { - throw new NoObjectGeneratedError({ - message: "No object generated: response did not match schema.", - cause: new TypeValidationError({ - value: outerValue, - cause: "response must be an object with an elements array" - }), - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - for (const element of outerValue.elements) { - const validationResult = await safeValidateTypes({ - value: element, - schema: elementSchema - }); - if (!validationResult.success) { - throw new NoObjectGeneratedError({ - message: "No object generated: response did not match schema.", - cause: validationResult.error, - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - } - return outerValue.elements; - }, - async parsePartialOutput({ text: text2 }) { - const result = await parsePartialJson(text2); - switch (result.state) { - case "failed-parse": - case "undefined-input": { - return; - } - case "repaired-parse": - case "successful-parse": { - const outerValue = result.value; - if (outerValue == null || typeof outerValue !== "object" || !("elements" in outerValue) || !Array.isArray(outerValue.elements)) { - return; - } - const rawElements = result.state === "repaired-parse" && outerValue.elements.length > 0 ? outerValue.elements.slice(0, -1) : outerValue.elements; - const parsedElements = []; - for (const rawElement of rawElements) { - const validationResult = await safeValidateTypes({ - value: rawElement, - schema: elementSchema - }); - if (validationResult.success) { - parsedElements.push(validationResult.value); - } - } - return { partial: parsedElements }; - } - } - }, - createElementStreamTransform() { - let publishedElements = 0; - return new TransformStream({ - transform({ partialOutput }, controller) { - if (partialOutput != null) { - for (;publishedElements < partialOutput.length; publishedElements++) { - controller.enqueue(partialOutput[publishedElements]); - } - } - } - }); - } - }; -}; -var choice = ({ - options: choiceOptions, - name: name21, - description -}) => { - return { - name: "choice", - responseFormat: Promise.resolve({ - type: "json", - schema: { - $schema: "http://json-schema.org/draft-07/schema#", - type: "object", - properties: { - result: { type: "string", enum: choiceOptions } - }, - required: ["result"], - additionalProperties: false - }, - ...name21 != null && { name: name21 }, - ...description != null && { description } - }), - async parseCompleteOutput({ text: text2 }, context2) { - const parseResult = await safeParseJSON({ text: text2 }); - if (!parseResult.success) { - throw new NoObjectGeneratedError({ - message: "No object generated: could not parse the response.", - cause: parseResult.error, - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - const outerValue = parseResult.value; - if (outerValue == null || typeof outerValue !== "object" || !("result" in outerValue) || typeof outerValue.result !== "string" || !choiceOptions.includes(outerValue.result)) { - throw new NoObjectGeneratedError({ - message: "No object generated: response did not match schema.", - cause: new TypeValidationError({ - value: outerValue, - cause: "response must be an object that contains a choice value." - }), - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - return outerValue.result; - }, - async parsePartialOutput({ text: text2 }) { - const result = await parsePartialJson(text2); - switch (result.state) { - case "failed-parse": - case "undefined-input": { - return; - } - case "repaired-parse": - case "successful-parse": { - const outerValue = result.value; - if (outerValue == null || typeof outerValue !== "object" || !("result" in outerValue) || typeof outerValue.result !== "string") { - return; - } - const potentialMatches = choiceOptions.filter((choiceOption) => choiceOption.startsWith(outerValue.result)); - if (result.state === "successful-parse") { - return potentialMatches.includes(outerValue.result) ? { partial: outerValue.result } : undefined; - } else { - return potentialMatches.length === 1 ? { partial: potentialMatches[0] } : undefined; - } - } - } - }, - createElementStreamTransform() { - return; - } - }; -}; -var json2 = ({ - name: name21, - description -} = {}) => { - return { - name: "json", - responseFormat: Promise.resolve({ - type: "json", - ...name21 != null && { name: name21 }, - ...description != null && { description } - }), - async parseCompleteOutput({ text: text2 }, context2) { - const parseResult = await safeParseJSON({ text: text2 }); - if (!parseResult.success) { - throw new NoObjectGeneratedError({ - message: "No object generated: could not parse the response.", - cause: parseResult.error, - text: text2, - response: context2.response, - usage: context2.usage, - finishReason: context2.finishReason - }); - } - return parseResult.value; - }, - async parsePartialOutput({ text: text2 }) { - const result = await parsePartialJson(text2); - switch (result.state) { - case "failed-parse": - case "undefined-input": { - return; - } - case "repaired-parse": - case "successful-parse": { - return result.value === undefined ? undefined : { partial: result.value }; - } - } - }, - createElementStreamTransform() { - return; - } - }; -}; -async function parseToolCall({ - toolCall, - tools, - repairToolCall, - system, - messages -}) { - var _a21; - try { - if (tools == null) { - if (toolCall.providerExecuted && toolCall.dynamic) { - return await parseProviderExecutedDynamicToolCall(toolCall); - } - throw new NoSuchToolError({ toolName: toolCall.toolName }); - } - try { - return await doParseToolCall({ toolCall, tools }); - } catch (error48) { - if (repairToolCall == null || !(NoSuchToolError.isInstance(error48) || InvalidToolInputError.isInstance(error48))) { - throw error48; - } - let repairedToolCall = null; - try { - repairedToolCall = await repairToolCall({ - toolCall, - tools, - inputSchema: async ({ toolName }) => { - const { inputSchema } = tools[toolName]; - return await asSchema(inputSchema).jsonSchema; - }, - system, - messages, - error: error48 - }); - } catch (repairError) { - throw new ToolCallRepairError({ - cause: repairError, - originalError: error48 - }); - } - if (repairedToolCall == null) { - throw error48; - } - return await doParseToolCall({ toolCall: repairedToolCall, tools }); - } - } catch (error48) { - const parsedInput = await safeParseJSON({ text: toolCall.input }); - const input = parsedInput.success ? parsedInput.value : toolCall.input; - return { - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input, - dynamic: true, - invalid: true, - error: error48, - title: (_a21 = tools == null ? undefined : tools[toolCall.toolName]) == null ? undefined : _a21.title, - providerExecuted: toolCall.providerExecuted, - providerMetadata: toolCall.providerMetadata - }; - } -} -async function parseProviderExecutedDynamicToolCall(toolCall) { - const parseResult = toolCall.input.trim() === "" ? { success: true, value: {} } : await safeParseJSON({ text: toolCall.input }); - if (parseResult.success === false) { - throw new InvalidToolInputError({ - toolName: toolCall.toolName, - toolInput: toolCall.input, - cause: parseResult.error - }); - } - return { - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: parseResult.value, - providerExecuted: true, - dynamic: true, - providerMetadata: toolCall.providerMetadata - }; -} -async function doParseToolCall({ - toolCall, - tools -}) { - const toolName = toolCall.toolName; - const tool2 = tools[toolName]; - if (tool2 == null) { - if (toolCall.providerExecuted && toolCall.dynamic) { - return await parseProviderExecutedDynamicToolCall(toolCall); - } - throw new NoSuchToolError({ - toolName: toolCall.toolName, - availableTools: Object.keys(tools) - }); - } - const schema = asSchema(tool2.inputSchema); - const parseResult = toolCall.input.trim() === "" ? await safeValidateTypes({ value: {}, schema }) : await safeParseJSON({ text: toolCall.input, schema }); - if (parseResult.success === false) { - throw new InvalidToolInputError({ - toolName, - toolInput: toolCall.input, - cause: parseResult.error - }); - } - return tool2.type === "dynamic" ? { - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: parseResult.value, - providerExecuted: toolCall.providerExecuted, - providerMetadata: toolCall.providerMetadata, - dynamic: true, - title: tool2.title - } : { - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName, - input: parseResult.value, - providerExecuted: toolCall.providerExecuted, - providerMetadata: toolCall.providerMetadata, - title: tool2.title - }; -} -var DefaultStepResult = class { - constructor({ - stepNumber, - model, - functionId, - metadata, - experimental_context, - content, - finishReason, - rawFinishReason, - usage, - warnings, - request, - response, - providerMetadata - }) { - this.stepNumber = stepNumber; - this.model = model; - this.functionId = functionId; - this.metadata = metadata; - this.experimental_context = experimental_context; - this.content = content; - this.finishReason = finishReason; - this.rawFinishReason = rawFinishReason; - this.usage = usage; - this.warnings = warnings; - this.request = request; - this.response = response; - this.providerMetadata = providerMetadata; - } - get text() { - return this.content.filter((part) => part.type === "text").map((part) => part.text).join(""); - } - get reasoning() { - return this.content.filter((part) => part.type === "reasoning"); - } - get reasoningText() { - return this.reasoning.length === 0 ? undefined : this.reasoning.map((part) => part.text).join(""); - } - get files() { - return this.content.filter((part) => part.type === "file").map((part) => part.file); - } - get sources() { - return this.content.filter((part) => part.type === "source"); - } - get toolCalls() { - return this.content.filter((part) => part.type === "tool-call"); - } - get staticToolCalls() { - return this.toolCalls.filter((toolCall) => toolCall.dynamic !== true); - } - get dynamicToolCalls() { - return this.toolCalls.filter((toolCall) => toolCall.dynamic === true); - } - get toolResults() { - return this.content.filter((part) => part.type === "tool-result"); - } - get staticToolResults() { - return this.toolResults.filter((toolResult) => toolResult.dynamic !== true); - } - get dynamicToolResults() { - return this.toolResults.filter((toolResult) => toolResult.dynamic === true); - } -}; -function stepCountIs(stepCount) { - return ({ steps }) => steps.length === stepCount; -} -async function isStopConditionMet({ - stopConditions, - steps -}) { - return (await Promise.all(stopConditions.map((condition) => condition({ steps })))).some((result) => result); -} -async function toResponseMessages({ - content: inputContent, - tools -}) { - const responseMessages = []; - const content = []; - for (const part of inputContent) { - if (part.type === "source") { - continue; - } - if ((part.type === "tool-result" || part.type === "tool-error") && !part.providerExecuted) { - continue; - } - if (part.type === "text" && part.text.length === 0) { - continue; - } - switch (part.type) { - case "text": - content.push({ - type: "text", - text: part.text, - providerOptions: part.providerMetadata - }); - break; - case "reasoning": - content.push({ - type: "reasoning", - text: part.text, - providerOptions: part.providerMetadata - }); - break; - case "file": - content.push({ - type: "file", - data: part.file.base64, - mediaType: part.file.mediaType, - providerOptions: part.providerMetadata - }); - break; - case "tool-call": - content.push({ - type: "tool-call", - toolCallId: part.toolCallId, - toolName: part.toolName, - input: part.input, - providerExecuted: part.providerExecuted, - providerOptions: part.providerMetadata - }); - break; - case "tool-result": { - const output = await createToolModelOutput({ - toolCallId: part.toolCallId, - input: part.input, - tool: tools == null ? undefined : tools[part.toolName], - output: part.output, - errorMode: "none" - }); - content.push({ - type: "tool-result", - toolCallId: part.toolCallId, - toolName: part.toolName, - output, - providerOptions: part.providerMetadata - }); - break; - } - case "tool-error": { - const output = await createToolModelOutput({ - toolCallId: part.toolCallId, - input: part.input, - tool: tools == null ? undefined : tools[part.toolName], - output: part.error, - errorMode: "json" - }); - content.push({ - type: "tool-result", - toolCallId: part.toolCallId, - toolName: part.toolName, - output, - providerOptions: part.providerMetadata - }); - break; - } - case "tool-approval-request": - content.push({ - type: "tool-approval-request", - approvalId: part.approvalId, - toolCallId: part.toolCall.toolCallId - }); - break; - } - } - if (content.length > 0) { - responseMessages.push({ - role: "assistant", - content - }); - } - const toolResultContent = []; - for (const part of inputContent) { - if (!(part.type === "tool-result" || part.type === "tool-error") || part.providerExecuted) { - continue; - } - const output = await createToolModelOutput({ - toolCallId: part.toolCallId, - input: part.input, - tool: tools == null ? undefined : tools[part.toolName], - output: part.type === "tool-result" ? part.output : part.error, - errorMode: part.type === "tool-error" ? "text" : "none" - }); - toolResultContent.push({ - type: "tool-result", - toolCallId: part.toolCallId, - toolName: part.toolName, - output, - ...part.providerMetadata != null ? { providerOptions: part.providerMetadata } : {} - }); - } - if (toolResultContent.length > 0) { - responseMessages.push({ - role: "tool", - content: toolResultContent - }); - } - return responseMessages; -} -function mergeAbortSignals(...signals) { - const validSignals = signals.filter((signal) => signal != null); - if (validSignals.length === 0) { - return; - } - if (validSignals.length === 1) { - return validSignals[0]; - } - const controller = new AbortController; - for (const signal of validSignals) { - if (signal.aborted) { - controller.abort(signal.reason); - return controller.signal; - } - signal.addEventListener("abort", () => { - controller.abort(signal.reason); - }, { once: true }); - } - return controller.signal; -} -var originalGenerateId = createIdGenerator({ - prefix: "aitxt", - size: 24 -}); -function prepareHeaders(headers, defaultHeaders) { - const responseHeaders = new Headers(headers != null ? headers : {}); - for (const [key, value] of Object.entries(defaultHeaders)) { - if (!responseHeaders.has(key)) { - responseHeaders.set(key, value); - } - } - return responseHeaders; -} -function createTextStreamResponse({ - status, - statusText, - headers, - textStream -}) { - return new Response(textStream.pipeThrough(new TextEncoderStream), { - status: status != null ? status : 200, - statusText, - headers: prepareHeaders(headers, { - "content-type": "text/plain; charset=utf-8" - }) - }); -} -function writeToServerResponse({ - response, - status, - statusText, - headers, - stream -}) { - const statusCode = status != null ? status : 200; - if (statusText !== undefined) { - response.writeHead(statusCode, statusText, headers); - } else { - response.writeHead(statusCode, headers); - } - const reader = stream.getReader(); - const read = async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) - break; - const canContinue = response.write(value); - if (!canContinue) { - await new Promise((resolve3) => { - response.once("drain", resolve3); - }); - } - } - } catch (error48) { - throw error48; - } finally { - response.end(); - } - }; - read(); -} -function pipeTextStreamToResponse({ - response, - status, - statusText, - headers, - textStream -}) { - writeToServerResponse({ - response, - status, - statusText, - headers: Object.fromEntries(prepareHeaders(headers, { - "content-type": "text/plain; charset=utf-8" - }).entries()), - stream: textStream.pipeThrough(new TextEncoderStream) - }); -} -var JsonToSseTransformStream = class extends TransformStream { - constructor() { - super({ - transform(part, controller) { - controller.enqueue(`data: ${JSON.stringify(part)} - -`); - }, - flush(controller) { - controller.enqueue(`data: [DONE] - -`); - } - }); - } -}; -var UI_MESSAGE_STREAM_HEADERS = { - "content-type": "text/event-stream", - "cache-control": "no-cache", - connection: "keep-alive", - "x-vercel-ai-ui-message-stream": "v1", - "x-accel-buffering": "no" -}; -function createUIMessageStreamResponse({ - status, - statusText, - headers, - stream, - consumeSseStream -}) { - let sseStream = stream.pipeThrough(new JsonToSseTransformStream); - if (consumeSseStream) { - const [stream1, stream2] = sseStream.tee(); - sseStream = stream1; - consumeSseStream({ stream: stream2 }); - } - return new Response(sseStream.pipeThrough(new TextEncoderStream), { - status, - statusText, - headers: prepareHeaders(headers, UI_MESSAGE_STREAM_HEADERS) - }); -} -function getResponseUIMessageId({ - originalMessages, - responseMessageId -}) { - if (originalMessages == null) { - return; - } - const lastMessage = originalMessages[originalMessages.length - 1]; - return (lastMessage == null ? undefined : lastMessage.role) === "assistant" ? lastMessage.id : typeof responseMessageId === "function" ? responseMessageId() : responseMessageId; -} -var uiMessageChunkSchema = lazySchema(() => zodSchema(exports_external.union([ - exports_external.strictObject({ - type: exports_external.literal("text-start"), - id: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("text-delta"), - id: exports_external.string(), - delta: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("text-end"), - id: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("error"), - errorText: exports_external.string() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-input-start"), - toolCallId: exports_external.string(), - toolName: exports_external.string(), - providerExecuted: exports_external.boolean().optional(), - providerMetadata: providerMetadataSchema.optional(), - dynamic: exports_external.boolean().optional(), - title: exports_external.string().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-input-delta"), - toolCallId: exports_external.string(), - inputTextDelta: exports_external.string() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-input-available"), - toolCallId: exports_external.string(), - toolName: exports_external.string(), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - providerMetadata: providerMetadataSchema.optional(), - dynamic: exports_external.boolean().optional(), - title: exports_external.string().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-input-error"), - toolCallId: exports_external.string(), - toolName: exports_external.string(), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - providerMetadata: providerMetadataSchema.optional(), - dynamic: exports_external.boolean().optional(), - errorText: exports_external.string(), - title: exports_external.string().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-approval-request"), - approvalId: exports_external.string(), - toolCallId: exports_external.string() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-output-available"), - toolCallId: exports_external.string(), - output: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - dynamic: exports_external.boolean().optional(), - preliminary: exports_external.boolean().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-output-error"), - toolCallId: exports_external.string(), - errorText: exports_external.string(), - providerExecuted: exports_external.boolean().optional(), - dynamic: exports_external.boolean().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("tool-output-denied"), - toolCallId: exports_external.string() - }), - exports_external.strictObject({ - type: exports_external.literal("reasoning-start"), - id: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("reasoning-delta"), - id: exports_external.string(), - delta: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("reasoning-end"), - id: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("source-url"), - sourceId: exports_external.string(), - url: exports_external.string(), - title: exports_external.string().optional(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("source-document"), - sourceId: exports_external.string(), - mediaType: exports_external.string(), - title: exports_external.string(), - filename: exports_external.string().optional(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.literal("file"), - url: exports_external.string(), - mediaType: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.strictObject({ - type: exports_external.custom((value) => typeof value === "string" && value.startsWith("data-"), { message: 'Type must start with "data-"' }), - id: exports_external.string().optional(), - data: exports_external.unknown(), - transient: exports_external.boolean().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("start-step") - }), - exports_external.strictObject({ - type: exports_external.literal("finish-step") - }), - exports_external.strictObject({ - type: exports_external.literal("start"), - messageId: exports_external.string().optional(), - messageMetadata: exports_external.unknown().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("finish"), - finishReason: exports_external.enum([ - "stop", - "length", - "content-filter", - "tool-calls", - "error", - "other" - ]).optional(), - messageMetadata: exports_external.unknown().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("abort"), - reason: exports_external.string().optional() - }), - exports_external.strictObject({ - type: exports_external.literal("message-metadata"), - messageMetadata: exports_external.unknown() - }) -]))); -function isDataUIMessageChunk(chunk) { - return chunk.type.startsWith("data-"); -} -function isDataUIPart(part) { - return part.type.startsWith("data-"); -} -function isTextUIPart(part) { - return part.type === "text"; -} -function isFileUIPart(part) { - return part.type === "file"; -} -function isReasoningUIPart(part) { - return part.type === "reasoning"; -} -function isStaticToolUIPart(part) { - return part.type.startsWith("tool-"); -} -function isDynamicToolUIPart(part) { - return part.type === "dynamic-tool"; -} -function isToolUIPart(part) { - return isStaticToolUIPart(part) || isDynamicToolUIPart(part); -} -function getStaticToolName(part) { - return part.type.split("-").slice(1).join("-"); -} -function getToolName(part) { - return isDynamicToolUIPart(part) ? part.toolName : getStaticToolName(part); -} -function createStreamingUIMessageState({ - lastMessage, - messageId -}) { - return { - message: (lastMessage == null ? undefined : lastMessage.role) === "assistant" ? lastMessage : { - id: messageId, - metadata: undefined, - role: "assistant", - parts: [] - }, - activeTextParts: {}, - activeReasoningParts: {}, - partialToolCalls: {} - }; -} -function processUIMessageStream({ - stream, - messageMetadataSchema, - dataPartSchemas, - runUpdateMessageJob, - onError, - onToolCall, - onData -}) { - return stream.pipeThrough(new TransformStream({ - async transform(chunk, controller) { - await runUpdateMessageJob(async ({ state, write }) => { - var _a21, _b16, _c, _d; - function getToolInvocation(toolCallId) { - const toolInvocations = state.message.parts.filter(isToolUIPart); - const toolInvocation = toolInvocations.find((invocation) => invocation.toolCallId === toolCallId); - if (toolInvocation == null) { - throw new UIMessageStreamError({ - chunkType: "tool-invocation", - chunkId: toolCallId, - message: `No tool invocation found for tool call ID "${toolCallId}".` - }); - } - return toolInvocation; - } - function updateToolPart(options) { - var _a222; - const part = state.message.parts.find((part2) => isStaticToolUIPart(part2) && part2.toolCallId === options.toolCallId); - const anyOptions = options; - const anyPart = part; - if (part != null) { - part.state = options.state; - anyPart.input = anyOptions.input; - anyPart.output = anyOptions.output; - anyPart.errorText = anyOptions.errorText; - anyPart.rawInput = anyOptions.rawInput; - anyPart.preliminary = anyOptions.preliminary; - if (options.title !== undefined) { - anyPart.title = options.title; - } - anyPart.providerExecuted = (_a222 = anyOptions.providerExecuted) != null ? _a222 : part.providerExecuted; - if (anyOptions.providerMetadata != null) { - part.callProviderMetadata = anyOptions.providerMetadata; - } - } else { - state.message.parts.push({ - type: `tool-${options.toolName}`, - toolCallId: options.toolCallId, - state: options.state, - title: options.title, - input: anyOptions.input, - output: anyOptions.output, - rawInput: anyOptions.rawInput, - errorText: anyOptions.errorText, - providerExecuted: anyOptions.providerExecuted, - preliminary: anyOptions.preliminary, - ...anyOptions.providerMetadata != null ? { callProviderMetadata: anyOptions.providerMetadata } : {} - }); - } - } - function updateDynamicToolPart(options) { - var _a222, _b23; - const part = state.message.parts.find((part2) => part2.type === "dynamic-tool" && part2.toolCallId === options.toolCallId); - const anyOptions = options; - const anyPart = part; - if (part != null) { - part.state = options.state; - anyPart.toolName = options.toolName; - anyPart.input = anyOptions.input; - anyPart.output = anyOptions.output; - anyPart.errorText = anyOptions.errorText; - anyPart.rawInput = (_a222 = anyOptions.rawInput) != null ? _a222 : anyPart.rawInput; - anyPart.preliminary = anyOptions.preliminary; - if (options.title !== undefined) { - anyPart.title = options.title; - } - anyPart.providerExecuted = (_b23 = anyOptions.providerExecuted) != null ? _b23 : part.providerExecuted; - if (anyOptions.providerMetadata != null) { - part.callProviderMetadata = anyOptions.providerMetadata; - } - } else { - state.message.parts.push({ - type: "dynamic-tool", - toolName: options.toolName, - toolCallId: options.toolCallId, - state: options.state, - input: anyOptions.input, - output: anyOptions.output, - errorText: anyOptions.errorText, - preliminary: anyOptions.preliminary, - providerExecuted: anyOptions.providerExecuted, - title: options.title, - ...anyOptions.providerMetadata != null ? { callProviderMetadata: anyOptions.providerMetadata } : {} - }); - } - } - async function updateMessageMetadata(metadata) { - if (metadata != null) { - const mergedMetadata = state.message.metadata != null ? mergeObjects(state.message.metadata, metadata) : metadata; - if (messageMetadataSchema != null) { - await validateTypes({ - value: mergedMetadata, - schema: messageMetadataSchema, - context: { - field: "message.metadata", - entityId: state.message.id - } - }); - } - state.message.metadata = mergedMetadata; - } - } - switch (chunk.type) { - case "text-start": { - const textPart = { - type: "text", - text: "", - providerMetadata: chunk.providerMetadata, - state: "streaming" - }; - state.activeTextParts[chunk.id] = textPart; - state.message.parts.push(textPart); - write(); - break; - } - case "text-delta": { - const textPart = state.activeTextParts[chunk.id]; - if (textPart == null) { - throw new UIMessageStreamError({ - chunkType: "text-delta", - chunkId: chunk.id, - message: `Received text-delta for missing text part with ID "${chunk.id}". Ensure a "text-start" chunk is sent before any "text-delta" chunks.` - }); - } - textPart.text += chunk.delta; - textPart.providerMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textPart.providerMetadata; - write(); - break; - } - case "text-end": { - const textPart = state.activeTextParts[chunk.id]; - if (textPart == null) { - throw new UIMessageStreamError({ - chunkType: "text-end", - chunkId: chunk.id, - message: `Received text-end for missing text part with ID "${chunk.id}". Ensure a "text-start" chunk is sent before any "text-end" chunks.` - }); - } - textPart.state = "done"; - textPart.providerMetadata = (_b16 = chunk.providerMetadata) != null ? _b16 : textPart.providerMetadata; - delete state.activeTextParts[chunk.id]; - write(); - break; - } - case "reasoning-start": { - const reasoningPart = { - type: "reasoning", - text: "", - providerMetadata: chunk.providerMetadata, - state: "streaming" - }; - state.activeReasoningParts[chunk.id] = reasoningPart; - state.message.parts.push(reasoningPart); - write(); - break; - } - case "reasoning-delta": { - const reasoningPart = state.activeReasoningParts[chunk.id]; - if (reasoningPart == null) { - throw new UIMessageStreamError({ - chunkType: "reasoning-delta", - chunkId: chunk.id, - message: `Received reasoning-delta for missing reasoning part with ID "${chunk.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.` - }); - } - reasoningPart.text += chunk.delta; - reasoningPart.providerMetadata = (_c = chunk.providerMetadata) != null ? _c : reasoningPart.providerMetadata; - write(); - break; - } - case "reasoning-end": { - const reasoningPart = state.activeReasoningParts[chunk.id]; - if (reasoningPart == null) { - throw new UIMessageStreamError({ - chunkType: "reasoning-end", - chunkId: chunk.id, - message: `Received reasoning-end for missing reasoning part with ID "${chunk.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-end" chunks.` - }); - } - reasoningPart.providerMetadata = (_d = chunk.providerMetadata) != null ? _d : reasoningPart.providerMetadata; - reasoningPart.state = "done"; - delete state.activeReasoningParts[chunk.id]; - write(); - break; - } - case "file": { - state.message.parts.push({ - type: "file", - mediaType: chunk.mediaType, - url: chunk.url - }); - write(); - break; - } - case "source-url": { - state.message.parts.push({ - type: "source-url", - sourceId: chunk.sourceId, - url: chunk.url, - title: chunk.title, - providerMetadata: chunk.providerMetadata - }); - write(); - break; - } - case "source-document": { - state.message.parts.push({ - type: "source-document", - sourceId: chunk.sourceId, - mediaType: chunk.mediaType, - title: chunk.title, - filename: chunk.filename, - providerMetadata: chunk.providerMetadata - }); - write(); - break; - } - case "tool-input-start": { - const toolInvocations = state.message.parts.filter(isStaticToolUIPart); - state.partialToolCalls[chunk.toolCallId] = { - text: "", - toolName: chunk.toolName, - index: toolInvocations.length, - dynamic: chunk.dynamic, - title: chunk.title - }; - if (chunk.dynamic) { - updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - state: "input-streaming", - input: undefined, - providerExecuted: chunk.providerExecuted, - title: chunk.title, - providerMetadata: chunk.providerMetadata - }); - } else { - updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - state: "input-streaming", - input: undefined, - providerExecuted: chunk.providerExecuted, - title: chunk.title, - providerMetadata: chunk.providerMetadata - }); - } - write(); - break; - } - case "tool-input-delta": { - const partialToolCall = state.partialToolCalls[chunk.toolCallId]; - if (partialToolCall == null) { - throw new UIMessageStreamError({ - chunkType: "tool-input-delta", - chunkId: chunk.toolCallId, - message: `Received tool-input-delta for missing tool call with ID "${chunk.toolCallId}". Ensure a "tool-input-start" chunk is sent before any "tool-input-delta" chunks.` - }); - } - partialToolCall.text += chunk.inputTextDelta; - const { value: partialArgs } = await parsePartialJson(partialToolCall.text); - if (partialToolCall.dynamic) { - updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: partialToolCall.toolName, - state: "input-streaming", - input: partialArgs, - title: partialToolCall.title - }); - } else { - updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: partialToolCall.toolName, - state: "input-streaming", - input: partialArgs, - title: partialToolCall.title - }); - } - write(); - break; - } - case "tool-input-available": { - if (chunk.dynamic) { - updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - state: "input-available", - input: chunk.input, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata, - title: chunk.title - }); - } else { - updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - state: "input-available", - input: chunk.input, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata, - title: chunk.title - }); - } - write(); - if (onToolCall && !chunk.providerExecuted) { - await onToolCall({ - toolCall: chunk - }); - } - break; - } - case "tool-input-error": { - if (chunk.dynamic) { - updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - state: "output-error", - input: chunk.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata - }); - } else { - updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - state: "output-error", - input: undefined, - rawInput: chunk.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - providerMetadata: chunk.providerMetadata - }); - } - write(); - break; - } - case "tool-approval-request": { - const toolInvocation = getToolInvocation(chunk.toolCallId); - toolInvocation.state = "approval-requested"; - toolInvocation.approval = { id: chunk.approvalId }; - write(); - break; - } - case "tool-output-denied": { - const toolInvocation = getToolInvocation(chunk.toolCallId); - toolInvocation.state = "output-denied"; - write(); - break; - } - case "tool-output-available": { - const toolInvocation = getToolInvocation(chunk.toolCallId); - if (toolInvocation.type === "dynamic-tool") { - updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: toolInvocation.toolName, - state: "output-available", - input: toolInvocation.input, - output: chunk.output, - preliminary: chunk.preliminary, - providerExecuted: chunk.providerExecuted, - title: toolInvocation.title - }); - } else { - updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: getStaticToolName(toolInvocation), - state: "output-available", - input: toolInvocation.input, - output: chunk.output, - providerExecuted: chunk.providerExecuted, - preliminary: chunk.preliminary, - title: toolInvocation.title - }); - } - write(); - break; - } - case "tool-output-error": { - const toolInvocation = getToolInvocation(chunk.toolCallId); - if (toolInvocation.type === "dynamic-tool") { - updateDynamicToolPart({ - toolCallId: chunk.toolCallId, - toolName: toolInvocation.toolName, - state: "output-error", - input: toolInvocation.input, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - title: toolInvocation.title - }); - } else { - updateToolPart({ - toolCallId: chunk.toolCallId, - toolName: getStaticToolName(toolInvocation), - state: "output-error", - input: toolInvocation.input, - rawInput: toolInvocation.rawInput, - errorText: chunk.errorText, - providerExecuted: chunk.providerExecuted, - title: toolInvocation.title - }); - } - write(); - break; - } - case "start-step": { - state.message.parts.push({ type: "step-start" }); - break; - } - case "finish-step": { - state.activeTextParts = {}; - state.activeReasoningParts = {}; - break; - } - case "start": { - if (chunk.messageId != null) { - state.message.id = chunk.messageId; - } - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageId != null || chunk.messageMetadata != null) { - write(); - } - break; - } - case "finish": { - if (chunk.finishReason != null) { - state.finishReason = chunk.finishReason; - } - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageMetadata != null) { - write(); - } - break; - } - case "message-metadata": { - await updateMessageMetadata(chunk.messageMetadata); - if (chunk.messageMetadata != null) { - write(); - } - break; - } - case "error": { - onError == null || onError(new Error(chunk.errorText)); - break; - } - default: { - if (isDataUIMessageChunk(chunk)) { - if ((dataPartSchemas == null ? undefined : dataPartSchemas[chunk.type]) != null) { - const partIdx = state.message.parts.findIndex((p) => ("id" in p) && ("data" in p) && p.id === chunk.id && p.type === chunk.type); - const actualPartIdx = partIdx >= 0 ? partIdx : state.message.parts.length; - await validateTypes({ - value: chunk.data, - schema: dataPartSchemas[chunk.type], - context: { - field: `message.parts[${actualPartIdx}].data`, - entityName: chunk.type, - entityId: chunk.id - } - }); - } - const dataChunk = chunk; - if (dataChunk.transient) { - onData == null || onData(dataChunk); - break; - } - const existingUIPart = dataChunk.id != null ? state.message.parts.find((chunkArg) => dataChunk.type === chunkArg.type && dataChunk.id === chunkArg.id) : undefined; - if (existingUIPart != null) { - existingUIPart.data = dataChunk.data; - } else { - state.message.parts.push(dataChunk); - } - onData == null || onData(dataChunk); - write(); - } - } - } - controller.enqueue(chunk); - }); - } - })); -} -function handleUIMessageStreamFinish({ - messageId, - originalMessages = [], - onStepFinish, - onFinish, - onError, - stream -}) { - let lastMessage = originalMessages == null ? undefined : originalMessages[originalMessages.length - 1]; - if ((lastMessage == null ? undefined : lastMessage.role) !== "assistant") { - lastMessage = undefined; - } else { - messageId = lastMessage.id; - } - let isAborted2 = false; - const idInjectedStream = stream.pipeThrough(new TransformStream({ - transform(chunk, controller) { - if (chunk.type === "start") { - const startChunk = chunk; - if (startChunk.messageId == null && messageId != null) { - startChunk.messageId = messageId; - } - } - if (chunk.type === "abort") { - isAborted2 = true; - } - controller.enqueue(chunk); - } - })); - if (onFinish == null && onStepFinish == null) { - return idInjectedStream; - } - const state = createStreamingUIMessageState({ - lastMessage: lastMessage ? structuredClone(lastMessage) : undefined, - messageId: messageId != null ? messageId : "" - }); - const runUpdateMessageJob = async (job) => { - await job({ state, write: () => {} }); - }; - let finishCalled = false; - const callOnFinish = async () => { - if (finishCalled || !onFinish) { - return; - } - finishCalled = true; - const isContinuation = state.message.id === (lastMessage == null ? undefined : lastMessage.id); - await onFinish({ - isAborted: isAborted2, - isContinuation, - responseMessage: state.message, - messages: [ - ...isContinuation ? originalMessages.slice(0, -1) : originalMessages, - state.message - ], - finishReason: state.finishReason - }); - }; - const callOnStepFinish = async () => { - if (!onStepFinish) { - return; - } - const isContinuation = state.message.id === (lastMessage == null ? undefined : lastMessage.id); - try { - await onStepFinish({ - isContinuation, - responseMessage: structuredClone(state.message), - messages: [ - ...isContinuation ? originalMessages.slice(0, -1) : originalMessages, - structuredClone(state.message) - ] - }); - } catch (error48) { - onError(error48); - } - }; - return processUIMessageStream({ - stream: idInjectedStream, - runUpdateMessageJob, - onError - }).pipeThrough(new TransformStream({ - async transform(chunk, controller) { - if (chunk.type === "finish-step") { - await callOnStepFinish(); - } - controller.enqueue(chunk); - }, - async cancel() { - await callOnFinish(); - }, - async flush() { - await callOnFinish(); - } - })); -} -function pipeUIMessageStreamToResponse({ - response, - status, - statusText, - headers, - stream, - consumeSseStream -}) { - let sseStream = stream.pipeThrough(new JsonToSseTransformStream); - if (consumeSseStream) { - const [stream1, stream2] = sseStream.tee(); - sseStream = stream1; - consumeSseStream({ stream: stream2 }); - } - writeToServerResponse({ - response, - status, - statusText, - headers: Object.fromEntries(prepareHeaders(headers, UI_MESSAGE_STREAM_HEADERS).entries()), - stream: sseStream.pipeThrough(new TextEncoderStream) - }); -} -function createAsyncIterableStream(source) { - const stream = source.pipeThrough(new TransformStream); - stream[Symbol.asyncIterator] = function() { - const reader = this.getReader(); - let finished = false; - async function cleanup(cancelStream) { - var _a21; - if (finished) - return; - finished = true; - try { - if (cancelStream) { - await ((_a21 = reader.cancel) == null ? undefined : _a21.call(reader)); - } - } finally { - try { - reader.releaseLock(); - } catch (e) {} - } - } - return { - async next() { - if (finished) { - return { done: true, value: undefined }; - } - const { done, value } = await reader.read(); - if (done) { - await cleanup(true); - return { done: true, value: undefined }; - } - return { done: false, value }; - }, - async return() { - await cleanup(true); - return { done: true, value: undefined }; - }, - async throw(err) { - await cleanup(true); - throw err; - } - }; - }; - return stream; -} -async function consumeStream({ - stream, - onError -}) { - const reader = stream.getReader(); - try { - while (true) { - const { done } = await reader.read(); - if (done) - break; - } - } catch (error48) { - onError == null || onError(error48); - } finally { - reader.releaseLock(); - } -} -function createResolvablePromise() { - let resolve3; - let reject; - const promise2 = new Promise((res, rej) => { - resolve3 = res; - reject = rej; - }); - return { - promise: promise2, - resolve: resolve3, - reject - }; -} -function createStitchableStream() { - let innerStreamReaders = []; - let controller = null; - let isClosed = false; - let waitForNewStream = createResolvablePromise(); - const terminate = () => { - isClosed = true; - waitForNewStream.resolve(); - innerStreamReaders.forEach((reader) => reader.cancel()); - innerStreamReaders = []; - controller == null || controller.close(); - }; - const processPull = async () => { - if (isClosed && innerStreamReaders.length === 0) { - controller == null || controller.close(); - return; - } - if (innerStreamReaders.length === 0) { - waitForNewStream = createResolvablePromise(); - await waitForNewStream.promise; - return processPull(); - } - try { - const { value, done } = await innerStreamReaders[0].read(); - if (done) { - innerStreamReaders.shift(); - if (innerStreamReaders.length === 0 && isClosed) { - controller == null || controller.close(); - } else { - await processPull(); - } - } else { - controller == null || controller.enqueue(value); - } - } catch (error48) { - controller == null || controller.error(error48); - innerStreamReaders.shift(); - terminate(); - } - }; - return { - stream: new ReadableStream({ - start(controllerParam) { - controller = controllerParam; - }, - pull: processPull, - async cancel() { - for (const reader of innerStreamReaders) { - await reader.cancel(); - } - innerStreamReaders = []; - isClosed = true; - } - }), - addStream: (innerStream) => { - if (isClosed) { - throw new Error("Cannot add inner stream: outer stream is closed"); - } - innerStreamReaders.push(innerStream.getReader()); - waitForNewStream.resolve(); - }, - close: () => { - isClosed = true; - waitForNewStream.resolve(); - if (innerStreamReaders.length === 0) { - controller == null || controller.close(); - } - }, - terminate - }; -} -function runToolsTransformation({ - tools, - generatorStream, - tracer, - telemetry, - system, - messages, - abortSignal, - repairToolCall, - experimental_context, - generateId: generateId2, - stepNumber, - model, - onToolCallStart, - onToolCallFinish -}) { - let toolResultsStreamController = null; - const toolResultsStream = new ReadableStream({ - start(controller) { - toolResultsStreamController = controller; - } - }); - const outstandingToolResults = /* @__PURE__ */ new Set; - const toolInputs = /* @__PURE__ */ new Map; - const toolCallsByToolCallId = /* @__PURE__ */ new Map; - let canClose = false; - let finishChunk = undefined; - function attemptClose() { - if (canClose && outstandingToolResults.size === 0) { - if (finishChunk != null) { - toolResultsStreamController.enqueue(finishChunk); - } - toolResultsStreamController.close(); - } - } - const forwardStream = new TransformStream({ - async transform(chunk, controller) { - const chunkType = chunk.type; - switch (chunkType) { - case "stream-start": - case "text-start": - case "text-delta": - case "text-end": - case "reasoning-start": - case "reasoning-delta": - case "reasoning-end": - case "tool-input-start": - case "tool-input-delta": - case "tool-input-end": - case "source": - case "response-metadata": - case "error": - case "raw": { - controller.enqueue(chunk); - break; - } - case "file": { - controller.enqueue({ - type: "file", - file: new DefaultGeneratedFileWithType({ - data: chunk.data, - mediaType: chunk.mediaType - }) - }); - break; - } - case "finish": { - finishChunk = { - type: "finish", - finishReason: chunk.finishReason.unified, - rawFinishReason: chunk.finishReason.raw, - usage: asLanguageModelUsage(chunk.usage), - providerMetadata: chunk.providerMetadata - }; - break; - } - case "tool-approval-request": { - const toolCall = toolCallsByToolCallId.get(chunk.toolCallId); - if (toolCall == null) { - toolResultsStreamController.enqueue({ - type: "error", - error: new ToolCallNotFoundForApprovalError({ - toolCallId: chunk.toolCallId, - approvalId: chunk.approvalId - }) - }); - break; - } - controller.enqueue({ - type: "tool-approval-request", - approvalId: chunk.approvalId, - toolCall - }); - break; - } - case "tool-call": { - try { - const toolCall = await parseToolCall({ - toolCall: chunk, - tools, - repairToolCall, - system, - messages - }); - toolCallsByToolCallId.set(toolCall.toolCallId, toolCall); - controller.enqueue(toolCall); - if (toolCall.invalid) { - toolResultsStreamController.enqueue({ - type: "tool-error", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: toolCall.input, - error: getErrorMessage2(toolCall.error), - dynamic: true, - title: toolCall.title - }); - break; - } - const tool2 = tools == null ? undefined : tools[toolCall.toolName]; - if (tool2 == null) { - break; - } - if (tool2.onInputAvailable != null) { - await tool2.onInputAvailable({ - input: toolCall.input, - toolCallId: toolCall.toolCallId, - messages, - abortSignal, - experimental_context - }); - } - if (await isApprovalNeeded({ - tool: tool2, - toolCall, - messages, - experimental_context - })) { - toolResultsStreamController.enqueue({ - type: "tool-approval-request", - approvalId: generateId2(), - toolCall - }); - break; - } - toolInputs.set(toolCall.toolCallId, toolCall.input); - if (tool2.execute != null && toolCall.providerExecuted !== true) { - const toolExecutionId = generateId2(); - outstandingToolResults.add(toolExecutionId); - executeToolCall({ - toolCall, - tools, - tracer, - telemetry, - messages, - abortSignal, - experimental_context, - stepNumber, - model, - onToolCallStart, - onToolCallFinish, - onPreliminaryToolResult: (result) => { - toolResultsStreamController.enqueue(result); - } - }).then((result) => { - toolResultsStreamController.enqueue(result); - }).catch((error48) => { - toolResultsStreamController.enqueue({ - type: "error", - error: error48 - }); - }).finally(() => { - outstandingToolResults.delete(toolExecutionId); - attemptClose(); - }); - } - } catch (error48) { - toolResultsStreamController.enqueue({ type: "error", error: error48 }); - } - break; - } - case "tool-result": { - const toolName = chunk.toolName; - if (chunk.isError) { - toolResultsStreamController.enqueue({ - type: "tool-error", - toolCallId: chunk.toolCallId, - toolName, - input: toolInputs.get(chunk.toolCallId), - providerExecuted: true, - error: chunk.result, - dynamic: chunk.dynamic - }); - } else { - controller.enqueue({ - type: "tool-result", - toolCallId: chunk.toolCallId, - toolName, - input: toolInputs.get(chunk.toolCallId), - output: chunk.result, - providerExecuted: true, - dynamic: chunk.dynamic - }); - } - break; - } - default: { - const _exhaustiveCheck = chunkType; - throw new Error(`Unhandled chunk type: ${_exhaustiveCheck}`); - } - } - }, - flush() { - canClose = true; - attemptClose(); - } - }); - return new ReadableStream({ - async start(controller) { - return Promise.all([ - generatorStream.pipeThrough(forwardStream).pipeTo(new WritableStream({ - write(chunk) { - controller.enqueue(chunk); - }, - close() {} - })), - toolResultsStream.pipeTo(new WritableStream({ - write(chunk) { - controller.enqueue(chunk); - }, - close() { - controller.close(); - } - })) - ]); - } - }); -} -var originalGenerateId2 = createIdGenerator({ - prefix: "aitxt", - size: 24 -}); -function streamText({ - model, - tools, - toolChoice, - system, - prompt, - messages, - maxRetries, - abortSignal, - timeout, - headers, - stopWhen = stepCountIs(1), - experimental_output, - output = experimental_output, - experimental_telemetry: telemetry, - prepareStep, - providerOptions, - experimental_activeTools, - activeTools = experimental_activeTools, - experimental_repairToolCall: repairToolCall, - experimental_transform: transform2, - experimental_download: download2, - includeRawChunks = false, - onChunk, - onError = ({ error: error48 }) => { - console.error(error48); - }, - onFinish, - onAbort, - onStepFinish, - experimental_onStart: onStart, - experimental_onStepStart: onStepStart, - experimental_onToolCallStart: onToolCallStart, - experimental_onToolCallFinish: onToolCallFinish, - experimental_context, - experimental_include: include, - _internal: { now: now2 = now, generateId: generateId2 = originalGenerateId2 } = {}, - ...settings -}) { - const totalTimeoutMs = getTotalTimeoutMs(timeout); - const stepTimeoutMs = getStepTimeoutMs(timeout); - const chunkTimeoutMs = getChunkTimeoutMs(timeout); - const stepAbortController = stepTimeoutMs != null ? new AbortController : undefined; - const chunkAbortController = chunkTimeoutMs != null ? new AbortController : undefined; - return new DefaultStreamTextResult({ - model: resolveLanguageModel(model), - telemetry, - headers, - settings, - maxRetries, - abortSignal: mergeAbortSignals(abortSignal, totalTimeoutMs != null ? AbortSignal.timeout(totalTimeoutMs) : undefined, stepAbortController == null ? undefined : stepAbortController.signal, chunkAbortController == null ? undefined : chunkAbortController.signal), - stepTimeoutMs, - stepAbortController, - chunkTimeoutMs, - chunkAbortController, - system, - prompt, - messages, - tools, - toolChoice, - transforms: asArray(transform2), - activeTools, - repairToolCall, - stopConditions: asArray(stopWhen), - output, - providerOptions, - prepareStep, - includeRawChunks, - timeout, - stopWhen, - originalAbortSignal: abortSignal, - onChunk, - onError, - onFinish, - onAbort, - onStepFinish, - onStart, - onStepStart, - onToolCallStart, - onToolCallFinish, - now: now2, - generateId: generateId2, - experimental_context, - download: download2, - include - }); -} -function createOutputTransformStream(output) { - let firstTextChunkId = undefined; - let text2 = ""; - let textChunk = ""; - let textProviderMetadata = undefined; - let lastPublishedJson = ""; - function publishTextChunk({ - controller, - partialOutput = undefined - }) { - controller.enqueue({ - part: { - type: "text-delta", - id: firstTextChunkId, - text: textChunk, - providerMetadata: textProviderMetadata - }, - partialOutput - }); - textChunk = ""; - } - return new TransformStream({ - async transform(chunk, controller) { - var _a21; - if (chunk.type === "finish-step" && textChunk.length > 0) { - publishTextChunk({ controller }); - } - if (chunk.type !== "text-delta" && chunk.type !== "text-start" && chunk.type !== "text-end") { - controller.enqueue({ part: chunk, partialOutput: undefined }); - return; - } - if (firstTextChunkId == null) { - firstTextChunkId = chunk.id; - } else if (chunk.id !== firstTextChunkId) { - controller.enqueue({ part: chunk, partialOutput: undefined }); - return; - } - if (chunk.type === "text-start") { - controller.enqueue({ part: chunk, partialOutput: undefined }); - return; - } - if (chunk.type === "text-end") { - if (textChunk.length > 0) { - publishTextChunk({ controller }); - } - controller.enqueue({ part: chunk, partialOutput: undefined }); - return; - } - text2 += chunk.text; - textChunk += chunk.text; - textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata; - const result = await output.parsePartialOutput({ text: text2 }); - if (result !== undefined) { - const currentJson = JSON.stringify(result.partial); - if (currentJson !== lastPublishedJson) { - publishTextChunk({ controller, partialOutput: result.partial }); - lastPublishedJson = currentJson; - } - } - } - }); -} -var DefaultStreamTextResult = class { - constructor({ - model, - telemetry, - headers, - settings, - maxRetries: maxRetriesArg, - abortSignal, - stepTimeoutMs, - stepAbortController, - chunkTimeoutMs, - chunkAbortController, - system, - prompt, - messages, - tools, - toolChoice, - transforms, - activeTools, - repairToolCall, - stopConditions, - output, - providerOptions, - prepareStep, - includeRawChunks, - now: now2, - generateId: generateId2, - timeout, - stopWhen, - originalAbortSignal, - onChunk, - onError, - onFinish, - onAbort, - onStepFinish, - onStart, - onStepStart, - onToolCallStart, - onToolCallFinish, - experimental_context, - download: download2, - include - }) { - this._totalUsage = new DelayedPromise; - this._finishReason = new DelayedPromise; - this._rawFinishReason = new DelayedPromise; - this._steps = new DelayedPromise; - this.outputSpecification = output; - this.includeRawChunks = includeRawChunks; - this.tools = tools; - let stepFinish; - let recordedContent = []; - const recordedResponseMessages = []; - let recordedFinishReason = undefined; - let recordedRawFinishReason = undefined; - let recordedTotalUsage = undefined; - let recordedRequest = {}; - let recordedWarnings = []; - const recordedSteps = []; - const pendingDeferredToolCalls = /* @__PURE__ */ new Map; - let rootSpan; - let activeTextContent = {}; - let activeReasoningContent = {}; - const eventProcessor = new TransformStream({ - async transform(chunk, controller) { - var _a21, _b16, _c, _d; - controller.enqueue(chunk); - const { part } = chunk; - if (part.type === "text-delta" || part.type === "reasoning-delta" || part.type === "source" || part.type === "tool-call" || part.type === "tool-result" || part.type === "tool-input-start" || part.type === "tool-input-delta" || part.type === "raw") { - await (onChunk == null ? undefined : onChunk({ chunk: part })); - } - if (part.type === "error") { - await onError({ error: wrapGatewayError(part.error) }); - } - if (part.type === "text-start") { - activeTextContent[part.id] = { - type: "text", - text: "", - providerMetadata: part.providerMetadata - }; - recordedContent.push(activeTextContent[part.id]); - } - if (part.type === "text-delta") { - const activeText = activeTextContent[part.id]; - if (activeText == null) { - controller.enqueue({ - part: { - type: "error", - error: `text part ${part.id} not found` - }, - partialOutput: undefined - }); - return; - } - activeText.text += part.text; - activeText.providerMetadata = (_a21 = part.providerMetadata) != null ? _a21 : activeText.providerMetadata; - } - if (part.type === "text-end") { - const activeText = activeTextContent[part.id]; - if (activeText == null) { - controller.enqueue({ - part: { - type: "error", - error: `text part ${part.id} not found` - }, - partialOutput: undefined - }); - return; - } - activeText.providerMetadata = (_b16 = part.providerMetadata) != null ? _b16 : activeText.providerMetadata; - delete activeTextContent[part.id]; - } - if (part.type === "reasoning-start") { - activeReasoningContent[part.id] = { - type: "reasoning", - text: "", - providerMetadata: part.providerMetadata - }; - recordedContent.push(activeReasoningContent[part.id]); - } - if (part.type === "reasoning-delta") { - const activeReasoning = activeReasoningContent[part.id]; - if (activeReasoning == null) { - controller.enqueue({ - part: { - type: "error", - error: `reasoning part ${part.id} not found` - }, - partialOutput: undefined - }); - return; - } - activeReasoning.text += part.text; - activeReasoning.providerMetadata = (_c = part.providerMetadata) != null ? _c : activeReasoning.providerMetadata; - } - if (part.type === "reasoning-end") { - const activeReasoning = activeReasoningContent[part.id]; - if (activeReasoning == null) { - controller.enqueue({ - part: { - type: "error", - error: `reasoning part ${part.id} not found` - }, - partialOutput: undefined - }); - return; - } - activeReasoning.providerMetadata = (_d = part.providerMetadata) != null ? _d : activeReasoning.providerMetadata; - delete activeReasoningContent[part.id]; - } - if (part.type === "file") { - recordedContent.push({ type: "file", file: part.file }); - } - if (part.type === "source") { - recordedContent.push(part); - } - if (part.type === "tool-call") { - recordedContent.push(part); - } - if (part.type === "tool-result" && !part.preliminary) { - recordedContent.push(part); - } - if (part.type === "tool-approval-request") { - recordedContent.push(part); - } - if (part.type === "tool-error") { - recordedContent.push(part); - } - if (part.type === "start-step") { - recordedContent = []; - activeReasoningContent = {}; - activeTextContent = {}; - recordedRequest = part.request; - recordedWarnings = part.warnings; - } - if (part.type === "finish-step") { - const stepMessages = await toResponseMessages({ - content: recordedContent, - tools - }); - const currentStepResult = new DefaultStepResult({ - stepNumber: recordedSteps.length, - model: modelInfo, - ...callbackTelemetryProps, - experimental_context, - content: recordedContent, - finishReason: part.finishReason, - rawFinishReason: part.rawFinishReason, - usage: part.usage, - warnings: recordedWarnings, - request: recordedRequest, - response: { - ...part.response, - messages: [...recordedResponseMessages, ...stepMessages] - }, - providerMetadata: part.providerMetadata - }); - await (onStepFinish == null ? undefined : onStepFinish(currentStepResult)); - logWarnings({ - warnings: recordedWarnings, - provider: modelInfo.provider, - model: modelInfo.modelId - }); - recordedSteps.push(currentStepResult); - recordedResponseMessages.push(...stepMessages); - stepFinish.resolve(); - } - if (part.type === "finish") { - recordedTotalUsage = part.totalUsage; - recordedFinishReason = part.finishReason; - recordedRawFinishReason = part.rawFinishReason; - } - }, - async flush(controller) { - try { - if (recordedSteps.length === 0) { - const error48 = (abortSignal == null ? undefined : abortSignal.aborted) ? abortSignal.reason : new NoOutputGeneratedError({ - message: "No output generated. Check the stream for errors." - }); - self._finishReason.reject(error48); - self._rawFinishReason.reject(error48); - self._totalUsage.reject(error48); - self._steps.reject(error48); - return; - } - const finishReason = recordedFinishReason != null ? recordedFinishReason : "other"; - const totalUsage = recordedTotalUsage != null ? recordedTotalUsage : createNullLanguageModelUsage(); - self._finishReason.resolve(finishReason); - self._rawFinishReason.resolve(recordedRawFinishReason); - self._totalUsage.resolve(totalUsage); - self._steps.resolve(recordedSteps); - const finalStep = recordedSteps[recordedSteps.length - 1]; - await (onFinish == null ? undefined : onFinish({ - stepNumber: finalStep.stepNumber, - model: finalStep.model, - functionId: finalStep.functionId, - metadata: finalStep.metadata, - experimental_context: finalStep.experimental_context, - finishReason: finalStep.finishReason, - rawFinishReason: finalStep.rawFinishReason, - totalUsage, - usage: finalStep.usage, - content: finalStep.content, - text: finalStep.text, - reasoningText: finalStep.reasoningText, - reasoning: finalStep.reasoning, - files: finalStep.files, - sources: finalStep.sources, - toolCalls: finalStep.toolCalls, - staticToolCalls: finalStep.staticToolCalls, - dynamicToolCalls: finalStep.dynamicToolCalls, - toolResults: finalStep.toolResults, - staticToolResults: finalStep.staticToolResults, - dynamicToolResults: finalStep.dynamicToolResults, - request: finalStep.request, - response: finalStep.response, - warnings: finalStep.warnings, - providerMetadata: finalStep.providerMetadata, - steps: recordedSteps - })); - rootSpan.setAttributes(await selectTelemetryAttributes({ - telemetry, - attributes: { - "ai.response.finishReason": finishReason, - "ai.response.text": { output: () => finalStep.text }, - "ai.response.reasoning": { - output: () => finalStep.reasoningText - }, - "ai.response.toolCalls": { - output: () => { - var _a21; - return ((_a21 = finalStep.toolCalls) == null ? undefined : _a21.length) ? JSON.stringify(finalStep.toolCalls) : undefined; - } - }, - "ai.response.providerMetadata": JSON.stringify(finalStep.providerMetadata), - "ai.usage.inputTokens": totalUsage.inputTokens, - "ai.usage.outputTokens": totalUsage.outputTokens, - "ai.usage.totalTokens": totalUsage.totalTokens, - "ai.usage.reasoningTokens": totalUsage.reasoningTokens, - "ai.usage.cachedInputTokens": totalUsage.cachedInputTokens - } - })); - } catch (error48) { - controller.error(error48); - } finally { - rootSpan.end(); - } - } - }); - const stitchableStream = createStitchableStream(); - this.addStream = stitchableStream.addStream; - this.closeStream = stitchableStream.close; - const reader = stitchableStream.stream.getReader(); - let stream = new ReadableStream({ - async start(controller) { - controller.enqueue({ type: "start" }); - }, - async pull(controller) { - function abort() { - onAbort == null || onAbort({ steps: recordedSteps }); - controller.enqueue({ - type: "abort", - ...(abortSignal == null ? undefined : abortSignal.reason) !== undefined ? { reason: getErrorMessage(abortSignal.reason) } : {} - }); - controller.close(); - } - try { - const { done, value } = await reader.read(); - if (done) { - controller.close(); - return; - } - if (abortSignal == null ? undefined : abortSignal.aborted) { - abort(); - return; - } - controller.enqueue(value); - } catch (error48) { - if (isAbortError(error48) && (abortSignal == null ? undefined : abortSignal.aborted)) { - abort(); - } else { - controller.error(error48); - } - } - }, - cancel(reason) { - return stitchableStream.stream.cancel(reason); - } - }); - for (const transform2 of transforms) { - stream = stream.pipeThrough(transform2({ - tools, - stopStream() { - stitchableStream.terminate(); - } - })); - } - this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor); - const { maxRetries, retry } = prepareRetries({ - maxRetries: maxRetriesArg, - abortSignal - }); - const tracer = getTracer(telemetry); - const callSettings = prepareCallSettings(settings); - const baseTelemetryAttributes = getBaseTelemetryAttributes({ - model, - telemetry, - headers, - settings: { ...callSettings, maxRetries } - }); - const self = this; - const modelInfo = { provider: model.provider, modelId: model.modelId }; - const callbackTelemetryProps = { - functionId: telemetry == null ? undefined : telemetry.functionId, - metadata: telemetry == null ? undefined : telemetry.metadata - }; - recordSpan({ - name: "ai.streamText", - attributes: selectTelemetryAttributes({ - telemetry, - attributes: { - ...assembleOperationName({ operationId: "ai.streamText", telemetry }), - ...baseTelemetryAttributes, - "ai.prompt": { - input: () => JSON.stringify({ system, prompt, messages }) - } - } - }), - tracer, - endWhenDone: false, - fn: async (rootSpanArg) => { - rootSpan = rootSpanArg; - const initialPrompt = await standardizePrompt({ - system, - prompt, - messages - }); - try { - await (onStart == null ? undefined : onStart({ - model: modelInfo, - system, - prompt, - messages, - tools, - toolChoice, - activeTools, - maxOutputTokens: callSettings.maxOutputTokens, - temperature: callSettings.temperature, - topP: callSettings.topP, - topK: callSettings.topK, - presencePenalty: callSettings.presencePenalty, - frequencyPenalty: callSettings.frequencyPenalty, - stopSequences: callSettings.stopSequences, - seed: callSettings.seed, - maxRetries, - timeout, - headers, - providerOptions, - stopWhen, - output, - abortSignal: originalAbortSignal, - include, - ...callbackTelemetryProps, - experimental_context - })); - } catch (_ignored) {} - const initialMessages = initialPrompt.messages; - const initialResponseMessages = []; - const { approvedToolApprovals, deniedToolApprovals } = collectToolApprovals({ messages: initialMessages }); - if (deniedToolApprovals.length > 0 || approvedToolApprovals.length > 0) { - const providerExecutedToolApprovals = [ - ...approvedToolApprovals, - ...deniedToolApprovals - ].filter((toolApproval) => toolApproval.toolCall.providerExecuted); - const localApprovedToolApprovals = approvedToolApprovals.filter((toolApproval) => !toolApproval.toolCall.providerExecuted); - const localDeniedToolApprovals = deniedToolApprovals.filter((toolApproval) => !toolApproval.toolCall.providerExecuted); - const deniedProviderExecutedToolApprovals = deniedToolApprovals.filter((toolApproval) => toolApproval.toolCall.providerExecuted); - let toolExecutionStepStreamController; - const toolExecutionStepStream = new ReadableStream({ - start(controller) { - toolExecutionStepStreamController = controller; - } - }); - self.addStream(toolExecutionStepStream); - try { - for (const toolApproval of [ - ...localDeniedToolApprovals, - ...deniedProviderExecutedToolApprovals - ]) { - toolExecutionStepStreamController == null || toolExecutionStepStreamController.enqueue({ - type: "tool-output-denied", - toolCallId: toolApproval.toolCall.toolCallId, - toolName: toolApproval.toolCall.toolName - }); - } - const toolOutputs = []; - await Promise.all(localApprovedToolApprovals.map(async (toolApproval) => { - const result = await executeToolCall({ - toolCall: toolApproval.toolCall, - tools, - tracer, - telemetry, - messages: initialMessages, - abortSignal, - experimental_context, - stepNumber: recordedSteps.length, - model: modelInfo, - onToolCallStart, - onToolCallFinish, - onPreliminaryToolResult: (result2) => { - toolExecutionStepStreamController == null || toolExecutionStepStreamController.enqueue(result2); - } - }); - if (result != null) { - toolExecutionStepStreamController == null || toolExecutionStepStreamController.enqueue(result); - toolOutputs.push(result); - } - })); - if (providerExecutedToolApprovals.length > 0) { - initialResponseMessages.push({ - role: "tool", - content: providerExecutedToolApprovals.map((toolApproval) => ({ - type: "tool-approval-response", - approvalId: toolApproval.approvalResponse.approvalId, - approved: toolApproval.approvalResponse.approved, - reason: toolApproval.approvalResponse.reason, - providerExecuted: true - })) - }); - } - if (toolOutputs.length > 0 || localDeniedToolApprovals.length > 0) { - const localToolContent = []; - for (const output2 of toolOutputs) { - localToolContent.push({ - type: "tool-result", - toolCallId: output2.toolCallId, - toolName: output2.toolName, - output: await createToolModelOutput({ - toolCallId: output2.toolCallId, - input: output2.input, - tool: tools == null ? undefined : tools[output2.toolName], - output: output2.type === "tool-result" ? output2.output : output2.error, - errorMode: output2.type === "tool-error" ? "json" : "none" - }) - }); - } - for (const toolApproval of localDeniedToolApprovals) { - localToolContent.push({ - type: "tool-result", - toolCallId: toolApproval.toolCall.toolCallId, - toolName: toolApproval.toolCall.toolName, - output: { - type: "execution-denied", - reason: toolApproval.approvalResponse.reason - } - }); - } - initialResponseMessages.push({ - role: "tool", - content: localToolContent - }); - } - } finally { - toolExecutionStepStreamController == null || toolExecutionStepStreamController.close(); - } - } - recordedResponseMessages.push(...initialResponseMessages); - async function streamStep({ - currentStep, - responseMessages, - usage - }) { - var _a21, _b16, _c, _d, _e, _f, _g, _h, _i; - const includeRawChunks2 = self.includeRawChunks; - const stepTimeoutId = stepTimeoutMs != null ? setTimeout(() => stepAbortController.abort(), stepTimeoutMs) : undefined; - let chunkTimeoutId = undefined; - function resetChunkTimeout() { - if (chunkTimeoutMs != null) { - if (chunkTimeoutId != null) { - clearTimeout(chunkTimeoutId); - } - chunkTimeoutId = setTimeout(() => chunkAbortController.abort(), chunkTimeoutMs); - } - } - function clearChunkTimeout() { - if (chunkTimeoutId != null) { - clearTimeout(chunkTimeoutId); - chunkTimeoutId = undefined; - } - } - function clearStepTimeout() { - if (stepTimeoutId != null) { - clearTimeout(stepTimeoutId); - } - } - try { - stepFinish = new DelayedPromise; - const stepInputMessages = [...initialMessages, ...responseMessages]; - const prepareStepResult = await (prepareStep == null ? undefined : prepareStep({ - model, - steps: recordedSteps, - stepNumber: recordedSteps.length, - messages: stepInputMessages, - experimental_context - })); - const stepModel = resolveLanguageModel((_a21 = prepareStepResult == null ? undefined : prepareStepResult.model) != null ? _a21 : model); - const stepModelInfo = { - provider: stepModel.provider, - modelId: stepModel.modelId - }; - const promptMessages = await convertToLanguageModelPrompt({ - prompt: { - system: (_b16 = prepareStepResult == null ? undefined : prepareStepResult.system) != null ? _b16 : initialPrompt.system, - messages: (_c = prepareStepResult == null ? undefined : prepareStepResult.messages) != null ? _c : stepInputMessages - }, - supportedUrls: await stepModel.supportedUrls, - download: download2 - }); - const stepActiveTools = (_d = prepareStepResult == null ? undefined : prepareStepResult.activeTools) != null ? _d : activeTools; - const { toolChoice: stepToolChoice, tools: stepTools } = await prepareToolsAndToolChoice({ - tools, - toolChoice: (_e = prepareStepResult == null ? undefined : prepareStepResult.toolChoice) != null ? _e : toolChoice, - activeTools: stepActiveTools - }); - experimental_context = (_f = prepareStepResult == null ? undefined : prepareStepResult.experimental_context) != null ? _f : experimental_context; - const stepMessages = (_g = prepareStepResult == null ? undefined : prepareStepResult.messages) != null ? _g : stepInputMessages; - const stepSystem = (_h = prepareStepResult == null ? undefined : prepareStepResult.system) != null ? _h : initialPrompt.system; - const stepProviderOptions = mergeObjects(providerOptions, prepareStepResult == null ? undefined : prepareStepResult.providerOptions); - try { - await (onStepStart == null ? undefined : onStepStart({ - stepNumber: recordedSteps.length, - model: stepModelInfo, - system: stepSystem, - messages: stepMessages, - tools, - toolChoice: stepToolChoice, - activeTools: stepActiveTools, - steps: [...recordedSteps], - providerOptions: stepProviderOptions, - timeout, - headers, - stopWhen, - output, - abortSignal: originalAbortSignal, - include, - ...callbackTelemetryProps, - experimental_context - })); - } catch (_ignored) {} - const { - result: { stream: stream2, response, request }, - doStreamSpan, - startTimestampMs - } = await retry(() => recordSpan({ - name: "ai.streamText.doStream", - attributes: selectTelemetryAttributes({ - telemetry, - attributes: { - ...assembleOperationName({ - operationId: "ai.streamText.doStream", - telemetry - }), - ...baseTelemetryAttributes, - "ai.model.provider": stepModel.provider, - "ai.model.id": stepModel.modelId, - "ai.prompt.messages": { - input: () => stringifyForTelemetry(promptMessages) - }, - "ai.prompt.tools": { - input: () => stepTools == null ? undefined : stepTools.map((tool2) => JSON.stringify(tool2)) - }, - "ai.prompt.toolChoice": { - input: () => stepToolChoice != null ? JSON.stringify(stepToolChoice) : undefined - }, - "gen_ai.system": stepModel.provider, - "gen_ai.request.model": stepModel.modelId, - "gen_ai.request.frequency_penalty": callSettings.frequencyPenalty, - "gen_ai.request.max_tokens": callSettings.maxOutputTokens, - "gen_ai.request.presence_penalty": callSettings.presencePenalty, - "gen_ai.request.stop_sequences": callSettings.stopSequences, - "gen_ai.request.temperature": callSettings.temperature, - "gen_ai.request.top_k": callSettings.topK, - "gen_ai.request.top_p": callSettings.topP - } - }), - tracer, - endWhenDone: false, - fn: async (doStreamSpan2) => ({ - startTimestampMs: now2(), - doStreamSpan: doStreamSpan2, - result: await stepModel.doStream({ - ...callSettings, - tools: stepTools, - toolChoice: stepToolChoice, - responseFormat: await (output == null ? undefined : output.responseFormat), - prompt: promptMessages, - providerOptions: stepProviderOptions, - abortSignal, - headers, - includeRawChunks: includeRawChunks2 - }) - }) - })); - const streamWithToolResults = runToolsTransformation({ - tools, - generatorStream: stream2, - tracer, - telemetry, - system, - messages: stepInputMessages, - repairToolCall, - abortSignal, - experimental_context, - generateId: generateId2, - stepNumber: recordedSteps.length, - model: stepModelInfo, - onToolCallStart, - onToolCallFinish - }); - const stepRequest = ((_i = include == null ? undefined : include.requestBody) != null ? _i : true) ? request != null ? request : {} : { ...request, body: undefined }; - const stepToolCalls = []; - const stepToolOutputs = []; - let warnings; - const activeToolCallToolNames = {}; - let stepFinishReason = "other"; - let stepRawFinishReason = undefined; - let stepUsage = createNullLanguageModelUsage(); - let stepProviderMetadata; - let stepFirstChunk = true; - let stepResponse = { - id: generateId2(), - timestamp: /* @__PURE__ */ new Date, - modelId: modelInfo.modelId - }; - let activeText = ""; - self.addStream(streamWithToolResults.pipeThrough(new TransformStream({ - async transform(chunk, controller) { - var _a222, _b23, _c2, _d2, _e2; - resetChunkTimeout(); - if (chunk.type === "stream-start") { - warnings = chunk.warnings; - return; - } - if (stepFirstChunk) { - const msToFirstChunk = now2() - startTimestampMs; - stepFirstChunk = false; - doStreamSpan.addEvent("ai.stream.firstChunk", { - "ai.response.msToFirstChunk": msToFirstChunk - }); - doStreamSpan.setAttributes({ - "ai.response.msToFirstChunk": msToFirstChunk - }); - controller.enqueue({ - type: "start-step", - request: stepRequest, - warnings: warnings != null ? warnings : [] - }); - } - const chunkType = chunk.type; - switch (chunkType) { - case "tool-approval-request": - case "text-start": - case "text-end": { - controller.enqueue(chunk); - break; - } - case "text-delta": { - if (chunk.delta.length > 0) { - controller.enqueue({ - type: "text-delta", - id: chunk.id, - text: chunk.delta, - providerMetadata: chunk.providerMetadata - }); - activeText += chunk.delta; - } - break; - } - case "reasoning-start": - case "reasoning-end": { - controller.enqueue(chunk); - break; - } - case "reasoning-delta": { - controller.enqueue({ - type: "reasoning-delta", - id: chunk.id, - text: chunk.delta, - providerMetadata: chunk.providerMetadata - }); - break; - } - case "tool-call": { - controller.enqueue(chunk); - stepToolCalls.push(chunk); - break; - } - case "tool-result": { - controller.enqueue(chunk); - if (!chunk.preliminary) { - stepToolOutputs.push(chunk); - } - break; - } - case "tool-error": { - controller.enqueue(chunk); - stepToolOutputs.push(chunk); - break; - } - case "response-metadata": { - stepResponse = { - id: (_a222 = chunk.id) != null ? _a222 : stepResponse.id, - timestamp: (_b23 = chunk.timestamp) != null ? _b23 : stepResponse.timestamp, - modelId: (_c2 = chunk.modelId) != null ? _c2 : stepResponse.modelId - }; - break; - } - case "finish": { - stepUsage = chunk.usage; - stepFinishReason = chunk.finishReason; - stepRawFinishReason = chunk.rawFinishReason; - stepProviderMetadata = chunk.providerMetadata; - const msToFinish = now2() - startTimestampMs; - doStreamSpan.addEvent("ai.stream.finish"); - doStreamSpan.setAttributes({ - "ai.response.msToFinish": msToFinish, - "ai.response.avgOutputTokensPerSecond": 1000 * ((_d2 = stepUsage.outputTokens) != null ? _d2 : 0) / msToFinish - }); - break; - } - case "file": { - controller.enqueue(chunk); - break; - } - case "source": { - controller.enqueue(chunk); - break; - } - case "tool-input-start": { - activeToolCallToolNames[chunk.id] = chunk.toolName; - const tool2 = tools == null ? undefined : tools[chunk.toolName]; - if ((tool2 == null ? undefined : tool2.onInputStart) != null) { - await tool2.onInputStart({ - toolCallId: chunk.id, - messages: stepInputMessages, - abortSignal, - experimental_context - }); - } - controller.enqueue({ - ...chunk, - dynamic: (_e2 = chunk.dynamic) != null ? _e2 : (tool2 == null ? undefined : tool2.type) === "dynamic", - title: tool2 == null ? undefined : tool2.title - }); - break; - } - case "tool-input-end": { - delete activeToolCallToolNames[chunk.id]; - controller.enqueue(chunk); - break; - } - case "tool-input-delta": { - const toolName = activeToolCallToolNames[chunk.id]; - const tool2 = tools == null ? undefined : tools[toolName]; - if ((tool2 == null ? undefined : tool2.onInputDelta) != null) { - await tool2.onInputDelta({ - inputTextDelta: chunk.delta, - toolCallId: chunk.id, - messages: stepInputMessages, - abortSignal, - experimental_context - }); - } - controller.enqueue(chunk); - break; - } - case "error": { - controller.enqueue(chunk); - stepFinishReason = "error"; - break; - } - case "raw": { - if (includeRawChunks2) { - controller.enqueue(chunk); - } - break; - } - default: { - const exhaustiveCheck = chunkType; - throw new Error(`Unknown chunk type: ${exhaustiveCheck}`); - } - } - }, - async flush(controller) { - const stepToolCallsJson = stepToolCalls.length > 0 ? JSON.stringify(stepToolCalls) : undefined; - try { - doStreamSpan.setAttributes(await selectTelemetryAttributes({ - telemetry, - attributes: { - "ai.response.finishReason": stepFinishReason, - "ai.response.text": { - output: () => activeText - }, - "ai.response.reasoning": { - output: () => { - const reasoningParts = recordedContent.filter((c) => c.type === "reasoning"); - return reasoningParts.length > 0 ? reasoningParts.map((r) => r.text).join(` -`) : undefined; - } - }, - "ai.response.toolCalls": { - output: () => stepToolCallsJson - }, - "ai.response.id": stepResponse.id, - "ai.response.model": stepResponse.modelId, - "ai.response.timestamp": stepResponse.timestamp.toISOString(), - "ai.response.providerMetadata": JSON.stringify(stepProviderMetadata), - "ai.usage.inputTokens": stepUsage.inputTokens, - "ai.usage.outputTokens": stepUsage.outputTokens, - "ai.usage.totalTokens": stepUsage.totalTokens, - "ai.usage.reasoningTokens": stepUsage.reasoningTokens, - "ai.usage.cachedInputTokens": stepUsage.cachedInputTokens, - "gen_ai.response.finish_reasons": [ - stepFinishReason - ], - "gen_ai.response.id": stepResponse.id, - "gen_ai.response.model": stepResponse.modelId, - "gen_ai.usage.input_tokens": stepUsage.inputTokens, - "gen_ai.usage.output_tokens": stepUsage.outputTokens - } - })); - } catch (error48) {} finally { - doStreamSpan.end(); - } - controller.enqueue({ - type: "finish-step", - finishReason: stepFinishReason, - rawFinishReason: stepRawFinishReason, - usage: stepUsage, - providerMetadata: stepProviderMetadata, - response: { - ...stepResponse, - headers: response == null ? undefined : response.headers - } - }); - const combinedUsage = addLanguageModelUsage(usage, stepUsage); - await stepFinish.promise; - const clientToolCalls = stepToolCalls.filter((toolCall) => toolCall.providerExecuted !== true); - const clientToolOutputs = stepToolOutputs.filter((toolOutput) => toolOutput.providerExecuted !== true); - for (const toolCall of stepToolCalls) { - if (toolCall.providerExecuted !== true) - continue; - const tool2 = tools == null ? undefined : tools[toolCall.toolName]; - if ((tool2 == null ? undefined : tool2.type) === "provider" && tool2.supportsDeferredResults) { - const hasResultInStep = stepToolOutputs.some((output2) => (output2.type === "tool-result" || output2.type === "tool-error") && output2.toolCallId === toolCall.toolCallId); - if (!hasResultInStep) { - pendingDeferredToolCalls.set(toolCall.toolCallId, { - toolName: toolCall.toolName - }); - } - } - } - for (const output2 of stepToolOutputs) { - if (output2.type === "tool-result" || output2.type === "tool-error") { - pendingDeferredToolCalls.delete(output2.toolCallId); - } - } - clearStepTimeout(); - clearChunkTimeout(); - if ((clientToolCalls.length > 0 && clientToolOutputs.length === clientToolCalls.length || pendingDeferredToolCalls.size > 0) && !await isStopConditionMet({ - stopConditions, - steps: recordedSteps - })) { - responseMessages.push(...await toResponseMessages({ - content: recordedSteps[recordedSteps.length - 1].content, - tools - })); - try { - await streamStep({ - currentStep: currentStep + 1, - responseMessages, - usage: combinedUsage - }); - } catch (error48) { - controller.enqueue({ - type: "error", - error: error48 - }); - self.closeStream(); - } - } else { - controller.enqueue({ - type: "finish", - finishReason: stepFinishReason, - rawFinishReason: stepRawFinishReason, - totalUsage: combinedUsage - }); - self.closeStream(); - } - } - }))); - } finally { - clearStepTimeout(); - clearChunkTimeout(); - } - } - await streamStep({ - currentStep: 0, - responseMessages: initialResponseMessages, - usage: createNullLanguageModelUsage() - }); - } - }).catch((error48) => { - self.addStream(new ReadableStream({ - start(controller) { - controller.enqueue({ type: "error", error: error48 }); - controller.close(); - } - })); - self.closeStream(); - }); - } - get steps() { - this.consumeStream(); - return this._steps.promise; - } - get finalStep() { - return this.steps.then((steps) => steps[steps.length - 1]); - } - get content() { - return this.finalStep.then((step) => step.content); - } - get warnings() { - return this.finalStep.then((step) => step.warnings); - } - get providerMetadata() { - return this.finalStep.then((step) => step.providerMetadata); - } - get text() { - return this.finalStep.then((step) => step.text); - } - get reasoningText() { - return this.finalStep.then((step) => step.reasoningText); - } - get reasoning() { - return this.finalStep.then((step) => step.reasoning); - } - get sources() { - return this.finalStep.then((step) => step.sources); - } - get files() { - return this.finalStep.then((step) => step.files); - } - get toolCalls() { - return this.finalStep.then((step) => step.toolCalls); - } - get staticToolCalls() { - return this.finalStep.then((step) => step.staticToolCalls); - } - get dynamicToolCalls() { - return this.finalStep.then((step) => step.dynamicToolCalls); - } - get toolResults() { - return this.finalStep.then((step) => step.toolResults); - } - get staticToolResults() { - return this.finalStep.then((step) => step.staticToolResults); - } - get dynamicToolResults() { - return this.finalStep.then((step) => step.dynamicToolResults); - } - get usage() { - return this.finalStep.then((step) => step.usage); - } - get request() { - return this.finalStep.then((step) => step.request); - } - get response() { - return this.finalStep.then((step) => step.response); - } - get totalUsage() { - this.consumeStream(); - return this._totalUsage.promise; - } - get finishReason() { - this.consumeStream(); - return this._finishReason.promise; - } - get rawFinishReason() { - this.consumeStream(); - return this._rawFinishReason.promise; - } - teeStream() { - const [stream1, stream2] = this.baseStream.tee(); - this.baseStream = stream2; - return stream1; - } - get textStream() { - return createAsyncIterableStream(this.teeStream().pipeThrough(new TransformStream({ - transform({ part }, controller) { - if (part.type === "text-delta") { - controller.enqueue(part.text); - } - } - }))); - } - get fullStream() { - return createAsyncIterableStream(this.teeStream().pipeThrough(new TransformStream({ - transform({ part }, controller) { - controller.enqueue(part); - } - }))); - } - async consumeStream(options) { - var _a21; - try { - await consumeStream({ - stream: this.fullStream, - onError: options == null ? undefined : options.onError - }); - } catch (error48) { - (_a21 = options == null ? undefined : options.onError) == null || _a21.call(options, error48); - } - } - get experimental_partialOutputStream() { - return this.partialOutputStream; - } - get partialOutputStream() { - return createAsyncIterableStream(this.teeStream().pipeThrough(new TransformStream({ - transform({ partialOutput }, controller) { - if (partialOutput != null) { - controller.enqueue(partialOutput); - } - } - }))); - } - get elementStream() { - var _a21, _b16, _c; - const transform2 = (_a21 = this.outputSpecification) == null ? undefined : _a21.createElementStreamTransform(); - if (transform2 == null) { - throw new UnsupportedFunctionalityError({ - functionality: `element streams in ${(_c = (_b16 = this.outputSpecification) == null ? undefined : _b16.name) != null ? _c : "text"} mode` - }); - } - return createAsyncIterableStream(this.teeStream().pipeThrough(transform2)); - } - get output() { - return this.finalStep.then((step) => { - var _a21; - const output = (_a21 = this.outputSpecification) != null ? _a21 : text(); - return output.parseCompleteOutput({ text: step.text }, { - response: step.response, - usage: step.usage, - finishReason: step.finishReason - }); - }); - } - toUIMessageStream({ - originalMessages, - generateMessageId, - onFinish, - messageMetadata, - sendReasoning = true, - sendSources = false, - sendStart = true, - sendFinish = true, - onError = getErrorMessage - } = {}) { - const responseMessageId = generateMessageId != null ? getResponseUIMessageId({ - originalMessages, - responseMessageId: generateMessageId - }) : undefined; - const isDynamic = (part) => { - var _a21; - const tool2 = (_a21 = this.tools) == null ? undefined : _a21[part.toolName]; - if (tool2 == null) { - return part.dynamic; - } - return (tool2 == null ? undefined : tool2.type) === "dynamic" ? true : undefined; - }; - const baseStream = this.fullStream.pipeThrough(new TransformStream({ - transform: async (part, controller) => { - const messageMetadataValue = messageMetadata == null ? undefined : messageMetadata({ part }); - const partType = part.type; - switch (partType) { - case "text-start": { - controller.enqueue({ - type: "text-start", - id: part.id, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - break; - } - case "text-delta": { - controller.enqueue({ - type: "text-delta", - id: part.id, - delta: part.text, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - break; - } - case "text-end": { - controller.enqueue({ - type: "text-end", - id: part.id, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - break; - } - case "reasoning-start": { - controller.enqueue({ - type: "reasoning-start", - id: part.id, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - break; - } - case "reasoning-delta": { - if (sendReasoning) { - controller.enqueue({ - type: "reasoning-delta", - id: part.id, - delta: part.text, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - } - break; - } - case "reasoning-end": { - controller.enqueue({ - type: "reasoning-end", - id: part.id, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - break; - } - case "file": { - controller.enqueue({ - type: "file", - mediaType: part.file.mediaType, - url: `data:${part.file.mediaType};base64,${part.file.base64}` - }); - break; - } - case "source": { - if (sendSources && part.sourceType === "url") { - controller.enqueue({ - type: "source-url", - sourceId: part.id, - url: part.url, - title: part.title, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - } - if (sendSources && part.sourceType === "document") { - controller.enqueue({ - type: "source-document", - sourceId: part.id, - mediaType: part.mediaType, - title: part.title, - filename: part.filename, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {} - }); - } - break; - } - case "tool-input-start": { - const dynamic = isDynamic(part); - controller.enqueue({ - type: "tool-input-start", - toolCallId: part.id, - toolName: part.toolName, - ...part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}, - ...dynamic != null ? { dynamic } : {}, - ...part.title != null ? { title: part.title } : {} - }); - break; - } - case "tool-input-delta": { - controller.enqueue({ - type: "tool-input-delta", - toolCallId: part.id, - inputTextDelta: part.delta - }); - break; - } - case "tool-call": { - const dynamic = isDynamic(part); - if (part.invalid) { - controller.enqueue({ - type: "tool-input-error", - toolCallId: part.toolCallId, - toolName: part.toolName, - input: part.input, - ...part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}, - ...dynamic != null ? { dynamic } : {}, - errorText: onError(part.error), - ...part.title != null ? { title: part.title } : {} - }); - } else { - controller.enqueue({ - type: "tool-input-available", - toolCallId: part.toolCallId, - toolName: part.toolName, - input: part.input, - ...part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}, - ...part.providerMetadata != null ? { providerMetadata: part.providerMetadata } : {}, - ...dynamic != null ? { dynamic } : {}, - ...part.title != null ? { title: part.title } : {} - }); - } - break; - } - case "tool-approval-request": { - controller.enqueue({ - type: "tool-approval-request", - approvalId: part.approvalId, - toolCallId: part.toolCall.toolCallId - }); - break; - } - case "tool-result": { - const dynamic = isDynamic(part); - controller.enqueue({ - type: "tool-output-available", - toolCallId: part.toolCallId, - output: part.output, - ...part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}, - ...part.preliminary != null ? { preliminary: part.preliminary } : {}, - ...dynamic != null ? { dynamic } : {} - }); - break; - } - case "tool-error": { - const dynamic = isDynamic(part); - controller.enqueue({ - type: "tool-output-error", - toolCallId: part.toolCallId, - errorText: onError(part.error), - ...part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}, - ...dynamic != null ? { dynamic } : {} - }); - break; - } - case "tool-output-denied": { - controller.enqueue({ - type: "tool-output-denied", - toolCallId: part.toolCallId - }); - break; - } - case "error": { - controller.enqueue({ - type: "error", - errorText: onError(part.error) - }); - break; - } - case "start-step": { - controller.enqueue({ type: "start-step" }); - break; - } - case "finish-step": { - controller.enqueue({ type: "finish-step" }); - break; - } - case "start": { - if (sendStart) { - controller.enqueue({ - type: "start", - ...messageMetadataValue != null ? { messageMetadata: messageMetadataValue } : {}, - ...responseMessageId != null ? { messageId: responseMessageId } : {} - }); - } - break; - } - case "finish": { - if (sendFinish) { - controller.enqueue({ - type: "finish", - finishReason: part.finishReason, - ...messageMetadataValue != null ? { messageMetadata: messageMetadataValue } : {} - }); - } - break; - } - case "abort": { - controller.enqueue(part); - break; - } - case "tool-input-end": { - break; - } - case "raw": { - break; - } - default: { - const exhaustiveCheck = partType; - throw new Error(`Unknown chunk type: ${exhaustiveCheck}`); - } - } - if (messageMetadataValue != null && partType !== "start" && partType !== "finish") { - controller.enqueue({ - type: "message-metadata", - messageMetadata: messageMetadataValue - }); - } - } - })); - return createAsyncIterableStream(handleUIMessageStreamFinish({ - stream: baseStream, - messageId: responseMessageId != null ? responseMessageId : generateMessageId == null ? undefined : generateMessageId(), - originalMessages, - onFinish, - onError - })); - } - pipeUIMessageStreamToResponse(response, { - originalMessages, - generateMessageId, - onFinish, - messageMetadata, - sendReasoning, - sendSources, - sendFinish, - sendStart, - onError, - ...init - } = {}) { - pipeUIMessageStreamToResponse({ - response, - stream: this.toUIMessageStream({ - originalMessages, - generateMessageId, - onFinish, - messageMetadata, - sendReasoning, - sendSources, - sendFinish, - sendStart, - onError - }), - ...init - }); - } - pipeTextStreamToResponse(response, init) { - pipeTextStreamToResponse({ - response, - textStream: this.textStream, - ...init - }); - } - toUIMessageStreamResponse({ - originalMessages, - generateMessageId, - onFinish, - messageMetadata, - sendReasoning, - sendSources, - sendFinish, - sendStart, - onError, - ...init - } = {}) { - return createUIMessageStreamResponse({ - stream: this.toUIMessageStream({ - originalMessages, - generateMessageId, - onFinish, - messageMetadata, - sendReasoning, - sendSources, - sendFinish, - sendStart, - onError - }), - ...init - }); - } - toTextStreamResponse(init) { - return createTextStreamResponse({ - textStream: this.textStream, - ...init - }); - } -}; -async function convertToModelMessages(messages, options) { - const modelMessages = []; - if (options == null ? undefined : options.ignoreIncompleteToolCalls) { - messages = messages.map((message) => ({ - ...message, - parts: message.parts.filter((part) => !isToolUIPart(part) || part.state !== "input-streaming" && part.state !== "input-available") - })); - } - for (const message of messages) { - switch (message.role) { - case "system": { - const textParts = message.parts.filter((part) => part.type === "text"); - const providerMetadata = textParts.reduce((acc, part) => { - if (part.providerMetadata != null) { - return { ...acc, ...part.providerMetadata }; - } - return acc; - }, {}); - modelMessages.push({ - role: "system", - content: textParts.map((part) => part.text).join(""), - ...Object.keys(providerMetadata).length > 0 ? { providerOptions: providerMetadata } : {} - }); - break; - } - case "user": { - modelMessages.push({ - role: "user", - content: message.parts.map((part) => { - var _a21; - if (isTextUIPart(part)) { - return { - type: "text", - text: part.text, - ...part.providerMetadata != null ? { providerOptions: part.providerMetadata } : {} - }; - } - if (isFileUIPart(part)) { - return { - type: "file", - mediaType: part.mediaType, - filename: part.filename, - data: part.url, - ...part.providerMetadata != null ? { providerOptions: part.providerMetadata } : {} - }; - } - if (isDataUIPart(part)) { - return (_a21 = options == null ? undefined : options.convertDataPart) == null ? undefined : _a21.call(options, part); - } - }).filter(isNonNullable) - }); - break; - } - case "assistant": { - if (message.parts != null) { - let block = []; - async function processBlock() { - var _a21, _b16, _c, _d, _e, _f; - if (block.length === 0) { - return; - } - const content = []; - for (const part of block) { - if (isTextUIPart(part)) { - content.push({ - type: "text", - text: part.text, - ...part.providerMetadata != null ? { providerOptions: part.providerMetadata } : {} - }); - } else if (isFileUIPart(part)) { - content.push({ - type: "file", - mediaType: part.mediaType, - filename: part.filename, - data: part.url - }); - } else if (isReasoningUIPart(part)) { - content.push({ - type: "reasoning", - text: part.text, - providerOptions: part.providerMetadata - }); - } else if (isToolUIPart(part)) { - const toolName = getToolName(part); - if (part.state !== "input-streaming") { - content.push({ - type: "tool-call", - toolCallId: part.toolCallId, - toolName, - input: part.state === "output-error" ? (_a21 = part.input) != null ? _a21 : ("rawInput" in part) ? part.rawInput : undefined : part.input, - providerExecuted: part.providerExecuted, - ...part.callProviderMetadata != null ? { providerOptions: part.callProviderMetadata } : {} - }); - if (part.approval != null) { - content.push({ - type: "tool-approval-request", - approvalId: part.approval.id, - toolCallId: part.toolCallId - }); - } - if (part.providerExecuted === true && part.state !== "approval-responded" && (part.state === "output-available" || part.state === "output-error")) { - content.push({ - type: "tool-result", - toolCallId: part.toolCallId, - toolName, - output: await createToolModelOutput({ - toolCallId: part.toolCallId, - input: part.input, - output: part.state === "output-error" ? part.errorText : part.output, - tool: (_b16 = options == null ? undefined : options.tools) == null ? undefined : _b16[toolName], - errorMode: part.state === "output-error" ? "json" : "none" - }), - ...part.callProviderMetadata != null ? { providerOptions: part.callProviderMetadata } : {} - }); - } - } - } else if (isDataUIPart(part)) { - const dataPart = (_c = options == null ? undefined : options.convertDataPart) == null ? undefined : _c.call(options, part); - if (dataPart != null) { - content.push(dataPart); - } - } else { - const _exhaustiveCheck = part; - throw new Error(`Unsupported part: ${_exhaustiveCheck}`); - } - } - modelMessages.push({ - role: "assistant", - content - }); - const toolParts = block.filter((part) => { - var _a222; - return isToolUIPart(part) && (part.providerExecuted !== true || ((_a222 = part.approval) == null ? undefined : _a222.approved) != null); - }); - if (toolParts.length > 0) { - { - const content2 = []; - for (const toolPart of toolParts) { - if (((_d = toolPart.approval) == null ? undefined : _d.approved) != null) { - content2.push({ - type: "tool-approval-response", - approvalId: toolPart.approval.id, - approved: toolPart.approval.approved, - reason: toolPart.approval.reason, - providerExecuted: toolPart.providerExecuted - }); - } - if (toolPart.providerExecuted === true) { - continue; - } - switch (toolPart.state) { - case "output-denied": { - content2.push({ - type: "tool-result", - toolCallId: toolPart.toolCallId, - toolName: getToolName(toolPart), - output: { - type: "error-text", - value: (_e = toolPart.approval.reason) != null ? _e : "Tool execution denied." - }, - ...toolPart.callProviderMetadata != null ? { providerOptions: toolPart.callProviderMetadata } : {} - }); - break; - } - case "output-error": - case "output-available": { - const toolName = getToolName(toolPart); - content2.push({ - type: "tool-result", - toolCallId: toolPart.toolCallId, - toolName, - output: await createToolModelOutput({ - toolCallId: toolPart.toolCallId, - input: toolPart.input, - output: toolPart.state === "output-error" ? toolPart.errorText : toolPart.output, - tool: (_f = options == null ? undefined : options.tools) == null ? undefined : _f[toolName], - errorMode: toolPart.state === "output-error" ? "text" : "none" - }), - ...toolPart.callProviderMetadata != null ? { providerOptions: toolPart.callProviderMetadata } : {} - }); - break; - } - } - } - if (content2.length > 0) { - modelMessages.push({ - role: "tool", - content: content2 - }); - } - } - } - block = []; - } - for (const part of message.parts) { - if (isTextUIPart(part) || isReasoningUIPart(part) || isFileUIPart(part) || isToolUIPart(part) || isDataUIPart(part)) { - block.push(part); - } else if (part.type === "step-start") { - await processBlock(); - } - } - await processBlock(); - break; - } - break; - } - default: { - const _exhaustiveCheck = message.role; - throw new MessageConversionError({ - originalMessage: message, - message: `Unsupported role: ${_exhaustiveCheck}` - }); - } - } - } - return modelMessages; -} -var uiMessagesSchema = lazySchema(() => zodSchema(exports_external.array(exports_external.object({ - id: exports_external.string(), - role: exports_external.enum(["system", "user", "assistant"]), - metadata: exports_external.unknown().optional(), - parts: exports_external.array(exports_external.union([ - exports_external.object({ - type: exports_external.literal("text"), - text: exports_external.string(), - state: exports_external.enum(["streaming", "done"]).optional(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("reasoning"), - text: exports_external.string(), - state: exports_external.enum(["streaming", "done"]).optional(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("source-url"), - sourceId: exports_external.string(), - url: exports_external.string(), - title: exports_external.string().optional(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("source-document"), - sourceId: exports_external.string(), - mediaType: exports_external.string(), - title: exports_external.string(), - filename: exports_external.string().optional(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("file"), - mediaType: exports_external.string(), - filename: exports_external.string().optional(), - url: exports_external.string(), - providerMetadata: providerMetadataSchema.optional() - }), - exports_external.object({ - type: exports_external.literal("step-start") - }), - exports_external.object({ - type: exports_external.string().startsWith("data-"), - id: exports_external.string().optional(), - data: exports_external.unknown() - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("input-streaming"), - input: exports_external.unknown().optional(), - providerExecuted: exports_external.boolean().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - approval: exports_external.never().optional() - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("input-available"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.never().optional() - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("approval-requested"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.never().optional(), - reason: exports_external.never().optional() - }) - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("approval-responded"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.boolean(), - reason: exports_external.string().optional() - }) - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("output-available"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.unknown(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - preliminary: exports_external.boolean().optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.literal(true), - reason: exports_external.string().optional() - }).optional() - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("output-error"), - input: exports_external.unknown(), - rawInput: exports_external.unknown().optional(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.string(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.literal(true), - reason: exports_external.string().optional() - }).optional() - }), - exports_external.object({ - type: exports_external.literal("dynamic-tool"), - toolName: exports_external.string(), - toolCallId: exports_external.string(), - state: exports_external.literal("output-denied"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.literal(false), - reason: exports_external.string().optional() - }) - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("input-streaming"), - providerExecuted: exports_external.boolean().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - input: exports_external.unknown().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - approval: exports_external.never().optional() - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("input-available"), - providerExecuted: exports_external.boolean().optional(), - input: exports_external.unknown(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.never().optional() - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("approval-requested"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.never().optional(), - reason: exports_external.never().optional() - }) - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("approval-responded"), - input: exports_external.unknown(), - providerExecuted: exports_external.boolean().optional(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.boolean(), - reason: exports_external.string().optional() - }) - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("output-available"), - providerExecuted: exports_external.boolean().optional(), - input: exports_external.unknown(), - output: exports_external.unknown(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - preliminary: exports_external.boolean().optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.literal(true), - reason: exports_external.string().optional() - }).optional() - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("output-error"), - providerExecuted: exports_external.boolean().optional(), - input: exports_external.unknown(), - rawInput: exports_external.unknown().optional(), - output: exports_external.never().optional(), - errorText: exports_external.string(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.literal(true), - reason: exports_external.string().optional() - }).optional() - }), - exports_external.object({ - type: exports_external.string().startsWith("tool-"), - toolCallId: exports_external.string(), - state: exports_external.literal("output-denied"), - providerExecuted: exports_external.boolean().optional(), - input: exports_external.unknown(), - output: exports_external.never().optional(), - errorText: exports_external.never().optional(), - callProviderMetadata: providerMetadataSchema.optional(), - approval: exports_external.object({ - id: exports_external.string(), - approved: exports_external.literal(false), - reason: exports_external.string().optional() - }) - }) - ])).nonempty("Message must contain at least one part") -})).nonempty("Messages array must not be empty"))); -var originalGenerateId3 = createIdGenerator({ prefix: "aiobj", size: 24 }); -function createDownload(options) { - return ({ url: url2, abortSignal }) => download({ url: url2, maxBytes: options == null ? undefined : options.maxBytes, abortSignal }); -} -var originalGenerateId4 = createIdGenerator({ prefix: "aiobj", size: 24 }); -var defaultDownload = createDownload(); -var name20 = "AI_NoSuchProviderError"; -var marker20 = `vercel.ai.error.${name20}`; -var symbol20 = Symbol.for(marker20); -var _a20; -_a20 = symbol20; -var defaultDownload2 = createDownload(); -// ../../node_modules/.pnpm/@assistant-ui+react-ai-sdk@_2b177b4a7400f163206dc2271ac28148/node_modules/@assistant-ui/react-ai-sdk/dist/frontendTools.js -var frontendTools = (tools) => Object.fromEntries(Object.entries(tools).map(([name21, tool2]) => [ - name21, - { - ...tool2.description ? { description: tool2.description } : undefined, - inputSchema: jsonSchema(tool2.parameters) - } -])); -// ../core/types/ai-tools/renameFilesTask.ts -var BEGIN_RENAME_FILES_TASK = "begin-rename-files-task"; -var ADD_RENAME_FILE_TO_TASK = "add-rename-file-to-task"; -var END_RENAME_FILES_TASK = "end-rename-files-task"; -var BEGIN_RENAME_FILES_TASK_DESCRIPTION = "Begin a rename task V2 for batch renaming media files. " + "This tool creates a task that can be used to add multiple files for renaming. " + `Use ${ADD_RENAME_FILE_TO_TASK} to add files, then ${END_RENAME_FILES_TASK} to execute.`; -var ADD_RENAME_FILE_TO_TASK_DESCRIPTION = "Add a file to a rename task. " + `This tool adds a single file to an existing task created by ${BEGIN_RENAME_FILES_TASK}. ` + "Provide the task ID, current file path, and new file path."; -var END_RENAME_FILES_TASK_DESCRIPTION = "End a rename task and execute the batch rename operation. " + `This tool finalizes the task created by ${BEGIN_RENAME_FILES_TASK} and ` + "executes all pending file renames."; -var beginRenameFilesTaskInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format") -}); -var addRenameFileToTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`), - from: exports_external.string().describe("Current absolute path of the video file to rename (POSIX or Windows format)"), - to: exports_external.string().describe("New absolute path for the file (POSIX or Windows format)") -}); -var endRenameFilesTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`) -}); - -// ../core/types/ai-tools/recognizeMediaFileTask.ts -var BEGIN_RECOGNIZE_TASK = "begin-recognize-task"; -var ADD_RECOGNIZED_MEDIA_FILE = "add-recognized-media-file"; -var END_RECOGNIZE_TASK = "end-recognize-task"; -var BEGIN_RECOGNIZE_TASK_DESCRIPTION = "Begin a recognition task for identifying media files. " + "This tool creates a task that can be used to add media files for recognition. " + `Use ${ADD_RECOGNIZED_MEDIA_FILE} to add files, then ${END_RECOGNIZE_TASK} to execute.`; -var ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION = "Add a recognized media file to a recognition task. " + `This tool adds a single video file to an existing task created by ${BEGIN_RECOGNIZE_TASK}. ` + "Provide the task ID, season number, episode number, and file path."; -var END_RECOGNIZE_TASK_DESCRIPTION = "End a recognition task and execute the recognition. " + `This tool finalizes the task created by ${BEGIN_RECOGNIZE_TASK} and ` + "processes all added media files."; -var beginRecognizeTaskInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format") -}); -var addRecognizedMediaFileInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`), - season: exports_external.number().describe("The season number of the episode."), - episode: exports_external.number().describe("The episode number."), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format).") -}); -var endRecognizeTaskInputSchema = exports_external.object({ - taskId: exports_external.string().describe(`The task ID returned from ${BEGIN_RECOGNIZE_TASK}`) -}); - -// ../core/types/ai-tools/getApplicationContext.ts -var GET_APPLICATION_CONTEXT = "get-app-context"; -var GET_APPLICATION_CONTEXT_DESCRIPTION = `Get SMM context: -` + ` * The media folder user selected/focused on SMM UI -` + " * The language in user preferences"; -var getApplicationContextInputSchema = exports_external.object({}); -var getApplicationContextOutputSchema = exports_external.object({ - selectedMediaFolder: exports_external.string().describe("The path of the media folder that user selected in UI."), - language: exports_external.string().describe("The language in user preferences."), - error: exports_external.string().optional().describe("Error message if the operation failed") -}); - -// ../core/types/ai-tools/getMediaMetadata.ts -var GET_MEDIA_METADATA = "get-media-metadata"; -var GET_MEDIA_METADATA_DESCRIPTION = "Get cached media metadata for a media folder. Returns normalized TV show " + "season/episode data and TMDB/TVDB movie information when available. " + "Use list-files-in-media-folder for raw file paths; episode-to-file mappings " + "are not included in this response."; -var GET_MEDIA_METADATA_NOT_MANAGED = "Media folder not found. The folder path may not be correct or the folder is not managed by SMM"; -var GET_MEDIA_METADATA_UNRECOGNIZED = "SMM未识别本文件夹, 请提示用户从SMM界面中搜索并匹配媒体"; -var GET_MEDIA_METADATA_NOT_DIRECTORY = "Path is not a directory"; -var GET_MEDIA_METADATA_FOLDER_NOT_FOUND = "Folder not found"; -var GET_MEDIA_METADATA_NO_CACHE = "No metadata cached for this folder"; -var getMediaMetadataInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder (POSIX or Windows format)") -}); -var seasonEpisodeSchema = exports_external.object({ - seasonNumber: exports_external.number(), - seasonName: exports_external.string(), - episodes: exports_external.array(exports_external.object({ - seasonNumber: exports_external.number(), - episodeNumber: exports_external.number(), - episodeName: exports_external.string() - })) -}); -var getMediaMetadataDataSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The path of the media folder"), - type: exports_external.enum(["tvshow-folder", "movie-folder", "music-folder"]).describe("The type of the media folder"), - tvShow: exports_external.union([ - exports_external.object({ - source: exports_external.enum(["TMDB", "TVDB"]), - id: exports_external.number(), - name: exports_external.string(), - seasons: exports_external.array(seasonEpisodeSchema) - }), - exports_external.string() - ]).optional().describe("Normalized TV show data or message if not recognized"), - tmdbMovie: exports_external.union([ - exports_external.object({ - tmdbId: exports_external.number(), - title: exports_external.string(), - originalTitle: exports_external.string(), - overview: exports_external.string(), - releaseDate: exports_external.string(), - posterPath: exports_external.string().nullable() - }), - exports_external.string() - ]).optional().describe("TMDB movie data or message if not recognized"), - tvdbMovie: exports_external.union([ - exports_external.object({ - tvdbId: exports_external.number(), - name: exports_external.string(), - database: exports_external.enum(["TMDB", "TVDB"]) - }), - exports_external.string() - ]).optional().describe("TVDB movie data or message if not recognized") -}); -var getMediaMetadataToolOutputSchema = getMediaMetadataDataSchema.extend({ - error: exports_external.string().optional().describe("Error message when lookup failed") -}); - -// ../core/types/ai-tools/getEpisodes.ts -var GET_EPISODES = "get-episodes"; -var GET_EPISODES_DESCRIPTION = "Get all episodes for a TV show with their video file paths. " + "Combines TMDB or TVDB episode data (from cached metadata) with local media file paths. " + "For each episode, returns season, episode number, and video file path. " + "The video file path may be undefined if the episode has not been recognized yet."; -var GET_EPISODES_INVALID_PATH = "Invalid path: 'mediaFolderPath' must be a non-empty string"; -var GET_EPISODES_NOT_MANAGED = "Media folder not found. The folder path may not be correct or the folder is not managed by SMM"; -var GET_EPISODES_NO_CACHE = "TV show not found. Please ensure the media folder is opened in SMM."; -var GET_EPISODES_NOT_TV_SHOW = "Not a TV show folder. This tool only works with TV show media folders that have TMDB or TVDB metadata cached."; -var getEpisodesInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the TV show media folder (POSIX or Windows format)") -}); -var getEpisodesEpisodeSchema = exports_external.object({ - season: exports_external.number().describe("The season number"), - episode: exports_external.number().describe("The episode number"), - videoFilePath: exports_external.string().optional().describe("The absolute path of the video file, undefined if not recognized yet") -}); -var getEpisodesDataSchema = exports_external.object({ - episodes: exports_external.array(getEpisodesEpisodeSchema).describe("Array of all episodes with their video file paths"), - totalCount: exports_external.number().describe("Total number of episodes"), - showName: exports_external.string().describe("The name of the TV show"), - numberOfSeasons: exports_external.number().describe("Number of seasons in the show") -}); -var getEpisodesToolOutputSchema = getEpisodesDataSchema.extend({ - error: exports_external.string().optional() -}); - -// ../core/types/ai-tools/listFilesInMediaFolder.ts -var LIST_FILES_IN_MEDIA_FOLDER = "list-files-in-media-folder"; -var LIST_FILES_IN_MEDIA_FOLDER_DESCRIPTION = "List files in a media folder by scanning the file system recursively. " + "Returns file paths in OS-native format. Use videoFileOnly to restrict to video files."; -var LIST_FILES_IN_MEDIA_FOLDER_INVALID_PATH = "Invalid path: 'mediaFolderPath' must be a non-empty string"; -var LIST_FILES_IN_MEDIA_FOLDER_NOT_MANAGED = "Media folder not found. The folder path may not be correct or the folder is not managed by SMM"; -var listFilesInMediaFolderInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder (POSIX or Windows format)"), - recursively: exports_external.boolean().optional().default(true).describe("Whether to list files recursively (default: true)"), - videoFileOnly: exports_external.boolean().optional().default(false).describe("Whether to return only video files (default: false)") -}); -var listFilesInMediaFolderDataSchema = exports_external.object({ - files: exports_external.array(exports_external.string()).describe("Array of file paths"), - count: exports_external.number().describe("Number of files listed") -}); -var listFilesInMediaFolderOutputSchema = listFilesInMediaFolderDataSchema.extend({ - error: exports_external.string().optional() -}); - -// ../core/ai-tool/systemPrompt.ts -var SYSTEM_PROMPT = `You're a helpful assistant for Simple Media Manager(SMM) software. -SMM is a media manager that helps user to manage their TV Show, anime, movie or music. -SMM holds multiple media folders and user can switch between them. - -SMM maintains below date for a media folder: -1. path in local file system -2. TV Show or Movie media metadata from TMDB -3. Media File Metadata - The linkage between local video file to Season and Episode of TV show - - -## Tasks - -This section defines tasks for how to handle certain user requirements. - -### Recognize Media File - -When user asks to recognize media file, match media file, or find the video file for episodes, -user is asking for this use case. - -SMM maintains a linkage between local video file to season and episode. This task will update the linkages. - -Below is the steps to recognize media file: - -1. The media folder user is asking for. - If user don't tell which folder he is asking for, you should call "${GET_APPLICATION_CONTEXT}" to get the selected media folder in UI. -2. Get episodes using "${GET_EPISODES}" tool -3. Get local files using "${LIST_FILES_IN_MEDIA_FOLDER}" tool -4. Call "${BEGIN_RECOGNIZE_TASK}" tool to notify AI Agent to start a recognize task -5. iterate each episodes, find the local video file for the episode, and call "${ADD_RECOGNIZED_MEDIA_FILE}" tool to add the recognized media file to the task - IMPORTANT: It's OK to skip the episode if the local video file is not found. -6. Call "${END_RECOGNIZE_TASK}" tool to notify AI Agent to end the recognize task - -### Rename Files - -When user ask to rename files, you need to understand below information: -1. Which media folder user is asking for. If user don't tell the media folder, you should call "${GET_APPLICATION_CONTEXT}" to get the selected media folder in UI. -2. The naming rules. If user don't tell the naming rules, you should ask user if he wants follow the Plex naming rules. - -You ONLY need to rename the video file. For image files, subtitle files, nfo files that link to video file, those files will be renamed implicitly when the video name got changed. - -Steps -[ ] Call "${GET_MEDIA_METADATA}" to get the video files needs to rename -[ ] Call "${BEGIN_RENAME_FILES_TASK}" to notify AI Agent to start a rename files task -[ ] Call "${ADD_RENAME_FILE_TO_TASK}" to add a file to rename task, call multiple times to add multiple files -[ ] Call "${END_RENAME_FILES_TASK}" to notify AI Agent to end the rename files task - -## User Preferences - -Language: zh-CN (you should answer in user's language) -TimeZone: Asia/Shanghai (you should use user's time zone to answer user's question) - -## Background Knowledge - -### Naming Rules - -Naming rules are file naming conventions for media server (such as Plex, Jellyfin, etc.) to recognize the media files. -Below are naming rules for different media servers: - -**Plex** - -{FolderName}/{TVShowName} - S{SeasonNumber}E{EpisodeNumber} - {EpisodeName}.{Extension} - -FolderName: The season folder name, such as "Specials", "Season 1", "Season 2", "Season 3", ... -TVShowName: The TV show name -SeasonNumber: The season number padded to 2 digits, such as "01", "02", "03", ... -EpisodeNumber: The episode number padded to 2 digits, such as "01", "02", "03", ... -EpisodeName: The episode name -Extension: The file extension, such as "mp4", "mkv", "avi", ... -`; - -// ../core/types/ai-tools/isFolderExist.ts -var IS_FOLDER_EXIST = "is-folder-exist"; -var IS_FOLDER_EXIST_DESCRIPTION = "Check if a folder exists in the file system. " + "Returns `{ exists, path, reason? }` where `exists` is true when " + "the path is an existing directory."; -var IS_FOLDER_EXIST_INVALID_PATH = "Invalid path: path must be a non-empty string"; -var IS_FOLDER_EXIST_NOT_DIRECTORY = "Path exists but is not a directory"; -var IS_FOLDER_EXIST_NOT_FOUND = "Path does not exist"; -var isFolderExistInputSchema = exports_external.object({ - path: exports_external.string().describe("The absolute path of the folder to check (POSIX or Windows format)") -}); -var isFolderExistOutputSchema = exports_external.object({ - exists: exports_external.boolean().describe("Whether the folder exists"), - path: exports_external.string().describe("The normalized path that was checked"), - reason: exports_external.string().optional().describe("Reason for non-existence or non-directory") -}); - -// ../core/types/ai-tools/getMediaFolders.ts -var GET_MEDIA_FOLDERS = "get-media-folders"; -var GET_MEDIA_FOLDERS_DESCRIPTION = "Get the list of media folders managed by SMM."; -var getMediaFoldersInputSchema = exports_external.object({}); -var getMediaFoldersDataSchema = exports_external.object({ - folders: exports_external.array(exports_external.string()).describe("Array of media folder paths managed by SMM") -}); -var getMediaFoldersOutputSchema = getMediaFoldersDataSchema.extend({ - error: exports_external.string().optional() -}); - -// ../core/types/ai-tools/renameFolder.ts -var RENAME_FOLDER = "rename-folder"; -var RENAME_FOLDER_DESCRIPTION = "Rename a media folder in SMM. " + "This tool accepts the source folder path and destination folder path. " + "This tool should ONLY be used to rename FOLDER, NOT FILE. " + `This tool will update media metadata accordingly. - -` + 'Example: Rename folder "/path/to/old-folder" to "/path/to/new-folder".'; -var renameFolderInputSchema = exports_external.object({ - from: exports_external.string().describe("The current absolute path of the folder to rename (POSIX or Windows format)"), - to: exports_external.string().describe("The new absolute path for the folder (POSIX or Windows format)") -}); -var renameFolderOutputSchema = exports_external.object({ - renamed: exports_external.boolean().describe("Whether the folder was successfully renamed"), - from: exports_external.string().describe("The source path after normalization"), - to: exports_external.string().describe("The destination path after normalization"), - error: exports_external.string().optional().describe("Error message if rename failed") -}); -var RENAME_FOLDER_CANCELLED = "User cancelled the operation"; - -// ../core/locale.ts -var APP_LANGUAGE_FALLBACK = "en"; -function normalizeToAppLanguage(raw) { - const lng = raw.trim(); - if (!lng) - return null; - const lower = lng.toLowerCase(); - if (lower.startsWith("zh")) { - if (lower === "zh-hk" || lower.startsWith("zh-hk")) { - return "zh-HK"; - } - if (lower === "zh-tw" || lower.startsWith("zh-tw")) { - return "zh-TW"; - } - return "zh-CN"; - } - if (lower.startsWith("en")) { - return "en"; - } - return null; -} -function resolveAppLanguage(opts) { - if (opts.configured) { - return opts.configured; - } - if (opts.browserLocale) { - const fromBrowser = normalizeToAppLanguage(opts.browserLocale); - if (fromBrowser) - return fromBrowser; - } - if (opts.osLocale) { - const fromOs = normalizeToAppLanguage(opts.osLocale); - if (fromOs) - return fromOs; - } - return APP_LANGUAGE_FALLBACK; -} -function detectOsLocale() { - const fromIntl = Intl.DateTimeFormat().resolvedOptions().locale; - if (fromIntl) { - return fromIntl; - } - const envLocale = process.env.LC_ALL ?? process.env.LC_MESSAGES ?? process.env.LANG; - if (envLocale) { - return envLocale.split(".")[0]?.replace("_", "-") ?? envLocale; - } - return ""; -} - -// src/chatFs.ts -var import_promises = require("node:fs/promises"); -function defaultChatFs() { - return { - async readJson(filePath) { - try { - const contents = await import_promises.readFile(filePath, "utf-8"); - return JSON.parse(contents); - } catch (error48) { - if (error48.code === "ENOENT") { - return null; - } - throw error48; - } - }, - async writeJson(filePath, value) { - const serialized = JSON.stringify(value, null, 2); - await import_promises.writeFile(filePath, serialized, "utf-8"); - }, - async exists(filePath) { - try { - await import_promises.stat(filePath); - return true; - } catch { - return false; - } - } - }; -} - -// src/tools/acknowledge.ts -async function defaultAcknowledge(_message, _timeoutMs) { - return; -} - -// src/tools/broadcast.ts -function defaultBroadcast(_message) {} - -// src/tools/getApplicationContext.ts -async function executeGetApplicationContext(params) { - return { - selectedMediaFolder: params.selectedMediaFolder, - language: params.language - }; -} -function buildGetApplicationContextTool(clientId, userConfig, resolveLanguage, acknowledge) { - const ack = acknowledge ?? defaultAcknowledge; - return { - description: GET_APPLICATION_CONTEXT_DESCRIPTION, - inputSchema: getApplicationContextInputSchema, - outputSchema: getApplicationContextOutputSchema, - execute: async () => { - try { - const [selectedMediaFolder, language] = await Promise.all([ - resolveSelectedMediaFolder(clientId, ack), - resolveLanguage(userConfig) - ]); - return executeGetApplicationContext({ - selectedMediaFolder, - language - }); - } catch (error48) { - return { - selectedMediaFolder: "", - language: "en", - error: error48 instanceof Error ? error48.message : "Unknown error" - }; - } - } - }; -} -async function resolveSelectedMediaFolder(clientId, acknowledge) { - const responseData = await acknowledge({ - event: "getSelectedMediaMetadata", - clientId - }, 1000); - return responseData?.selectedMediaMetadata?.mediaFolderPath ?? ""; -} -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flatten.mjs -function flatten(arr, depth = 1) { - const result = []; - const flooredDepth = Math.floor(depth); - const recursive = (arr2, currentDepth) => { - for (let i = 0;i < arr2.length; i++) { - const item = arr2[i]; - if (Array.isArray(item) && currentDepth < flooredDepth) { - recursive(item, currentDepth + 1); - } else { - result.push(item); - } - } - }; - recursive(arr, 0); - return result; -} - -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/flattenDeep.mjs -function flattenDeep(arr) { - return flatten(arr, Infinity); -} -// ../../node_modules/.pnpm/es-toolkit@1.44.0/node_modules/es-toolkit/dist/array/last.mjs -function last(arr) { - return arr[arr.length - 1]; -} -// ../../node_modules/.pnpm/slash@5.1.0/node_modules/slash/index.js -function slash(path) { - const isExtendedLengthPath = path.startsWith("\\\\?\\"); - if (isExtendedLengthPath) { - return path; - } - return path.replace(/\\/g, "/"); -} - -// ../../node_modules/.pnpm/filename-reserved-regex@4.0.0/node_modules/filename-reserved-regex/index.js -function filenameReservedRegex() { - return /[<>:"/\\|?*\u0000-\u001F]|[. ]$/g; -} -function windowsReservedNameRegex() { - return /^(con|prn|aux|nul|com\d|lpt\d)$/i; -} - -// ../../node_modules/.pnpm/filenamify@7.0.1/node_modules/filenamify/filenamify.js -var MAX_FILENAME_LENGTH = 100; -var reRelativePath = /^\.+(\\|\/)|^\.+$/; -var reTrailingDotsAndSpaces = /[. ]+$/; -var reControlChars = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu; -var reControlCharsTest = /[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u; -var isZeroWidthJoiner = (char) => char === "‍"; -var reRepeatedReservedCharacters = /([<>:"/\\|?*\u0000-\u001F]){2,}/g; -var reReplacementReservedCharacters = /[<>:"/\\|?*\u0000-\u001F]/; -var reUnicodeWhitespace = /[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g; -var segmenter; -function getSegmenter() { - segmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); - return segmenter; -} -function truncateFilename(filename, maxLength) { - if (filename.length <= maxLength) { - return filename; - } - const extensionIndex = filename.lastIndexOf("."); - if (extensionIndex === -1) { - return truncateByGraphemeBudget(filename, maxLength); - } - const base = filename.slice(0, extensionIndex); - const extension = filename.slice(extensionIndex); - const baseBudget = Math.max(0, maxLength - extension.length); - const truncatedBase = truncateByGraphemeBudget(base, baseBudget); - return truncatedBase.replace(/ +$/, "") + extension; -} -function filenamify(string4, options = {}) { - if (typeof string4 !== "string") { - throw new TypeError("Expected a string"); - } - const replacement = options.replacement ?? "!"; - const hasReservedChars = reReplacementReservedCharacters.test(replacement); - const hasControlChars = [...replacement].some((char) => reControlCharsTest.test(char) && !isZeroWidthJoiner(char)); - if (hasReservedChars || hasControlChars) { - throw new Error("Replacement string cannot contain reserved filename characters"); - } - string4 = string4.normalize("NFC"); - string4 = string4.replaceAll(reUnicodeWhitespace, " "); - if (replacement.length > 0) { - string4 = string4.replaceAll(reRepeatedReservedCharacters, "$1"); - } - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - string4 = string4.replace(reRelativePath, replacement); - string4 = string4.replace(filenameReservedRegex(), replacement); - string4 = string4.replaceAll(reControlChars, (char) => isZeroWidthJoiner(char) ? char : replacement); - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - if (string4.length === 0) { - string4 = replacement.replace(reTrailingDotsAndSpaces, ""); - if (string4.length === 0 && replacement.length > 0) { - string4 = "!"; - } - } - const allowedLength = typeof options.maxLength === "number" ? options.maxLength : MAX_FILENAME_LENGTH; - string4 = truncateFilename(string4, allowedLength); - string4 = string4.replace(reTrailingDotsAndSpaces, ""); - if (windowsReservedNameRegex().test(string4)) { - string4 += replacement; - } - return string4; -} -function truncateByGraphemeBudget(input, budget) { - if (input.length <= budget) { - return input; - } - let count = 0; - let output = ""; - for (const { segment } of getSegmenter().segment(input)) { - const next = count + segment.length; - if (next > budget) { - break; - } - output += segment; - count = next; - } - return output; -} -// ../core/path.ts -var WIN_PATH_SEPARATOR = "\\"; -var POSIX_PATH_SEPARATOR = "/"; -function isNotEmpty(part) { - return part.trim() !== ""; -} -function split(path) { - let parts = path.split(":\\").filter(isNotEmpty); - parts = flattenDeep(parts.map((part) => part.split("\\").filter(isNotEmpty))); - parts = flattenDeep(parts.map((part) => part.split("/").filter(isNotEmpty))); - return parts; -} - -class Path { - static serverPlatform = null; - root; - sub; - unc; - constructor(root, sub) { - if (root.trim() === "") { - throw new Error("InvalidArgumentError: root path cannot be empty"); - } - if (sub !== undefined) { - if (split(sub).length === 0) { - if (sub.length === 0) { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } else { - throw new Error("InvalidArgumentError: invalid sub path"); - } - } - } - if (sub?.trim() === "") { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } - this.unc = root.startsWith("\\\\"); - if (!(root.startsWith("/") || /^[A-Za-z]:/.test(root) || root.startsWith("\\\\"))) { - throw new Error(`InvalidArgumentError: root=${root}. root path must start with "/" for POSIX format, "C:" for Windows format, or "\\\\" for Windows UNC format`); - } - this.root = split(root); - this.sub = sub === undefined ? [] : split(sub); - if (this.root.length === 0) { - throw new Error("InvalidArgumentError: invalid root path"); - } - } - _uncPath() { - const serverName = this.root[0]; - const parentPath = this.root.slice(1).join(WIN_PATH_SEPARATOR); - const subPath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); - return `\\\\${serverName}\\${parentPath}${subPath}`; - } - abs(type = "posix") { - if (type === "win") { - if (this.unc) { - return this._uncPath(); - } else { - if (this.root[0]?.length !== 1) { - return this._uncPath(); - } - const rootFolderPaths = this.root.slice(1).join(WIN_PATH_SEPARATOR); - const subpath = this.sub.length === 0 ? "" : WIN_PATH_SEPARATOR + this.sub.join(WIN_PATH_SEPARATOR); - return `${this.root[0]}:${WIN_PATH_SEPARATOR}${rootFolderPaths}${subpath}`; - } - } else { - const subpath = this.sub.length === 0 ? "" : POSIX_PATH_SEPARATOR + this.sub.join(POSIX_PATH_SEPARATOR); - return `${POSIX_PATH_SEPARATOR}${this.root.join(POSIX_PATH_SEPARATOR)}${subpath}`; - } - } - rel(type = "posix") { - if (type === "win") { - return this.sub.join(WIN_PATH_SEPARATOR); - } else { - return this.sub.join(POSIX_PATH_SEPARATOR); - } - } - name() { - return last(this.sub) || last(this.root) || ""; - } - dir() { - return "/" + this.root.join(POSIX_PATH_SEPARATOR); - } - cd(subpath) { - return new Path(this.dir(), subpath); - } - platformAbsPath() { - return Path.isWindows() ? this.abs("win") : this.abs("posix"); - } - platformRelPath() { - return Path.isWindows() ? this.rel("win") : this.rel("posix"); - } - join(subpath) { - const parts = split(subpath); - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub, ...parts].join(POSIX_PATH_SEPARATOR)); - } - filename(newFileName) { - if (this.sub.length === 0) { - throw new Error("InvalidArgumentError: sub path cannot be empty"); - } else { - const validName = filenamify(newFileName); - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), [...this.sub.slice(0, -1), validName].join(POSIX_PATH_SEPARATOR)); - } - } - parent() { - if (this.sub.length === 0) { - throw new Error("reaching parent folder is not allowed"); - } else { - const parentSub = this.sub.slice(0, -1); - if (parentSub.length === 0) { - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR)); - } else { - return new Path(POSIX_PATH_SEPARATOR + this.root.join(POSIX_PATH_SEPARATOR), parentSub.join(POSIX_PATH_SEPARATOR)); - } - } - } - static fromAbsolutePath(absolutePath, root) { - return new Path(root, absolutePath.replace(root, "")); - } - static posix(windowsPath) { - const p = new Path(windowsPath); - return p.abs("posix"); - } - static win(posixPath) { - const p = new Path(posixPath); - return p.abs("win"); - } - static slash(windowsPath) { - return slash(windowsPath); - } - static backslash(posixPath) { - return posixPath.replace(POSIX_PATH_SEPARATOR, WIN_PATH_SEPARATOR); - } - static setServerPlatform(platform) { - Path.serverPlatform = platform; - } - static resetServerPlatformForTests() { - Path.serverPlatform = null; - } - static getServerPlatform() { - return Path.serverPlatform; - } - static isWindows() { - if (Path.serverPlatform !== null) { - return Path.serverPlatform === "win32"; - } - const proc = typeof globalThis !== "undefined" ? globalThis.process : undefined; - if (proc?.platform) { - return proc.platform === "win32"; - } - const win = typeof globalThis !== "undefined" ? globalThis.window : undefined; - if (win) { - const electron = win.electron; - if (electron?.process?.platform) { - return electron.process.platform === "win32"; - } - } - return false; - } - static pathSeparator() { - return Path.isWindows() ? WIN_PATH_SEPARATOR : POSIX_PATH_SEPARATOR; - } - static toPlatformPath(path) { - return Path.isWindows() ? Path.win(path) : Path.posix(path); - } - toString() { - return this.abs(); - } -} - -// ../core/ai-tool/isFolderExistResult.ts -function isFolderExistInvalidPath() { - return { - exists: false, - path: "", - reason: IS_FOLDER_EXIST_INVALID_PATH - }; -} -function isFolderExistSucceeded(path) { - return { - exists: true, - path: Path.toPlatformPath(path) - }; -} -function isFolderExistNotDirectory(path) { - const platformPath = Path.toPlatformPath(path); - return { - exists: false, - path: platformPath, - reason: IS_FOLDER_EXIST_NOT_DIRECTORY - }; -} -function isFolderExistNotFound(path) { - return { - exists: false, - path: Path.toPlatformPath(path), - reason: IS_FOLDER_EXIST_NOT_FOUND - }; -} -function isFolderExistCheckFailed(path, message) { - return { - exists: false, - path: Path.toPlatformPath(path), - reason: `Error checking folder existence: ${message}` - }; -} - -// ../core/ai-tool/toolResult.ts -function toolOk(data) { - return { ...data, error: undefined }; -} -function toolError(reason) { - const message = reason.startsWith("Error Reason:") ? reason : `Error Reason: ${reason}`; - return { error: message }; -} -function requireNonEmptyString(value, field) { - if (typeof value !== "string" || value.trim() === "") { - return { error: `Invalid ${field}: must be a non-empty string` }; - } - return value; -} -function messageFromUnknownError(error48) { - if (error48 instanceof Error) { - return error48.message; - } - if (typeof error48 === "string") { - return error48; - } - if (error48 === null || error48 === undefined) { - return "Unknown error (null/undefined thrown)"; - } - try { - const json3 = JSON.stringify(error48); - if (json3 && json3 !== "{}") { - return json3; - } - } catch {} - const text2 = String(error48); - return text2 || "Unknown error"; -} -function formatToolError(error48) { - return toolError(messageFromUnknownError(error48)); -} - -// src/isFolderAvailable.ts -var import_promises2 = require("node:fs/promises"); -var isFolderAvailableRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "path is required") -}); -async function resolveFolderExistence(folderPath) { - if (!folderPath || typeof folderPath !== "string" || folderPath.trim() === "") { - return isFolderExistInvalidPath(); - } - try { - const normalizedPath = Path.toPlatformPath(folderPath); - const stats = await import_promises2.stat(normalizedPath); - if (stats.isDirectory()) { - return isFolderExistSucceeded(folderPath); - } - return isFolderExistNotDirectory(folderPath); - } catch (error48) { - const code = error48.code; - if (code === "ENOENT" || code === "ENOTFOUND") { - return isFolderExistNotFound(folderPath); - } - const message = error48 instanceof Error ? error48.message : String(error48); - throw new Error(`Error checking folder existence: ${message}`); - } -} -async function checkFolderPathAvailable(folderPath) { - const result = await resolveFolderExistence(folderPath); - return result.exists; -} -async function doIsFolderAvailable(body, _config = {}) { - const parsed = isFolderAvailableRequestSchema.safeParse(body); - if (!parsed.success) { - return { available: false, reason: "path is required" }; - } - try { - const result = await resolveFolderExistence(parsed.data.path); - if (result.exists) { - _config.logger?.debug({ path: parsed.data.path }, "doIsFolderAvailable: folder exists"); - } else { - _config.logger?.info({ path: parsed.data.path, reason: result.reason }, "doIsFolderAvailable: folder not available"); - } - return { - available: result.exists, - reason: result.reason - }; - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - _config.logger?.error({ path: parsed.data.path, error: message }, "doIsFolderAvailable: check failed"); - return { - available: false, - reason: isFolderExistCheckFailed(parsed.data.path, message).reason - }; - } -} - -// src/tools/isFolderExist.ts -async function executeIsFolderExist(path) { - const pathCheck = requireNonEmptyString(path, "path"); - if (typeof pathCheck !== "string") { - return { - exists: false, - path: "", - reason: pathCheck.error - }; - } - try { - return await resolveFolderExistence(pathCheck); - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - return isFolderExistCheckFailed(pathCheck, message); - } -} -function buildIsFolderExistTool() { - return { - description: IS_FOLDER_EXIST_DESCRIPTION, - inputSchema: isFolderExistInputSchema, - outputSchema: isFolderExistOutputSchema, - execute: async (args) => { - const { path } = args ?? {}; - if (typeof path !== "string") { - return { - exists: false, - path: "", - reason: "path is required" - }; - } - return executeIsFolderExist(path); - } - }; -} - -// src/tools/getMediaMetadata.ts -var import_promises4 = require("node:fs/promises"); - -// ../core/ai-tool/getMediaMetadataResponse.ts -function parseMediaIdString(id) { - const n = Number.parseInt(id, 10); - return Number.isFinite(n) ? n : 0; -} -function mapTvShowToResponse(tv) { - return { - source: tv.database ?? "TVDB", - id: parseMediaIdString(tv.id), - name: tv.name, - seasons: tv.seasons?.map((season) => ({ - seasonNumber: season.season, - seasonName: season.name, - episodes: season.episodes?.map((ep) => ({ - seasonNumber: ep.season, - episodeNumber: ep.episode, - episodeName: ep.name - })) ?? [] - })) ?? [] - }; -} -function mapTvdbMovieToResponse(m) { - return { - tvdbId: parseMediaIdString(m.id), - name: m.name, - database: m.database - }; -} -function createBaseGetMediaMetadataData(mediaFolderPath) { - return { - mediaFolderPath: Path.toPlatformPath(mediaFolderPath), - type: "tvshow-folder" - }; -} -function fillMediaMetadataResponseData(metadata, posixPath) { - const data = { - mediaFolderPath: Path.toPlatformPath(metadata.mediaFolderPath || posixPath), - type: metadata.type || "tvshow-folder" - }; - if (data.type === "tvshow-folder") { - if (metadata.tvShow) { - data.tvShow = mapTvShowToResponse(metadata.tvShow); - } else { - data.tvShow = GET_MEDIA_METADATA_UNRECOGNIZED; - } - } else if (data.type === "movie-folder") { - if (metadata.movie) { - if (metadata.movie.database === "TMDB") { - const tmdbId = parseMediaIdString(metadata.movie.id); - data.tmdbMovie = { - tmdbId, - title: metadata.movie.name, - originalTitle: metadata.movie.name, - overview: "", - releaseDate: metadata.movie.airDate ?? "", - posterPath: null - }; - data.tvdbMovie = GET_MEDIA_METADATA_UNRECOGNIZED; - } else { - data.tvdbMovie = mapTvdbMovieToResponse(metadata.movie); - data.tmdbMovie = GET_MEDIA_METADATA_UNRECOGNIZED; - } - } else { - data.tmdbMovie = GET_MEDIA_METADATA_UNRECOGNIZED; - data.tvdbMovie = GET_MEDIA_METADATA_UNRECOGNIZED; - } - } - return data; -} - -// src/mediaMetadataCache.ts -var import_promises3 = require("node:fs/promises"); -var import_node_path = __toESM(require("node:path")); -function metadataCacheFilePath(appDataDir, folderPathInPosix) { - const filename = folderPathInPosix.replace(/[\/\\:?*|<>"]/g, "_"); - return import_node_path.default.join(appDataDir, "metadata", `${filename}.json`); -} -async function readMediaMetadataCache(appDataDir, mediaFolderPath) { - const folderPathInPosix = Path.posix(mediaFolderPath); - const filePath = metadataCacheFilePath(appDataDir, folderPathInPosix); - try { - const content = await import_promises3.readFile(filePath, "utf-8"); - return JSON.parse(content); - } catch { - return null; - } -} -async function writeMediaMetadataCache(appDataDir, mediaMetadata) { - if (!mediaMetadata.mediaFolderPath) { - throw new Error("Media folder path is required"); - } - const metadataDir = import_node_path.default.join(appDataDir, "metadata"); - await import_promises3.mkdir(metadataDir, { recursive: true }); - const filePath = metadataCacheFilePath(appDataDir, Path.posix(mediaMetadata.mediaFolderPath)); - await import_promises3.writeFile(filePath, JSON.stringify(mediaMetadata, null, 2), "utf-8"); -} -async function deleteMediaMetadataCache(appDataDir, mediaFolderPath) { - const filePath = metadataCacheFilePath(appDataDir, Path.posix(mediaFolderPath)); - try { - await import_promises3.unlink(filePath); - } catch (error48) { - const code = error48.code; - if (code !== "ENOENT") { - throw error48; - } - } -} - -// src/tools/getMediaMetadata.ts -async function executeGetMediaMetadata(params, userConfig, appDataDir, abortSignal) { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const pathCheck = requireNonEmptyString(params.mediaFolderPath, "mediaFolderPath"); - if (typeof pathCheck !== "string") { - return { - ...createBaseGetMediaMetadataData(""), - error: pathCheck.error - }; - } - const baseData = createBaseGetMediaMetadataData(pathCheck); - if (!isMediaFolderManagedFromConfig(userConfig, pathCheck)) { - return { ...baseData, error: GET_MEDIA_METADATA_NOT_MANAGED }; - } - try { - const normalizedPath = Path.toPlatformPath(pathCheck); - try { - const stats = await import_promises4.stat(normalizedPath); - if (!stats.isDirectory()) { - return { ...baseData, error: GET_MEDIA_METADATA_NOT_DIRECTORY }; - } - } catch (error48) { - if (error48.code === "ENOENT") { - return { ...baseData, error: GET_MEDIA_METADATA_FOLDER_NOT_FOUND }; - } - throw error48; - } - const posixPath = Path.posix(pathCheck); - const metadata = await readMediaMetadataCache(appDataDir, posixPath); - if (!metadata) { - return { ...baseData, error: GET_MEDIA_METADATA_NO_CACHE }; - } - return fillMediaMetadataResponseData(metadata, posixPath); - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - throw new Error(`Error reading media metadata: ${message}`); - } -} -function isMediaFolderManagedFromConfig(userConfig, mediaFolderPath) { - const targetPlatform = Path.toPlatformPath(mediaFolderPath); - const targetPosix = Path.posix(mediaFolderPath); - return (userConfig.folders ?? []).some((folder) => { - return Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix; - }); -} -function buildGetMediaMetadataTool(userConfig, appDataDir, abortSignal) { - return { - description: GET_MEDIA_METADATA_DESCRIPTION, - inputSchema: getMediaMetadataInputSchema, - outputSchema: getMediaMetadataToolOutputSchema, - execute: async (args) => { - const params = args ?? {}; - return executeGetMediaMetadata({ mediaFolderPath: params.mediaFolderPath ?? "" }, userConfig, appDataDir, abortSignal); - } - }; -} - -// ../core/ai-tool/buildGetEpisodesResponse.ts -function createEmptyGetEpisodesData() { - return { - episodes: [], - totalCount: 0, - showName: "", - numberOfSeasons: 0 - }; -} -function buildGetEpisodesResponse(metadata) { - const tvShow = metadata.tvShow; - if (!tvShow) { - return createEmptyGetEpisodesData(); - } - const episodeToVideoMap = new Map; - if (metadata.mediaFiles && Array.isArray(metadata.mediaFiles)) { - for (const mediaFile of metadata.mediaFiles) { - if (mediaFile.seasonNumber !== undefined && mediaFile.episodeNumber !== undefined) { - const key = `${mediaFile.seasonNumber}:${mediaFile.episodeNumber}`; - episodeToVideoMap.set(key, mediaFile.absolutePath); - } - } - } - const episodes = []; - for (const season of tvShow.seasons ?? []) { - if (season.episodes && Array.isArray(season.episodes)) { - for (const ep of season.episodes) { - const key = `${season.season}:${ep.episode}`; - const videoFilePath = episodeToVideoMap.get(key); - episodes.push({ - season: season.season, - episode: ep.episode, - videoFilePath: videoFilePath ? Path.toPlatformPath(videoFilePath) : undefined - }); - } - } - } - return { - episodes, - totalCount: episodes.length, - showName: tvShow.name ?? "", - numberOfSeasons: tvShow.seasons?.length ?? 0 - }; -} - -// src/types.ts -var EMPTY_CORE_ROUTES_CONFIG = { - allowlist: [] -}; - -// src/userConfig.ts -var import_promises5 = require("node:fs/promises"); -var import_node_path2 = __toESM(require("node:path")); -var DEFAULT_USER_CONFIG = { - folders: [], - tmdb: {}, - tvdb: {}, - renameRules: [], - dryRun: false, - selectedRenameRule: "plex" -}; -function resolveUserDataDir(config2) { - return config2.hello?.userDataDir ?? config2.appDataDir; -} -function resolveAppDataDir(config2) { - return config2.appDataDir ?? config2.hello?.appDataDir; -} -async function readUserConfig(config2) { - const userDataDir = resolveUserDataDir(config2); - if (!userDataDir) { - return DEFAULT_USER_CONFIG; - } - const configPath = import_node_path2.default.join(userDataDir, "smm.json"); - try { - const content = await import_promises5.readFile(configPath, "utf-8"); - return JSON.parse(content); - } catch { - return DEFAULT_USER_CONFIG; - } -} -async function isMediaFolderManaged(mediaFolderPath, config2) { - const userConfig = await readUserConfig(config2); - const folders = userConfig.folders ?? []; - if (folders.length === 0) { - return false; - } - const targetPlatform = Path.toPlatformPath(mediaFolderPath); - const targetPosix = Path.posix(mediaFolderPath); - return folders.some((folder) => Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix); -} -async function writeUserConfigToDisk(config2, userConfig) { - const userDataDir = resolveUserDataDir(config2); - if (!userDataDir) { - throw new Error("userDataDir is not configured"); - } - await import_promises5.mkdir(userDataDir, { recursive: true }); - await import_promises5.writeFile(import_node_path2.default.join(userDataDir, "smm.json"), JSON.stringify(userConfig, null, 2), "utf-8"); -} - -// src/getEpisodes.ts -var getEpisodesRequestSchema = exports_external2.object({ - mediaFolderPath: exports_external2.string().min(1, "The absolute path of the media folder is required") -}); -async function doGetEpisodes(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { - const parsed = getEpisodesRequestSchema.safeParse(body); - if (!parsed.success) { - const msg = parsed.error.issues.map((i) => i.message).join(", "); - return { - ...createEmptyGetEpisodesData(), - error: `Validation Failed: ${msg}` - }; - } - const pathCheck = requireNonEmptyString(parsed.data.mediaFolderPath, "mediaFolderPath"); - if (typeof pathCheck !== "string") { - return { ...createEmptyGetEpisodesData(), error: GET_EPISODES_INVALID_PATH }; - } - const empty = createEmptyGetEpisodesData(); - if (!await isMediaFolderManaged(pathCheck, config2)) { - return { ...empty, error: GET_EPISODES_NOT_MANAGED }; - } - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - return { ...empty, error: GET_EPISODES_NO_CACHE }; - } - const metadata = await readMediaMetadataCache(appDataDir, pathCheck); - if (!metadata) { - return { ...empty, error: GET_EPISODES_NO_CACHE }; - } - if (!metadata.tvShow) { - return { ...empty, error: GET_EPISODES_NOT_TV_SHOW }; - } - config2.logger?.info({ - mediaFolderPath: pathCheck, - seasonCount: metadata.tvShow.seasons?.length ?? 0, - mediaFileCount: metadata.mediaFiles?.length ?? 0 - }, "[getEpisodes] building episode list"); - return toolOk(buildGetEpisodesResponse(metadata)); -} - -// src/tools/getEpisodes.ts -function buildGetEpisodesTool(config2, abortSignal) { - return { - description: GET_EPISODES_DESCRIPTION, - inputSchema: getEpisodesInputSchema, - outputSchema: getEpisodesToolOutputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const params = args ?? {}; - return doGetEpisodes({ mediaFolderPath: params.mediaFolderPath ?? "" }, config2); - } - }; -} - -// ../core/ai-tool/buildGetMediaFoldersResponse.ts -function createEmptyGetMediaFoldersData() { - return { folders: [] }; -} -function buildGetMediaFoldersResponse(userConfig) { - return { - folders: userConfig.folders ?? [] - }; -} - -// src/tools/getMediaFolders.ts -async function executeGetMediaFolders(userConfig, abortSignal) { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - try { - return toolOk(buildGetMediaFoldersResponse(userConfig)); - } catch (error48) { - return { - ...createEmptyGetMediaFoldersData(), - ...formatToolError(error48) - }; - } -} -function buildGetMediaFoldersTool(userConfig, abortSignal) { - return { - description: GET_MEDIA_FOLDERS_DESCRIPTION, - inputSchema: getMediaFoldersInputSchema, - outputSchema: getMediaFoldersOutputSchema, - execute: async () => executeGetMediaFolders(userConfig, abortSignal) - }; -} - -// ../core/utils.ts -var extensions = { - audioTrackFileExtensions: [".mka"], - videoFileExtensions: [ - ".mp4", - ".mkv", - ".avi", - ".mov", - ".wmv", - ".flv", - ".webm", - ".m4v", - ".mpg", - ".mpeg", - ".m2v", - ".m1v", - ".qt", - ".3gp", - ".3g2", - ".rm", - ".rmvb", - ".ra", - ".asf", - ".wm", - ".ogv", - ".ogm", - ".vob", - ".divx", - ".f4v", - ".h264", - ".mxf", - ".svi", - ".tp", - ".trp", - ".wtv", - ".ts", - ".m2ts", - ".mts", - ".swf", - ".yuv", - ".m4p", - ".m4b", - ".m4r" - ], - subtitleFileExtensions: [ - ".srt", - ".ass", - ".ssa", - ".vtt", - ".sub", - ".idx", - ".smi", - ".sami", - ".lrc", - ".sbv", - ".ttml", - ".dfxp", - ".stl", - ".usf", - ".dks", - ".jss", - ".pjs", - ".psb", - ".rt", - ".s2k", - ".sbt", - ".scc", - ".cap", - ".cdg", - ".scr", - ".xas", - ".mpl", - ".mks", - ".sup", - ".aqt", - ".gsub", - ".vsf", - ".zeg", - ".cif", - ".cip", - ".ets", - ".itk", - ".slt", - ".ssf", - ".tds", - ".txt" - ], - imageFileExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".svg"], - musicFileExtensions: [".mp3", ".m4a", ".aac", ".ogg", ".wav", ".flac", ".ape", ".mka", ".wma", ".aac", ".m4a", ".ogg", ".wav", ".flac", ".ape"] -}; -var videoFileExtensions = extensions.videoFileExtensions; -var imageFileExtensions = extensions.imageFileExtensions; -var subtitleFileExtensions = extensions.subtitleFileExtensions; - -// ../core/ai-tool/buildListFilesInMediaFolderResponse.ts -function createEmptyListFilesInMediaFolderData() { - return { files: [], count: 0 }; -} -function isVideoFile(filePath) { - const dotIndex = filePath.lastIndexOf("."); - if (dotIndex === -1) { - return false; - } - const ext = filePath.substring(dotIndex).toLowerCase(); - return videoFileExtensions.includes(ext); -} -function buildListFilesInMediaFolderResponse(filePaths, videoFileOnly = false) { - let files = filePaths.map((filePath) => Path.toPlatformPath(filePath)); - if (videoFileOnly) { - files = files.filter(isVideoFile); - } - return { - files, - count: files.length - }; -} - -// src/listFiles.ts -var import_node_os = __toESM(require("node:os")); -var import_promises6 = require("node:fs/promises"); -var import_node_path4 = __toESM(require("node:path")); - -// src/resolveListFilesPath.ts -var import_node_path3 = __toESM(require("node:path")); -function joinListFilesChildPath(dirPath, childName) { - if (dirPath.startsWith("file://")) { - const separator = dirPath.endsWith("/") ? "" : "/"; - return `${dirPath}${separator}${childName}`; - } - return import_node_path3.default.join(dirPath, childName); -} -function resolveListFilesAbsolutePath(folderPath) { - if (folderPath.startsWith("file://")) { - return folderPath; - } - return import_node_path3.default.resolve(folderPath); -} -function normalizeListFilesInputPath(folderPath) { - if (folderPath.startsWith("file://")) { - return folderPath; - } - if (folderPath.startsWith("/") && Path.isWindows()) { - const normalizedPosixPath = folderPath.replace(/^\/([A-Za-z]):\//, "/$1/"); - return Path.win(normalizedPosixPath); - } - if (/^[A-Za-z]:/.test(folderPath) && !Path.isWindows()) { - return new Path(folderPath).abs("posix"); - } - return Path.toPlatformPath(folderPath); -} - -// src/describeToolFailure.ts -function describeToolFailure(error48) { - if (error48 instanceof Error) { - return { - message: error48.message, - errorType: error48.constructor.name, - stack: error48.stack, - raw: String(error48) - }; - } - if (typeof error48 === "string") { - return { message: error48, errorType: "string", raw: error48 }; - } - if (error48 === null || error48 === undefined) { - return { - message: "null or undefined thrown", - errorType: String(error48), - raw: String(error48) - }; - } - try { - const json3 = JSON.stringify(error48); - return { - message: json3 && json3 !== "{}" ? json3 : String(error48), - errorType: typeof error48, - raw: json3 ?? String(error48) - }; - } catch { - return { - message: String(error48), - errorType: typeof error48, - raw: String(error48) - }; - } -} - -// src/listFiles.ts -var listFilesRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "Path is required"), - onlyFiles: exports_external2.boolean().optional(), - onlyFolders: exports_external2.boolean().optional(), - includeHiddenFiles: exports_external2.boolean().optional(), - recursively: exports_external2.boolean().optional() -}); -var emptyListFilesData = { - path: "", - items: [], - size: 0 -}; -async function doListFiles(body, config2 = {}) { - const { logger, activatePersistedFileAccess } = config2; - const requestId = `listFiles-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - logger?.info({ requestId, body }, "[ListFiles] request received"); - try { - const validationResult = listFilesRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.info({ requestId, issues: validationResult.error.issues }, "[ListFiles] validation failed"); - return { - data: { path: "", items: [], size: 0 }, - error: `Validation Failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - let { - path: folderPath, - onlyFiles, - onlyFolders, - includeHiddenFiles = false, - recursively = false - } = validationResult.data; - logger?.debug({ requestId, folderPath, onlyFiles, onlyFolders, includeHiddenFiles, recursively }, "[ListFiles] validated params"); - if (folderPath === "~" || folderPath.startsWith("~/")) { - const homeDir = import_node_os.default.homedir(); - folderPath = folderPath === "~" ? homeDir : import_node_path4.default.join(homeDir, folderPath.slice(2)); - } - try { - folderPath = normalizeListFilesInputPath(folderPath); - } catch (error48) { - logger?.info({ requestId, folderPath, error: error48 }, "[ListFiles] Path normalization threw, using raw path"); - } - const validatedPath = resolveListFilesAbsolutePath(folderPath); - logger?.info({ requestId, validatedPath }, "[ListFiles] resolved absolute path"); - if (validatedPath.startsWith("file://")) { - if (activatePersistedFileAccess) { - try { - await activatePersistedFileAccess([validatedPath]); - } catch (error48) { - const failure = describeToolFailure(error48); - logger?.warn({ requestId, validatedPath, ...failure }, "[ListFiles] file access activation failed"); - return { - data: { path: validatedPath, items: [], size: 0 }, - error: `File Access Activation Failed: ${failure.message}` - }; - } - } else { - logger?.warn({ requestId, validatedPath }, "[ListFiles] file:// path without activatePersistedFileAccess hook"); - } - } - try { - const stats = await import_promises6.stat(validatedPath); - logger?.info({ requestId, validatedPath, isDirectory: stats.isDirectory(), isFile: stats.isFile() }, "[ListFiles] stat result"); - if (!stats.isDirectory()) { - logger?.info({ requestId, validatedPath }, "[ListFiles] path is not a directory"); - return { - data: { path: validatedPath, items: [], size: 0 }, - error: `Path Not Directory: ${folderPath} is not a directory` - }; - } - } catch (error48) { - logger?.info({ - requestId, - validatedPath, - folderPath, - errMessage: error48 instanceof Error ? error48.message : String(error48), - errCode: error48 instanceof Error ? error48.code : undefined - }, "[ListFiles] stat failed (path not found or not accessible)"); - return { - data: { path: validatedPath, items: [], size: 0 }, - error: `Directory Not Found: ${folderPath} was not found` - }; - } - try { - logger?.debug({ requestId, validatedPath }, "[ListFiles] starting readdir"); - const results = []; - let totalCount = 0; - async function scanDirectory(dirPath, isTopLevel = false) { - logger?.debug({ requestId, dirPath, isTopLevel }, "[ListFiles] scanDirectory readdir"); - const items = await import_promises6.readdir(dirPath); - for (const item of items) { - const fullPath = joinListFilesChildPath(dirPath, item); - try { - const itemStats = await import_promises6.stat(fullPath); - const isFile2 = itemStats.isFile(); - const isDirectory = itemStats.isDirectory(); - const filename = import_node_path4.default.basename(item); - const isHidden = filename.startsWith(".") || filename === "Thumbs.db" || filename === "desktop.ini"; - if (!includeHiddenFiles && isHidden) { - continue; - } - if (isTopLevel) { - totalCount++; - } - let shouldAddToResults = true; - if (onlyFiles && onlyFolders) { - shouldAddToResults = isFile2; - } else { - if (onlyFiles === true && !isFile2) { - shouldAddToResults = false; - } - if (onlyFolders === true && !isDirectory) { - shouldAddToResults = false; - } - } - if (shouldAddToResults) { - results.push({ - path: fullPath, - size: itemStats.size, - mtime: itemStats.mtimeMs, - isDirectory - }); - } - if (isDirectory && recursively) { - await scanDirectory(fullPath, false); - } - } catch { - continue; - } - } - } - await scanDirectory(validatedPath, true); - return { - data: { - path: validatedPath, - items: results, - size: totalCount - } - }; - } catch (error48) { - const err = error48 instanceof Error ? error48 : new Error(String(error48)); - const errNo = error48?.code; - logger?.info({ - requestId, - validatedPath, - errMessage: err.message, - errCode: errNo, - errStack: err.stack - }, "[ListFiles] readdir/list failed"); - return { - data: { path: validatedPath, items: [], size: 0 }, - error: `List Directory Failed: ${err.message}` - }; - } - } catch (error48) { - const err = error48 instanceof Error ? error48 : new Error(String(error48)); - logger?.info({ requestId, errMessage: err.message, errStack: err.stack }, "[ListFiles] unexpected error in doListFiles"); - return { - data: emptyListFilesData, - error: `Unexpected Error: ${err.message}` - }; - } -} - -// src/tools/listFilesInMediaFolder.ts -async function executeListFilesInMediaFolder(params, userConfig, abortSignal) { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const pathCheck = requireNonEmptyString(params.mediaFolderPath, "mediaFolderPath"); - if (typeof pathCheck !== "string") { - return { - ...createEmptyListFilesInMediaFolderData(), - error: LIST_FILES_IN_MEDIA_FOLDER_INVALID_PATH - }; - } - const empty = createEmptyListFilesInMediaFolderData(); - if (!isMediaFolderManagedFromConfig2(userConfig, pathCheck)) { - return { ...empty, error: LIST_FILES_IN_MEDIA_FOLDER_NOT_MANAGED }; - } - try { - const listResult = await doListFiles({ - path: pathCheck, - recursively: params.recursively ?? true, - onlyFiles: true - }); - if (listResult.error) { - return { ...empty, error: listResult.error }; - } - const filePaths = listResult.data?.items.filter((item) => !item.isDirectory).map((item) => item.path) ?? []; - return toolOk(buildListFilesInMediaFolderResponse(filePaths, params.videoFileOnly ?? false)); - } catch (error48) { - return { - ...empty, - ...formatToolError(error48) - }; - } -} -function isMediaFolderManagedFromConfig2(userConfig, mediaFolderPath) { - const targetPlatform = Path.toPlatformPath(mediaFolderPath); - const targetPosix = Path.posix(mediaFolderPath); - return (userConfig.folders ?? []).some((folder) => { - return Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix; - }); -} -function buildListFilesInMediaFolderTool(userConfig, abortSignal) { - return { - description: LIST_FILES_IN_MEDIA_FOLDER_DESCRIPTION, - inputSchema: listFilesInMediaFolderInputSchema, - outputSchema: listFilesInMediaFolderOutputSchema, - execute: async (args) => { - const params = args ?? {}; - return executeListFilesInMediaFolder({ - mediaFolderPath: params.mediaFolderPath ?? "", - recursively: params.recursively, - videoFileOnly: params.videoFileOnly - }, userConfig, abortSignal); - } - }; -} - -// ../core/ai-tool/renameFolderConfirm.ts -function getFolderBasename(folderPath) { - const parts = Path.posix(folderPath).split("/").filter(Boolean); - return parts[parts.length - 1] ?? Path.posix(folderPath); -} -function buildRenameFolderConfirmationMessage(from, to) { - return `Rename folder "${getFolderBasename(from)}" to "${getFolderBasename(to)}"? - -` + `This will: - • Rename the folder on disk - • Update media metadata`; -} - -// ../core/ai-tool/renameFolderResult.ts -function renameFolderCancelled(from, to) { - return { - renamed: false, - from: Path.toPlatformPath(from), - to: Path.toPlatformPath(to), - error: RENAME_FOLDER_CANCELLED - }; -} -function renameFolderFailed(from, to, error48) { - return { - renamed: false, - from: Path.toPlatformPath(from), - to: Path.toPlatformPath(to), - error: error48 - }; -} -function renameFolderSucceeded(from, to) { - return { - renamed: true, - from: Path.toPlatformPath(from), - to: Path.toPlatformPath(to) - }; -} - -// src/renameFolder.ts -var import_promises7 = require("node:fs/promises"); - -// ../core/mediaMetadata.ts -function renameFolderInMediaMetadata(mediaMetadata, from, to) { - const fromNormalized = from.endsWith("/") ? from : from + "/"; - const toNormalized = to.endsWith("/") ? to : to + "/"; - const result = structuredClone(mediaMetadata); - if (result.mediaFolderPath) { - const mediaFolderPathNormalized = result.mediaFolderPath.endsWith("/") ? result.mediaFolderPath : result.mediaFolderPath + "/"; - if (result.mediaFolderPath === fromNormalized.slice(0, -1)) { - result.mediaFolderPath = toNormalized.slice(0, -1); - } else if (mediaFolderPathNormalized.startsWith(fromNormalized)) { - result.mediaFolderPath = toNormalized + result.mediaFolderPath.slice(fromNormalized.length); - } - } - if (result.files) { - result.files = result.files.map((file2) => { - if (file2.startsWith(fromNormalized)) { - return toNormalized + file2.slice(fromNormalized.length); - } - return file2; - }); - } - if (result.mediaFiles) { - result.mediaFiles = result.mediaFiles.map((mediaFile) => { - if (mediaFile.absolutePath.startsWith(fromNormalized)) { - return { - ...mediaFile, - absolutePath: toNormalized + mediaFile.absolutePath.slice(fromNormalized.length) - }; - } - return mediaFile; - }); - } - return result; -} -function updateMediaMetadataAfterRename(mediaMetadata, renameMappings) { - const renameMap = new Map; - for (const { from, to } of renameMappings) { - renameMap.set(Path.posix(from), Path.posix(to)); - } - const updatedFiles = mediaMetadata.files?.map((file2) => { - const normalizedFile = Path.posix(file2); - return renameMap.get(normalizedFile) ?? file2; - }); - const updatedMediaFiles = mediaMetadata.mediaFiles?.map((mediaFile) => { - const normalizedPath = Path.posix(mediaFile.absolutePath); - const newPath = renameMap.get(normalizedPath); - if (newPath) { - return { - ...mediaFile, - absolutePath: newPath - }; - } - return mediaFile; - }); - const fullyUpdatedMediaFiles = updatedMediaFiles?.map((mediaFile) => { - if (mediaFile.subtitleFilePaths || mediaFile.audioFilePaths) { - const updatedSubtitlePaths = mediaFile.subtitleFilePaths?.map((subtitlePath) => { - const normalizedPath = Path.posix(subtitlePath); - return renameMap.get(normalizedPath) ?? subtitlePath; - }); - const updatedAudioPaths = mediaFile.audioFilePaths?.map((audioPath) => { - const normalizedPath = Path.posix(audioPath); - return renameMap.get(normalizedPath) ?? audioPath; - }); - return { - ...mediaFile, - subtitleFilePaths: updatedSubtitlePaths, - audioFilePaths: updatedAudioPaths - }; - } - return mediaFile; - }); - return { - ...mediaMetadata, - files: updatedFiles, - mediaFiles: fullyUpdatedMediaFiles - }; -} - -// ../core/userConfig.ts -function renameFolderInUserConfig(userConfig, from, to) { - const actualFromPosix = Path.posix(from); - const actualFromWindows = Path.win(from); - const actualTo = Path.toPlatformPath(to); - const newFolders = userConfig.folders.map((folder) => folder === actualFromPosix ? actualTo : folder).map((folder) => folder === actualFromWindows ? actualTo : folder); - return { - ...userConfig, - folders: newFolders - }; -} - -// src/renameFolder.ts -var renameFolderRequestSchema = exports_external2.object({ - from: exports_external2.string().min(1, "Source folder path is required, in POSIX format"), - to: exports_external2.string().min(1, "Destination folder path is required, in POSIX format") -}); -async function doRenameFolder(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { - try { - const validationResult = renameFolderRequestSchema.safeParse(body); - if (!validationResult.success) { - return { - error: `Validation Failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { from, to } = validationResult.data; - const fromAsPosix = Path.posix(from); - const toAsPosix = Path.posix(to); - if (!await isMediaFolderManaged(from, config2)) { - config2.logger?.error({ from: fromAsPosix, to: toAsPosix }, "[renameFolder] source folder is not managed by SMM"); - return { - error: `${fromAsPosix} is not managed by SMM` - }; - } - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - return { error: "appDataDir is not configured" }; - } - const mediaMetadata = await readMediaMetadataCache(appDataDir, fromAsPosix); - if (!mediaMetadata) { - config2.logger?.error({ from: fromAsPosix, to: toAsPosix }, "[renameFolder] media metadata not found"); - return { - error: `Media metadata not found: ${from}` - }; - } - const updatedMetadata = renameFolderInMediaMetadata(mediaMetadata, fromAsPosix, toAsPosix); - await writeMediaMetadataCache(appDataDir, updatedMetadata); - await deleteMediaMetadataCache(appDataDir, fromAsPosix); - const userConfig = await readUserConfig(config2); - const newUserConfig = renameFolderInUserConfig(userConfig, fromAsPosix, toAsPosix); - await writeUserConfigToDisk(config2, newUserConfig); - await import_promises7.rename(Path.toPlatformPath(fromAsPosix), Path.toPlatformPath(toAsPosix)); - config2.logger?.info({ from: fromAsPosix, to: toAsPosix }, "[renameFolder] renamed media folder"); - return {}; - } catch (error48) { - const message = error48 instanceof Error ? error48.message : "Unknown error"; - config2.logger?.error({ error: message }, "[renameFolder] unexpected error"); - return { - error: `Unexpected Error: ${message}` - }; - } -} - -// src/tools/renameFolder.ts -async function executeRenameFolder(params, config2, abortSignal) { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const fromCheck = requireNonEmptyString(params.from, "from"); - if (typeof fromCheck !== "string") { - return renameFolderFailed("", "", fromCheck.error); - } - const toCheck = requireNonEmptyString(params.to, "to"); - if (typeof toCheck !== "string") { - return renameFolderFailed(fromCheck, "", toCheck.error); - } - try { - const result = await doRenameFolder({ from: fromCheck, to: toCheck }, config2); - if (result.error) { - return renameFolderFailed(fromCheck, toCheck, result.error); - } - return renameFolderSucceeded(fromCheck, toCheck); - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - return renameFolderFailed(fromCheck, toCheck, `Error renaming folder: ${message}`); - } -} -async function confirmRenameFolderViaSocket(clientId, from, to, acknowledge) { - const confirmationMessage = buildRenameFolderConfirmationMessage(from, to); - try { - const responseData = await acknowledge({ - event: "askForConfirmation", - data: { message: confirmationMessage }, - clientId - }, 30000); - const confirmed = responseData?.confirmed ?? responseData?.response === "yes"; - if (!confirmed) { - return renameFolderCancelled(from, to); - } - return null; - } catch (error48) { - return renameFolderFailed(from, to, `Failed to get user confirmation: ${error48 instanceof Error ? error48.message : "Unknown error"}`); - } -} -function buildRenameFolderTool(clientId, config2, abortSignal, acknowledge) { - const ack = acknowledge ?? defaultAcknowledge; - return { - description: RENAME_FOLDER_DESCRIPTION, - inputSchema: renameFolderInputSchema, - outputSchema: renameFolderOutputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const params = args ?? {}; - const fromCheck = requireNonEmptyString(params.from, "from"); - if (typeof fromCheck !== "string") { - return renameFolderFailed("", "", fromCheck.error); - } - const toCheck = requireNonEmptyString(params.to, "to"); - if (typeof toCheck !== "string") { - return renameFolderFailed(fromCheck, "", toCheck.error); - } - const cancelOrError = await confirmRenameFolderViaSocket(clientId, fromCheck, toCheck, ack); - if (cancelOrError) { - return cancelOrError; - } - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - return executeRenameFolder({ from: fromCheck, to: toCheck }, config2, abortSignal); - } - }; -} - -// ../core/plan/renamePlan.ts -function createEmptyRenamePlan(mediaFolderPath, id, options) { - const planId = id ?? (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`); - return { - id: planId, - task: "rename-files", - status: options?.status ?? "pending", - creator: options?.creator ?? "app", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [] - }; -} -function assertMediaFolderHasMetadata(exists, folderPath) { - if (!exists) { - return `Error Reason: folderPath "${Path.posix(folderPath)}" is not opened in SMM`; - } - return; -} -function assertEpisodeVideoFile(metadata, fromPath) { - const fromPosix = Path.posix(fromPath); - const mediaFile = (metadata.mediaFiles ?? []).find((mf) => mf.absolutePath === fromPosix); - if (!mediaFile) { - return "Error Reason: Not Episode Video File"; - } - return; -} -async function prepareAppendRenameEntry(plan, entry, deps) { - const fromPosix = Path.posix(entry.from); - const toPosix = Path.posix(entry.to); - const candidateFiles = [...plan.files, { from: fromPosix, to: toPosix }]; - const validationResult = await deps.validateOperations(candidateFiles, plan.mediaFolderPath); - if (!validationResult.isValid) { - return { error: `Error Reason: ${validationResult.errors.join(` -`)}` }; - } - const mm = await deps.getMediaMetadata(plan.mediaFolderPath); - if (!mm) { - return { - error: `Error Reason: Media metadata not found for media folder: ${plan.mediaFolderPath}` - }; - } - const episodeError = assertEpisodeVideoFile(mm, fromPosix); - if (episodeError) { - return { error: episodeError }; - } - return { - ...plan, - files: [...plan.files, { from: fromPosix, to: toPosix }] - }; -} - -// ../core/types/ai-tools/planTaskMessages.ts -var END_PLAN_TASK_SUCCESS_MESSAGE = "Task is created successfuly. User need to go to SMM, review and approve the task."; -var PLAN_CANCELLED_BY_USER_MESSAGE = "该任务已被用户取消, 请停止后续操作"; - -// ../core/event-types.ts -var RecognizeMediaFilePlanReady = { - event: "recognizeMediaFilePlanReady" -}; -var RenameFilesPlanReady = { - event: "renameFilesPlanReady" -}; -var USER_CONFIG_UPDATED_EVENT = "userConfigUpdated"; -var USER_CONFIG_FOLDER_RENAMED_EVENT = "userConfig.folderRenamed"; - -// src/tools/plans.ts -var import_promises8 = require("node:fs/promises"); -var import_node_path5 = __toESM(require("node:path")); -var import_node_crypto2 = require("node:crypto"); - -// ../core/types/planCommon.ts -function isActivePlanStatus(status) { - return status === "preparing" || status === "pending"; -} - -// src/tools/plans.ts -function plansDir(appDataDir) { - return import_node_path5.default.join(appDataDir, "plans"); -} -function planFilePath(appDataDir, planId) { - return import_node_path5.default.join(plansDir(appDataDir), `${planId}.plan.json`); -} -async function ensurePlansDirExists(appDataDir, fs) { - const dir = plansDir(appDataDir); - try { - const stats = await import_promises8.stat(dir); - if (!stats.isDirectory()) { - throw new Error("Plans path exists but is not a directory"); - } - } catch (error48) { - if (error48.code === "ENOENT") { - await import_promises8.mkdir(dir, { recursive: true }); - return; - } - throw error48; - } -} -async function beginRenamePlan(appDataDir, mediaFolderPath, fs) { - await ensurePlansDirExists(appDataDir, fs); - const plan = createEmptyRenamePlan(Path.posix(mediaFolderPath), undefined, { - creator: "ai", - status: "preparing" - }); - await fs.writeJson(planFilePath(appDataDir, plan.id), plan); - return plan.id; -} -async function appendRenamePlanEntry(appDataDir, planId, from, to, fs, deps) { - const filePath = planFilePath(appDataDir, planId); - const plan = await fs.readJson(filePath) ?? null; - if (!plan) { - throw new Error(`Task with id ${planId} not found`); - } - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); - } - const result = await prepareAppendRenameEntry(plan, { from, to }, deps); - if ("error" in result) { - throw new Error(result.error.replace(/^Error Reason: /, "")); - } - await fs.writeJson(filePath, result); -} -async function readRenamePlan(appDataDir, planId, fs) { - const plan = await readPlanById(appDataDir, planId, fs); - if (!plan || plan.task !== "rename-files") { - return null; - } - return plan; -} -async function readPlanById(appDataDir, planId, fs) { - const plan = await fs.readJson(planFilePath(appDataDir, planId)); - if (!plan) { - return null; - } - return normalizePlanPaths(withCreatorDefault(plan)); -} -async function beginRecognizePlan(appDataDir, mediaFolderPath, fs) { - await ensurePlansDirExists(appDataDir, fs); - const planId = import_node_crypto2.randomUUID(); - const plan = { - id: planId, - task: "recognize-media-file", - status: "preparing", - creator: "ai", - mediaFolderPath: Path.posix(mediaFolderPath), - files: [] - }; - await fs.writeJson(planFilePath(appDataDir, planId), plan); - return planId; -} -async function defaultValidateRecognizedFiles(files, fs) { - for (const file2 of files) { - if (!file2.path) { - throw new Error(`File path is empty for S${file2.season}E${file2.episode}`); - } - const platformPath = Path.toPlatformPath(Path.posix(file2.path)); - const exists = await fs.exists(platformPath); - if (!exists) { - throw new Error(`File "${Path.posix(file2.path)}" (S${file2.season}E${file2.episode}) does not exist in the media folder`); - } - } -} -async function appendRecognizedFile(appDataDir, taskId, file2, fs, deps = {}) { - const filePath = planFilePath(appDataDir, taskId); - const plan = await fs.readJson(filePath) ?? null; - if (!plan) { - throw new Error(`Task with id ${taskId} not found`); - } - if (plan.status === "rejected") { - throw new Error(PLAN_CANCELLED_BY_USER_MESSAGE); - } - const normalizedPath = Path.posix(file2.path); - const validate = deps.validateFiles ?? ((files) => defaultValidateRecognizedFiles(files, fs)); - await validate([{ ...file2, path: normalizedPath }]); - plan.files.push({ - season: file2.season, - episode: file2.episode, - path: normalizedPath - }); - await fs.writeJson(filePath, plan); -} -async function readRecognizePlan(appDataDir, taskId, fs) { - const plan = await readPlanById(appDataDir, taskId, fs); - if (!plan || plan.task !== "recognize-media-file") { - return null; - } - return plan; -} -async function listPlanFiles(appDataDir) { - const dir = plansDir(appDataDir); - try { - const stats = await import_promises8.stat(dir); - if (!stats.isDirectory()) { - return []; - } - } catch { - return []; - } - const files = await import_promises8.readdir(dir); - return files.filter((file2) => file2.endsWith(".plan.json")).map((file2) => import_node_path5.default.join(dir, file2)); -} -function withCreatorDefault(plan) { - if (plan.creator) { - return plan; - } - return { ...plan, creator: "app" }; -} -function normalizePlanPaths(plan) { - const mediaFolderPath = Path.posix(plan.mediaFolderPath); - if (plan.task === "recognize-media-file") { - return { - ...plan, - mediaFolderPath, - files: plan.files.map((f) => ({ ...f, path: Path.posix(f.path) })) - }; - } - return { - ...plan, - mediaFolderPath, - files: plan.files.map((f) => ({ - from: Path.posix(f.from), - to: Path.posix(f.to) - })) - }; -} -async function createPlan(appDataDir, input, fs) { - await ensurePlansDirExists(appDataDir, fs); - const id = input.id ?? import_node_crypto2.randomUUID(); - const mediaFolderPath = Path.posix(input.mediaFolderPath); - const plan = input.task === "recognize-media-file" ? { - id, - task: "recognize-media-file", - status: "preparing", - creator: input.creator, - mediaFolderPath, - files: [] - } : { - id, - task: "rename-files", - status: "preparing", - creator: input.creator, - mediaFolderPath, - files: [] - }; - await fs.writeJson(planFilePath(appDataDir, id), plan); - return plan; -} -async function updatePlanContent(appDataDir, id, patch, fs) { - const filePath = planFilePath(appDataDir, id); - const existing = await fs.readJson(filePath); - if (!existing) { - return null; - } - const merged = withCreatorDefault({ - ...existing, - ...patch.status !== undefined ? { status: patch.status } : {}, - ...patch.files !== undefined ? { files: patch.files } : {} - }); - const updated = normalizePlanPaths(merged); - if (patch.status === "completed") { - await deletePlan(appDataDir, id); - return updated; - } - await fs.writeJson(filePath, updated); - return updated; -} -async function deletePlan(appDataDir, id) { - try { - await import_promises8.unlink(planFilePath(appDataDir, id)); - } catch (error48) { - if (error48.code !== "ENOENT") { - throw error48; - } - } -} -async function cleanPreparingPlans(appDataDir, fs, logger) { - const start = Date.now(); - const plansPath = plansDir(appDataDir); - logger?.info({ appDataDir, plansDir: plansPath }, "[cleanup] plan cleanup: scanning for stale preparing plans"); - const files = await listPlanFiles(appDataDir); - logger?.info({ plansDir: plansPath, scanned: files.length }, "[cleanup] plan cleanup: enumerated plan files"); - let removed = 0; - let failed = 0; - for (const filePath of files) { - try { - const plan = await fs.readJson(filePath); - if (!plan) { - logger?.debug({ filePath }, "[cleanup] plan cleanup: skipping unreadable plan file"); - continue; - } - if (plan.status === "preparing") { - await import_promises8.unlink(filePath); - removed++; - logger?.debug({ filePath, planId: plan.id, task: plan.task }, "[cleanup] plan cleanup: removed stale preparing plan"); - } else { - logger?.debug({ filePath, planId: plan.id, status: plan.status }, "[cleanup] plan cleanup: keeping plan (not preparing)"); - } - } catch (err) { - failed++; - logger?.warn({ filePath, error: err.message }, "[cleanup] plan cleanup: failed to process plan file, skipping"); - } - } - logger?.info({ - plansDir: plansPath, - scanned: files.length, - removed, - failed, - durationMs: Date.now() - start - }, "[cleanup] plan cleanup: complete"); - return removed; -} -async function getActivePlansForFolder(appDataDir, mediaFolderPath, fs) { - const target = Path.posix(mediaFolderPath); - const files = await listPlanFiles(appDataDir); - const plans = []; - for (const file2 of files) { - const plan = await fs.readJson(file2); - if (!plan) { - continue; - } - const normalized = normalizePlanPaths(withCreatorDefault(plan)); - if (normalized.mediaFolderPath === target && isActivePlanStatus(normalized.status)) { - plans.push(normalized); - } - } - return plans; -} - -// src/tools/renameFilesTask.ts -function makeLogger(logger) { - return { - info: (obj, msg) => logger?.info(obj, msg), - warn: (obj, msg) => logger?.warn(obj, msg), - error: (obj, msg) => logger?.error(obj, msg) - }; -} -function buildBeginRenameFilesTaskTool(clientId, appDataDir, fs, _deps, broadcast, logger, abortSignal) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: BEGIN_RENAME_FILES_TASK_DESCRIPTION, - toolName: BEGIN_RENAME_FILES_TASK, - inputSchema: beginRenameFilesTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { mediaFolderPath } = args ?? {}; - log.info({ mediaFolderPath, clientId }, `[tool][${BEGIN_RENAME_FILES_TASK}] Starting new rename task`); - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); - const metadataFilePath = metadataCacheFilePath(appDataDir, folderPathInPosix); - const metadataExists = await fs.exists(metadataFilePath); - const metadataError = assertMediaFolderHasMetadata(metadataExists, folderPathInPosix); - if (metadataError) { - log.warn({ folderPath: folderPathInPosix }, `[tool][${BEGIN_RENAME_FILES_TASK}] Media metadata not found`); - return toolError(metadataError.replace(/^Error Reason: /, "")); - } - try { - const taskId = await beginRenamePlan(appDataDir, folderPathInPosix, fs); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId }, `[tool][${BEGIN_RENAME_FILES_TASK}] Task created successfully`); - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId, - planFilePath: planFilePathInPosix - }; - emit({ - event: RenameFilesPlanReady.event, - data - }); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, `[tool][${BEGIN_RENAME_FILES_TASK}] RenameFilesPlanReady broadcast sent`); - return toolOk({ taskId }); - } catch (error48) { - log.error({ - mediaFolderPath: folderPathInPosix, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${BEGIN_RENAME_FILES_TASK}] Failed to create task`); - return formatToolError(error48); - } - } - }; -} -function buildAddRenameFileToTaskTool(clientId, appDataDir, fs, deps, logger, abortSignal) { - const log = makeLogger(logger); - return { - description: ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - toolName: ADD_RENAME_FILE_TO_TASK, - inputSchema: addRenameFileToTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, from, to } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, from, to, clientId }, `[tool][${ADD_RENAME_FILE_TO_TASK}] Adding file to task`); - try { - await appendRenamePlanEntry(appDataDir, normalizedTaskId, from ?? "", to ?? "", fs, deps); - log.info({ taskId: normalizedTaskId, from, to, clientId }, `[tool][${ADD_RENAME_FILE_TO_TASK}] File added successfully`); - return toolOk({}); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - from, - to, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${ADD_RENAME_FILE_TO_TASK}] Failed to add file`); - return formatToolError(error48); - } - } - }; -} -function buildEndRenameFilesTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: END_RENAME_FILES_TASK_DESCRIPTION, - toolName: END_RENAME_FILES_TASK, - inputSchema: endRenameFilesTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Ending rename task`); - try { - const task = await readRenamePlan(appDataDir, normalizedTaskId, fs); - if (!task) { - log.error({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Task not found`); - return toolError(`Task with id "${normalizedTaskId}" not found`); - } - if (task.status === "rejected") { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] Task cancelled by user`); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - if (task.files.length === 0) { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RENAME_FILES_TASK}] No files in task`); - return toolError("No rename entries in task"); - } - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId: task.id, - planFilePath: planFilePathInPosix - }; - emit({ - event: RenameFilesPlanReady.event, - data - }); - log.info({ taskId: normalizedTaskId, fileCount: task.files.length, clientId }, `[tool][${END_RENAME_FILES_TASK}] Plan ready, UI notified`); - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${END_RENAME_FILES_TASK}] End task error`); - return formatToolError(error48); - } - } - }; -} - -// src/renameFilesValidation.ts -var import_promises9 = require("node:fs/promises"); - -// ../core/validations/rename/validateChainingConflicts.ts -function validateChainingConflicts(tasks) { - const sourcePaths = new Set; - for (const task of tasks) { - sourcePaths.add(task.from); - } - for (const task of tasks) { - if (sourcePaths.has(task.to)) { - return false; - } - } - return true; -} - -// ../core/validations/rename/validateNoAbnormalPaths.ts -function isPathNormal(p) { - if (p.startsWith("../")) { - return true; - } - if (p.includes("/..") || p.includes("/./") || p.endsWith("/.")) { - return false; - } - const platform = Path.toPlatformPath(p); - const posix = Path.posix(platform); - const parts = posix.split("/").filter((part) => part.length > 0); - const resolved = []; - for (const part of parts) { - if (part === "..") { - if (resolved.length === 0) { - return false; - } - resolved.pop(); - } else if (part !== ".") { - resolved.push(part); - } - } - const normalized = posix.startsWith("/") || /^\/[A-Za-z]:/.test(posix) ? (posix.startsWith("/") ? "/" : "") + resolved.join("/") : resolved.join("/"); - return normalized === posix || normalized === platform; -} -function validateNoAbnormalPaths(tasks) { - const errors4 = []; - for (const task of tasks) { - if (!task) - continue; - if (!isPathNormal(task.from)) { - errors4.push(`Source path "${task.from}" is abnormal`); - } - if (!isPathNormal(task.to)) { - errors4.push(`Destination path "${task.to}" is abnormal`); - } - } - return errors4; -} - -// ../core/validations/rename/validateNoDuplicatedDestFile.ts -function validateNoDuplicatedDestFile(tasks) { - const destPaths = new Map; - for (let i = 0;i < tasks.length; i++) { - const task = tasks[i]; - if (!task) - continue; - const existing = destPaths.get(task.to) ?? []; - existing.push(i); - destPaths.set(task.to, existing); - } - const duplicates = []; - for (const [path6, indices] of destPaths) { - if (indices.length > 1) { - duplicates.push(path6); - } - } - return { - isValid: duplicates.length === 0, - duplicates - }; -} - -// ../core/validations/rename/validateNoDuplicatedSourceFile.ts -function validateNoDuplicatedSourceFile(tasks) { - const sourcePaths = new Map; - for (let i = 0;i < tasks.length; i++) { - const task = tasks[i]; - if (!task) - continue; - const existing = sourcePaths.get(task.from) ?? []; - existing.push(i); - sourcePaths.set(task.from, existing); - } - const duplicates = []; - for (const [path6, indices] of sourcePaths) { - if (indices.length > 1) { - duplicates.push(path6); - } - } - return { - isValid: duplicates.length === 0, - duplicates - }; -} - -// ../core/validations/rename/validateNoIdenticalSourceAndDestFile.ts -function validateNoIdenticalSourceAndDestFile(tasks) { - const identicals = []; - for (const task of tasks) { - if (task && task.from === task.to) { - identicals.push(task.from); - } - } - return { - isValid: identicals.length === 0, - identicals - }; -} - -// ../core/validations/rename/validatePathWithinMediaFolder.ts -function validatePathWithinMediaFolder(mediaFolderPath, tasks) { - const invalidPaths = []; - const mediaFolderObj = new Path(mediaFolderPath); - const mediaFolderNormalized = mediaFolderObj.abs("posix"); - const mediaFolderWithoutDrive = mediaFolderNormalized.replace(/^\/[A-Za-z](?::|\/)/, ""); - for (const task of tasks) { - if (!task) - continue; - const fromPathObj = new Path(task.from); - const fromNormalized = fromPathObj.abs("posix"); - const fromWithoutDrive = fromNormalized.replace(/^\/[A-Za-z](?::|\/)/, ""); - if (!fromWithoutDrive.startsWith(mediaFolderWithoutDrive)) { - invalidPaths.push({ path: task.from, type: "source" }); - } - const toPathObj = new Path(task.to); - const toNormalized = toPathObj.abs("posix"); - const toWithoutDrive = toNormalized.replace(/^\/[A-Za-z](?::|\/)/, ""); - if (!toWithoutDrive.startsWith(mediaFolderWithoutDrive)) { - invalidPaths.push({ path: task.to, type: "destination" }); - } - } - return { - isValid: invalidPaths.length === 0, - invalidPaths - }; -} - -// ../core/validations/rename/validateRenameOperationsSync.ts -function validateRenameOperationsSync(files, folderPathInPosix) { - const errors4 = []; - const normalizedTasks = []; - const taskIndexMap = new Map; - for (let i = 0;i < files.length; i++) { - const renameOp = files[i]; - if (!renameOp) { - continue; - } - taskIndexMap.set(normalizedTasks.length, i); - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to) - }); - } - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [] - }; - } - const abnormalPathErrors = validateNoAbnormalPaths(normalizedTasks); - if (abnormalPathErrors.length > 0) { - errors4.push(...abnormalPathErrors); - } - const duplicateSourceResult = validateNoDuplicatedSourceFile(normalizedTasks); - if (!duplicateSourceResult.isValid) { - for (const duplicatePath of duplicateSourceResult.duplicates) { - const indices = []; - normalizedTasks.forEach((task, idx) => { - if (task.from === duplicatePath) { - indices.push(taskIndexMap.get(idx) ?? idx); - } - }); - errors4.push(`Source file "${duplicatePath}" appears multiple times in the batch (at indices ${indices.join(", ")})`); - } - } - const duplicateDestResult = validateNoDuplicatedDestFile(normalizedTasks); - if (!duplicateDestResult.isValid) { - for (const duplicatePath of duplicateDestResult.duplicates) { - const indices = []; - normalizedTasks.forEach((task, idx) => { - if (task.to === duplicatePath) { - indices.push(taskIndexMap.get(idx) ?? idx); - } - }); - errors4.push(`Target file "${duplicatePath}" appears multiple times in the batch (at indices ${indices.join(", ")})`); - } - } - const identicalResult = validateNoIdenticalSourceAndDestFile(normalizedTasks); - const sourcePaths = new Set; - normalizedTasks.forEach((task) => sourcePaths.add(task.from)); - const hasChainingConflicts = !validateChainingConflicts(normalizedTasks); - const chainingConflictTasks = new Set; - if (hasChainingConflicts) { - normalizedTasks.forEach((task, idx) => { - if (sourcePaths.has(task.to)) { - chainingConflictTasks.add(idx); - errors4.push(`Target file "${task.to}" conflicts with a source path in the same batch (cannot chain renames)`); - } - }); - } - const pathWithinFolderResult = validatePathWithinMediaFolder(folderPathInPosix, normalizedTasks); - if (!pathWithinFolderResult.isValid) { - for (const invalidPath of pathWithinFolderResult.invalidPaths) { - errors4.push(`${invalidPath.type === "source" ? "Source" : "Target"} path "${invalidPath.path}" is outside the media folder "${folderPathInPosix}"`); - } - } - const invalidTasks = new Set; - normalizedTasks.forEach((task, idx) => { - const hasError = abnormalPathErrors.some((e) => e.includes(`"${task.from}"`) || e.includes(`"${task.to}"`)) || duplicateSourceResult.duplicates.includes(task.from) || duplicateDestResult.duplicates.includes(task.to) || identicalResult.identicals.includes(task.from) || pathWithinFolderResult.invalidPaths.some((p) => p.path === task.from || p.path === task.to) || chainingConflictTasks.has(idx); - if (hasError) { - invalidTasks.add(idx); - } - }); - const validatedRenames = []; - normalizedTasks.forEach((task, idx) => { - if (!invalidTasks.has(idx) && task.from !== task.to) { - validatedRenames.push({ - from: task.from, - to: task.to - }); - } - }); - return { - isValid: errors4.length === 0, - errors: errors4, - validatedRenames - }; -} - -// src/renameFilesValidation.ts -async function validateRenameOperations(files, folderPathInPosix) { - const normalizedTasks = []; - for (let i = 0;i < files.length; i++) { - const renameOp = files[i]; - if (!renameOp) { - continue; - } - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to) - }); - } - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [] - }; - } - const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); - const errors4 = [...syncResult.errors]; - const sourceExistResult = await validateSourceFileExist(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - errors4.push(`Source file "${missingFile}" does not exist in the media folder`); - } - } - const destNotExistResult = await validateDestFileNotExist(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - errors4.push(`Target file "${existingFile}" already exists in the filesystem`); - } - } - if (errors4.length > 0) { - return { - isValid: false, - errors: errors4, - validatedRenames: [] - }; - } - return syncResult; -} -async function validateSourceFileExist(tasks) { - const missingFiles = []; - for (const task of tasks) { - if (!task) - continue; - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await import_promises9.stat(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch { - missingFiles.push(task.from); - } - } - return { - isValid: missingFiles.length === 0, - missingFiles - }; -} -async function validateDestFileNotExist(tasks) { - const existingFiles = []; - for (const task of tasks) { - if (!task) - continue; - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await statWithTimeout(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; - } - } - return { - isValid: existingFiles.length === 0, - existingFiles - }; -} -function statWithTimeout(filePath, timeoutMs = 1000) { - return Promise.race([ - import_promises9.stat(filePath), - new Promise((_, reject) => setTimeout(() => reject(new Error(`stat timeout for path: ${filePath}`)), timeoutMs)) - ]); -} - -// src/tools/renameFilesTaskDefaults.ts -function defaultRenameFilesTaskDeps(appDataDir) { - return { - validateOperations: async (files, folderPathInPosix) => { - return validateRenameOperations(files, folderPathInPosix); - }, - getMediaMetadata: async (folderPathInPosix) => { - return await readMediaMetadataCache(appDataDir, folderPathInPosix) ?? null; - } - }; -} - -// src/tools/recognizeMediaFilesTask.ts -function defaultRecognizeFilesTaskDeps(fs) { - return { - validateFiles: (files) => defaultValidateRecognizedFiles(files, fs) - }; -} -function makeLogger2(logger) { - return { - info: (obj, msg) => logger?.info(obj, msg), - warn: (obj, msg) => logger?.warn(obj, msg), - error: (obj, msg) => logger?.error(obj, msg) - }; -} -function buildBeginRecognizeTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger2(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: BEGIN_RECOGNIZE_TASK_DESCRIPTION, - toolName: BEGIN_RECOGNIZE_TASK, - inputSchema: beginRecognizeTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { mediaFolderPath } = args ?? {}; - log.info({ mediaFolderPath, clientId }, `[tool][${BEGIN_RECOGNIZE_TASK}] Starting new recognition task`); - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); - try { - const taskId = await beginRecognizePlan(appDataDir, folderPathInPosix, fs); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId }, `[tool][${BEGIN_RECOGNIZE_TASK}] Task created successfully`); - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId, - planFilePath: planFilePathInPosix - }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data - }); - log.info({ taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, `[DIAG] begin-recognize-task: plan created, RecognizeMediaFilePlanReady broadcast sent`); - return toolOk({ taskId }); - } catch (error48) { - log.error({ - mediaFolderPath: folderPathInPosix, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${BEGIN_RECOGNIZE_TASK}] Failed to create task`); - return formatToolError(error48); - } - } - }; -} -function buildAddRecognizedMediaFileTool(clientId, appDataDir, fs, logger, abortSignal, deps) { - const log = makeLogger2(logger); - return { - description: ADD_RECOGNIZED_MEDIA_FILE_DESCRIPTION, - toolName: ADD_RECOGNIZED_MEDIA_FILE, - inputSchema: addRecognizedMediaFileInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, season, episode, path: filePath } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, season, episode, path: filePath, clientId }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Adding file to task`); - try { - const recognizedFile = { - season: season ?? 0, - episode: episode ?? 0, - path: filePath ?? "" - }; - await appendRecognizedFile(appDataDir, normalizedTaskId, recognizedFile, fs, { validateFiles: deps?.validateFiles }); - log.info({ - taskId: normalizedTaskId, - season, - episode, - path: filePath, - clientId - }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] File added to task successfully`); - return toolOk({}); - } catch (error48) { - log.error({ - taskId: normalizedTaskId, - season, - episode, - path: filePath, - error: error48 instanceof Error ? error48.message : String(error48), - clientId - }, `[tool][${ADD_RECOGNIZED_MEDIA_FILE}] Failed to add file to task`); - return formatToolError(error48); - } - } - }; -} -function buildEndRecognizeTaskTool(clientId, appDataDir, fs, broadcast, logger, abortSignal) { - const log = makeLogger2(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: END_RECOGNIZE_TASK_DESCRIPTION, - toolName: END_RECOGNIZE_TASK, - inputSchema: endRecognizeTaskInputSchema, - execute: async (args) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId } = args ?? {}; - const normalizedTaskId = (taskId ?? "").trim(); - log.info({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Ending recognition task`); - try { - const task = await readRecognizePlan(appDataDir, normalizedTaskId, fs); - if (!task) { - log.error({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Task not found`); - return formatToolError(`Task with id "${normalizedTaskId}" not found`); - } - if (task.status === "rejected") { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] Task cancelled by user`); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - if (task.files.length === 0) { - log.warn({ taskId: normalizedTaskId, clientId }, `[tool][${END_RECOGNIZE_TASK}] No files in task`); - return formatToolError("No recognized files in task"); - } - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data = { - taskId: task.id, - planFilePath: planFilePathInPosix - }; - emit({ - event: RecognizeMediaFilePlanReady.event, - data - }); - log.info({ - taskId: normalizedTaskId, - folderPath: task.mediaFolderPath, - fileCount: task.files.length, - clientId - }, `[tool][${END_RECOGNIZE_TASK}] Task completed successfully`); - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); - } catch (error48) { - return formatToolError(error48); - } - } - }; -} - -// src/tools/index.ts -function createChatTools(args) { - const { config: config2, coreRoutesConfig, userConfig, clientId, abortSignal, fs, extra } = args; - const logger = config2.logger ?? coreRoutesConfig?.logger; - const acknowledge = config2.acknowledge ?? defaultAcknowledge; - const broadcast = coreRoutesConfig?.broadcast ?? config2.broadcast ?? defaultBroadcast; - const syntheticConfig = coreRoutesConfig ?? { - allowlist: [], - hello: { - version: "0.0.0", - userDataDir: config2.appDataDir, - appDataDir: config2.appDataDir, - logDir: "", - tmpDir: "", - reverseProxyUrl: null, - osLocale: "en-US", - coreRoutesPort: 0 - }, - appDataDir: config2.appDataDir, - logger - }; - const renameFilesTaskDeps = extra?.renameFilesTask ?? defaultRenameFilesTaskDeps(config2.appDataDir); - return { - [GET_APPLICATION_CONTEXT]: buildGetApplicationContextTool(clientId, userConfig, (cfg) => resolveAppLanguage({ - configured: cfg.applicationLanguage, - osLocale: detectOsLocale() - }), acknowledge), - [IS_FOLDER_EXIST]: buildIsFolderExistTool(), - [GET_MEDIA_METADATA]: buildGetMediaMetadataTool(userConfig, config2.appDataDir, abortSignal), - [GET_EPISODES]: buildGetEpisodesTool(syntheticConfig, abortSignal), - [GET_MEDIA_FOLDERS]: buildGetMediaFoldersTool(userConfig, abortSignal), - [LIST_FILES_IN_MEDIA_FOLDER]: buildListFilesInMediaFolderTool(userConfig, abortSignal), - [RENAME_FOLDER]: buildRenameFolderTool(clientId, syntheticConfig, abortSignal, acknowledge), - [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool(clientId, config2.appDataDir, fs, renameFilesTaskDeps, broadcast, logger, abortSignal), - [ADD_RENAME_FILE_TO_TASK]: buildAddRenameFileToTaskTool(clientId, config2.appDataDir, fs, renameFilesTaskDeps, logger, abortSignal), - [END_RENAME_FILES_TASK]: buildEndRenameFilesTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal), - [BEGIN_RECOGNIZE_TASK]: buildBeginRecognizeTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal), - [ADD_RECOGNIZED_MEDIA_FILE]: buildAddRecognizedMediaFileTool(clientId, config2.appDataDir, fs, logger, abortSignal), - [END_RECOGNIZE_TASK]: buildEndRecognizeTaskTool(clientId, config2.appDataDir, fs, broadcast, logger, abortSignal) - }; -} - -// src/chat.ts -var CHAT_STEP_LIMIT = 100; -async function doChat(config2, request, extra = {}) { - const fs = config2.fs ?? defaultChatFs(); - const log = config2.logger; - const outcome = await prepareChat(config2, request, fs); - if (outcome.kind === "respond") { - return outcome.response; - } - const { userConfig, body, providerResult } = outcome; - const { messages, model, tools: frontendToolsInput, system, clientId } = body; - log?.debug({ clientId, hasMessages: Array.isArray(messages), messageCount: messages?.length ?? 0 }, "doChat: received chat request"); - log?.debug({ - selectedAIProvider: userConfig.selectedAIProvider, - model: model || providerResult.model - }, "doChat: using AI model configuration"); - const modelMessages = await convertToModelMessages(messages ?? []); - const provider = providerResult.provider; - const defaultModel = providerResult.model; - const providerForAiSdk = provider; - const tools = createChatTools({ - config: config2, - userConfig, - clientId, - abortSignal: request.signal, - fs, - extra - }); - const result = streamText({ - model: providerForAiSdk.chatModel(model || defaultModel), - messages: modelMessages, - system: system || config2.systemPrompt || SYSTEM_PROMPT, - abortSignal: request.signal, - tools: { - ...frontendTools(frontendToolsInput), - [GET_APPLICATION_CONTEXT]: tools[GET_APPLICATION_CONTEXT], - [IS_FOLDER_EXIST]: tools[IS_FOLDER_EXIST], - [GET_MEDIA_METADATA]: tools[GET_MEDIA_METADATA], - [GET_EPISODES]: tools[GET_EPISODES], - [GET_MEDIA_FOLDERS]: tools[GET_MEDIA_FOLDERS], - [LIST_FILES_IN_MEDIA_FOLDER]: tools[LIST_FILES_IN_MEDIA_FOLDER], - [RENAME_FOLDER]: tools[RENAME_FOLDER], - [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], - [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], - [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], - [BEGIN_RECOGNIZE_TASK]: tools[BEGIN_RECOGNIZE_TASK], - [ADD_RECOGNIZED_MEDIA_FILE]: tools[ADD_RECOGNIZED_MEDIA_FILE], - [END_RECOGNIZE_TASK]: tools[END_RECOGNIZE_TASK] - }, - stopWhen: stepCountIs(CHAT_STEP_LIMIT) - }); - if (request.signal?.aborted) { - log?.info({}, "doChat: request aborted before response creation"); - throw new Error("Request aborted"); - } - log?.debug({}, "doChat: streaming response created"); - return result.toUIMessageStreamResponse(); -} -async function prepareChat(config2, request, _fs) { - const log = config2.logger; - if (request.signal?.aborted) { - log?.info({}, "doChat: request was already aborted"); - return { - kind: "respond", - response: jsonResponse(499, { error: "Request was aborted" }) - }; - } - let userConfig; - try { - userConfig = await config2.getUserConfig(); - } catch (error48) { - log?.error({ error: serializeError(error48) }, "doChat: getUserConfig failed"); - return { - kind: "respond", - response: jsonResponse(500, { - error: "Failed to read user config", - details: error48 instanceof Error ? error48.message : String(error48) - }) - }; - } - if (!userConfig.selectedAIProvider) { - log?.debug({ selectedAIProvider: userConfig.selectedAIProvider }, "doChat: no AI provider selected"); - return { - kind: "respond", - response: jsonResponse(400, { error: "No AI selected" }) - }; - } - let providerResult; - try { - providerResult = await config2.createAIProvider(userConfig); - } catch (error48) { - log?.error({ error: serializeError(error48) }, "doChat: failed to create AI provider"); - return { - kind: "respond", - response: jsonResponse(400, { - error: error48 instanceof Error ? error48.message : "Failed to create AI provider" - }) - }; - } - let body; - try { - body = await request.json(); - } catch (error48) { - log?.error({ error: serializeError(error48) }, "doChat: invalid JSON body"); - return { - kind: "respond", - response: jsonResponse(400, { - error: "Invalid JSON body", - details: error48 instanceof Error ? error48.message : String(error48) - }) - }; - } - return { kind: "stream", userConfig, body, providerResult }; -} -function handleChatPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/chat") { - return Promise.resolve(false); - } - if (!ctx.config.chat) { - sendJson(res, 503, { error: "chat not configured" }); - return Promise.resolve(true); - } - return runNodeChat(ctx.config.chat, req, res); -} -async function runNodeChat(chatConfig, req, res) { - const webRequest = await nodeRequestToWebRequest(req); - let response; - try { - response = await doChat(chatConfig, webRequest); - } catch (error48) { - if (error48 instanceof Error && (error48.name === "AbortError" || error48.message === "Request aborted")) { - sendJson(res, 499, { error: "Request was aborted" }); - return true; - } - chatConfig.logger?.error({ error: serializeError(error48) }, "doChat: handler caught error"); - sendJson(res, 500, { - error: "Failed to process chat request", - details: error48 instanceof Error ? error48.message : String(error48) - }); - return true; - } - await forwardWebResponseToNode(response, res); - return true; -} -function jsonResponse(status, body) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" } - }); -} -function serializeError(error48) { - if (error48 instanceof Error) { - return { message: error48.message, stack: error48.stack, name: error48.name }; - } - return { value: String(error48) }; -} -async function nodeRequestToWebRequest(req) { - const protocol = "http"; - const host = req.headers.host ?? "127.0.0.1:0"; - const url2 = `${protocol}://${host}${req.url ?? "/"}`; - const headers = new Headers; - for (const [key, value] of Object.entries(req.headers)) { - if (value === undefined) - continue; - if (Array.isArray(value)) { - for (const v of value) - headers.append(key, v); - } else { - headers.set(key, String(value)); - } - } - const init = { method: req.method, headers }; - if (req.method && !["GET", "HEAD"].includes(req.method.toUpperCase())) { - init.body = req; - init.duplex = "half"; - } - return new Request(url2, init); -} -async function forwardWebResponseToNode(response, res) { - res.statusCode = response.status; - response.headers.forEach((value, key) => { - res.setHeader(key, value); - }); - if (response.body) { - const reader = response.body.getReader(); - res.flushHeaders?.(); - while (true) { - const { done, value } = await reader.read(); - if (done) - break; - const ok = res.write(value); - if (!ok) { - await new Promise((resolve2) => res.once("drain", resolve2)); - } - } - } - res.end(); -} -// ../../node_modules/.pnpm/@ai-sdk+openai-compatible@2.0.30_zod@4.3.6/node_modules/@ai-sdk/openai-compatible/dist/index.mjs -var openaiCompatibleErrorDataSchema = exports_external.object({ - error: exports_external.object({ - message: exports_external.string(), - type: exports_external.string().nullish(), - param: exports_external.any().nullish(), - code: exports_external.union([exports_external.string(), exports_external.number()]).nullish() - }) -}); -var defaultOpenAICompatibleErrorStructure = { - errorSchema: openaiCompatibleErrorDataSchema, - errorToMessage: (data) => data.error.message -}; -function convertOpenAICompatibleChatUsage(usage) { - var _a21, _b16, _c, _d, _e, _f; - if (usage == null) { - return { - inputTokens: { - total: undefined, - noCache: undefined, - cacheRead: undefined, - cacheWrite: undefined - }, - outputTokens: { - total: undefined, - text: undefined, - reasoning: undefined - }, - raw: undefined - }; - } - const promptTokens = (_a21 = usage.prompt_tokens) != null ? _a21 : 0; - const completionTokens = (_b16 = usage.completion_tokens) != null ? _b16 : 0; - const cacheReadTokens = (_d = (_c = usage.prompt_tokens_details) == null ? undefined : _c.cached_tokens) != null ? _d : 0; - const reasoningTokens = (_f = (_e = usage.completion_tokens_details) == null ? undefined : _e.reasoning_tokens) != null ? _f : 0; - return { - inputTokens: { - total: promptTokens, - noCache: promptTokens - cacheReadTokens, - cacheRead: cacheReadTokens, - cacheWrite: undefined - }, - outputTokens: { - total: completionTokens, - text: completionTokens - reasoningTokens, - reasoning: reasoningTokens - }, - raw: usage - }; -} -function getOpenAIMetadata(message) { - var _a21, _b16; - return (_b16 = (_a21 = message == null ? undefined : message.providerOptions) == null ? undefined : _a21.openaiCompatible) != null ? _b16 : {}; -} -function getAudioFormat(mediaType) { - switch (mediaType) { - case "audio/wav": - return "wav"; - case "audio/mp3": - case "audio/mpeg": - return "mp3"; - default: - return null; - } -} -function convertToOpenAICompatibleChatMessages(prompt) { - var _a21, _b16, _c; - const messages = []; - for (const { role, content, ...message } of prompt) { - const metadata = getOpenAIMetadata({ ...message }); - switch (role) { - case "system": { - messages.push({ role: "system", content, ...metadata }); - break; - } - case "user": { - if (content.length === 1 && content[0].type === "text") { - messages.push({ - role: "user", - content: content[0].text, - ...getOpenAIMetadata(content[0]) - }); - break; - } - messages.push({ - role: "user", - content: content.map((part) => { - var _a24; - const partMetadata = getOpenAIMetadata(part); - switch (part.type) { - case "text": { - return { type: "text", text: part.text, ...partMetadata }; - } - case "file": { - if (part.mediaType.startsWith("image/")) { - const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType; - return { - type: "image_url", - image_url: { - url: part.data instanceof URL ? part.data.toString() : `data:${mediaType};base64,${convertToBase64(part.data)}` - }, - ...partMetadata - }; - } - if (part.mediaType.startsWith("audio/")) { - if (part.data instanceof URL) { - throw new UnsupportedFunctionalityError({ - functionality: "audio file parts with URLs" - }); - } - const format = getAudioFormat(part.mediaType); - if (format === null) { - throw new UnsupportedFunctionalityError({ - functionality: `audio media type ${part.mediaType}` - }); - } - return { - type: "input_audio", - input_audio: { - data: convertToBase64(part.data), - format - }, - ...partMetadata - }; - } - if (part.mediaType === "application/pdf") { - if (part.data instanceof URL) { - throw new UnsupportedFunctionalityError({ - functionality: "PDF file parts with URLs" - }); - } - return { - type: "file", - file: { - filename: (_a24 = part.filename) != null ? _a24 : "document.pdf", - file_data: `data:application/pdf;base64,${convertToBase64(part.data)}` - }, - ...partMetadata - }; - } - if (part.mediaType.startsWith("text/")) { - const textContent = part.data instanceof URL ? part.data.toString() : typeof part.data === "string" ? part.data : new TextDecoder().decode(part.data); - return { - type: "text", - text: textContent, - ...partMetadata - }; - } - throw new UnsupportedFunctionalityError({ - functionality: `file part media type ${part.mediaType}` - }); - } - } - }), - ...metadata - }); - break; - } - case "assistant": { - let text2 = ""; - let reasoning = ""; - const toolCalls = []; - for (const part of content) { - const partMetadata = getOpenAIMetadata(part); - switch (part.type) { - case "text": { - text2 += part.text; - break; - } - case "reasoning": { - reasoning += part.text; - break; - } - case "tool-call": { - const thoughtSignature = (_b16 = (_a21 = part.providerOptions) == null ? undefined : _a21.google) == null ? undefined : _b16.thoughtSignature; - toolCalls.push({ - id: part.toolCallId, - type: "function", - function: { - name: part.toolName, - arguments: JSON.stringify(part.input) - }, - ...partMetadata, - ...thoughtSignature ? { - extra_content: { - google: { - thought_signature: String(thoughtSignature) - } - } - } : {} - }); - break; - } - } - } - messages.push({ - role: "assistant", - content: text2, - ...reasoning.length > 0 ? { reasoning_content: reasoning } : {}, - tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - ...metadata - }); - break; - } - case "tool": { - for (const toolResponse of content) { - if (toolResponse.type === "tool-approval-response") { - continue; - } - const output = toolResponse.output; - let contentValue; - switch (output.type) { - case "text": - case "error-text": - contentValue = output.value; - break; - case "execution-denied": - contentValue = (_c = output.reason) != null ? _c : "Tool execution denied."; - break; - case "content": - case "json": - case "error-json": - contentValue = JSON.stringify(output.value); - break; - } - const toolResponseMetadata = getOpenAIMetadata(toolResponse); - messages.push({ - role: "tool", - tool_call_id: toolResponse.toolCallId, - content: contentValue, - ...toolResponseMetadata - }); - } - break; - } - default: { - const _exhaustiveCheck = role; - throw new Error(`Unsupported role: ${_exhaustiveCheck}`); - } - } - } - return messages; -} -function getResponseMetadata({ - id, - model, - created -}) { - return { - id: id != null ? id : undefined, - modelId: model != null ? model : undefined, - timestamp: created != null ? new Date(created * 1000) : undefined - }; -} -function mapOpenAICompatibleFinishReason(finishReason) { - switch (finishReason) { - case "stop": - return "stop"; - case "length": - return "length"; - case "content_filter": - return "content-filter"; - case "function_call": - case "tool_calls": - return "tool-calls"; - default: - return "other"; - } -} -var openaiCompatibleLanguageModelChatOptions = exports_external.object({ - user: exports_external.string().optional(), - reasoningEffort: exports_external.string().optional(), - textVerbosity: exports_external.string().optional(), - strictJsonSchema: exports_external.boolean().optional() -}); -function prepareTools({ - tools, - toolChoice -}) { - tools = (tools == null ? undefined : tools.length) ? tools : undefined; - const toolWarnings = []; - if (tools == null) { - return { tools: undefined, toolChoice: undefined, toolWarnings }; - } - const openaiCompatTools = []; - for (const tool2 of tools) { - if (tool2.type === "provider") { - toolWarnings.push({ - type: "unsupported", - feature: `provider-defined tool ${tool2.id}` - }); - } else { - openaiCompatTools.push({ - type: "function", - function: { - name: tool2.name, - description: tool2.description, - parameters: tool2.inputSchema, - ...tool2.strict != null ? { strict: tool2.strict } : {} - } - }); - } - } - if (toolChoice == null) { - return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings }; - } - const type = toolChoice.type; - switch (type) { - case "auto": - case "none": - case "required": - return { tools: openaiCompatTools, toolChoice: type, toolWarnings }; - case "tool": - return { - tools: openaiCompatTools, - toolChoice: { - type: "function", - function: { name: toolChoice.toolName } - }, - toolWarnings - }; - default: { - const _exhaustiveCheck = type; - throw new UnsupportedFunctionalityError({ - functionality: `tool choice type: ${_exhaustiveCheck}` - }); - } - } -} -var OpenAICompatibleChatLanguageModel = class { - constructor(modelId, config2) { - this.specificationVersion = "v3"; - var _a21, _b16; - this.modelId = modelId; - this.config = config2; - const errorStructure = (_a21 = config2.errorStructure) != null ? _a21 : defaultOpenAICompatibleErrorStructure; - this.chunkSchema = createOpenAICompatibleChatChunkSchema(errorStructure.errorSchema); - this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure); - this.supportsStructuredOutputs = (_b16 = config2.supportsStructuredOutputs) != null ? _b16 : false; - } - get provider() { - return this.config.provider; - } - get providerOptionsName() { - return this.config.provider.split(".")[0].trim(); - } - get supportedUrls() { - var _a21, _b16, _c; - return (_c = (_b16 = (_a21 = this.config).supportedUrls) == null ? undefined : _b16.call(_a21)) != null ? _c : {}; - } - transformRequestBody(args) { - var _a21, _b16, _c; - return (_c = (_b16 = (_a21 = this.config).transformRequestBody) == null ? undefined : _b16.call(_a21, args)) != null ? _c : args; - } - async getArgs({ - prompt, - maxOutputTokens, - temperature, - topP, - topK, - frequencyPenalty, - presencePenalty, - providerOptions, - stopSequences, - responseFormat, - seed, - toolChoice, - tools - }) { - var _a21, _b16, _c, _d, _e; - const warnings = []; - const deprecatedOptions = await parseProviderOptions({ - provider: "openai-compatible", - providerOptions, - schema: openaiCompatibleLanguageModelChatOptions - }); - if (deprecatedOptions != null) { - warnings.push({ - type: "other", - message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.` - }); - } - const compatibleOptions = Object.assign(deprecatedOptions != null ? deprecatedOptions : {}, (_a21 = await parseProviderOptions({ - provider: "openaiCompatible", - providerOptions, - schema: openaiCompatibleLanguageModelChatOptions - })) != null ? _a21 : {}, (_b16 = await parseProviderOptions({ - provider: this.providerOptionsName, - providerOptions, - schema: openaiCompatibleLanguageModelChatOptions - })) != null ? _b16 : {}); - const strictJsonSchema = (_c = compatibleOptions == null ? undefined : compatibleOptions.strictJsonSchema) != null ? _c : true; - if (topK != null) { - warnings.push({ type: "unsupported", feature: "topK" }); - } - if ((responseFormat == null ? undefined : responseFormat.type) === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) { - warnings.push({ - type: "unsupported", - feature: "responseFormat", - details: "JSON response format schema is only supported with structuredOutputs" - }); - } - const { - tools: openaiTools, - toolChoice: openaiToolChoice, - toolWarnings - } = prepareTools({ - tools, - toolChoice - }); - return { - args: { - model: this.modelId, - user: compatibleOptions.user, - max_tokens: maxOutputTokens, - temperature, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, - response_format: (responseFormat == null ? undefined : responseFormat.type) === "json" ? this.supportsStructuredOutputs === true && responseFormat.schema != null ? { - type: "json_schema", - json_schema: { - schema: responseFormat.schema, - strict: strictJsonSchema, - name: (_d = responseFormat.name) != null ? _d : "response", - description: responseFormat.description - } - } : { type: "json_object" } : undefined, - stop: stopSequences, - seed, - ...Object.fromEntries(Object.entries((_e = providerOptions == null ? undefined : providerOptions[this.providerOptionsName]) != null ? _e : {}).filter(([key]) => !Object.keys(openaiCompatibleLanguageModelChatOptions.shape).includes(key))), - reasoning_effort: compatibleOptions.reasoningEffort, - verbosity: compatibleOptions.textVerbosity, - messages: convertToOpenAICompatibleChatMessages(prompt), - tools: openaiTools, - tool_choice: openaiToolChoice - }, - warnings: [...warnings, ...toolWarnings] - }; - } - async doGenerate(options) { - var _a21, _b16, _c, _d, _e, _f, _g, _h; - const { args, warnings } = await this.getArgs({ ...options }); - const transformedBody = this.transformRequestBody(args); - const body = JSON.stringify(transformedBody); - const { - responseHeaders, - value: responseBody, - rawValue: rawResponse - } = await postJsonToApi({ - url: this.config.url({ - path: "/chat/completions", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: transformedBody, - failedResponseHandler: this.failedResponseHandler, - successfulResponseHandler: createJsonResponseHandler(OpenAICompatibleChatResponseSchema), - abortSignal: options.abortSignal, - fetch: this.config.fetch - }); - const choice2 = responseBody.choices[0]; - const content = []; - const text2 = choice2.message.content; - if (text2 != null && text2.length > 0) { - content.push({ type: "text", text: text2 }); - } - const reasoning = (_a21 = choice2.message.reasoning_content) != null ? _a21 : choice2.message.reasoning; - if (reasoning != null && reasoning.length > 0) { - content.push({ - type: "reasoning", - text: reasoning - }); - } - if (choice2.message.tool_calls != null) { - for (const toolCall of choice2.message.tool_calls) { - const thoughtSignature = (_c = (_b16 = toolCall.extra_content) == null ? undefined : _b16.google) == null ? undefined : _c.thought_signature; - content.push({ - type: "tool-call", - toolCallId: (_d = toolCall.id) != null ? _d : generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - ...thoughtSignature ? { - providerMetadata: { - [this.providerOptionsName]: { thoughtSignature } - } - } : {} - }); - } - } - const providerMetadata = { - [this.providerOptionsName]: {}, - ...await ((_f = (_e = this.config.metadataExtractor) == null ? undefined : _e.extractMetadata) == null ? undefined : _f.call(_e, { - parsedBody: rawResponse - })) - }; - const completionTokenDetails = (_g = responseBody.usage) == null ? undefined : _g.completion_tokens_details; - if ((completionTokenDetails == null ? undefined : completionTokenDetails.accepted_prediction_tokens) != null) { - providerMetadata[this.providerOptionsName].acceptedPredictionTokens = completionTokenDetails == null ? undefined : completionTokenDetails.accepted_prediction_tokens; - } - if ((completionTokenDetails == null ? undefined : completionTokenDetails.rejected_prediction_tokens) != null) { - providerMetadata[this.providerOptionsName].rejectedPredictionTokens = completionTokenDetails == null ? undefined : completionTokenDetails.rejected_prediction_tokens; - } - return { - content, - finishReason: { - unified: mapOpenAICompatibleFinishReason(choice2.finish_reason), - raw: (_h = choice2.finish_reason) != null ? _h : undefined - }, - usage: convertOpenAICompatibleChatUsage(responseBody.usage), - providerMetadata, - request: { body }, - response: { - ...getResponseMetadata(responseBody), - headers: responseHeaders, - body: rawResponse - }, - warnings - }; - } - async doStream(options) { - var _a21; - const { args, warnings } = await this.getArgs({ ...options }); - const body = this.transformRequestBody({ - ...args, - stream: true, - stream_options: this.config.includeUsage ? { include_usage: true } : undefined - }); - const metadataExtractor = (_a21 = this.config.metadataExtractor) == null ? undefined : _a21.createStreamExtractor(); - const { responseHeaders, value: response } = await postJsonToApi({ - url: this.config.url({ - path: "/chat/completions", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), options.headers), - body, - failedResponseHandler: this.failedResponseHandler, - successfulResponseHandler: createEventSourceResponseHandler(this.chunkSchema), - abortSignal: options.abortSignal, - fetch: this.config.fetch - }); - const toolCalls = []; - let finishReason = { - unified: "other", - raw: undefined - }; - let usage = undefined; - let isFirstChunk = true; - const providerOptionsName = this.providerOptionsName; - let isActiveReasoning = false; - let isActiveText = false; - return { - stream: response.pipeThrough(new TransformStream({ - start(controller) { - controller.enqueue({ type: "stream-start", warnings }); - }, - transform(chunk2, controller) { - var _a24, _b16, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r; - if (options.includeRawChunks) { - controller.enqueue({ type: "raw", rawValue: chunk2.rawValue }); - } - if (!chunk2.success) { - finishReason = { unified: "error", raw: undefined }; - controller.enqueue({ type: "error", error: chunk2.error }); - return; - } - metadataExtractor == null || metadataExtractor.processChunk(chunk2.rawValue); - if ("error" in chunk2.value) { - finishReason = { unified: "error", raw: undefined }; - controller.enqueue({ - type: "error", - error: chunk2.value.error.message - }); - return; - } - const value = chunk2.value; - if (isFirstChunk) { - isFirstChunk = false; - controller.enqueue({ - type: "response-metadata", - ...getResponseMetadata(value) - }); - } - if (value.usage != null) { - usage = value.usage; - } - const choice2 = value.choices[0]; - if ((choice2 == null ? undefined : choice2.finish_reason) != null) { - finishReason = { - unified: mapOpenAICompatibleFinishReason(choice2.finish_reason), - raw: (_a24 = choice2.finish_reason) != null ? _a24 : undefined - }; - } - if ((choice2 == null ? undefined : choice2.delta) == null) { - return; - } - const delta = choice2.delta; - const reasoningContent = (_b16 = delta.reasoning_content) != null ? _b16 : delta.reasoning; - if (reasoningContent) { - if (!isActiveReasoning) { - controller.enqueue({ - type: "reasoning-start", - id: "reasoning-0" - }); - isActiveReasoning = true; - } - controller.enqueue({ - type: "reasoning-delta", - id: "reasoning-0", - delta: reasoningContent - }); - } - if (delta.content) { - if (isActiveReasoning) { - controller.enqueue({ - type: "reasoning-end", - id: "reasoning-0" - }); - isActiveReasoning = false; - } - if (!isActiveText) { - controller.enqueue({ type: "text-start", id: "txt-0" }); - isActiveText = true; - } - controller.enqueue({ - type: "text-delta", - id: "txt-0", - delta: delta.content - }); - } - if (delta.tool_calls != null) { - if (isActiveReasoning) { - controller.enqueue({ - type: "reasoning-end", - id: "reasoning-0" - }); - isActiveReasoning = false; - } - for (const toolCallDelta of delta.tool_calls) { - const index = (_c = toolCallDelta.index) != null ? _c : toolCalls.length; - if (toolCalls[index] == null) { - if (toolCallDelta.id == null) { - throw new InvalidResponseDataError({ - data: toolCallDelta, - message: `Expected 'id' to be a string.` - }); - } - if (((_d = toolCallDelta.function) == null ? undefined : _d.name) == null) { - throw new InvalidResponseDataError({ - data: toolCallDelta, - message: `Expected 'function.name' to be a string.` - }); - } - controller.enqueue({ - type: "tool-input-start", - id: toolCallDelta.id, - toolName: toolCallDelta.function.name - }); - toolCalls[index] = { - id: toolCallDelta.id, - type: "function", - function: { - name: toolCallDelta.function.name, - arguments: (_e = toolCallDelta.function.arguments) != null ? _e : "" - }, - hasFinished: false, - thoughtSignature: (_h = (_g = (_f = toolCallDelta.extra_content) == null ? undefined : _f.google) == null ? undefined : _g.thought_signature) != null ? _h : undefined - }; - const toolCall2 = toolCalls[index]; - if (((_i = toolCall2.function) == null ? undefined : _i.name) != null && ((_j = toolCall2.function) == null ? undefined : _j.arguments) != null) { - if (toolCall2.function.arguments.length > 0) { - controller.enqueue({ - type: "tool-input-delta", - id: toolCall2.id, - delta: toolCall2.function.arguments - }); - } - if (isParsableJson(toolCall2.function.arguments)) { - controller.enqueue({ - type: "tool-input-end", - id: toolCall2.id - }); - controller.enqueue({ - type: "tool-call", - toolCallId: (_k = toolCall2.id) != null ? _k : generateId(), - toolName: toolCall2.function.name, - input: toolCall2.function.arguments, - ...toolCall2.thoughtSignature ? { - providerMetadata: { - [providerOptionsName]: { - thoughtSignature: toolCall2.thoughtSignature - } - } - } : {} - }); - toolCall2.hasFinished = true; - } - } - continue; - } - const toolCall = toolCalls[index]; - if (toolCall.hasFinished) { - continue; - } - if (((_l = toolCallDelta.function) == null ? undefined : _l.arguments) != null) { - toolCall.function.arguments += (_n = (_m = toolCallDelta.function) == null ? undefined : _m.arguments) != null ? _n : ""; - } - controller.enqueue({ - type: "tool-input-delta", - id: toolCall.id, - delta: (_o = toolCallDelta.function.arguments) != null ? _o : "" - }); - if (((_p = toolCall.function) == null ? undefined : _p.name) != null && ((_q = toolCall.function) == null ? undefined : _q.arguments) != null && isParsableJson(toolCall.function.arguments)) { - controller.enqueue({ - type: "tool-input-end", - id: toolCall.id - }); - controller.enqueue({ - type: "tool-call", - toolCallId: (_r = toolCall.id) != null ? _r : generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - ...toolCall.thoughtSignature ? { - providerMetadata: { - [providerOptionsName]: { - thoughtSignature: toolCall.thoughtSignature - } - } - } : {} - }); - toolCall.hasFinished = true; - } - } - } - }, - flush(controller) { - var _a24, _b16, _c, _d, _e; - if (isActiveReasoning) { - controller.enqueue({ type: "reasoning-end", id: "reasoning-0" }); - } - if (isActiveText) { - controller.enqueue({ type: "text-end", id: "txt-0" }); - } - for (const toolCall of toolCalls.filter((toolCall2) => !toolCall2.hasFinished)) { - controller.enqueue({ - type: "tool-input-end", - id: toolCall.id - }); - controller.enqueue({ - type: "tool-call", - toolCallId: (_a24 = toolCall.id) != null ? _a24 : generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - ...toolCall.thoughtSignature ? { - providerMetadata: { - [providerOptionsName]: { - thoughtSignature: toolCall.thoughtSignature - } - } - } : {} - }); - } - const providerMetadata = { - [providerOptionsName]: {}, - ...metadataExtractor == null ? undefined : metadataExtractor.buildMetadata() - }; - if (((_b16 = usage == null ? undefined : usage.completion_tokens_details) == null ? undefined : _b16.accepted_prediction_tokens) != null) { - providerMetadata[providerOptionsName].acceptedPredictionTokens = (_c = usage == null ? undefined : usage.completion_tokens_details) == null ? undefined : _c.accepted_prediction_tokens; - } - if (((_d = usage == null ? undefined : usage.completion_tokens_details) == null ? undefined : _d.rejected_prediction_tokens) != null) { - providerMetadata[providerOptionsName].rejectedPredictionTokens = (_e = usage == null ? undefined : usage.completion_tokens_details) == null ? undefined : _e.rejected_prediction_tokens; - } - controller.enqueue({ - type: "finish", - finishReason, - usage: convertOpenAICompatibleChatUsage(usage), - providerMetadata - }); - } - })), - request: { body }, - response: { headers: responseHeaders } - }; - } -}; -var openaiCompatibleTokenUsageSchema = exports_external.looseObject({ - prompt_tokens: exports_external.number().nullish(), - completion_tokens: exports_external.number().nullish(), - total_tokens: exports_external.number().nullish(), - prompt_tokens_details: exports_external.object({ - cached_tokens: exports_external.number().nullish() - }).nullish(), - completion_tokens_details: exports_external.object({ - reasoning_tokens: exports_external.number().nullish(), - accepted_prediction_tokens: exports_external.number().nullish(), - rejected_prediction_tokens: exports_external.number().nullish() - }).nullish() -}).nullish(); -var OpenAICompatibleChatResponseSchema = exports_external.looseObject({ - id: exports_external.string().nullish(), - created: exports_external.number().nullish(), - model: exports_external.string().nullish(), - choices: exports_external.array(exports_external.object({ - message: exports_external.object({ - role: exports_external.literal("assistant").nullish(), - content: exports_external.string().nullish(), - reasoning_content: exports_external.string().nullish(), - reasoning: exports_external.string().nullish(), - tool_calls: exports_external.array(exports_external.object({ - id: exports_external.string().nullish(), - function: exports_external.object({ - name: exports_external.string(), - arguments: exports_external.string() - }), - extra_content: exports_external.object({ - google: exports_external.object({ - thought_signature: exports_external.string().nullish() - }).nullish() - }).nullish() - })).nullish() - }), - finish_reason: exports_external.string().nullish() - })), - usage: openaiCompatibleTokenUsageSchema -}); -var chunkBaseSchema = exports_external.looseObject({ - id: exports_external.string().nullish(), - created: exports_external.number().nullish(), - model: exports_external.string().nullish(), - choices: exports_external.array(exports_external.object({ - delta: exports_external.object({ - role: exports_external.enum(["assistant"]).nullish(), - content: exports_external.string().nullish(), - reasoning_content: exports_external.string().nullish(), - reasoning: exports_external.string().nullish(), - tool_calls: exports_external.array(exports_external.object({ - index: exports_external.number().nullish(), - id: exports_external.string().nullish(), - function: exports_external.object({ - name: exports_external.string().nullish(), - arguments: exports_external.string().nullish() - }), - extra_content: exports_external.object({ - google: exports_external.object({ - thought_signature: exports_external.string().nullish() - }).nullish() - }).nullish() - })).nullish() - }).nullish(), - finish_reason: exports_external.string().nullish() - })), - usage: openaiCompatibleTokenUsageSchema -}); -var createOpenAICompatibleChatChunkSchema = (errorSchema) => exports_external.union([chunkBaseSchema, errorSchema]); -function convertOpenAICompatibleCompletionUsage(usage) { - var _a21, _b16; - if (usage == null) { - return { - inputTokens: { - total: undefined, - noCache: undefined, - cacheRead: undefined, - cacheWrite: undefined - }, - outputTokens: { - total: undefined, - text: undefined, - reasoning: undefined - }, - raw: undefined - }; - } - const promptTokens = (_a21 = usage.prompt_tokens) != null ? _a21 : 0; - const completionTokens = (_b16 = usage.completion_tokens) != null ? _b16 : 0; - return { - inputTokens: { - total: promptTokens, - noCache: promptTokens, - cacheRead: undefined, - cacheWrite: undefined - }, - outputTokens: { - total: completionTokens, - text: completionTokens, - reasoning: undefined - }, - raw: usage - }; -} -function convertToOpenAICompatibleCompletionPrompt({ - prompt, - user = "user", - assistant = "assistant" -}) { - let text2 = ""; - if (prompt[0].role === "system") { - text2 += `${prompt[0].content} - -`; - prompt = prompt.slice(1); - } - for (const { role, content } of prompt) { - switch (role) { - case "system": { - throw new InvalidPromptError({ - message: "Unexpected system message in prompt: ${content}", - prompt - }); - } - case "user": { - const userMessage = content.map((part) => { - switch (part.type) { - case "text": { - return part.text; - } - } - }).filter(Boolean).join(""); - text2 += `${user}: -${userMessage} - -`; - break; - } - case "assistant": { - const assistantMessage = content.map((part) => { - switch (part.type) { - case "text": { - return part.text; - } - case "tool-call": { - throw new UnsupportedFunctionalityError({ - functionality: "tool-call messages" - }); - } - } - }).join(""); - text2 += `${assistant}: -${assistantMessage} - -`; - break; - } - case "tool": { - throw new UnsupportedFunctionalityError({ - functionality: "tool messages" - }); - } - default: { - const _exhaustiveCheck = role; - throw new Error(`Unsupported role: ${_exhaustiveCheck}`); - } - } - } - text2 += `${assistant}: -`; - return { - prompt: text2, - stopSequences: [` -${user}:`] - }; -} -function getResponseMetadata2({ - id, - model, - created -}) { - return { - id: id != null ? id : undefined, - modelId: model != null ? model : undefined, - timestamp: created != null ? new Date(created * 1000) : undefined - }; -} -function mapOpenAICompatibleFinishReason2(finishReason) { - switch (finishReason) { - case "stop": - return "stop"; - case "length": - return "length"; - case "content_filter": - return "content-filter"; - case "function_call": - case "tool_calls": - return "tool-calls"; - default: - return "other"; - } -} -var openaiCompatibleLanguageModelCompletionOptions = exports_external.object({ - echo: exports_external.boolean().optional(), - logitBias: exports_external.record(exports_external.string(), exports_external.number()).optional(), - suffix: exports_external.string().optional(), - user: exports_external.string().optional() -}); -var OpenAICompatibleCompletionLanguageModel = class { - constructor(modelId, config2) { - this.specificationVersion = "v3"; - var _a21; - this.modelId = modelId; - this.config = config2; - const errorStructure = (_a21 = config2.errorStructure) != null ? _a21 : defaultOpenAICompatibleErrorStructure; - this.chunkSchema = createOpenAICompatibleCompletionChunkSchema(errorStructure.errorSchema); - this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure); - } - get provider() { - return this.config.provider; - } - get providerOptionsName() { - return this.config.provider.split(".")[0].trim(); - } - get supportedUrls() { - var _a21, _b16, _c; - return (_c = (_b16 = (_a21 = this.config).supportedUrls) == null ? undefined : _b16.call(_a21)) != null ? _c : {}; - } - async getArgs({ - prompt, - maxOutputTokens, - temperature, - topP, - topK, - frequencyPenalty, - presencePenalty, - stopSequences: userStopSequences, - responseFormat, - seed, - providerOptions, - tools, - toolChoice - }) { - var _a21; - const warnings = []; - const completionOptions = (_a21 = await parseProviderOptions({ - provider: this.providerOptionsName, - providerOptions, - schema: openaiCompatibleLanguageModelCompletionOptions - })) != null ? _a21 : {}; - if (topK != null) { - warnings.push({ type: "unsupported", feature: "topK" }); - } - if (tools == null ? undefined : tools.length) { - warnings.push({ type: "unsupported", feature: "tools" }); - } - if (toolChoice != null) { - warnings.push({ type: "unsupported", feature: "toolChoice" }); - } - if (responseFormat != null && responseFormat.type !== "text") { - warnings.push({ - type: "unsupported", - feature: "responseFormat", - details: "JSON response format is not supported." - }); - } - const { prompt: completionPrompt, stopSequences } = convertToOpenAICompatibleCompletionPrompt({ prompt }); - const stop = [...stopSequences != null ? stopSequences : [], ...userStopSequences != null ? userStopSequences : []]; - return { - args: { - model: this.modelId, - echo: completionOptions.echo, - logit_bias: completionOptions.logitBias, - suffix: completionOptions.suffix, - user: completionOptions.user, - max_tokens: maxOutputTokens, - temperature, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, - seed, - ...providerOptions == null ? undefined : providerOptions[this.providerOptionsName], - prompt: completionPrompt, - stop: stop.length > 0 ? stop : undefined - }, - warnings - }; - } - async doGenerate(options) { - const { args, warnings } = await this.getArgs(options); - const { - responseHeaders, - value: response, - rawValue: rawResponse - } = await postJsonToApi({ - url: this.config.url({ - path: "/completions", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: args, - failedResponseHandler: this.failedResponseHandler, - successfulResponseHandler: createJsonResponseHandler(openaiCompatibleCompletionResponseSchema), - abortSignal: options.abortSignal, - fetch: this.config.fetch - }); - const choice2 = response.choices[0]; - const content = []; - if (choice2.text != null && choice2.text.length > 0) { - content.push({ type: "text", text: choice2.text }); - } - return { - content, - usage: convertOpenAICompatibleCompletionUsage(response.usage), - finishReason: { - unified: mapOpenAICompatibleFinishReason2(choice2.finish_reason), - raw: choice2.finish_reason - }, - request: { body: args }, - response: { - ...getResponseMetadata2(response), - headers: responseHeaders, - body: rawResponse - }, - warnings - }; - } - async doStream(options) { - const { args, warnings } = await this.getArgs(options); - const body = { - ...args, - stream: true, - stream_options: this.config.includeUsage ? { include_usage: true } : undefined - }; - const { responseHeaders, value: response } = await postJsonToApi({ - url: this.config.url({ - path: "/completions", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), options.headers), - body, - failedResponseHandler: this.failedResponseHandler, - successfulResponseHandler: createEventSourceResponseHandler(this.chunkSchema), - abortSignal: options.abortSignal, - fetch: this.config.fetch - }); - let finishReason = { - unified: "other", - raw: undefined - }; - let usage = undefined; - let isFirstChunk = true; - return { - stream: response.pipeThrough(new TransformStream({ - start(controller) { - controller.enqueue({ type: "stream-start", warnings }); - }, - transform(chunk2, controller) { - var _a21; - if (options.includeRawChunks) { - controller.enqueue({ type: "raw", rawValue: chunk2.rawValue }); - } - if (!chunk2.success) { - finishReason = { unified: "error", raw: undefined }; - controller.enqueue({ type: "error", error: chunk2.error }); - return; - } - const value = chunk2.value; - if ("error" in value) { - finishReason = { unified: "error", raw: undefined }; - controller.enqueue({ type: "error", error: value.error }); - return; - } - if (isFirstChunk) { - isFirstChunk = false; - controller.enqueue({ - type: "response-metadata", - ...getResponseMetadata2(value) - }); - controller.enqueue({ - type: "text-start", - id: "0" - }); - } - if (value.usage != null) { - usage = value.usage; - } - const choice2 = value.choices[0]; - if ((choice2 == null ? undefined : choice2.finish_reason) != null) { - finishReason = { - unified: mapOpenAICompatibleFinishReason2(choice2.finish_reason), - raw: (_a21 = choice2.finish_reason) != null ? _a21 : undefined - }; - } - if ((choice2 == null ? undefined : choice2.text) != null) { - controller.enqueue({ - type: "text-delta", - id: "0", - delta: choice2.text - }); - } - }, - flush(controller) { - if (!isFirstChunk) { - controller.enqueue({ type: "text-end", id: "0" }); - } - controller.enqueue({ - type: "finish", - finishReason, - usage: convertOpenAICompatibleCompletionUsage(usage) - }); - } - })), - request: { body }, - response: { headers: responseHeaders } - }; - } -}; -var usageSchema = exports_external.object({ - prompt_tokens: exports_external.number(), - completion_tokens: exports_external.number(), - total_tokens: exports_external.number() -}); -var openaiCompatibleCompletionResponseSchema = exports_external.object({ - id: exports_external.string().nullish(), - created: exports_external.number().nullish(), - model: exports_external.string().nullish(), - choices: exports_external.array(exports_external.object({ - text: exports_external.string(), - finish_reason: exports_external.string() - })), - usage: usageSchema.nullish() -}); -var createOpenAICompatibleCompletionChunkSchema = (errorSchema) => exports_external.union([ - exports_external.object({ - id: exports_external.string().nullish(), - created: exports_external.number().nullish(), - model: exports_external.string().nullish(), - choices: exports_external.array(exports_external.object({ - text: exports_external.string(), - finish_reason: exports_external.string().nullish(), - index: exports_external.number() - })), - usage: usageSchema.nullish() - }), - errorSchema -]); -var openaiCompatibleEmbeddingModelOptions = exports_external.object({ - dimensions: exports_external.number().optional(), - user: exports_external.string().optional() -}); -var OpenAICompatibleEmbeddingModel = class { - constructor(modelId, config2) { - this.specificationVersion = "v3"; - this.modelId = modelId; - this.config = config2; - } - get provider() { - return this.config.provider; - } - get maxEmbeddingsPerCall() { - var _a21; - return (_a21 = this.config.maxEmbeddingsPerCall) != null ? _a21 : 2048; - } - get supportsParallelCalls() { - var _a21; - return (_a21 = this.config.supportsParallelCalls) != null ? _a21 : true; - } - get providerOptionsName() { - return this.config.provider.split(".")[0].trim(); - } - async doEmbed({ - values, - headers, - abortSignal, - providerOptions - }) { - var _a21, _b16, _c; - const warnings = []; - const deprecatedOptions = await parseProviderOptions({ - provider: "openai-compatible", - providerOptions, - schema: openaiCompatibleEmbeddingModelOptions - }); - if (deprecatedOptions != null) { - warnings.push({ - type: "other", - message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.` - }); - } - const compatibleOptions = Object.assign(deprecatedOptions != null ? deprecatedOptions : {}, (_a21 = await parseProviderOptions({ - provider: "openaiCompatible", - providerOptions, - schema: openaiCompatibleEmbeddingModelOptions - })) != null ? _a21 : {}, (_b16 = await parseProviderOptions({ - provider: this.providerOptionsName, - providerOptions, - schema: openaiCompatibleEmbeddingModelOptions - })) != null ? _b16 : {}); - if (values.length > this.maxEmbeddingsPerCall) { - throw new TooManyEmbeddingValuesForCallError({ - provider: this.provider, - modelId: this.modelId, - maxEmbeddingsPerCall: this.maxEmbeddingsPerCall, - values - }); - } - const { - responseHeaders, - value: response, - rawValue - } = await postJsonToApi({ - url: this.config.url({ - path: "/embeddings", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), headers), - body: { - model: this.modelId, - input: values, - encoding_format: "float", - dimensions: compatibleOptions.dimensions, - user: compatibleOptions.user - }, - failedResponseHandler: createJsonErrorResponseHandler((_c = this.config.errorStructure) != null ? _c : defaultOpenAICompatibleErrorStructure), - successfulResponseHandler: createJsonResponseHandler(openaiTextEmbeddingResponseSchema), - abortSignal, - fetch: this.config.fetch - }); - return { - warnings, - embeddings: response.data.map((item) => item.embedding), - usage: response.usage ? { tokens: response.usage.prompt_tokens } : undefined, - providerMetadata: response.providerMetadata, - response: { headers: responseHeaders, body: rawValue } - }; - } -}; -var openaiTextEmbeddingResponseSchema = exports_external.object({ - data: exports_external.array(exports_external.object({ embedding: exports_external.array(exports_external.number()) })), - usage: exports_external.object({ prompt_tokens: exports_external.number() }).nullish(), - providerMetadata: exports_external.record(exports_external.string(), exports_external.record(exports_external.string(), exports_external.any())).optional() -}); -var OpenAICompatibleImageModel = class { - constructor(modelId, config2) { - this.modelId = modelId; - this.config = config2; - this.specificationVersion = "v3"; - this.maxImagesPerCall = 10; - } - get provider() { - return this.config.provider; - } - get providerOptionsKey() { - return this.config.provider.split(".")[0].trim(); - } - getArgs(providerOptions) { - return { - ...providerOptions[this.providerOptionsKey], - ...providerOptions[toCamelCase(this.providerOptionsKey)] - }; - } - async doGenerate({ - prompt, - n, - size, - aspectRatio, - seed, - providerOptions, - headers, - abortSignal, - files, - mask - }) { - var _a21, _b16, _c, _d, _e; - const warnings = []; - if (aspectRatio != null) { - warnings.push({ - type: "unsupported", - feature: "aspectRatio", - details: "This model does not support aspect ratio. Use `size` instead." - }); - } - if (seed != null) { - warnings.push({ type: "unsupported", feature: "seed" }); - } - const currentDate = (_c = (_b16 = (_a21 = this.config._internal) == null ? undefined : _a21.currentDate) == null ? undefined : _b16.call(_a21)) != null ? _c : /* @__PURE__ */ new Date; - const args = this.getArgs(providerOptions); - if (files != null && files.length > 0) { - const { value: response2, responseHeaders: responseHeaders2 } = await postFormDataToApi({ - url: this.config.url({ - path: "/images/edits", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), headers), - formData: convertToFormData({ - model: this.modelId, - prompt, - image: await Promise.all(files.map((file2) => fileToBlob(file2))), - mask: mask != null ? await fileToBlob(mask) : undefined, - n, - size, - ...args - }), - failedResponseHandler: createJsonErrorResponseHandler((_d = this.config.errorStructure) != null ? _d : defaultOpenAICompatibleErrorStructure), - successfulResponseHandler: createJsonResponseHandler(openaiCompatibleImageResponseSchema), - abortSignal, - fetch: this.config.fetch - }); - return { - images: response2.data.map((item) => item.b64_json), - warnings, - response: { - timestamp: currentDate, - modelId: this.modelId, - headers: responseHeaders2 - } - }; - } - const { value: response, responseHeaders } = await postJsonToApi({ - url: this.config.url({ - path: "/images/generations", - modelId: this.modelId - }), - headers: combineHeaders(this.config.headers(), headers), - body: { - model: this.modelId, - prompt, - n, - size, - ...args, - response_format: "b64_json" - }, - failedResponseHandler: createJsonErrorResponseHandler((_e = this.config.errorStructure) != null ? _e : defaultOpenAICompatibleErrorStructure), - successfulResponseHandler: createJsonResponseHandler(openaiCompatibleImageResponseSchema), - abortSignal, - fetch: this.config.fetch - }); - return { - images: response.data.map((item) => item.b64_json), - warnings, - response: { - timestamp: currentDate, - modelId: this.modelId, - headers: responseHeaders - } - }; - } -}; -var openaiCompatibleImageResponseSchema = exports_external.object({ - data: exports_external.array(exports_external.object({ b64_json: exports_external.string() })) -}); -async function fileToBlob(file2) { - if (file2.type === "url") { - return downloadBlob(file2.url); - } - const data = file2.data instanceof Uint8Array ? file2.data : convertBase64ToUint8Array(file2.data); - return new Blob([data], { type: file2.mediaType }); -} -function toCamelCase(str) { - return str.replace(/[_-]([a-z])/g, (g) => g[1].toUpperCase()); -} -var VERSION4 = "2.0.30"; -function createOpenAICompatible(options) { - const baseURL = withoutTrailingSlash(options.baseURL); - const providerName = options.name; - const headers = { - ...options.apiKey && { Authorization: `Bearer ${options.apiKey}` }, - ...options.headers - }; - const getHeaders = () => withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION4}`); - const getCommonModelConfig = (modelType) => ({ - provider: `${providerName}.${modelType}`, - url: ({ path: path6 }) => { - const url2 = new URL(`${baseURL}${path6}`); - if (options.queryParams) { - url2.search = new URLSearchParams(options.queryParams).toString(); - } - return url2.toString(); - }, - headers: getHeaders, - fetch: options.fetch - }); - const createLanguageModel = (modelId) => createChatModel(modelId); - const createChatModel = (modelId) => new OpenAICompatibleChatLanguageModel(modelId, { - ...getCommonModelConfig("chat"), - includeUsage: options.includeUsage, - supportsStructuredOutputs: options.supportsStructuredOutputs, - transformRequestBody: options.transformRequestBody, - metadataExtractor: options.metadataExtractor - }); - const createCompletionModel = (modelId) => new OpenAICompatibleCompletionLanguageModel(modelId, { - ...getCommonModelConfig("completion"), - includeUsage: options.includeUsage - }); - const createEmbeddingModel = (modelId) => new OpenAICompatibleEmbeddingModel(modelId, { - ...getCommonModelConfig("embedding") - }); - const createImageModel = (modelId) => new OpenAICompatibleImageModel(modelId, getCommonModelConfig("image")); - const provider = (modelId) => createLanguageModel(modelId); - provider.specificationVersion = "v3"; - provider.languageModel = createLanguageModel; - provider.chatModel = createChatModel; - provider.completionModel = createCompletionModel; - provider.embeddingModel = createEmbeddingModel; - provider.textEmbeddingModel = createEmbeddingModel; - provider.imageModel = createImageModel; - return provider; -} -// ../core/types.ts -var DEFAULT_AI_PROVIDERS = [ - { name: "DeepSeek", baseURL: "https://api.deepseek.com", apiKey: "", model: "deepseek-v4-flash" }, - { name: "OpenAI", baseURL: "https://api.openai.com/v1", apiKey: "", model: "gpt-4o" }, - { name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", apiKey: "", model: "deepseek/deepseek-v4-flash" }, - { name: "GLM", baseURL: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", model: "GLM-4.5" }, - { name: "Other", baseURL: "", apiKey: "", model: "" } -]; -var DEFAULT_SELECTED_AI_PROVIDER = "DeepSeek"; - -// ../core/configMigration.ts -var NAME_TO_OLD_KEY = { - DeepSeek: "deepseek", - OpenAI: "openAI", - OpenRouter: "openrouter", - GLM: "glm", - Other: "other" -}; -var OLD_AI_TO_NAME = { - OpenAI: "OpenAI", - DeepSeek: "DeepSeek", - OpenRouter: "OpenRouter", - GLM: "GLM", - Other: "Other" -}; -function migrateAIConfig(raw) { - if (!raw.ai && raw.aiProviders) { - return false; - } - if (!raw.ai) { - return false; - } - const oldAi = raw.ai; - const providers = DEFAULT_AI_PROVIDERS.map((defaultProvider) => { - const oldKey = NAME_TO_OLD_KEY[defaultProvider.name]; - const oldConfig = oldKey ? oldAi[oldKey] : undefined; - return { - name: defaultProvider.name, - baseURL: oldConfig?.baseURL?.trim() ? oldConfig.baseURL : defaultProvider.baseURL, - apiKey: oldConfig?.apiKey ?? defaultProvider.apiKey ?? "", - model: oldConfig?.model?.trim() ? oldConfig.model : defaultProvider.model - }; - }); - raw.aiProviders = providers; - const oldSelectedAI = raw.selectedAI; - if (oldSelectedAI) { - raw.selectedAIProvider = OLD_AI_TO_NAME[oldSelectedAI] || DEFAULT_SELECTED_AI_PROVIDER; - } else { - raw.selectedAIProvider = DEFAULT_SELECTED_AI_PROVIDER; - } - delete raw.ai; - delete raw.selectedAI; - return true; -} -// src/proxiedFetch.ts -var import_node_http = __toESM(require("node:http")); -var import_node_https = __toESM(require("node:https")); -var import_http_proxy_agent = __toESM(require_dist3(), 1); -var import_https_proxy_agent = __toESM(require_dist4(), 1); -var import_socks_proxy_agent = __toESM(require_dist5(), 1); - -// src/httpContentEncoding.ts -var import_node_zlib = __toESM(require("node:zlib")); -var import_node_util = require("node:util"); -var gunzip = import_node_util.promisify(import_node_zlib.default.gunzip); -var inflate = import_node_util.promisify(import_node_zlib.default.inflate); -var brotliDecompress = import_node_util.promisify(import_node_zlib.default.brotliDecompress); -async function decompressBody(buf, contentEncoding) { - if (!contentEncoding || typeof contentEncoding !== "string") { - return buf; - } - const encoding = contentEncoding.split(",")[0]?.trim().toLowerCase(); - if (encoding === "gzip" || encoding === "x-gzip") { - return gunzip(buf); - } - if (encoding === "deflate") { - return inflate(buf); - } - if (encoding === "br") { - return brotliDecompress(buf); - } - return buf; -} -function toFetchApiStatus(statusCode) { - const status = statusCode ?? 502; - if (status === 304) - return 200; - return status; -} -function incomingHeadersToObject(headers) { - const result = {}; - for (const [key, val] of Object.entries(headers)) { - if (val === undefined) - continue; - const lower = key.toLowerCase(); - if (lower === "content-encoding" || lower === "content-length") - continue; - if (Array.isArray(val)) { - result[key] = val.join(", "); - } else { - result[key] = val; - } - } - return result; -} -async function nodeHttpMessageToFetchResponse(res, wireBody) { - const body = await decompressBody(wireBody, res.headers["content-encoding"]); - const headers = incomingHeadersToObject(res.headers); - headers["Content-Length"] = String(body.length); - return new Response(body, { - status: toFetchApiStatus(res.statusCode), - statusText: res.statusMessage ?? "", - headers - }); -} - -// src/fetchInput.ts -function toRequest(input, init) { - if (input instanceof Request) { - return init !== undefined ? new Request(input, init) : input; - } - const url2 = typeof input === "string" ? input : input.href; - return new Request(url2, init); -} - -// src/proxiedFetch.ts -function isBunRuntime() { - return typeof globalThis.Bun !== "undefined"; -} -function formatProxyHostForLog(proxyUrl) { - try { - const u = new URL(proxyUrl); - const defaultPort = u.protocol === "https:" ? "443" : u.protocol === "http:" ? "80" : ""; - const port = u.port || defaultPort; - return port ? `${u.hostname}:${port}` : u.hostname; - } catch { - return "(invalid-proxy-url)"; - } -} -function getOutboundProxyMode(proxyUrl, targetUrl) { - const proxy = new URL(proxyUrl); - if (proxy.protocol === "socks5:" || proxy.protocol === "socks5h:") { - return "socks5"; - } - if (isBunRuntime()) { - return "bun-native"; - } - if (targetUrl) { - const target = new URL(targetUrl); - if (target.protocol === "http:") { - return "node-forward"; - } - } - return "node-connect"; -} -function wrapFetchWithLogging(inner, mode, logger) { - return async (input, init) => { - const request = toRequest(input, init); - const target = new URL(request.url); - logger?.debug({ - proxyMode: mode, - method: request.method, - targetHost: target.host, - targetPath: target.pathname - }, "[ProxiedFetch] outbound request"); - try { - const response = await inner(request); - logger?.debug({ - proxyMode: mode, - status: response.status, - targetHost: target.host - }, "[ProxiedFetch] outbound response"); - return response; - } catch (err) { - logger?.debug({ - proxyMode: mode, - targetHost: target.host, - err, - errorMessage: err instanceof Error ? err.message : String(err) - }, "[ProxiedFetch] outbound failed"); - throw err; - } - }; -} -function buildAgentRequestHeaders(request) { - const headers = new Headers(request.headers); - headers.delete("accept-encoding"); - headers.set("Host", new URL(request.url).host); - return Object.fromEntries(headers.entries()); -} -function requestViaAgent(request, agent, timeoutMessage) { - const url2 = new URL(request.url); - const method = request.method.toUpperCase(); - const isBodyAllowed = method !== "GET" && method !== "HEAD"; - const isHttps = url2.protocol === "https:"; - const headers = buildAgentRequestHeaders(request); - return new Promise((resolve2, reject) => { - const requestOptions = { - hostname: url2.hostname, - port: Number(url2.port) || (isHttps ? 443 : 80), - path: url2.pathname + url2.search, - method, - headers, - agent, - timeout: 30000 - }; - const req = (isHttps ? import_node_https.default : import_node_http.default).request(requestOptions, (res) => { - const chunks = []; - res.on("data", (chunk2) => chunks.push(chunk2)); - res.on("end", () => { - nodeHttpMessageToFetchResponse(res, Buffer.concat(chunks)).then(resolve2, reject); - }); - res.on("error", reject); - }); - req.on("error", reject); - req.on("timeout", () => { - req.destroy(); - reject(new Error(timeoutMessage)); - }); - if (isBodyAllowed) { - request.arrayBuffer().then((buf) => { - req.write(Buffer.from(buf)); - req.end(); - }, reject); - } else { - req.end(); - } - }); -} -function httpProxyAgentRequest(request, proxyUrl) { - const url2 = new URL(request.url); - const isHttps = url2.protocol === "https:"; - const agent = isHttps ? new import_https_proxy_agent.HttpsProxyAgent(proxyUrl) : new import_http_proxy_agent.HttpProxyAgent(proxyUrl); - return requestViaAgent(request, agent, isHttps ? "HTTPS proxy request timeout" : "HTTP proxy request timeout"); -} -function socksProxyRequest(request, proxyUrl) { - return requestViaAgent(request, new import_socks_proxy_agent.SocksProxyAgent(proxyUrl), "SOCKS5 proxy request timeout"); -} -function createProxiedFetch(proxyUrl, logger) { - const proxy = new URL(proxyUrl); - const isSocks = proxy.protocol === "socks5:" || proxy.protocol === "socks5h:"; - if (isSocks) { - logger?.debug({ - proxyMode: "socks5", - httpProxyHost: formatProxyHostForLog(proxyUrl) - }, "[ProxiedFetch] using outbound proxy"); - return wrapFetchWithLogging((request) => socksProxyRequest(request, proxyUrl), "socks5", logger); - } - if (proxy.protocol !== "http:" && proxy.protocol !== "https:") { - throw new Error(`Unsupported proxy scheme: "${proxy.protocol}". Use http://, https://, or socks5://.`); - } - if (isBunRuntime()) { - logger?.debug({ - proxyMode: "bun-native", - httpProxyHost: formatProxyHostForLog(proxyUrl) - }, "[ProxiedFetch] using outbound proxy"); - return wrapFetchWithLogging(async (request) => { - const method = request.method; - const headers = new Headers(request.headers); - const body = method.toUpperCase() !== "GET" && method.toUpperCase() !== "HEAD" ? await request.arrayBuffer() : undefined; - return fetch(request.url, { - method, - headers, - body, - proxy: proxyUrl - }); - }, "bun-native", logger); - } - logger?.debug({ - proxyMode: "node-connect", - httpProxyHost: formatProxyHostForLog(proxyUrl) - }, "[ProxiedFetch] using outbound proxy"); - return async (input, init) => { - const request = toRequest(input, init); - const url2 = new URL(request.url); - const mode = url2.protocol === "https:" ? "node-connect" : "node-forward"; - logger?.debug({ - proxyMode: mode, - method: request.method, - targetHost: url2.host, - targetPath: url2.pathname - }, "[ProxiedFetch] outbound request"); - try { - const response = await httpProxyAgentRequest(request, proxyUrl); - logger?.debug({ proxyMode: mode, status: response.status, targetHost: url2.host }, "[ProxiedFetch] outbound response"); - return response; - } catch (err) { - logger?.debug({ - proxyMode: mode, - targetHost: url2.host, - err, - errorMessage: err instanceof Error ? err.message : String(err) - }, "[ProxiedFetch] outbound failed"); - throw err; - } - }; -} - -// src/downloadImage.ts -var import_node_buffer = require("node:buffer"); -var import_promises10 = require("node:fs/promises"); -var import_node_url = require("node:url"); -var import_node_path6 = require("node:path"); -var DEFAULT_CONTENT_TYPE = "image/jpeg"; -var EXTENSION_TO_CONTENT_TYPE = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".bmp": "image/bmp", - ".avif": "image/avif", - ".apng": "image/apng" -}; -function getContentTypeFromExtension(ext) { - return EXTENSION_TO_CONTENT_TYPE[ext.toLowerCase()] ?? DEFAULT_CONTENT_TYPE; -} -function normalizeUrl(url2) { - if (url2.startsWith("//")) { - return `https:${url2}`; - } - return url2; -} -var REMOTE_IMAGE_REQUEST_HEADERS = { - accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "accept-language": "en-US,en;q=0.9", - "cache-control": "no-cache", - "sec-fetch-dest": "image", - "sec-fetch-mode": "no-cors", - "sec-fetch-site": "cross-site", - "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" -}; -function describeFetchError(error48) { - if (!(error48 instanceof Error)) { - return String(error48); - } - const cause = error48.cause; - const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : undefined; - const causeMessage = cause instanceof Error ? cause.message : undefined; - const directCode = !causeCode && typeof error48 === "object" && "code" in error48 ? String(error48.code) : undefined; - const directErrno = !causeCode && typeof error48 === "object" && "errno" in error48 ? String(error48.errno) : undefined; - const code = causeCode ?? directCode ?? directErrno; - const message = causeMessage; - const segments = []; - if (code) - segments.push(code); - if (message && message !== code) { - segments.push(message); - } - if (segments.length > 0) { - return `${error48.message} (${segments.join(": ")})`; - } - return error48.message; -} -function resolveUrl(url2) { - const normalizedUrl = normalizeUrl(url2); - if (normalizedUrl.startsWith("file://")) { - const platformPath = import_node_url.fileURLToPath(normalizedUrl); - return { kind: "file", normalizedUrl, platformPath }; - } - if (normalizedUrl.startsWith("http://") || normalizedUrl.startsWith("https://")) { - return { kind: "http", normalizedUrl }; - } - throw new Error(`Invalid image URL: ${url2}. ` + `Must be http://, https://, protocol-relative (//), or file://`); -} -async function doDownloadImage(url2, config2) { - const { allowlist, logger, fetchImpl = fetch } = config2; - const resolved = resolveUrl(url2); - logger?.info({ url: resolved.normalizedUrl, kind: resolved.kind }, "[DownloadImage] processing request"); - if (resolved.kind === "file") { - const platformPath = resolved.platformPath; - const posixPath = Path.posix(platformPath); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - throw new Error(`Permission denied: file ${platformPath} is not allowed to be read`); - } - const buffer2 = await import_promises10.readFile(platformPath); - const ext = import_node_path6.extname(platformPath); - const contentType2 = getContentTypeFromExtension(ext); - logger?.info({ platformPath, bytes: buffer2.length, contentType: contentType2 }, "[DownloadImage] read file"); - return { buffer: buffer2, contentType: contentType2 }; - } - let response; - try { - response = await fetchImpl(resolved.normalizedUrl, { - method: "GET", - headers: REMOTE_IMAGE_REQUEST_HEADERS - }); - } catch (error48) { - throw new Error(`Failed to download image: ${describeFetchError(error48)}`); - } - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const contentType = response.headers.get("content-type") ?? DEFAULT_CONTENT_TYPE; - const arrayBuffer = await response.arrayBuffer(); - const buffer = import_node_buffer.Buffer.from(arrayBuffer); - logger?.info({ url: resolved.normalizedUrl, bytes: buffer.length, contentType }, "[DownloadImage] fetched remote image"); - return { buffer, contentType }; -} - -// src/reverseProxy.ts -var PORT_RANGE_START = 30000; -var PORT_RANGE_END = 31000; -var DEFAULT_ALLOWED_UPSTREAM_HOSTS = new Set([ - "api.themoviedb.org", - "api4.thetvdb.com", - "mediadb.vercel.app", - "tmdb-mcp-server.imlc.me", - "httpbin.io", - "api.deepseek.com", - "api.openai.com", - "openrouter.ai", - "open.bigmodel.cn" -]); -var HOP_BY_HOP_REQUEST_HEADERS = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade" -]); -var HOP_BY_HOP_RESPONSE_HEADERS = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "content-length", - "content-encoding" -]); -var PROXY_CONTROL_HEADERS = new Set([ - "x-smm-proxy-upstream-baseurl", - "x-http-proxy" -]); -var CONDITIONAL_REQUEST_HEADERS = new Set([ - "if-none-match", - "if-modified-since", - "if-match", - "if-unmodified-since" -]); -function buildUpstreamUrl(upstreamBaseURL, incomingPath, incomingSearch) { - const base = new URL(upstreamBaseURL); - const basePath = base.pathname.replace(/\/+$/, ""); - const normalizedPath = incomingPath.startsWith("/") ? incomingPath : `/${incomingPath}`; - const path6 = `${basePath}${normalizedPath}`; - const query = incomingSearch.startsWith("?") ? incomingSearch : ""; - return `${base.origin}${path6}${query}`; -} -function validateUpstreamBaseURL(headerValue, allowedUpstreamHosts) { - let upstreamUrl; - try { - upstreamUrl = new URL(headerValue); - } catch { - throw new Error(`Invalid upstream base URL: "${headerValue}"`); - } - if (upstreamUrl.protocol !== "https:" && upstreamUrl.protocol !== "http:") { - throw new Error(`Upstream base URL must use http or https protocol, got: "${upstreamUrl.protocol}"`); - } - if (!allowedUpstreamHosts.has(upstreamUrl.hostname)) { - throw new Error(`Upstream host "${upstreamUrl.hostname}" is not allowed. Allowed hosts: ${[...allowedUpstreamHosts].join(", ")}`); - } - return upstreamUrl; -} -function filterRequestHeaders(request, upstreamUrl) { - const headers = new Headers; - request.headers.forEach((value, key) => { - const lowerKey = key.toLowerCase(); - if (HOP_BY_HOP_REQUEST_HEADERS.has(lowerKey)) - return; - if (PROXY_CONTROL_HEADERS.has(lowerKey)) - return; - if (CONDITIONAL_REQUEST_HEADERS.has(lowerKey)) - return; - headers.set(key, value); - }); - headers.set("Host", upstreamUrl.host); - return headers; -} -function filterResponseHeaders(response) { - const headers = new Headers; - response.headers.forEach((value, key) => { - const lowerKey = key.toLowerCase(); - if (HOP_BY_HOP_RESPONSE_HEADERS.has(lowerKey)) - return; - headers.set(key, value); - }); - return headers; -} -function corsHeaders() { - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS", - "Access-Control-Allow-Headers": "*", - "Access-Control-Max-Age": "86400" - }; -} -function applyCorsToBody(body, init = {}) { - const headers = new Headers; - if (init.headers) { - const source = init.headers; - if (source instanceof Headers) { - source.forEach((value, key) => headers.set(key, value)); - } else if (Array.isArray(source)) { - for (const [key, value] of source) { - headers.set(key, value); - } - } else { - for (const [key, value] of Object.entries(source)) { - headers.set(key, value); - } - } - } - for (const [key, value] of Object.entries(corsHeaders())) { - if (!headers.has(key)) { - headers.set(key, value); - } - } - return new Response(body, { - status: init.status ?? 200, - statusText: init.statusText, - headers - }); -} -function noopLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }; -} -function buildOutboundProxyLogFields(httpProxyHeader, usingProxiedFetch, forwardUrl) { - const trimmed = httpProxyHeader?.trim(); - const viaHttpProxy = Boolean(trimmed); - if (!viaHttpProxy) { - return { viaHttpProxy: false, proxyMode: "direct" }; - } - const httpProxyHost = formatProxyHostForLog(trimmed); - if (!usingProxiedFetch) { - return { viaHttpProxy: true, httpProxyHost, proxyMode: "direct-fallback" }; - } - return { - viaHttpProxy: true, - httpProxyHost, - proxyMode: getOutboundProxyMode(trimmed, forwardUrl) - }; -} -var PROXY_ERROR_CODES = { - ENOTFOUND: { - message: "DNS resolution failed for upstream host", - code: "DNS_RESOLUTION_FAILED" - }, - ECONNREFUSED: { - message: "Connection refused by upstream host", - code: "CONNECTION_REFUSED" - }, - ConnectionRefused: { - message: "Connection refused by upstream host", - code: "CONNECTION_REFUSED" - }, - ECONNRESET: { - message: "Connection was reset by upstream host", - code: "CONNECTION_RESET" - }, - ETIMEDOUT: { - message: "Connection to upstream host timed out", - code: "CONNECTION_TIMEOUT" - }, - ENETUNREACH: { - message: "Upstream network is unreachable", - code: "NETWORK_UNREACHABLE" - }, - ECONNABORTED: { - message: "Connection was aborted", - code: "CONNECTION_ABORTED" - }, - UND_ERR_CONNECT_TIMEOUT: { - message: "Connection to upstream host timed out", - code: "CONNECTION_TIMEOUT" - }, - UND_ERR_HEADERS_TIMEOUT: { - message: "Upstream host did not respond with headers in time", - code: "HEADERS_TIMEOUT" - } -}; -var TLS_ERROR_CODES = new Set([ - "ERR_TLS_CERT_ALTNAME_INVALID", - "CERT_HAS_EXPIRED", - "DEPTH_ZERO_SELF_SIGNED_CERT", - "UNABLE_TO_VERIFY_LEAF_SIGNATURE" -]); -function extractLoggingErrorDetail(error48) { - if (!(error48 instanceof Error)) { - return { originalError: String(error48) }; - } - const originalError = describeFetchError(error48); - let systemCode; - let causeMessage; - if (typeof error48 === "object" && "code" in error48) { - systemCode = String(error48.code); - } else if (typeof error48 === "object" && "errno" in error48) { - systemCode = String(error48.errno); - } - if (!systemCode) { - let current = error48; - for (let depth = 0;depth < 3; depth++) { - const cause = current.cause; - if (!(cause instanceof Error)) - break; - if (!systemCode) { - systemCode = cause.code; - } - if (cause.message && cause.message !== error48.message) { - causeMessage = cause.message; - } - current = cause; - } - } - return { originalError, systemCode, causeMessage }; -} -function lookupSystemCode(code) { - if (TLS_ERROR_CODES.has(code)) { - return { message: "TLS certificate validation failed for upstream host", code: "TLS_ERROR" }; - } - return PROXY_ERROR_CODES[code]; -} -function classifyProxyError(error48) { - if (!(error48 instanceof Error)) { - return { message: String(error48), code: "UPSTREAM_REQUEST_FAILED" }; - } - let current = error48; - for (let depth = 0;depth < 3; depth++) { - if (!(current instanceof Error)) - break; - const causeCode = current.code; - if (typeof causeCode === "string") { - const found = lookupSystemCode(causeCode); - if (found) - return found; - } - if (!causeCode) { - const causeErrno = current.errno; - if (typeof causeErrno === "string") { - const found = lookupSystemCode(causeErrno); - if (found) - return found; - } - } - current = current.cause; - } - const msg = error48.message; - if (msg.includes("timeout")) { - return { message: "Proxy request timed out", code: "PROXY_TIMEOUT" }; - } - if (msg.includes("CONNECT refused")) { - const statusMatch = msg.match(/HTTP\/\d\.\d\s+(\d+)/); - return { - message: statusMatch ? `Proxy CONNECT tunnel refused with status ${statusMatch[1]}` : "Proxy CONNECT tunnel was refused by the proxy server", - code: "PROXY_CONNECT_REFUSED" - }; - } - if (msg.includes("Unsupported proxy scheme")) { - return { message: msg, code: "UNSUPPORTED_PROXY_SCHEME" }; - } - return { message: error48.message, code: "UPSTREAM_REQUEST_FAILED" }; -} -function proxyErrorToProblemDetails(errInfo, status) { - return { - type: "about:blank", - title: status === 502 ? "Bad Gateway" : status === 400 ? "Bad Request" : "Upstream Error", - status, - detail: errInfo.message - }; -} -async function handleProxyRequest(request, config2 = {}) { - const logger = config2.logger ?? noopLogger(); - const fetchImpl = config2.fetchImpl ?? fetch; - let allowedUpstreamHosts; - if (config2.resolveAllowedUpstreamHosts) { - try { - allowedUpstreamHosts = await config2.resolveAllowedUpstreamHosts(); - } catch (error48) { - logger.error({ err: error48, errorMessage: error48 instanceof Error ? error48.message : String(error48) }, "[Reverse Proxy] failed to resolve allowed upstream hosts, falling back to defaults"); - allowedUpstreamHosts = config2.allowedUpstreamHosts ?? DEFAULT_ALLOWED_UPSTREAM_HOSTS; - } - } else { - allowedUpstreamHosts = config2.allowedUpstreamHosts ?? DEFAULT_ALLOWED_UPSTREAM_HOSTS; - } - if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: corsHeaders() }); - } - const httpProxyHeader = request.headers.get("X-Http-Proxy"); - let activeFetch = fetchImpl; - let usingProxiedFetch = false; - if (httpProxyHeader?.trim() && config2.createProxiedFetch) { - try { - const proxiedFetch = config2.createProxiedFetch(httpProxyHeader, logger); - if (proxiedFetch) { - activeFetch = proxiedFetch; - usingProxiedFetch = true; - } else { - logger.warn({ - httpProxyHost: formatProxyHostForLog(httpProxyHeader) - }, "[Reverse Proxy] createProxiedFetch returned undefined; using direct fetch"); - } - } catch (error48) { - logger.error({ - httpProxyHost: formatProxyHostForLog(httpProxyHeader), - err: error48, - errorMessage: error48 instanceof Error ? error48.message : String(error48) - }, "[Reverse Proxy] failed to create proxied fetch, falling back to direct"); - } - } else if (httpProxyHeader?.trim() && !config2.createProxiedFetch) { - logger.warn({ - httpProxyHost: formatProxyHostForLog(httpProxyHeader) - }, "[Reverse Proxy] X-Http-Proxy set but createProxiedFetch is not configured; using direct fetch"); - } - const upstreamBaseURL = request.headers.get("X-SMM-Proxy-Upstream-BaseURL"); - if (!upstreamBaseURL) { - return applyCorsToBody(JSON.stringify({ - type: "about:blank", - title: "Bad Request", - status: 400, - detail: "Missing X-SMM-Proxy-Upstream-BaseURL header" - }), { status: 400, headers: { "Content-Type": "application/problem+json" } }); - } - let upstreamUrl; - try { - upstreamUrl = validateUpstreamBaseURL(upstreamBaseURL, allowedUpstreamHosts); - } catch (error48) { - return applyCorsToBody(JSON.stringify({ - type: "about:blank", - title: "Bad Request", - status: 400, - detail: error48 instanceof Error ? error48.message : "Invalid upstream base URL" - }), { status: 400, headers: { "Content-Type": "application/problem+json" } }); - } - const incomingUrl = new URL(request.url); - const forwardUrl = buildUpstreamUrl(upstreamBaseURL, incomingUrl.pathname, incomingUrl.search); - const proxyLogFields = buildOutboundProxyLogFields(httpProxyHeader, usingProxiedFetch, forwardUrl); - try { - const reqHeaders = filterRequestHeaders(request, upstreamUrl); - const method = request.method; - const upstreamReq = new Request(forwardUrl, { - method, - headers: reqHeaders, - body: method !== "GET" && method !== "HEAD" ? request.body : undefined, - ...method !== "GET" && method !== "HEAD" ? { duplex: "half" } : {} - }); - logger.info({ - method, - forwardUrl, - upstreamHost: upstreamUrl.host, - incomingPath: incomingUrl.pathname, - upstreamBaseURL, - ...proxyLogFields - }, "[Reverse Proxy] forwarding request"); - const response = await activeFetch(upstreamReq); - const respHeaders = filterResponseHeaders(response); - const respBody = await response.arrayBuffer(); - logger.info({ - method, - forwardUrl, - status: response.status, - responseBytes: respBody.byteLength, - ...proxyLogFields - }, "[Reverse Proxy] upstream response"); - return applyCorsToBody(respBody, { - status: response.status, - statusText: response.statusText, - headers: respHeaders - }); - } catch (error48) { - const errInfo = classifyProxyError(error48); - const errDetail = extractLoggingErrorDetail(error48); - logger.error({ - err: error48, - errorMessage: error48 instanceof Error ? error48.message : String(error48), - originalError: errDetail.originalError, - systemCode: errDetail.systemCode, - causeMessage: errDetail.causeMessage, - method: request.method, - forwardUrl, - incomingPath: incomingUrl.pathname, - upstreamBaseURL, - ...proxyLogFields - }, "[Reverse Proxy] upstream request failed"); - const problem = proxyErrorToProblemDetails(errInfo, 502); - return applyCorsToBody(JSON.stringify(problem), { - status: 502, - headers: { "Content-Type": "application/problem+json" } - }); - } -} -// src/reverseProxyNode.ts -var import_node_http2 = __toESM(require("node:http")); -var import_node_net = __toESM(require("node:net")); -var import_node_stream = require("node:stream"); -function createReverseProxyRequestHandler(config2 = {}) { - return async (req, res) => { - try { - const webRequest = incomingMessageToRequest(req); - const webResponse = await handleProxyRequest(webRequest, config2); - await sendNodeResponse(res, webResponse); - } catch (error48) { - const logger = config2.logger; - if (logger) { - logger.error({ err: error48, method: req.method, url: req.url }, "[Reverse Proxy] node handler error"); - } - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - } - res.end("Internal Server Error"); - } - }; -} -function incomingMessageToRequest(req) { - const host = req.headers.host ?? "127.0.0.1"; - const url2 = `http://${host}${req.url ?? "/"}`; - const headers = new Headers; - copyIncomingHeaders(req.headers, headers); - const method = req.method ?? "GET"; - const init = { method, headers }; - if (method !== "GET" && method !== "HEAD") { - init.body = import_node_stream.Readable.toWeb(req); - init.duplex = "half"; - } - return new Request(url2, init); -} -function copyIncomingHeaders(source, target) { - for (const [key, value] of Object.entries(source)) { - if (value === undefined) - continue; - if (Array.isArray(value)) { - for (const v of value) - target.append(key, v); - } else { - target.set(key, value); - } - } -} -async function sendNodeResponse(res, response) { - const body = response.body === null ? null : Buffer.from(await response.arrayBuffer()); - res.statusCode = response.status; - res.statusMessage = response.statusText; - response.headers.forEach((value, key) => { - const lowerKey = key.toLowerCase(); - if (lowerKey === "content-length" || lowerKey === "content-encoding") { - return; - } - try { - res.setHeader(key, value); - } catch {} - }); - if (body === null || body.length === 0) { - res.end(); - return; - } - res.setHeader("Content-Length", body.length); - res.end(body); -} -function tryListen(port, hostname3 = "127.0.0.1") { - return new Promise((resolve2) => { - const tester = import_node_net.default.createServer(); - tester.once("error", () => { - tester.removeAllListeners(); - resolve2(false); - }); - tester.once("listening", () => { - tester.close(() => resolve2(true)); - }); - tester.listen(port, hostname3); - }); -} -async function findAvailableReverseProxyPort(reservedPorts = new Set, portRange = { - start: PORT_RANGE_START, - end: PORT_RANGE_END -}, bindAddress = resolveReverseProxyBindAddress()) { - for (let port = portRange.start;port <= portRange.end; port++) { - if (reservedPorts.has(port)) - continue; - if (await tryListen(port, bindAddress)) - return port; - } - throw new Error(`Could not find an available port in range ${portRange.start}-${portRange.end}`); -} -function createReverseProxyManager(config2 = {}) { - let server = null; - let currentUrl = null; - const handler = createReverseProxyRequestHandler(config2); - async function start() { - if (server) { - config2.logger?.warn({}, "[Reverse Proxy] already running"); - return; - } - const bindAddress = config2.bindAddress ?? resolveReverseProxyBindAddress(); - const advertisedHost = resolveReverseProxyAdvertisedHost(bindAddress); - let port; - try { - if (typeof config2.port === "number") { - port = config2.port; - } else { - port = await findAvailableReverseProxyPort(config2.reservedPorts, config2.portRange, bindAddress); - } - } catch (error48) { - config2.logger?.error({ err: error48 }, "[Reverse Proxy] failed to find available port"); - currentUrl = null; - return; - } - const newServer = import_node_http2.default.createServer(handler); - await new Promise((resolve2, reject) => { - newServer.once("error", (err) => { - newServer.removeListener("listening", onListening); - reject(err); - }); - const onListening = () => { - newServer.removeListener("error", onError); - resolve2(); - }; - const onError = (err) => reject(err); - newServer.once("listening", onListening); - newServer.listen(port, bindAddress); - }); - server = newServer; - currentUrl = `http://${advertisedHost}:${port}`; - config2.logger?.info({ url: currentUrl, bindAddress }, "[Reverse Proxy] started"); - } - async function stop() { - if (!server) - return; - const s = server; - server = null; - currentUrl = null; - await new Promise((resolve2) => { - s.close(() => resolve2()); - }); - config2.logger?.info({}, "[Reverse Proxy] stopped"); - } - return { - get url() { - return currentUrl; - }, - start, - stop - }; -} -// src/nodeHttpFetch.ts -var import_node_stream2 = require("node:stream"); -var import_node_http3 = __toESM(require("node:http")); -var import_node_https2 = __toESM(require("node:https")); -var import_node_zlib2 = __toESM(require("node:zlib")); -var HOP_BY_HOP_REQUEST_HEADERS2 = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "host" -]); -var CONDITIONAL_REQUEST_HEADERS2 = new Set([ - "if-none-match", - "if-modified-since", - "if-match", - "if-unmodified-since" -]); -var STRIPPED_RESPONSE_HEADERS = new Set([ - "content-encoding", - "content-length" -]); -function buildOutgoingHeaders(request) { - const headers = {}; - request.headers.forEach((value, key) => { - const lowerKey = key.toLowerCase(); - if (HOP_BY_HOP_REQUEST_HEADERS2.has(lowerKey)) - return; - if (CONDITIONAL_REQUEST_HEADERS2.has(lowerKey)) - return; - if (lowerKey === "accept-encoding") - return; - headers[key] = value; - }); - return headers; -} -function toFetchApiStatus2(statusCode) { - const status = statusCode ?? 502; - if (status === 304) - return 200; - return status; -} -function createNodeHttpResponse(body, statusCode, statusMessage, sourceHeaders, bodyLength) { - return new Response(body, { - status: toFetchApiStatus2(statusCode), - statusText: statusMessage ?? "", - headers: buildResponseHeaders(sourceHeaders, bodyLength) - }); -} -function buildResponseHeaders(source, bodyLength) { - const headers = new Headers; - for (const [key, val] of Object.entries(source)) { - if (val === undefined) - continue; - const lowerKey = key.toLowerCase(); - if (STRIPPED_RESPONSE_HEADERS.has(lowerKey)) - continue; - if (Array.isArray(val)) { - for (const v of val) - headers.append(key, v); - } else { - headers.append(key, val); - } - } - if (bodyLength !== undefined && bodyLength >= 0) { - headers.set("Content-Length", String(bodyLength)); - } - return headers; -} -function decompressorFor(contentEncoding) { - if (!contentEncoding || typeof contentEncoding !== "string") - return null; - const encoding = contentEncoding.split(",")[0]?.trim().toLowerCase(); - if (encoding === "gzip" || encoding === "x-gzip") - return import_node_zlib2.default.createGunzip(); - if (encoding === "deflate") - return import_node_zlib2.default.createInflate(); - if (encoding === "br") - return import_node_zlib2.default.createBrotliDecompress(); - return null; -} -function requestViaNodeHttp(request) { - const url2 = new URL(request.url); - const isHttps = url2.protocol === "https:"; - if (!isHttps && url2.protocol !== "http:") { - return Promise.reject(new Error(`Unsupported URL protocol: ${url2.protocol}`)); - } - const method = request.method.toUpperCase(); - const port = url2.port ? Number(url2.port) : isHttps ? 443 : 80; - const pathWithQuery = `${url2.pathname}${url2.search}`; - const headers = buildOutgoingHeaders(request); - return new Promise((resolve2, reject) => { - const onResponse = (res) => { - const chunks = []; - res.on("data", (chunk2) => chunks.push(chunk2)); - res.on("end", () => { - (async () => { - try { - const wireBody = Buffer.concat(chunks); - const body = await decompressBody(wireBody, res.headers["content-encoding"]); - const bodyBytes = Buffer.from(body); - resolve2(createNodeHttpResponse(bodyBytes, res.statusCode, res.statusMessage, res.headers, bodyBytes.length)); - } catch (error48) { - reject(error48); - } - })(); - }); - res.on("error", reject); - }; - (async () => { - try { - let requestBody; - if (method !== "GET" && method !== "HEAD") { - requestBody = Buffer.from(await request.arrayBuffer()); - } - const requestOptions = { - hostname: url2.hostname, - port, - path: pathWithQuery, - method: request.method, - headers - }; - const req = isHttps ? import_node_https2.default.request(requestOptions, onResponse) : import_node_http3.default.request(requestOptions, onResponse); - req.on("error", reject); - if (requestBody !== undefined && requestBody.length > 0) { - req.write(requestBody); - } - req.end(); - } catch (error48) { - reject(error48); - } - })(); - }); -} -function requestViaNodeHttpStreaming(request) { - const url2 = new URL(request.url); - const isHttps = url2.protocol === "https:"; - if (!isHttps && url2.protocol !== "http:") { - return Promise.reject(new Error(`Unsupported URL protocol: ${url2.protocol}`)); - } - const method = request.method.toUpperCase(); - const port = url2.port ? Number(url2.port) : isHttps ? 443 : 80; - const pathWithQuery = `${url2.pathname}${url2.search}`; - const headers = buildOutgoingHeaders(request); - return new Promise((resolve2, reject) => { - const onResponse = (res) => { - const contentEncoding = res.headers["content-encoding"]; - const decompressor = decompressorFor(contentEncoding); - if (decompressor) { - const decompressed = res.pipe(decompressor); - decompressor.on("error", reject); - const webStream = import_node_stream2.Readable.toWeb(decompressed); - resolve2(createNodeHttpResponse(webStream, res.statusCode, res.statusMessage, res.headers)); - } else { - const webStream = import_node_stream2.Readable.toWeb(res); - resolve2(createNodeHttpResponse(webStream, res.statusCode, res.statusMessage, res.headers)); - } - }; - (async () => { - try { - let requestBody; - if (method !== "GET" && method !== "HEAD") { - requestBody = Buffer.from(await request.arrayBuffer()); - } - const requestOptions = { - hostname: url2.hostname, - port, - path: pathWithQuery, - method: request.method, - headers - }; - const req = isHttps ? import_node_https2.default.request(requestOptions, onResponse) : import_node_http3.default.request(requestOptions, onResponse); - req.on("error", reject); - if (requestBody !== undefined && requestBody.length > 0) { - req.write(requestBody); - } - req.end(); - } catch (error48) { - reject(error48); - } - })(); - }); -} -function createNodeHttpFetch() { - return (input, init) => { - return requestViaNodeHttp(toRequest(input, init)); - }; -} -function createStreamingNodeHttpFetch() { - return (input, init) => { - return requestViaNodeHttpStreaming(toRequest(input, init)); - }; -} -// src/writeFile.ts -var import_node_path7 = __toESM(require("node:path")); -var import_promises11 = require("node:fs/promises"); -var import_node_fs = require("node:fs"); - -// ../core/errors.ts -function isError2(error48, message) { - return error48.startsWith(`${message}:`); -} -var ExistedFileError = "File Already Existed"; -function existedFileError(path6) { - return `${ExistedFileError}: ${path6}`; -} -var FileNotFoundError = "File Not Found"; -function fileNotFoundError(path6) { - return `${FileNotFoundError}: ${path6}`; -} - -// src/writeFile.ts -var writeFileRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "Path is required"), - mode: exports_external2.enum(["overwrite", "append", "create"]), - data: exports_external2.string() -}); -var fileLocks = new Map; -async function acquireFileLock(resolvedPath) { - const previousLock = fileLocks.get(resolvedPath) || Promise.resolve(); - let releaseLock; - const newLock = new Promise((r) => { - releaseLock = r; - }); - fileLocks.set(resolvedPath, newLock); - try { - await previousLock; - } catch {} - return () => { - releaseLock(); - if (fileLocks.get(resolvedPath) === newLock) { - fileLocks.delete(resolvedPath); - } - }; -} -async function fileExists(filePath) { - try { - await import_promises11.access(filePath, import_node_fs.constants.F_OK); - return true; - } catch { - return false; - } -} -async function doWriteFile(body, config2, traceId = "") { - const { logger, allowlist } = config2; - try { - logger?.info({ traceId }, "doWriteFile: Starting file write operation"); - const validationResult = writeFileRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.error({ traceId, error: validationResult.error }, "doWriteFile: Validation failed"); - return { - error: `Validation failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { path: filePath, mode, data } = validationResult.data; - logger?.debug({ traceId, filePath, mode, dataSize: data.length }, "doWriteFile: Processing write request"); - const resolvedPath = import_node_path7.default.resolve(filePath); - const posixPath = Path.posix(resolvedPath); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - logger?.warn({ traceId, filePath }, "doWriteFile: Path not in allowlist"); - return { - error: `Path "${filePath}" is not in allowlist` - }; - } - const release = await acquireFileLock(resolvedPath); - try { - const validatedPath = resolvedPath; - const parentDir = import_node_path7.default.dirname(validatedPath); - try { - await import_promises11.mkdir(parentDir, { recursive: true }); - logger?.debug({ traceId, parentDir }, "doWriteFile: Parent directory ensured"); - } catch (error48) { - logger?.warn({ traceId, error: error48 }, "doWriteFile: Failed to ensure parent directory"); - } - if (mode === "create") { - logger?.debug({ traceId, path: validatedPath }, "doWriteFile: Create mode"); - if (await fileExists(validatedPath)) { - logger?.error({ traceId, path: validatedPath }, "doWriteFile: File already exists"); - return { - error: existedFileError(validatedPath) - }; - } - try { - await import_promises11.writeFile(validatedPath, data, "utf-8"); - logger?.info({ traceId, path: validatedPath, size: data.length }, "doWriteFile: File written successfully (create mode)"); - return {}; - } catch (error48) { - logger?.error({ traceId, path: validatedPath, error: error48 }, "doWriteFile: Failed to write file (create mode)"); - return { - error: `Failed to write file: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } - if (mode === "overwrite") { - logger?.debug({ traceId, path: validatedPath }, "doWriteFile: Overwrite mode"); - try { - await import_promises11.writeFile(validatedPath, data, "utf-8"); - logger?.info({ traceId, path: validatedPath, size: data.length }, "doWriteFile: File written successfully (overwrite mode)"); - return {}; - } catch (error48) { - logger?.error({ traceId, path: validatedPath, error: error48 }, "doWriteFile: Failed to write file (overwrite mode)"); - return { - error: `Failed to write file: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } - if (mode === "append") { - logger?.debug({ traceId, path: validatedPath }, "doWriteFile: Append mode"); - try { - await import_promises11.appendFile(validatedPath, data, "utf-8"); - logger?.info({ traceId, path: validatedPath, appendedSize: data.length }, "doWriteFile: Data appended successfully"); - return {}; - } catch (error48) { - logger?.error({ traceId, path: validatedPath, error: error48 }, "doWriteFile: Failed to append to file"); - return { - error: `Failed to append to file: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } - logger?.error({ traceId, mode }, "doWriteFile: Invalid mode"); - return { - error: `Invalid mode: ${mode}` - }; - } finally { - release(); - } - } catch (error48) { - const loggableError = error48 instanceof Error ? { - message: error48.message, - stack: error48.stack, - name: error48.name - } : { error: error48 }; - logger?.error({ traceId, error: loggableError }, "doWriteFile: Unexpected error"); - return { - error: `Unexpected error: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } -} -// src/readFile.ts -var import_node_path8 = __toESM(require("node:path")); -var import_promises12 = require("node:fs/promises"); -var import_node_fs2 = require("node:fs"); -var readFileRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "Path is required"), - requireValidPath: exports_external2.boolean().optional() -}); -async function fileExists2(filePath) { - try { - await import_promises12.access(filePath, import_node_fs2.constants.F_OK); - return true; - } catch { - return false; - } -} -async function checkFileIsReadable(filePath) { - if (!await fileExists2(filePath)) { - return null; - } - try { - return await import_promises12.readFile(filePath, "utf-8"); - } catch { - return null; - } -} -async function doReadFile(body, config2) { - const { logger, allowlist } = config2; - try { - const validationResult = readFileRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.info({ issues: validationResult.error.issues }, "doReadFile: validation failed"); - return { - error: `Validation failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { path: filePath, requireValidPath } = validationResult.data; - logger?.debug({ filePath, requireValidPath }, "doReadFile: processing request"); - const posixPath = Path.posix(filePath); - const resolvedPath = import_node_path8.default.posix.resolve(posixPath); - if (requireValidPath === undefined || requireValidPath === true) { - const isAllowed = validatePathIsInAllowlist(resolvedPath, allowlist); - if (!isAllowed) { - logger?.warn({ filePath }, "doReadFile: path not in allowlist"); - return { - error: `Path "${filePath}" is not in the allowlist` - }; - } - } - const platformPath = Path.toPlatformPath(resolvedPath); - try { - const data = await checkFileIsReadable(platformPath); - if (data === null) { - logger?.info({ filePath, platformPath }, "doReadFile: file not found"); - return { - error: fileNotFoundError(filePath) - }; - } - return { data }; - } catch (error48) { - logger?.error({ filePath, platformPath, error: error48 }, "doReadFile: failed to read file"); - return { - error: `Failed to read file: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } catch (error48) { - logger?.error({ error: error48 }, "doReadFile: unexpected error"); - return { - error: `Unexpected error: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } -} -// src/deleteFile.ts -var import_node_path9 = __toESM(require("node:path")); -var import_promises13 = require("node:fs/promises"); -var deleteFileRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "Path is required") -}); -async function doDeleteFile(body, config2) { - const { logger, allowlist } = config2; - try { - const validationResult = deleteFileRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.info({ issues: validationResult.error.issues }, "doDeleteFile: validation failed"); - return { - error: `Validation Failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { path: filePath } = validationResult.data; - logger?.debug({ filePath }, "doDeleteFile: processing request"); - const resolvedPath = import_node_path9.default.resolve(filePath); - const posixPath = Path.posix(resolvedPath); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - logger?.warn({ filePath: posixPath }, "doDeleteFile: path not in allowlist"); - return { - error: `Path "${filePath}" is not in the allowlist` - }; - } - const platformPath = Path.toPlatformPath(posixPath); - try { - const fileStats = await import_promises13.stat(platformPath); - if (!fileStats.isFile()) { - logger?.info({ filePath: platformPath }, "doDeleteFile: path is not a file"); - return { - error: `Path Is Directory: ${filePath} is a directory, not a file` - }; - } - } catch (error48) { - const errorCode = error48.code; - if (errorCode === "ENOENT") { - logger?.info({ filePath: platformPath }, "doDeleteFile: file already absent"); - return { data: { path: platformPath } }; - } - logger?.error({ filePath: platformPath, error: error48 }, "doDeleteFile: cannot access file"); - return { - error: `Cannot access file: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - try { - await import_promises13.unlink(platformPath); - logger?.info({ filePath: platformPath }, "doDeleteFile: file deleted successfully"); - return { data: { path: platformPath } }; - } catch (error48) { - const errorCode = error48.code; - if (errorCode === "ENOENT") { - logger?.info({ filePath: platformPath }, "doDeleteFile: file already absent during unlink"); - return { data: { path: platformPath } }; - } - if (errorCode === "EACCES" || errorCode === "EPERM") { - logger?.warn({ filePath: platformPath }, "doDeleteFile: permission denied"); - return { - error: `Permission denied: Cannot delete file ${filePath}` - }; - } - logger?.error({ filePath: platformPath, error: error48 }, "doDeleteFile: unlink failed"); - return { - error: `Failed to delete file ${filePath}: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } catch (error48) { - logger?.error({ error: error48 }, "doDeleteFile: unexpected error"); - return { - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } -} -// src/deleteFolder.ts -var import_node_path10 = __toESM(require("node:path")); -var import_promises14 = require("node:fs/promises"); -var deleteFolderRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "Path is required") -}); -async function doDeleteFolder(body, config2) { - const { logger, allowlist } = config2; - try { - const validationResult = deleteFolderRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.info({ issues: validationResult.error.issues }, "doDeleteFolder: validation failed"); - return { - error: `Validation Failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { path: folderPath } = validationResult.data; - logger?.debug({ folderPath }, "doDeleteFolder: processing request"); - const resolvedPath = import_node_path10.default.resolve(folderPath); - const posixPath = Path.posix(resolvedPath); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - logger?.warn({ folderPath: posixPath }, "doDeleteFolder: path not in allowlist"); - return { - error: `Path "${folderPath}" is not in the allowlist` - }; - } - const platformPath = Path.toPlatformPath(posixPath); - try { - const folderStats = await import_promises14.stat(platformPath); - if (!folderStats.isDirectory()) { - logger?.info({ folderPath: platformPath }, "doDeleteFolder: path is not a directory"); - return { - error: `Path Is File: ${folderPath} is a file, not a directory` - }; - } - } catch (error48) { - const errorCode = error48.code; - if (errorCode === "ENOENT") { - logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder already absent"); - return { data: { path: platformPath } }; - } - logger?.error({ folderPath: platformPath, error: error48 }, "doDeleteFolder: cannot access path"); - return { - error: `Cannot access path: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - try { - await import_promises14.rm(platformPath, { recursive: true, force: true }); - logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder deleted successfully"); - return { data: { path: platformPath } }; - } catch (error48) { - const errorCode = error48.code; - if (errorCode === "ENOENT") { - logger?.info({ folderPath: platformPath }, "doDeleteFolder: folder already absent during rm"); - return { data: { path: platformPath } }; - } - if (errorCode === "EACCES" || errorCode === "EPERM") { - logger?.warn({ folderPath: platformPath }, "doDeleteFolder: permission denied"); - return { - error: `Permission denied: Cannot delete folder ${folderPath}` - }; - } - logger?.error({ folderPath: platformPath, error: error48 }, "doDeleteFolder: rm failed"); - return { - error: `Failed to delete folder ${folderPath}: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } catch (error48) { - logger?.error({ error: error48 }, "doDeleteFolder: unexpected error"); - return { - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } -} -// src/listFilesInMediaFolder.ts -var listFilesInMediaFolderRequestSchema = exports_external2.object({ - mediaFolderPath: exports_external2.string().min(1, "The absolute path of the media folder is required"), - recursively: exports_external2.boolean().optional(), - videoFileOnly: exports_external2.boolean().optional() -}); -async function doListFilesInMediaFolder(body, config2 = EMPTY_CORE_ROUTES_CONFIG) { - const parsed = listFilesInMediaFolderRequestSchema.safeParse(body); - if (!parsed.success) { - const msg = parsed.error.issues.map((i) => i.message).join(", "); - return { - ...createEmptyListFilesInMediaFolderData(), - error: `Validation Failed: ${msg}` - }; - } - const pathCheck = requireNonEmptyString(parsed.data.mediaFolderPath, "mediaFolderPath"); - if (typeof pathCheck !== "string") { - return { - ...createEmptyListFilesInMediaFolderData(), - error: LIST_FILES_IN_MEDIA_FOLDER_INVALID_PATH - }; - } - const empty = createEmptyListFilesInMediaFolderData(); - if (!await isMediaFolderManaged(pathCheck, config2)) { - return { ...empty, error: LIST_FILES_IN_MEDIA_FOLDER_NOT_MANAGED }; - } - try { - const listResult = await doListFiles({ - path: pathCheck, - recursively: parsed.data.recursively ?? true, - onlyFiles: true - }, config2); - if (listResult.error) { - return { ...empty, error: listResult.error }; - } - const filePaths = listResult.data?.items.filter((item) => !item.isDirectory).map((item) => item.path) ?? []; - return toolOk(buildListFilesInMediaFolderResponse(filePaths, parsed.data.videoFileOnly ?? false)); - } catch (error48) { - return { - ...empty, - ...formatToolError(error48) - }; - } -} -// ../core/getMediaFolder.ts -function getMediaFolder(filePath, folderPaths) { - const filePathNorm = new Path(filePath).abs("posix").replace(/^\/[A-Za-z](?::|\/)/, ""); - for (const folder of folderPaths) { - const folderNorm = new Path(folder).abs("posix").replace(/^\/[A-Za-z](?::|\/)/, ""); - const prefix = folderNorm.endsWith("/") ? folderNorm : `${folderNorm}/`; - if (filePathNorm === folderNorm || filePathNorm.startsWith(prefix)) { - return folder; - } - } - return null; -} - -// src/renameFileExecution.ts -var import_promises15 = require("node:fs/promises"); -var import_node_path11 = __toESM(require("node:path")); -async function directoryExists(dirPath) { - try { - const stats = await import_promises15.stat(dirPath); - return stats.isDirectory(); - } catch { - return false; - } -} -async function executeRenameOperation(from, to) { - const fromPathPlatform = new Path(from).platformAbsPath(); - const toPathPlatform = new Path(to).platformAbsPath(); - try { - const destDir = import_node_path11.default.dirname(toPathPlatform); - await import_promises15.mkdir(destDir, { recursive: true }); - if (!await directoryExists(destDir)) { - return { - success: false, - error: `Destination directory does not exist and could not be created: ${destDir}` - }; - } - await import_promises15.rename(fromPathPlatform, toPathPlatform); - return { success: true }; - } catch (error48) { - const errorMessage = error48 instanceof Error ? error48.message : "Unknown error"; - return { - success: false, - error: errorMessage.includes("ENOENT") ? `Source file does not exist: ${fromPathPlatform}` : errorMessage - }; - } -} -async function executeBatchRenameOperations(renameMappings, _options = {}) { - if (renameMappings.length === 0) { - return { - success: false, - errors: ["No rename operations provided"] - }; - } - const errors4 = []; - const successfulRenames = []; - for (const renameMapping of renameMappings) { - const result = await executeRenameOperation(renameMapping.from, renameMapping.to); - if (result.success) { - successfulRenames.push(renameMapping); - } else { - errors4.push(`Failed to rename "${renameMapping.from}" to "${renameMapping.to}": ${result.error ?? "Unknown error"}`); - } - } - if (errors4.length > 0) { - return { - success: false, - errors: errors4, - successfulRenames: successfulRenames.length > 0 ? successfulRenames : undefined - }; - } - return { - success: true, - successfulRenames - }; -} - -// src/validateRenameOperations.ts -var import_promises16 = require("node:fs/promises"); -async function validateSourceFileExist2(tasks) { - const missingFiles = []; - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.from); - const stats = await import_promises16.stat(platformPath); - if (!stats.isFile()) { - missingFiles.push(task.from); - } - } catch { - missingFiles.push(task.from); - } - } - return { - isValid: missingFiles.length === 0, - missingFiles - }; -} -async function validateDestFileNotExist2(tasks) { - const existingFiles = []; - for (const task of tasks) { - try { - const platformPath = Path.toPlatformPath(task.to); - const stats = await import_promises16.stat(platformPath); - if (stats.isFile()) { - existingFiles.push(task.to); - } - } catch { - continue; - } - } - return { - isValid: existingFiles.length === 0, - existingFiles - }; -} -async function validateRenameOperations2(files, folderPathInPosix) { - const normalizedTasks = []; - for (const renameOp of files) { - if (!renameOp) { - continue; - } - normalizedTasks.push({ - from: Path.posix(renameOp.from), - to: Path.posix(renameOp.to) - }); - } - if (normalizedTasks.length === 0) { - return { - isValid: true, - errors: [], - validatedRenames: [] - }; - } - const syncResult = validateRenameOperationsSync(normalizedTasks, folderPathInPosix); - const errors4 = [...syncResult.errors]; - const sourceExistResult = await validateSourceFileExist2(normalizedTasks); - if (!sourceExistResult.isValid) { - for (const missingFile of sourceExistResult.missingFiles) { - errors4.push(`Source file "${missingFile}" does not exist in the media folder`); - } - } - const destNotExistResult = await validateDestFileNotExist2(normalizedTasks); - if (!destNotExistResult.isValid) { - for (const existingFile of destNotExistResult.existingFiles) { - errors4.push(`Target file "${existingFile}" already exists in the filesystem`); - } - } - if (errors4.length > 0) { - return { - isValid: false, - errors: errors4, - validatedRenames: [] - }; - } - return syncResult; -} - -// src/renameFiles.ts -var requestSchema = exports_external2.object({ - files: exports_external2.array(exports_external2.object({ - from: exports_external2.string().min(1, "The source file path, absolute path in platform-specific format"), - to: exports_external2.string().min(1, "The target file path, absolute path in platform-specific format") - })).min(1, "At least one file rename is required"), - traceId: exports_external2.string().optional(), - strict: exports_external2.boolean().optional(), - mediaFolder: exports_external2.string().optional(), - clientId: exports_external2.string().optional() -}); -async function updateMediaMetadataAndBroadcast(mediaFolder, renameMappings, config2, clientId) { - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - config2.logger?.warn({ mediaFolder }, "[renameFiles] appDataDir not configured, skip metadata update"); - return; - } - const mediaMetadata = await readMediaMetadataCache(appDataDir, mediaFolder); - if (!mediaMetadata) { - config2.logger?.debug({ mediaFolder }, "[renameFiles] media metadata not found, skip update"); - return; - } - const mediaFolderPosix = Path.posix(mediaFolder); - if (mediaMetadata.mediaFolderPath !== mediaFolderPosix) { - config2.logger?.warn({ providedPath: mediaFolderPosix, metadataPath: mediaMetadata.mediaFolderPath }, "[renameFiles] folder path mismatch, skip metadata update"); - return; - } - const updatedMediaMetadata = updateMediaMetadataAfterRename(mediaMetadata, renameMappings); - await writeMediaMetadataCache(appDataDir, updatedMediaMetadata); - config2.broadcast?.({ - clientId, - event: "mediaMetadataUpdated", - data: { - folderPath: mediaFolderPosix - } - }); -} -function findAllowlistViolation(paths, allowlist) { - for (const path11 of paths) { - const posixPath = Path.posix(path11); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - return path11; - } - } - return; -} -async function doRenameFiles(body, config2 = EMPTY_CORE_ROUTES_CONFIG, headerClientId) { - const parsed = requestSchema.safeParse(body); - if (!parsed.success) { - const msg = parsed.error.issues.map((i) => i.message).join(", "); - return { error: `Validation Failed: ${msg}` }; - } - const { - files, - traceId, - strict: strictFromBody, - mediaFolder: mediaFolderFromBody, - clientId: clientIdFromBody - } = parsed.data; - const strict = strictFromBody ?? true; - const effectiveClientId = headerClientId ?? clientIdFromBody; - const logCtx = traceId ? { traceId } : {}; - if (strict && !mediaFolderFromBody) { - return { error: "Validation Failed: mediaFolder is required when strict is true" }; - } - const userConfig = await readUserConfig(config2); - const mediaFolderPath = mediaFolderFromBody ?? getMediaFolder(files[0].from, userConfig.folders ?? []); - if (mediaFolderPath === null) { - return { error: `Media folder not found for ${files[0].from}` }; - } - const mediaFolderInPosix = Path.posix(mediaFolderPath); - const pathsToCheck = [ - mediaFolderPath, - ...files.flatMap((f) => [f.from, f.to]) - ]; - const violatedPath = findAllowlistViolation(pathsToCheck, config2.allowlist); - if (violatedPath !== undefined) { - return { error: `Path "${violatedPath}" is not in the allowlist` }; - } - const validationResult = await validateRenameOperations2(files, mediaFolderInPosix); - if (!validationResult.isValid) { - return { error: validationResult.errors.join(", ") }; - } - const renameResult = await executeBatchRenameOperations(validationResult.validatedRenames, { - logPrefix: "[POST /api/renameFiles]" - }); - const succeeded = (renameResult.successfulRenames ?? []).map((r) => r.from); - const failed = []; - if (!renameResult.success && renameResult.errors) { - for (const errMsg of renameResult.errors) { - const match = errMsg.match(/^Failed to rename "([^"]+)"/); - failed.push({ path: match?.[1] ?? "unknown", error: errMsg }); - } - } - config2.logger?.info({ ...logCtx, succeededCount: succeeded.length, failedCount: failed.length }, "[POST /api/renameFiles] completed"); - if (renameResult.successfulRenames && renameResult.successfulRenames.length > 0) { - await updateMediaMetadataAndBroadcast(mediaFolderInPosix, renameResult.successfulRenames, config2, effectiveClientId); - } - return { - data: { - succeeded, - failed - } - }; -} -// src/plansApi.ts -var getPlansRequestSchema = exports_external2.object({ - mediaFolderPath: exports_external2.string().min(1, "mediaFolderPath is required") -}); -var createPlanRequestSchema = exports_external2.object({ - id: exports_external2.string().optional(), - task: exports_external2.enum(["recognize-media-file", "rename-files"]), - mediaFolderPath: exports_external2.string().min(1, "mediaFolderPath is required"), - creator: exports_external2.enum(["app", "ai"]) -}); -var recognizedFileSchema = exports_external2.object({ - season: exports_external2.number(), - episode: exports_external2.number(), - path: exports_external2.string() -}); -var renameEntrySchema = exports_external2.object({ - from: exports_external2.string(), - to: exports_external2.string() -}); -var getPlanByIdRequestSchema = exports_external2.object({ - id: exports_external2.string().min(1, "id is required") -}); -var updatePlanRequestSchema = exports_external2.object({ - id: exports_external2.string().min(1, "id is required"), - status: exports_external2.enum(["preparing", "pending", "completed", "rejected"]).optional(), - files: exports_external2.union([exports_external2.array(recognizedFileSchema), exports_external2.array(renameEntrySchema)]).optional() -}); -function resolveFs(config2) { - return config2.chat?.fs ?? defaultChatFs(); -} -function validationError(error48) { - return `Error Reason: Invalid request body: ${error48.issues.map((i) => i.message).join(", ")}`; -} -async function doGetPlans(body, config2 = { allowlist: [] }) { - const parsed = getPlansRequestSchema.safeParse(body); - if (!parsed.success) { - return { error: validationError(parsed.error) }; - } - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - return { error: "Error Reason: appDataDir is not configured" }; - } - const plans = await getActivePlansForFolder(appDataDir, parsed.data.mediaFolderPath, resolveFs(config2)); - return { data: { plans } }; -} -async function doCreatePlan(body, config2 = { allowlist: [] }) { - const parsed = createPlanRequestSchema.safeParse(body); - if (!parsed.success) { - return { error: validationError(parsed.error) }; - } - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - return { error: "Error Reason: appDataDir is not configured" }; - } - const plan = await createPlan(appDataDir, parsed.data, resolveFs(config2)); - return { data: { plan } }; -} -async function doGetPlanById(body, config2 = { allowlist: [] }) { - const parsed = getPlanByIdRequestSchema.safeParse(body); - if (!parsed.success) { - return { error: validationError(parsed.error) }; - } - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - return { error: "Error Reason: appDataDir is not configured" }; - } - const plan = await readPlanById(appDataDir, parsed.data.id.trim(), resolveFs(config2)); - if (!plan) { - return { - error: `Error Reason: Plan with id "${parsed.data.id.trim()}" not found` - }; - } - return { data: { plan } }; -} -async function doUpdatePlan(body, config2 = { allowlist: [] }) { - const parsed = updatePlanRequestSchema.safeParse(body); - if (!parsed.success) { - return { error: validationError(parsed.error) }; - } - const appDataDir = resolveAppDataDir(config2); - if (!appDataDir) { - return { error: "Error Reason: appDataDir is not configured" }; - } - const { id, status, files } = parsed.data; - const plan = await updatePlanContent(appDataDir, id.trim(), { - status, - files - }, resolveFs(config2)); - if (!plan) { - return { error: `Error Reason: Plan with id "${id}" not found` }; - } - return { data: { plan } }; -} -// src/cleanup.ts -async function cleanupStalePlans(appDataDir, fs = defaultChatFs(), logger) { - return cleanPreparingPlans(appDataDir, fs, logger); -} -// src/downloadImageAsFile.ts -var import_node_buffer2 = require("node:buffer"); -var import_promises17 = require("node:fs/promises"); -var import_node_fs3 = require("node:fs"); -var import_node_path12 = __toESM(require("node:path")); -var downloadImageAsFileRequestSchema = exports_external2.object({ - url: exports_external2.string().min(1, "url is required"), - path: exports_external2.string().min(1, "path is required") -}); -async function fileExists3(filePath) { - try { - await import_promises17.access(filePath, import_node_fs3.constants.F_OK); - return true; - } catch { - return false; - } -} -function normalizeUrl2(url2) { - if (url2.startsWith("//")) { - return `https:${url2}`; - } - return url2; -} -async function doDownloadImageAsFile(body, config2) { - const { allowlist, logger, fetchImpl = fetch } = config2; - try { - const validationResult = downloadImageAsFileRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.info({ issues: validationResult.error.issues }, "[DownloadImageAsFile] validation failed"); - return { - data: { - url: typeof body?.url === "string" ? body.url : "", - path: typeof body?.path === "string" ? body.path : "" - }, - error: `Validation failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { url: url2, path: destPath } = validationResult.data; - logger?.debug({ url: url2, destPath }, "[DownloadImageAsFile] processing request"); - const posixDestPath = import_node_path12.default.posix.resolve(Path.posix(destPath)); - if (!validatePathIsInAllowlist(posixDestPath, allowlist)) { - logger?.warn({ destPath, posixDestPath }, "[DownloadImageAsFile] destination not in allowlist"); - return { - data: { url: url2, path: destPath }, - error: `Path "${destPath}" is not in the allowlist` - }; - } - const platformDestPath = Path.toPlatformPath(posixDestPath); - if (await fileExists3(platformDestPath)) { - logger?.info({ platformDestPath }, "[DownloadImageAsFile] destination already exists"); - return { - data: { url: url2, path: destPath }, - error: existedFileError(platformDestPath) - }; - } - const normalizedUrl = normalizeUrl2(url2); - if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) { - return { - data: { url: url2, path: destPath }, - error: `Invalid image URL: ${url2}. Must be http://, https://, or protocol-relative (//)` - }; - } - logger?.debug({ url: normalizedUrl, destPath: platformDestPath }, "[DownloadImageAsFile] fetching image"); - let response; - try { - response = await fetchImpl(normalizedUrl, { - method: "GET", - headers: { - accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "accept-language": "en-US,en;q=0.9", - "cache-control": "no-cache", - "sec-fetch-dest": "image", - "sec-fetch-mode": "no-cors", - "sec-fetch-site": "cross-site", - "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" - } - }); - } catch (error48) { - return { - data: { url: url2, path: destPath }, - error: `Image URL fetch failed: ${describeFetchError(error48)}` - }; - } - if (!response.ok) { - logger?.warn({ url: normalizedUrl, status: response.status }, "[DownloadImageAsFile] non-2xx response"); - return { - data: { url: url2, path: destPath }, - error: `HTTP error! status: ${response.status}` - }; - } - const arrayBuffer = await response.arrayBuffer(); - const buffer = import_node_buffer2.Buffer.from(arrayBuffer); - await import_promises17.writeFile(platformDestPath, buffer); - logger?.info({ url: normalizedUrl, destPath: platformDestPath, bytes: buffer.length }, "[DownloadImageAsFile] wrote file"); - return { data: { url: url2, path: destPath } }; - } catch (error48) { - logger?.error({ error: error48 }, "[DownloadImageAsFile] unexpected error"); - return { - data: { - url: typeof body?.url === "string" ? body.url : "", - path: typeof body?.path === "string" ? body.path : "" - }, - error: `Unexpected Error: ${describeFetchError(error48)}` - }; - } -} -// src/readImage.ts -var import_node_buffer3 = require("node:buffer"); -var import_promises18 = require("node:fs/promises"); -var import_node_fs4 = require("node:fs"); -var import_node_path13 = __toESM(require("node:path")); -var readImageRequestSchema = exports_external2.object({ - path: exports_external2.string().min(1, "path is required") -}); -var VALID_IMAGE_EXTENSIONS = [ - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webp", - ".svg", - ".bmp", - ".ico", - ".tiff", - ".tif" -]; -var EXTENSION_TO_MIME = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".tiff": "image/tiff", - ".tif": "image/tiff" -}; -function isValidImageFile(filePath) { - const ext = import_node_path13.default.extname(filePath).toLowerCase(); - return VALID_IMAGE_EXTENSIONS.includes(ext); -} -function getImageMimeType(filePath) { - const ext = import_node_path13.default.extname(filePath).toLowerCase(); - return EXTENSION_TO_MIME[ext] ?? "image/jpeg"; -} -async function fileExists4(filePath) { - try { - await import_promises18.access(filePath, import_node_fs4.constants.F_OK); - return true; - } catch { - return false; - } -} -async function doReadImage(body, config2) { - const { allowlist, logger } = config2; - try { - const validationResult = readImageRequestSchema.safeParse(body); - if (!validationResult.success) { - logger?.info({ issues: validationResult.error.issues }, "[ReadImage] validation failed"); - return { - error: `Validation failed: ${validationResult.error.issues.map((i) => i.message).join(", ")}` - }; - } - const { path: filePath } = validationResult.data; - const posixPath = import_node_path13.default.posix.resolve(Path.posix(filePath)); - if (!validatePathIsInAllowlist(posixPath, allowlist)) { - logger?.warn({ filePath, posixPath }, "[ReadImage] path not in allowlist"); - return { - error: `Path "${filePath}" is not in the allowlist` - }; - } - const platformPath = Path.toPlatformPath(posixPath); - if (!isValidImageFile(platformPath)) { - return { - error: `File is not a valid image file. Supported formats: ${VALID_IMAGE_EXTENSIONS.join(", ")}` - }; - } - if (!await fileExists4(platformPath)) { - return { - error: fileNotFoundError(filePath) - }; - } - try { - const arrayBuffer = await import_promises18.readFile(platformPath); - const base643 = import_node_buffer3.Buffer.from(arrayBuffer).toString("base64"); - const mimeType = getImageMimeType(platformPath); - return { - data: `data:${mimeType};base64,${base643}` - }; - } catch (error48) { - logger?.error({ filePath, platformPath, error: error48 }, "[ReadImage] failed to read file"); - return { - error: `Failed to read image file: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } - } catch (error48) { - logger?.error({ error: error48 }, "[ReadImage] unexpected error"); - return { - error: `Unexpected error: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }; - } -} -// src/discover.ts -function discoverLog(logger, level, message, details) { - const payload = details ?? {}; - const msg = `[Discover] ${message}`; - if (logger) { - logger[level](payload, msg); - return; - } - const line = details === undefined ? msg : `${msg} ${JSON.stringify(details)}`; - if (level === "error") { - console.error(line); - } else if (level === "warn") { - console.warn(line); - } else { - console.log(line); - } -} -var DEFAULT_DISCOVER_CONFIG_URL = "https://lawrenceching.github.io/SMM/config.json"; -var DISCOVER_TIMEOUT_MS = 1e4; -var EMPTY_DISCOVER_CONFIG = { - mediaDatabases: [], - reverseProxies: [] -}; -var FALLBACK_MEDIA_DATABASES = [ - { - type: "tmdb", - url: "https://mediadb.vercel.app/api/tmdb", - authorizationMethod: "none" - }, - { - type: "tmdb", - url: "https://1255396852-23teay8jtp.ap-hongkong.tencentscf.com", - authorizationMethod: "none" - }, - { - type: "tvdb", - url: "https://mediadb.vercel.app/api/tvdb", - authorizationMethod: "none" - }, - { - type: "tvdb", - url: "https://1255396852-24lotax0vl.ap-hongkong.tencentscf.com", - authorizationMethod: "none" - }, - { - type: "tmdb-asset", - url: "https://1255396852-19bqcvs6wn.ap-hongkong.tencentscf.com", - authorizationMethod: "none" - }, - { - type: "tvdb-asset", - url: "https://1255396852-2gz8ynvtkt.ap-hongkong.tencentscf.com", - authorizationMethod: "none" - } -]; -var FALLBACK_DISCOVER_CONFIG = { - mediaDatabases: FALLBACK_MEDIA_DATABASES, - reverseProxies: [] -}; -function fallbackDiscoverConfig(logger, reason, details) { - discoverLog(logger, "warn", `using hardcoded fallback mediaDatabases (${reason})`, { - mediaDatabasesCount: FALLBACK_MEDIA_DATABASES.length, - ...details - }); - return { - mediaDatabases: [...FALLBACK_MEDIA_DATABASES], - reverseProxies: [] - }; -} -function resolveDiscoverConfigUrl() { - const fromEnv = process.env.EXTERNAL_CONFIG_FILE_URL?.trim(); - if (fromEnv) { - return { url: fromEnv, urlFromEnv: true }; - } - return { url: DEFAULT_DISCOVER_CONFIG_URL, urlFromEnv: false }; -} -function normalizeAuthorizationMethod(value) { - if (typeof value !== "string") - return "none"; - if (value === "date-token") - return "date-token"; - return "none"; -} -function normalizeMediaDatabaseEntry(entry) { - const endpointUrl = (entry.baseUrl ?? entry.url ?? "").trim(); - if (!endpointUrl) - return null; - const type = entry.type; - if (type !== "tmdb" && type !== "tvdb" && type !== "tmdb-asset" && type !== "tvdb-asset") { - return null; - } - return { - type, - url: endpointUrl, - authorizationMethod: normalizeAuthorizationMethod(entry.authorizationMethod) - }; -} -function normalizeReverseProxyEntry(entry) { - const id = (entry.id ?? "").trim(); - const url2 = (entry.url ?? "").trim(); - if (!id || !url2) - return null; - const type = entry.type; - if (type !== "general") - return null; - return { - id, - type, - url: url2, - authorizationMethod: normalizeAuthorizationMethod(entry.authMethod) - }; -} -function normalizeMediaDatabases(rawEntries) { - if (!Array.isArray(rawEntries)) { - return []; - } - const normalized = []; - for (const raw of rawEntries) { - if (!raw || typeof raw !== "object") - continue; - const entry = normalizeMediaDatabaseEntry(raw); - if (entry) - normalized.push(entry); - } - return normalized; -} -function normalizeReverseProxies(rawEntries) { - if (!Array.isArray(rawEntries)) { - return []; - } - const normalized = []; - for (const raw of rawEntries) { - if (!raw || typeof raw !== "object") - continue; - const entry = normalizeReverseProxyEntry(raw); - if (entry) - normalized.push(entry); - } - return normalized; -} -function normalizeLatestVersion(value) { - if (value === undefined || value === null) { - return {}; - } - if (typeof value !== "string") { - return { invalidReason: `expected string, got ${typeof value}` }; - } - const trimmed = value.trim(); - if (trimmed.length === 0) { - return { invalidReason: "blank string" }; - } - return { latestVersion: trimmed }; -} -function formatFetchError(error48) { - if (!(error48 instanceof Error)) { - return { message: String(error48) }; - } - const cause = error48.cause; - return { - message: error48.message, - name: error48.name, - cause: cause instanceof Error ? cause.message : cause !== undefined ? String(cause) : undefined, - stack: error48.stack - }; -} -function isAbortError2(error48) { - return error48 instanceof Error && error48.name === "AbortError" || typeof DOMException !== "undefined" && error48 instanceof DOMException && error48.name === "AbortError"; -} -function isPlainObject3(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} -async function readResponseBodyPreview(logger, response) { - try { - const text2 = await response.text(); - if (!text2) - return; - return text2.length > 300 ? `${text2.slice(0, 300)}…` : text2; - } catch (error48) { - discoverLog(logger, "warn", "failed to read response body for preview", { - err: formatFetchError(error48) - }); - return; - } -} -function logEmptyDiscoverResult(logger, url2, durationMs, body, mediaDatabases, latestVersion) { - const rawMediaDatabasesCount = Array.isArray(body.mediaDatabases) ? body.mediaDatabases.length : null; - const rawReverseProxiesCount = Array.isArray(body.reverseProxies) ? body.reverseProxies.length : null; - const common = { - url: url2, - durationMs, - latestVersion, - hasLatestVersionField: "latestVersion" in body, - rawReverseProxiesCount - }; - if (rawMediaDatabasesCount === null) { - discoverLog(logger, "warn", "remote config missing mediaDatabases array", { - ...common, - hasMediaDatabasesField: false - }); - return; - } - if (rawMediaDatabasesCount === 0) { - discoverLog(logger, "warn", "remote config has empty mediaDatabases array", { - ...common, - rawMediaDatabasesCount - }); - return; - } - if (mediaDatabases.length === 0) { - discoverLog(logger, "warn", "remote config mediaDatabases entries were all filtered out during normalization", { - ...common, - rawMediaDatabasesCount, - normalizedMediaDatabasesCount: 0 - }); - } -} -async function doFetchDiscoverConfig(config2 = {}) { - const { logger, fetchImpl = fetch } = config2; - const { url: url2, urlFromEnv } = resolveDiscoverConfigUrl(); - const startedAt = Date.now(); - const controller = new AbortController; - const timeout2 = setTimeout(() => controller.abort(), DISCOVER_TIMEOUT_MS); - discoverLog(logger, "info", "fetching remote config", { - url: url2, - urlFromEnv, - timeoutMs: DISCOVER_TIMEOUT_MS, - method: "GET" - }); - try { - let response; - try { - response = await fetchImpl(url2, { - method: "GET", - signal: controller.signal, - headers: { Accept: "application/json" } - }); - } catch (fetchError) { - const durationMs2 = Date.now() - startedAt; - const aborted2 = isAbortError2(fetchError) || controller.signal.aborted; - discoverLog(logger, "error", aborted2 ? "remote config fetch aborted (timeout or cancellation)" : "failed to fetch remote config", { - url: url2, - urlFromEnv, - durationMs: durationMs2, - aborted: aborted2, - signalAborted: controller.signal.aborted, - timedOut: aborted2 && durationMs2 >= DISCOVER_TIMEOUT_MS - 50, - err: formatFetchError(fetchError) - }); - return fallbackDiscoverConfig(logger, aborted2 ? "fetch aborted" : "fetch failed", { - url: url2, - urlFromEnv, - durationMs: durationMs2 - }); - } - const durationMs = Date.now() - startedAt; - discoverLog(logger, "info", "remote config HTTP response", { - url: url2, - urlFromEnv, - durationMs, - status: response.status, - statusText: response.statusText, - contentType: response.headers.get("content-type"), - redirected: response.redirected, - responseUrl: response.url - }); - if (!response.ok) { - const bodyPreview = await readResponseBodyPreview(logger, response); - discoverLog(logger, "error", "remote config returned non-OK status", { - url: url2, - urlFromEnv, - durationMs, - status: response.status, - statusText: response.statusText, - contentType: response.headers.get("content-type"), - bodyPreview - }); - return fallbackDiscoverConfig(logger, "non-OK status", { - url: url2, - status: response.status - }); - } - let rawJson; - try { - rawJson = await response.json(); - } catch (parseError) { - discoverLog(logger, "error", "remote config response is not valid JSON", { - url: url2, - urlFromEnv, - durationMs, - contentType: response.headers.get("content-type"), - err: formatFetchError(parseError) - }); - return fallbackDiscoverConfig(logger, "invalid JSON", { url: url2 }); - } - if (!isPlainObject3(rawJson)) { - discoverLog(logger, "error", "remote config JSON root is not an object", { - url: url2, - urlFromEnv, - durationMs, - rootType: rawJson === null ? "null" : Array.isArray(rawJson) ? "array" : typeof rawJson - }); - return fallbackDiscoverConfig(logger, "JSON root is not an object", { url: url2 }); - } - let mediaDatabases; - let reverseProxies; - let latestVersion; - try { - mediaDatabases = normalizeMediaDatabases(rawJson.mediaDatabases); - reverseProxies = normalizeReverseProxies(rawJson.reverseProxies); - const versionResult = normalizeLatestVersion(rawJson.latestVersion); - if (versionResult.invalidReason) { - discoverLog(logger, "warn", "remote config latestVersion ignored", { - url: url2, - reason: versionResult.invalidReason, - rawType: typeof rawJson.latestVersion - }); - } - latestVersion = versionResult.latestVersion; - } catch (normalizeError) { - discoverLog(logger, "error", "failed to normalize remote config", { - url: url2, - urlFromEnv, - durationMs, - err: formatFetchError(normalizeError) - }); - return fallbackDiscoverConfig(logger, "normalize failed", { url: url2 }); - } - if (mediaDatabases.length === 0) { - logEmptyDiscoverResult(logger, url2, durationMs, rawJson, mediaDatabases, latestVersion); - const fallback = fallbackDiscoverConfig(logger, "empty mediaDatabases", { - url: url2 - }); - return { - mediaDatabases: fallback.mediaDatabases, - reverseProxies, - latestVersion - }; - } - discoverLog(logger, "info", "remote config loaded", { - url: url2, - urlFromEnv, - durationMs, - mediaDatabasesCount: mediaDatabases.length, - reverseProxiesCount: reverseProxies.length, - mediaDatabaseTypes: [ - ...new Set(mediaDatabases.map((entry) => entry.type)) - ], - latestVersion, - hasLatestVersionField: "latestVersion" in rawJson - }); - return { mediaDatabases, reverseProxies, latestVersion }; - } catch (error48) { - const durationMs = Date.now() - startedAt; - discoverLog(logger, "error", "unexpected error while loading remote config", { - url: url2, - urlFromEnv, - durationMs, - signalAborted: controller.signal.aborted, - err: formatFetchError(error48) - }); - return fallbackDiscoverConfig(logger, "unexpected error", { url: url2, durationMs }); - } finally { - clearTimeout(timeout2); - } -} -async function doFetchDiscoveredMediaDatabases(config2 = {}) { - const discoverConfig = await doFetchDiscoverConfig(config2); - return discoverConfig.mediaDatabases; -} -// src/routes/listFilesRoute.ts -var emptyListFilesResponse = { - data: { path: "", items: [], size: 0 } -}; -async function handleListFilesGet(req, res, ctx) { - if (req.method !== "GET" || ctx.url.pathname !== "/api/listFiles") { - return false; - } - const query = ctx.url.searchParams; - ctx.config.logger?.info({ query: Object.fromEntries(query) }, "[ListFiles] GET /api/listFiles"); - try { - const body = { - path: query.get("path") ?? "" - }; - const onlyFiles = parseBooleanQuery(query.get("onlyFiles")); - if (onlyFiles !== undefined) { - body.onlyFiles = onlyFiles; - } - const onlyFolders = parseBooleanQuery(query.get("onlyFolders")); - if (onlyFolders !== undefined) { - body.onlyFolders = onlyFolders; - } - const includeHiddenFiles = parseBooleanQuery(query.get("includeHiddenFiles")); - if (includeHiddenFiles !== undefined) { - body.includeHiddenFiles = includeHiddenFiles; - } - const recursively = parseBooleanQuery(query.get("recursively")); - if (recursively !== undefined) { - body.recursively = recursively; - } - const result = await doListFiles(body, ctx.config); - if (result.error) { - ctx.config.logger?.info({ body, resultError: result.error }, "[ListFiles] GET result has error"); - } - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "ListFiles GET route error"); - sendJson(res, 200, { - ...emptyListFilesResponse, - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Failed to process list files request"}` - }); - return true; - } -} -async function handleListFilesPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/listFiles") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[ListFiles] POST /api/listFiles"); - const result = await doListFiles(rawBody, ctx.config); - if (result.error) { - ctx.config.logger?.info({ rawBody, resultError: result.error }, "[ListFiles] POST result has error"); - } - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "ListFiles POST route error"); - sendJson(res, 200, { - ...emptyListFilesResponse, - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Failed to process list files request"}` - }); - return true; - } -} - -// src/routes/helloRoute.ts -async function handleHelloPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/hello") { - return false; - } - if (ctx.config.hello === undefined) { - sendJson(res, 200, { error: "hello not configured" }); - return true; - } - const result = doHello(ctx.config.hello); - sendJson(res, 200, result); - return true; -} - -// src/routes/isFolderAvailableRoute.ts -var isFolderAvailableRequestSchema2 = exports_external2.object({ - path: exports_external2.string().min(1, "path is required") -}); -async function handleIsFolderAvailablePost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/isFolderAvailable") { - return false; - } - try { - const rawBody = await readJsonBody(req); - const validationResult = isFolderAvailableRequestSchema2.safeParse(rawBody); - if (!validationResult.success) { - sendJson(res, 400, { - error: "Validation failed", - details: validationResult.error.issues.map((issue2) => ({ - path: issue2.path.join("."), - message: issue2.message - })) - }); - return true; - } - const result = await doIsFolderAvailable(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.debug({ error: error48 }, "[IsFolderAvailable] invalid JSON"); - sendJson(res, 400, { error: "Invalid JSON body" }); - return true; - } -} - -// src/routes/readFileRoute.ts -async function handleReadFilePost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/readFile") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[ReadFile] POST /api/readFile"); - const result = await doReadFile(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "ReadFile POST route error"); - sendJson(res, 400, { - error: "Invalid JSON body", - details: error48 instanceof Error ? error48.message : "Unknown error" - }); - return true; - } -} - -// src/routes/writeFileRoute.ts -async function handleWriteFilePost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/writeFile") { - return false; - } - const traceId = req.headers["x-trace-id"]; - const traceIdStr = typeof traceId === "string" ? traceId : Array.isArray(traceId) ? traceId[0] ?? "" : ""; - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ traceId: traceIdStr, rawBody }, "POST /api/writeFile"); - const result = await doWriteFile(rawBody, ctx.config, traceIdStr); - if (result.error) { - if (isError2(result.error, ExistedFileError)) { - sendJson(res, 200, result); - return true; - } - sendJson(res, 400, result); - return true; - } - sendJson(res, 200, result); - return true; - } catch (error48) { - const respBody = { - error: "Failed to process write file request", - details: error48 instanceof Error ? error48.message : "Unknown error" - }; - sendJson(res, 500, respBody); - return true; - } -} - -// src/routes/deleteFileRoute.ts -async function handleDeleteFilePost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/deleteFile") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[DeleteFile] POST /api/deleteFile"); - const result = await doDeleteFile(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "DeleteFile POST route error"); - sendJson(res, 400, { - error: "Invalid JSON body", - details: error48 instanceof Error ? error48.message : "Unknown error" - }); - return true; - } -} - -// src/routes/deleteFolderRoute.ts -async function handleDeleteFolderPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/deleteFolder") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[DeleteFolder] POST /api/deleteFolder"); - const result = await doDeleteFolder(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "DeleteFolder POST route error"); - sendJson(res, 400, { - error: "Invalid JSON body", - details: error48 instanceof Error ? error48.message : "Unknown error" - }); - return true; - } -} - -// src/routes/getEpisodesRoute.ts -async function handleGetEpisodesPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/getEpisodes") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[GetEpisodes] POST /api/getEpisodes"); - const result = await doGetEpisodes(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[GetEpisodes] route error"); - sendJson(res, 200, { - ...createEmptyGetEpisodesData(), - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Failed to process getEpisodes request"}` - }); - return true; - } -} - -// src/routes/listFilesInMediaFolderRoute.ts -async function handleListFilesInMediaFolderPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/listFilesInMediaFolder") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[ListFilesInMediaFolder] POST /api/listFilesInMediaFolder"); - const result = await doListFilesInMediaFolder(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[ListFilesInMediaFolder] route error"); - sendJson(res, 200, { - ...createEmptyListFilesInMediaFolderData(), - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Failed to process listFilesInMediaFolder request"}` - }); - return true; - } -} - -// src/routes/renameFolderRoute.ts -async function handleRenameFolderPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/renameFolder") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ from: rawBody.from, to: rawBody.to }, "[RenameFolder] POST /api/renameFolder"); - const result = await doRenameFolder(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[RenameFolder] route error"); - sendJson(res, 200, { - error: "Unexpected Error: Failed to process rename folder request", - details: error48 instanceof Error ? error48.message : "Unknown error" - }); - return true; - } -} - -// src/routes/renameFilesRoute.ts -async function handleRenameFilesPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/renameFiles") { - return false; - } - try { - const rawBody = await readJsonBody(req); - const clientId = req.headers.clientid ?? req.headers.clientId; - const headerClientId = typeof clientId === "string" ? clientId : undefined; - ctx.config.logger?.info({ fileCount: rawBody.files?.length, traceId: rawBody.traceId }, "[RenameFiles] POST /api/renameFiles"); - const result = await doRenameFiles(rawBody, ctx.config, headerClientId); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[RenameFiles] route error"); - sendJson(res, 200, { - error: `Unexpected Error: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }); - return true; - } -} - -// src/routes/downloadImageRoute.ts -async function handleDownloadImageGet(req, res, ctx) { - if (req.method !== "GET" || ctx.url.pathname !== "/api/image") { - return false; - } - const url2 = ctx.url.searchParams.get("url"); - if (!url2) { - sendJson(res, 400, { - error: "Missing required query parameter: url" - }); - return true; - } - try { - const result = await doDownloadImage(url2, ctx.config); - res.writeHead(200, { - "Content-Type": result.contentType, - "Content-Length": result.buffer.length.toString(), - "Cache-Control": "public, max-age=31536000" - }); - res.end(result.buffer); - return true; - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - ctx.config.logger?.error({ url: url2, error: message }, "DownloadImage GET route error"); - sendJson(res, 500, { - error: `Failed to download image: ${message}` - }); - return true; - } -} - -// src/routes/downloadImageAsFileRoute.ts -async function handleDownloadImageAsFilePost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/downloadImage") { - return false; - } - try { - const rawBody = await readJsonBody(req); - const { url: url2, path: path13, httpProxy } = rawBody; - ctx.config.logger?.info({ url: url2, path: path13 }, "[DownloadImageAsFile] POST /api/downloadImage"); - const trimmedProxy = httpProxy?.trim(); - const fetchImpl = trimmedProxy ? createProxiedFetch(trimmedProxy, ctx.config.logger) : ctx.config.fetchImpl; - let allowlist = ctx.config.allowlist; - if (ctx.config.resolveAllowlist) { - try { - allowlist = await ctx.config.resolveAllowlist(); - } catch (error48) { - ctx.config.logger?.warn({ error: error48 }, "[DownloadImageAsFile] failed to resolve allowlist, falling back to static"); - allowlist = ctx.config.allowlist; - } - } - const result = await doDownloadImageAsFile(rawBody, { - ...ctx.config, - allowlist, - fetchImpl - }); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "DownloadImageAsFile POST route error"); - sendJson(res, 400, { - error: "Invalid JSON body", - details: error48 instanceof Error ? error48.message : "Unknown error" - }); - return true; - } -} - -// src/routes/readImageRoute.ts -async function handleReadImagePost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/readImage") { - return false; - } - try { - const rawBody = await readJsonBody(req); - ctx.config.logger?.info({ rawBody }, "[ReadImage] POST /api/readImage"); - const result = await doReadImage(rawBody, ctx.config); - sendJson(res, 200, result); - return true; - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "ReadImage POST route error"); - sendJson(res, 400, { - error: "Invalid JSON body", - details: error48 instanceof Error ? error48.message : "Unknown error" - }); - return true; - } -} - -// src/routes/discoverRoute.ts -async function handleDiscoverGet(req, res, ctx) { - if (req.method !== "GET" || ctx.url.pathname !== "/api/discover") { - return false; - } - try { - const config2 = await doFetchDiscoverConfig({ - logger: ctx.config.logger, - fetchImpl: ctx.config.fetchImpl - }); - const body = { data: config2 }; - sendJson(res, 200, body); - return true; - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - ctx.config.logger?.error({ error: message }, "[Discover] handleDiscoverGet unexpected throw"); - sendJson(res, 200, { data: { ...EMPTY_DISCOVER_CONFIG } }); - return true; - } -} - -// src/mcp/lifecycle.ts -function parseStartOptions(body) { - if (!body || typeof body !== "object") { - return; - } - const record2 = body; - const options = {}; - if (typeof record2.host === "string" && record2.host.trim() !== "") { - options.hostname = record2.host; - } - if (typeof record2.port === "number" && Number.isFinite(record2.port)) { - options.port = record2.port; - } - return Object.keys(options).length > 0 ? options : undefined; -} -async function doMcpStart(manager, body) { - try { - const options = parseStartOptions(body); - await manager.start(options); - return { status: 200, body: manager.getState() }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const state = manager.getState(); - return { - status: 500, - body: { ...state, error: message } - }; - } -} -async function doMcpStop(manager) { - try { - await manager.stop(); - return { status: 200, body: manager.getState() }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - status: 500, - body: { status: "error", error: message } - }; - } -} -function doMcpGetStatus(manager) { - return { status: 200, body: manager.getState() }; -} -async function applyMcpLifecycleFromConfig(manager, getUserConfig, logger) { - let userConfig; - try { - userConfig = await getUserConfig(); - } catch (err) { - logger?.warn({ err }, "applyMcpLifecycleFromConfig: failed to read user config, skipping MCP update"); - return; - } - if (!userConfig.enableMcpServer) { - await manager.stop(); - return; - } - try { - await manager.start({ - hostname: userConfig.mcpHost, - port: userConfig.mcpPort - }); - } catch (err) { - logger?.error({ - err, - hostname: userConfig.mcpHost, - port: userConfig.mcpPort - }, "MCP server failed to start on boot"); - throw err; - } -} - -// src/routes/mcpLifecycleRoute.ts -async function handleMcpStartPut(req, res, ctx) { - if (req.method !== "PUT" || ctx.url.pathname !== "/api/mcp/start") { - return false; - } - const manager = ctx.config.mcp?.manager; - if (!manager) { - sendJson(res, 200, { error: "MCP lifecycle not configured" }); - return true; - } - const body = await readJsonBody(req); - const result = await doMcpStart(manager, body); - sendJson(res, result.status, result.body); - return true; -} -async function handleMcpStopPut(req, res, ctx) { - if (req.method !== "PUT" || ctx.url.pathname !== "/api/mcp/stop") { - return false; - } - const manager = ctx.config.mcp?.manager; - if (!manager) { - sendJson(res, 200, { error: "MCP lifecycle not configured" }); - return true; - } - const result = await doMcpStop(manager); - sendJson(res, result.status, result.body); - return true; -} -async function handleMcpStatusGet(req, res, ctx) { - if (req.method !== "GET" || ctx.url.pathname !== "/api/mcp/status") { - return false; - } - const manager = ctx.config.mcp?.manager; - if (!manager) { - sendJson(res, 200, { error: "MCP lifecycle not configured" }); - return true; - } - const result = doMcpGetStatus(manager); - sendJson(res, result.status, result.body); - return true; -} - -// src/routes/plansRoute.ts -async function handleGetPlansPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/getPlans") { - return false; - } - try { - const body = await readJsonBody(req); - const result = await doGetPlans(body, ctx.config); - sendJson(res, 200, result); - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[GetPlans] route error"); - sendJson(res, 200, { - error: `Error Reason: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }); - } - return true; -} -async function handleGetPlanByIdPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/getPlanById") { - return false; - } - try { - const body = await readJsonBody(req); - const result = await doGetPlanById(body, ctx.config); - sendJson(res, 200, result); - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[GetPlanById] route error"); - sendJson(res, 200, { - error: `Error Reason: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }); - } - return true; -} -async function handleCreatePlanPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/createPlan") { - return false; - } - try { - const body = await readJsonBody(req); - const result = await doCreatePlan(body, ctx.config); - sendJson(res, 200, result); - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[CreatePlan] route error"); - sendJson(res, 200, { - error: `Error Reason: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }); - } - return true; -} -async function handleUpdatePlanPost(req, res, ctx) { - if (req.method !== "POST" || ctx.url.pathname !== "/api/updatePlan") { - return false; - } - try { - const body = await readJsonBody(req); - const result = await doUpdatePlan(body, ctx.config); - sendJson(res, 200, result); - } catch (error48) { - ctx.config.logger?.error({ error: error48 }, "[UpdatePlan] route error"); - sendJson(res, 200, { - error: `Error Reason: ${error48 instanceof Error ? error48.message : "Unknown error"}` - }); - } - return true; -} - -// src/register.ts -var coreRouteHandlers = [ - handleListFilesGet, - handleListFilesPost, - handleWriteFilePost, - handleHelloPost, - handleIsFolderAvailablePost, - handleGetEpisodesPost, - handleListFilesInMediaFolderPost, - handleRenameFolderPost, - handleRenameFilesPost, - handleReadFilePost, - handleDeleteFilePost, - handleDeleteFolderPost, - handleDownloadImageGet, - handleDownloadImageAsFilePost, - handleReadImagePost, - handleDiscoverGet, - handleChatPost, - handleMcpStartPut, - handleMcpStopPut, - handleMcpStatusGet, - handleGetPlansPost, - handleGetPlanByIdPost, - handleCreatePlanPost, - handleUpdatePlanPost -]; -function createCoreRoutesRequestHandler(config2, options = {}) { - const fallbackPort = options.fallbackPort ?? 3001; - return (req, res) => { - handleCoreRoutesRequest(req, res, config2, fallbackPort); - }; -} -async function handleCoreRoutesRequest(req, res, config2, fallbackPort = 3001) { - const url2 = createRequestUrl(req, fallbackPort); - if (enforceCoreRoutesAuth(req, res, config2)) { - return; - } - const ctx = { config: config2, url: url2 }; - for (const handler of coreRouteHandlers) { - const handled = await handler(req, res, ctx); - if (handled) { - return; - } - } - sendJson(res, 404, { error: `Not found: ${req.method ?? "UNKNOWN"} ${url2.pathname}` }); -} -function registerCoreRoutes(server, config2) { - const fallbackPort = typeof server.address() === "object" && server.address() !== null ? server.address().port : 3001; - server.on("request", createCoreRoutesRequestHandler(config2, { fallbackPort })); -} -// ../../node_modules/.pnpm/socket.io@4.8.3/node_modules/socket.io/wrapper.mjs -var import_dist = __toESM(require_dist7(), 1); -var { Server, Namespace, Socket } = import_dist.default; - -// src/socketIO/connection.ts -function logInfo(logger, obj, msg) { - if (logger) { - logger.info(obj, msg); - } -} -function logWarn(logger, obj, msg) { - if (logger) { - logger.warn(obj, msg); - } -} -function logError(logger, obj, msg) { - if (logger) { - logger.error(obj, msg); - } -} -function registerSocketIOConnectionHandlers(io2, logger) { - io2.on("connection", (socket) => { - logInfo(logger, { - socketId: socket.id, - rooms: Array.from(socket.rooms) - }, "[DEBUG] socket.io connection established"); - socket.emit("hello"); - logInfo(logger, { - socketId: socket.id, - event: "hello" - }, "socket.io message sent"); - socket.on("userAgent", (data) => { - const userAgent = data?.userAgent; - const clientId = data?.clientId; - logInfo(logger, { - socketId: socket.id, - userAgent, - clientId - }, "[DEBUG] socket.io received userAgent"); - if (clientId) { - socket.join(clientId); - const socketsInRoom = io2.sockets.adapter.rooms.get(clientId); - logInfo(logger, { - socketId: socket.id, - clientId, - rooms: Array.from(socket.rooms), - socketsInRoom: socketsInRoom ? Array.from(socketsInRoom) : [] - }, "[DEBUG] socket.io client joined room"); - } else { - logWarn(logger, { - socketId: socket.id - }, "[DEBUG] socket.io userAgent event missing clientId"); - } - }); - socket.on("disconnect", (reason) => { - logInfo(logger, { - socketId: socket.id, - reason - }, "socket.io connection closed"); - }); - socket.on("error", (error48) => { - logError(logger, { - socketId: socket.id, - error: error48.message - }, "socket.io connection error"); - }); - }); - logInfo(logger, {}, "Socket.IO connection handlers initialized"); -} - -// src/socketIO/messaging.ts -function logInfo2(logger, obj, msg) { - if (logger) { - logger.info(obj, msg); - } -} -function logWarn2(logger, obj, msg) { - if (logger) { - logger.warn(obj, msg); - } -} -function logError2(logger, obj, msg) { - if (logger) { - logger.error(obj, msg); - } -} -function logDebug(logger, obj, msg) { - if (logger) { - logger.debug(obj, msg); - } -} -function getClientIdFromSocket(socket) { - const rooms = Array.from(socket.rooms); - return rooms.find((room) => room !== socket.id) ?? null; -} -function createSocketIOMessaging(io2, logger) { - function broadcast(message) { - logInfo2(logger, { - event: message.event, - type: "broadcast" - }, "socket.io message broadcast"); - io2.emit(message.event, message.data); - } - function getFirstAvailableSocket() { - const sockets = Array.from(io2.sockets.sockets.values()); - if (sockets.length === 0) { - return null; - } - for (const socket of sockets) { - const foundClientId = getClientIdFromSocket(socket); - if (foundClientId) { - return { socket, clientId: foundClientId }; - } - } - return null; - } - function findSocketByClientId(clientId) { - if (clientId) { - const socketsInRoom = io2.sockets.adapter.rooms.get(clientId); - if (socketsInRoom && socketsInRoom.size > 0) { - const socketId = Array.from(socketsInRoom)[0]; - if (socketId) { - const socket = io2.sockets.sockets.get(socketId); - if (socket) { - return { socket, clientId }; +var wh=Object.create;var{getPrototypeOf:Hh,defineProperty:xJ,getOwnPropertyNames:PI,getOwnPropertyDescriptor:jh}=Object,TI=Object.prototype.hasOwnProperty;function SI($){return this[$]}var Vh,Bh,x$=($,X,Q)=>{var J=$!=null&&typeof $==="object";if(J){var v=X?Vh??=new WeakMap:Bh??=new WeakMap,Y=v.get($);if(Y)return Y}Q=$!=null?wh(Hh($)):{};let z=X||!$||!$.__esModule?xJ(Q,"default",{value:$,enumerable:!0}):Q;for(let W of PI($))if(!TI.call(z,W))xJ(z,W,{get:SI.bind($,W),enumerable:!0});if(J)v.set($,z);return z},Dh=($)=>{var X=(EI??=new WeakMap).get($),Q;if(X)return X;if(X=xJ({},"__esModule",{value:!0}),$&&typeof $==="object"||typeof $==="function"){for(var J of PI($))if(!TI.call(X,J))xJ(X,J,{get:SI.bind($,J),enumerable:!(Q=jh($,J))||Q.enumerable})}return EI.set($,X),X},EI,N=($,X)=>()=>(X||$((X={exports:{}}).exports,X),X.exports);var Fh=($)=>$;function Nh($,X){this[$]=Fh.bind(null,X)}var u1=($,X)=>{for(var Q in X)xJ($,Q,{get:X[Q],enumerable:!0,configurable:!0,set:Nh.bind(X,Q)})};var YN=N((HN$,fb)=>{var{defineProperty:JN,getOwnPropertyDescriptor:os,getOwnPropertyNames:as}=Object,rs=Object.prototype.hasOwnProperty,ts=($,X)=>{for(var Q in X)JN($,Q,{get:X[Q],enumerable:!0})},ss=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of as(X))if(!rs.call($,v)&&v!==Q)JN($,v,{get:()=>X[v],enumerable:!(J=os(X,v))||J.enumerable})}return $},es=($)=>ss(JN({},"__esModule",{value:!0}),$),_b={};ts(_b,{SYMBOL_FOR_REQ_CONTEXT:()=>yb,getContext:()=>$e});fb.exports=es(_b);var yb=Symbol.for("@vercel/request-context");function $e(){return globalThis[yb]?.get?.()??{}}});var cY=N((jN$,gb)=>{var{defineProperty:zN,getOwnPropertyDescriptor:Xe,getOwnPropertyNames:Qe}=Object,ve=Object.prototype.hasOwnProperty,Je=($,X)=>{for(var Q in X)zN($,Q,{get:X[Q],enumerable:!0})},Ye=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of Qe(X))if(!ve.call($,v)&&v!==Q)zN($,v,{get:()=>X[v],enumerable:!(J=Xe(X,v))||J.enumerable})}return $},ze=($)=>Ye(zN({},"__esModule",{value:!0}),$),kb={};Je(kb,{VercelOidcTokenError:()=>nb});gb.exports=ze(kb);class nb extends Error{constructor($,X){super($);this.name="VercelOidcTokenError",this.cause=X}toString(){if(this.cause)return`${this.name}: ${this.message}: ${this.cause}`;return`${this.name}: ${this.message}`}}});var lb=N((VN$,hb)=>{var{create:We,defineProperty:GU,getOwnPropertyDescriptor:Ge,getOwnPropertyNames:Ue,getPrototypeOf:Ke}=Object,we=Object.prototype.hasOwnProperty,He=($,X)=>{for(var Q in X)GU($,Q,{get:X[Q],enumerable:!0})},cb=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of Ue(X))if(!we.call($,v)&&v!==Q)GU($,v,{get:()=>X[v],enumerable:!(J=Ge(X,v))||J.enumerable})}return $},GN=($,X,Q)=>(Q=$!=null?We(Ke($)):{},cb(X||!$||!$.__esModule?GU(Q,"default",{value:$,enumerable:!0}):Q,$)),je=($)=>cb(GU({},"__esModule",{value:!0}),$),mb={};He(mb,{findRootDir:()=>De,getUserDataDir:()=>Fe});hb.exports=je(mb);var mY=GN(require("path")),Ve=GN(require("fs")),WN=GN(require("os")),Be=cY();function De(){try{let $=process.cwd();while($!==mY.default.dirname($)){let X=mY.default.join($,".vercel");if(Ve.default.existsSync(X))return $;$=mY.default.dirname($)}}catch($){throw new Be.VercelOidcTokenError("Token refresh only supported in node server environments")}return null}function Fe(){if(process.env.XDG_DATA_HOME)return process.env.XDG_DATA_HOME;switch(WN.default.platform()){case"darwin":return mY.default.join(WN.default.homedir(),"Library/Application Support");case"linux":return mY.default.join(WN.default.homedir(),".local/share");case"win32":if(process.env.LOCALAPPDATA)return process.env.LOCALAPPDATA;return null;default:return null}}});var sb=N((BN$,tb)=>{var{create:Ne,defineProperty:UU,getOwnPropertyDescriptor:Oe,getOwnPropertyNames:Le,getPrototypeOf:Ae}=Object,Ze=Object.prototype.hasOwnProperty,qe=($,X)=>{for(var Q in X)UU($,Q,{get:X[Q],enumerable:!0})},pb=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of Le(X))if(!Ze.call($,v)&&v!==Q)UU($,v,{get:()=>X[v],enumerable:!(J=Oe(X,v))||J.enumerable})}return $},db=($,X,Q)=>(Q=$!=null?Ne(Ae($)):{},pb(X||!$||!$.__esModule?UU(Q,"default",{value:$,enumerable:!0}):Q,$)),Ie=($)=>pb(UU({},"__esModule",{value:!0}),$),ob={};qe(ob,{isValidAccessToken:()=>ie,readAuthConfig:()=>xe,writeAuthConfig:()=>be});tb.exports=Ie(ob);var hY=db(require("fs")),ab=db(require("path")),Me=KU();function rb(){let $=(0,Me.getVercelDataDir)();if(!$)throw Error(`Unable to find Vercel CLI data directory. Your platform: ${process.platform}. Supported: darwin, linux, win32.`);return ab.join($,"auth.json")}function xe(){try{let $=rb();if(!hY.existsSync($))return null;let X=hY.readFileSync($,"utf8");if(!X)return null;return JSON.parse(X)}catch($){return null}}function be($){let X=rb(),Q=ab.dirname(X);if(!hY.existsSync(Q))hY.mkdirSync(Q,{mode:504,recursive:!0});hY.writeFileSync(X,JSON.stringify($,null,2),{mode:384})}function ie($){if(!$.token)return!1;if(typeof $.expiresAt!=="number")return!0;let X=Math.floor(Date.now()/1000);return $.expiresAt>=X}});var Qi=N((DN$,Xi)=>{var{defineProperty:wN,getOwnPropertyDescriptor:Re,getOwnPropertyNames:Ee}=Object,Pe=Object.prototype.hasOwnProperty,Te=($,X)=>{for(var Q in X)wN($,Q,{get:X[Q],enumerable:!0})},Se=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of Ee(X))if(!Pe.call($,v)&&v!==Q)wN($,v,{get:()=>X[v],enumerable:!(J=Re(X,v))||J.enumerable})}return $},Ce=($)=>Se(wN({},"__esModule",{value:!0}),$),eb={};Te(eb,{processTokenResponse:()=>ke,refreshTokenRequest:()=>fe});Xi.exports=Ce(eb);var UN=require("os"),ue="https://vercel.com",_e="cl_HYyOPBNtFMfHhaUn9L4QPfTZz6TP47bp",$i=`@vercel/oidc node-${process.version} ${(0,UN.platform)()} (${(0,UN.arch)()}) ${(0,UN.hostname)()}`,KN=null;async function ye(){if(KN)return KN;let $=`${ue}/.well-known/openid-configuration`,X=await fetch($,{headers:{"user-agent":$i}});if(!X.ok)throw Error("Failed to discover OAuth endpoints");let Q=await X.json();if(!Q||typeof Q.token_endpoint!=="string")throw Error("Invalid OAuth discovery response");let J=Q.token_endpoint;return KN=J,J}async function fe($){let X=await ye();return await fetch(X,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","user-agent":$i},body:new URLSearchParams({client_id:_e,grant_type:"refresh_token",...$})})}async function ke($){let X=await $.json();if(!$.ok){let Q=typeof X==="object"&&X&&"error"in X?String(X.error):"Token refresh failed";return[Error(Q)]}if(typeof X!=="object"||X===null)return[Error("Invalid token response")];if(typeof X.access_token!=="string")return[Error("Missing access_token in response")];if(X.token_type!=="Bearer")return[Error("Invalid token_type in response")];if(typeof X.expires_in!=="number")return[Error("Missing expires_in in response")];return[null,X]}});var KU=N((FN$,Wi)=>{var{create:ne,defineProperty:wU,getOwnPropertyDescriptor:ge,getOwnPropertyNames:ce,getPrototypeOf:me}=Object,he=Object.prototype.hasOwnProperty,le=($,X)=>{for(var Q in X)wU($,Q,{get:X[Q],enumerable:!0})},Ji=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of ce(X))if(!he.call($,v)&&v!==Q)wU($,v,{get:()=>X[v],enumerable:!(J=ge(X,v))||J.enumerable})}return $},Yi=($,X,Q)=>(Q=$!=null?ne(me($)):{},Ji(X||!$||!$.__esModule?wU(Q,"default",{value:$,enumerable:!0}):Q,$)),pe=($)=>Ji(wU({},"__esModule",{value:!0}),$),zi={};le(zi,{assertVercelOidcTokenResponse:()=>HN,findProjectInfo:()=>re,getTokenPayload:()=>ee,getVercelCliToken:()=>oe,getVercelDataDir:()=>de,getVercelOidcToken:()=>ae,isExpired:()=>$$$,loadToken:()=>se,saveToken:()=>te});Wi.exports=pe(zi);var lY=Yi(require("path")),e9=Yi(require("fs")),cQ=cY(),HU=lb(),gQ=sb(),vi=Qi();function de(){let X=(0,HU.getUserDataDir)();if(!X)return null;return lY.join(X,"com.vercel.cli")}async function oe(){let $=(0,gQ.readAuthConfig)();if(!$)return null;if((0,gQ.isValidAccessToken)($))return $.token||null;if(!$.refreshToken)return(0,gQ.writeAuthConfig)({}),null;try{let X=await(0,vi.refreshTokenRequest)({refresh_token:$.refreshToken}),[Q,J]=await(0,vi.processTokenResponse)(X);if(Q||!J)return(0,gQ.writeAuthConfig)({}),null;let v={token:J.access_token,expiresAt:Math.floor(Date.now()/1000)+J.expires_in};if(J.refresh_token)v.refreshToken=J.refresh_token;return(0,gQ.writeAuthConfig)(v),v.token??null}catch(X){return(0,gQ.writeAuthConfig)({}),null}}async function ae($,X,Q){let J=`https://api.vercel.com/v1/projects/${X}/token?source=vercel-oidc-refresh${Q?`&teamId=${Q}`:""}`,v=await fetch(J,{method:"POST",headers:{Authorization:`Bearer ${$}`}});if(!v.ok)throw new cQ.VercelOidcTokenError(`Failed to refresh OIDC token: ${v.statusText}`);let Y=await v.json();return HN(Y),Y}function HN($){if(!$||typeof $!=="object")throw TypeError("Vercel OIDC token is malformed. Expected an object. Please run `vc env pull` and try again");if(!("token"in $)||typeof $.token!=="string")throw TypeError("Vercel OIDC token is malformed. Expected a string-valued token property. Please run `vc env pull` and try again")}function re(){let $=(0,HU.findRootDir)();if(!$)throw new cQ.VercelOidcTokenError("Unable to find project root directory. Have you linked your project with `vc link?`");let X=lY.join($,".vercel","project.json");if(!e9.existsSync(X))throw new cQ.VercelOidcTokenError("project.json not found, have you linked your project with `vc link?`");let Q=JSON.parse(e9.readFileSync(X,"utf8"));if(typeof Q.projectId!=="string"&&typeof Q.orgId!=="string")throw TypeError("Expected a string-valued projectId property. Try running `vc link` to re-link your project.");return{projectId:Q.projectId,teamId:Q.orgId}}function te($,X){let Q=(0,HU.getUserDataDir)();if(!Q)throw new cQ.VercelOidcTokenError("Unable to find user data directory. Please reach out to Vercel support.");let J=lY.join(Q,"com.vercel.token",`${X}.json`),v=JSON.stringify($);e9.mkdirSync(lY.dirname(J),{mode:504,recursive:!0}),e9.writeFileSync(J,v),e9.chmodSync(J,432);return}function se($){let X=(0,HU.getUserDataDir)();if(!X)throw new cQ.VercelOidcTokenError("Unable to find user data directory. Please reach out to Vercel support.");let Q=lY.join(X,"com.vercel.token",`${$}.json`);if(!e9.existsSync(Q))return null;let J=JSON.parse(e9.readFileSync(Q,"utf8"));return HN(J),J}function ee($){let X=$.split(".");if(X.length!==3)throw new cQ.VercelOidcTokenError("Invalid token. Please run `vc env pull` and try again");let Q=X[1].replace(/-/g,"+").replace(/_/g,"/"),J=Q.padEnd(Q.length+(4-Q.length%4)%4,"=");return JSON.parse(Buffer.from(J,"base64").toString("utf8"))}function $$$($){return $.exp*1000{var{defineProperty:VN,getOwnPropertyDescriptor:X$$,getOwnPropertyNames:Q$$}=Object,v$$=Object.prototype.hasOwnProperty,J$$=($,X)=>{for(var Q in X)VN($,Q,{get:X[Q],enumerable:!0})},Y$$=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of Q$$(X))if(!v$$.call($,v)&&v!==Q)VN($,v,{get:()=>X[v],enumerable:!(J=X$$(X,v))||J.enumerable})}return $},z$$=($)=>Y$$(VN({},"__esModule",{value:!0}),$),Gi={};J$$(Gi,{refreshToken:()=>W$$});Ui.exports=z$$(Gi);var jN=cY(),$X=KU();async function W$$(){let{projectId:$,teamId:X}=(0,$X.findProjectInfo)(),Q=(0,$X.loadToken)($);if(!Q||(0,$X.isExpired)((0,$X.getTokenPayload)(Q.token))){let J=await(0,$X.getVercelCliToken)();if(!J)throw new jN.VercelOidcTokenError("Failed to refresh OIDC token: Log in to Vercel CLI and link your project with `vc link`");if(!$)throw new jN.VercelOidcTokenError("Failed to refresh OIDC token: Try re-linking your project with `vc link`");if(Q=await(0,$X.getVercelOidcToken)(J,$,X),!Q)throw new jN.VercelOidcTokenError("Failed to refresh OIDC token");(0,$X.saveToken)(Q,$)}process.env.VERCEL_OIDC_TOKEN=Q.token;return}});var ji=N((ON$,Hi)=>{var{defineProperty:DN,getOwnPropertyDescriptor:G$$,getOwnPropertyNames:U$$}=Object,K$$=Object.prototype.hasOwnProperty,w$$=($,X)=>{for(var Q in X)DN($,Q,{get:X[Q],enumerable:!0})},H$$=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of U$$(X))if(!K$$.call($,v)&&v!==Q)DN($,v,{get:()=>X[v],enumerable:!(J=G$$(X,v))||J.enumerable})}return $},j$$=($)=>H$$(DN({},"__esModule",{value:!0}),$),wi={};w$$(wi,{getVercelOidcToken:()=>D$$,getVercelOidcTokenSync:()=>BN});Hi.exports=j$$(wi);var V$$=YN(),B$$=cY();async function D$$(){let $="",X;try{$=BN()}catch(Q){X=Q}try{let[{getTokenPayload:Q,isExpired:J},{refreshToken:v}]=await Promise.all([await Promise.resolve().then(() => x$(KU())),await Promise.resolve().then(() => x$(Ki()))]);if(!$||J(Q($)))await v(),$=BN()}catch(Q){let J=X instanceof Error?X.message:"";if(Q instanceof Error)J=`${J} +${Q.message}`;if(J)throw new B$$.VercelOidcTokenError(J);throw Q}return $}function BN(){let $=(0,V$$.getContext)().headers?.["x-vercel-oidc-token"]??process.env.VERCEL_OIDC_TOKEN;if(!$)throw Error("The 'x-vercel-oidc-token' header is missing from the request. Do you have the OIDC option enabled in the Vercel project settings?");return $}});var NN=N((LN$,Di)=>{var{defineProperty:FN,getOwnPropertyDescriptor:F$$,getOwnPropertyNames:N$$}=Object,O$$=Object.prototype.hasOwnProperty,L$$=($,X)=>{for(var Q in X)FN($,Q,{get:X[Q],enumerable:!0})},A$$=($,X,Q,J)=>{if(X&&typeof X==="object"||typeof X==="function"){for(let v of N$$(X))if(!O$$.call($,v)&&v!==Q)FN($,v,{get:()=>X[v],enumerable:!(J=F$$(X,v))||J.enumerable})}return $},Z$$=($)=>A$$(FN({},"__esModule",{value:!0}),$),Bi={};L$$(Bi,{getContext:()=>q$$.getContext,getVercelOidcToken:()=>Vi.getVercelOidcToken,getVercelOidcTokenSync:()=>Vi.getVercelOidcTokenSync});Di.exports=Z$$(Bi);var Vi=ji(),q$$=YN()});var vR=N((VU)=>{Object.defineProperty(VU,"__esModule",{value:!0});VU._globalThis=void 0;VU._globalThis=typeof globalThis==="object"?globalThis:global});var JR=N((QX)=>{var D4$=QX&&QX.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;Object.defineProperty($,J,{enumerable:!0,get:function(){return X[Q]}})}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),F4$=QX&&QX.__exportStar||function($,X){for(var Q in $)if(Q!=="default"&&!Object.prototype.hasOwnProperty.call(X,Q))D4$(X,$,Q)};Object.defineProperty(QX,"__esModule",{value:!0});F4$(vR(),QX)});var YR=N((vX)=>{var N4$=vX&&vX.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;Object.defineProperty($,J,{enumerable:!0,get:function(){return X[Q]}})}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),O4$=vX&&vX.__exportStar||function($,X){for(var Q in $)if(Q!=="default"&&!Object.prototype.hasOwnProperty.call(X,Q))N4$(X,$,Q)};Object.defineProperty(vX,"__esModule",{value:!0});O4$(JR(),vX)});var LN=N((BU)=>{Object.defineProperty(BU,"__esModule",{value:!0});BU.VERSION=void 0;BU.VERSION="1.9.0"});var GR=N((mQ)=>{Object.defineProperty(mQ,"__esModule",{value:!0});mQ.isCompatible=mQ._makeCompatibilityCheck=void 0;var L4$=LN(),zR=/^(\d+)\.(\d+)\.(\d+)(-(.+))?$/;function WR($){let X=new Set([$]),Q=new Set,J=$.match(zR);if(!J)return()=>!1;let v={major:+J[1],minor:+J[2],patch:+J[3],prerelease:J[4]};if(v.prerelease!=null)return function(G){return G===$};function Y(W){return Q.add(W),!1}function z(W){return X.add(W),!0}return function(G){if(X.has(G))return!0;if(Q.has(G))return!1;let U=G.match(zR);if(!U)return Y(G);let w={major:+U[1],minor:+U[2],patch:+U[3],prerelease:U[4]};if(w.prerelease!=null)return Y(G);if(v.major!==w.major)return Y(G);if(v.major===0){if(v.minor===w.minor&&v.patch<=w.patch)return z(G);return Y(G)}if(v.minor<=w.minor)return z(G);return Y(G)}}mQ._makeCompatibilityCheck=WR;mQ.isCompatible=WR(L4$.VERSION)});var JX=N((p8)=>{Object.defineProperty(p8,"__esModule",{value:!0});p8.unregisterGlobal=p8.getGlobal=p8.registerGlobal=void 0;var A4$=YR(),hQ=LN(),Z4$=GR(),q4$=hQ.VERSION.split(".")[0],pY=Symbol.for(`opentelemetry.js.api.${q4$}`),dY=A4$._globalThis;function I4$($,X,Q,J=!1){var v;let Y=dY[pY]=(v=dY[pY])!==null&&v!==void 0?v:{version:hQ.VERSION};if(!J&&Y[$]){let z=Error(`@opentelemetry/api: Attempted duplicate registration of API: ${$}`);return Q.error(z.stack||z.message),!1}if(Y.version!==hQ.VERSION){let z=Error(`@opentelemetry/api: Registration of version v${Y.version} for ${$} does not match previously registered API v${hQ.VERSION}`);return Q.error(z.stack||z.message),!1}return Y[$]=X,Q.debug(`@opentelemetry/api: Registered a global for ${$} v${hQ.VERSION}.`),!0}p8.registerGlobal=I4$;function M4$($){var X,Q;let J=(X=dY[pY])===null||X===void 0?void 0:X.version;if(!J||!(0,Z4$.isCompatible)(J))return;return(Q=dY[pY])===null||Q===void 0?void 0:Q[$]}p8.getGlobal=M4$;function x4$($,X){X.debug(`@opentelemetry/api: Unregistering a global for ${$} v${hQ.VERSION}.`);let Q=dY[pY];if(Q)delete Q[$]}p8.unregisterGlobal=x4$});var KR=N((DU)=>{Object.defineProperty(DU,"__esModule",{value:!0});DU.DiagComponentLogger=void 0;var b4$=JX();class UR{constructor($){this._namespace=$.namespace||"DiagComponentLogger"}debug(...$){return oY("debug",this._namespace,$)}error(...$){return oY("error",this._namespace,$)}info(...$){return oY("info",this._namespace,$)}warn(...$){return oY("warn",this._namespace,$)}verbose(...$){return oY("verbose",this._namespace,$)}}DU.DiagComponentLogger=UR;function oY($,X,Q){let J=(0,b4$.getGlobal)("diag");if(!J)return;return Q.unshift(X),J[$](...Q)}});var FU=N((aY)=>{Object.defineProperty(aY,"__esModule",{value:!0});aY.DiagLogLevel=void 0;var i4$;(function($){$[$.NONE=0]="NONE",$[$.ERROR=30]="ERROR",$[$.WARN=50]="WARN",$[$.INFO=60]="INFO",$[$.DEBUG=70]="DEBUG",$[$.VERBOSE=80]="VERBOSE",$[$.ALL=9999]="ALL"})(i4$=aY.DiagLogLevel||(aY.DiagLogLevel={}))});var wR=N((NU)=>{Object.defineProperty(NU,"__esModule",{value:!0});NU.createLogLevelDiagLogger=void 0;var Y8=FU();function R4$($,X){if($Y8.DiagLogLevel.ALL)$=Y8.DiagLogLevel.ALL;X=X||{};function Q(J,v){let Y=X[J];if(typeof Y==="function"&&$>=v)return Y.bind(X);return function(){}}return{error:Q("error",Y8.DiagLogLevel.ERROR),warn:Q("warn",Y8.DiagLogLevel.WARN),info:Q("info",Y8.DiagLogLevel.INFO),debug:Q("debug",Y8.DiagLogLevel.DEBUG),verbose:Q("verbose",Y8.DiagLogLevel.VERBOSE)}}NU.createLogLevelDiagLogger=R4$});var YX=N((LU)=>{Object.defineProperty(LU,"__esModule",{value:!0});LU.DiagAPI=void 0;var E4$=KR(),P4$=wR(),HR=FU(),OU=JX(),T4$="diag";class AN{constructor(){function $(J){return function(...v){let Y=(0,OU.getGlobal)("diag");if(!Y)return;return Y[J](...v)}}let X=this,Q=(J,v={logLevel:HR.DiagLogLevel.INFO})=>{var Y,z,W;if(J===X){let w=Error("Cannot use diag as the logger for itself. Please use a DiagLogger implementation like ConsoleDiagLogger or a custom implementation");return X.error((Y=w.stack)!==null&&Y!==void 0?Y:w.message),!1}if(typeof v==="number")v={logLevel:v};let G=(0,OU.getGlobal)("diag"),U=(0,P4$.createLogLevelDiagLogger)((z=v.logLevel)!==null&&z!==void 0?z:HR.DiagLogLevel.INFO,J);if(G&&!v.suppressOverrideMessage){let w=(W=Error().stack)!==null&&W!==void 0?W:"";G.warn(`Current logger will be overwritten from ${w}`),U.warn(`Current logger will overwrite one already registered from ${w}`)}return(0,OU.registerGlobal)("diag",U,X,!0)};X.setLogger=Q,X.disable=()=>{(0,OU.unregisterGlobal)(T4$,X)},X.createComponentLogger=(J)=>{return new E4$.DiagComponentLogger(J)},X.verbose=$("verbose"),X.debug=$("debug"),X.info=$("info"),X.warn=$("warn"),X.error=$("error")}static instance(){if(!this._instance)this._instance=new AN;return this._instance}}LU.DiagAPI=AN});var jR=N((AU)=>{Object.defineProperty(AU,"__esModule",{value:!0});AU.BaggageImpl=void 0;class lQ{constructor($){this._entries=$?new Map($):new Map}getEntry($){let X=this._entries.get($);if(!X)return;return Object.assign({},X)}getAllEntries(){return Array.from(this._entries.entries()).map(([$,X])=>[$,X])}setEntry($,X){let Q=new lQ(this._entries);return Q._entries.set($,X),Q}removeEntry($){let X=new lQ(this._entries);return X._entries.delete($),X}removeEntries(...$){let X=new lQ(this._entries);for(let Q of $)X._entries.delete(Q);return X}clear(){return new lQ}}AU.BaggageImpl=lQ});var VR=N((ZU)=>{Object.defineProperty(ZU,"__esModule",{value:!0});ZU.baggageEntryMetadataSymbol=void 0;ZU.baggageEntryMetadataSymbol=Symbol("BaggageEntryMetadata")});var ZN=N((pQ)=>{Object.defineProperty(pQ,"__esModule",{value:!0});pQ.baggageEntryMetadataFromString=pQ.createBaggage=void 0;var S4$=YX(),C4$=jR(),u4$=VR(),_4$=S4$.DiagAPI.instance();function y4$($={}){return new C4$.BaggageImpl(new Map(Object.entries($)))}pQ.createBaggage=y4$;function f4$($){if(typeof $!=="string")_4$.error(`Cannot create baggage metadata from unknown type: ${typeof $}`),$="";return{__TYPE__:u4$.baggageEntryMetadataSymbol,toString(){return $}}}pQ.baggageEntryMetadataFromString=f4$});var rY=N((dQ)=>{Object.defineProperty(dQ,"__esModule",{value:!0});dQ.ROOT_CONTEXT=dQ.createContextKey=void 0;function k4$($){return Symbol.for($)}dQ.createContextKey=k4$;class qU{constructor($){let X=this;X._currentContext=$?new Map($):new Map,X.getValue=(Q)=>X._currentContext.get(Q),X.setValue=(Q,J)=>{let v=new qU(X._currentContext);return v._currentContext.set(Q,J),v},X.deleteValue=(Q)=>{let J=new qU(X._currentContext);return J._currentContext.delete(Q),J}}}dQ.ROOT_CONTEXT=new qU});var DR=N((IU)=>{Object.defineProperty(IU,"__esModule",{value:!0});IU.DiagConsoleLogger=void 0;var qN=[{n:"error",c:"error"},{n:"warn",c:"warn"},{n:"info",c:"info"},{n:"debug",c:"debug"},{n:"verbose",c:"trace"}];class BR{constructor(){function $(X){return function(...Q){if(console){let J=console[X];if(typeof J!=="function")J=console.log;if(typeof J==="function")return J.apply(console,Q)}}}for(let X=0;X{Object.defineProperty(Y$,"__esModule",{value:!0});Y$.createNoopMeter=Y$.NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC=Y$.NOOP_OBSERVABLE_GAUGE_METRIC=Y$.NOOP_OBSERVABLE_COUNTER_METRIC=Y$.NOOP_UP_DOWN_COUNTER_METRIC=Y$.NOOP_HISTOGRAM_METRIC=Y$.NOOP_GAUGE_METRIC=Y$.NOOP_COUNTER_METRIC=Y$.NOOP_METER=Y$.NoopObservableUpDownCounterMetric=Y$.NoopObservableGaugeMetric=Y$.NoopObservableCounterMetric=Y$.NoopObservableMetric=Y$.NoopHistogramMetric=Y$.NoopGaugeMetric=Y$.NoopUpDownCounterMetric=Y$.NoopCounterMetric=Y$.NoopMetric=Y$.NoopMeter=void 0;class IN{constructor(){}createGauge($,X){return Y$.NOOP_GAUGE_METRIC}createHistogram($,X){return Y$.NOOP_HISTOGRAM_METRIC}createCounter($,X){return Y$.NOOP_COUNTER_METRIC}createUpDownCounter($,X){return Y$.NOOP_UP_DOWN_COUNTER_METRIC}createObservableGauge($,X){return Y$.NOOP_OBSERVABLE_GAUGE_METRIC}createObservableCounter($,X){return Y$.NOOP_OBSERVABLE_COUNTER_METRIC}createObservableUpDownCounter($,X){return Y$.NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC}addBatchObservableCallback($,X){}removeBatchObservableCallback($){}}Y$.NoopMeter=IN;class oQ{}Y$.NoopMetric=oQ;class MN extends oQ{add($,X){}}Y$.NoopCounterMetric=MN;class xN extends oQ{add($,X){}}Y$.NoopUpDownCounterMetric=xN;class bN extends oQ{record($,X){}}Y$.NoopGaugeMetric=bN;class iN extends oQ{record($,X){}}Y$.NoopHistogramMetric=iN;class tY{addCallback($){}removeCallback($){}}Y$.NoopObservableMetric=tY;class RN extends tY{}Y$.NoopObservableCounterMetric=RN;class EN extends tY{}Y$.NoopObservableGaugeMetric=EN;class PN extends tY{}Y$.NoopObservableUpDownCounterMetric=PN;Y$.NOOP_METER=new IN;Y$.NOOP_COUNTER_METRIC=new MN;Y$.NOOP_GAUGE_METRIC=new bN;Y$.NOOP_HISTOGRAM_METRIC=new iN;Y$.NOOP_UP_DOWN_COUNTER_METRIC=new xN;Y$.NOOP_OBSERVABLE_COUNTER_METRIC=new RN;Y$.NOOP_OBSERVABLE_GAUGE_METRIC=new EN;Y$.NOOP_OBSERVABLE_UP_DOWN_COUNTER_METRIC=new PN;function n4$(){return Y$.NOOP_METER}Y$.createNoopMeter=n4$});var FR=N((sY)=>{Object.defineProperty(sY,"__esModule",{value:!0});sY.ValueType=void 0;var g4$;(function($){$[$.INT=0]="INT",$[$.DOUBLE=1]="DOUBLE"})(g4$=sY.ValueType||(sY.ValueType={}))});var SN=N((aQ)=>{Object.defineProperty(aQ,"__esModule",{value:!0});aQ.defaultTextMapSetter=aQ.defaultTextMapGetter=void 0;aQ.defaultTextMapGetter={get($,X){if($==null)return;return $[X]},keys($){if($==null)return[];return Object.keys($)}};aQ.defaultTextMapSetter={set($,X,Q){if($==null)return;$[X]=Q}}});var OR=N((MU)=>{Object.defineProperty(MU,"__esModule",{value:!0});MU.NoopContextManager=void 0;var c4$=rY();class NR{active(){return c4$.ROOT_CONTEXT}with($,X,Q,...J){return X.call(Q,...J)}bind($,X){return X}enable(){return this}disable(){return this}}MU.NoopContextManager=NR});var eY=N((xU)=>{Object.defineProperty(xU,"__esModule",{value:!0});xU.ContextAPI=void 0;var m4$=OR(),CN=JX(),LR=YX(),uN="context",h4$=new m4$.NoopContextManager;class _N{constructor(){}static getInstance(){if(!this._instance)this._instance=new _N;return this._instance}setGlobalContextManager($){return(0,CN.registerGlobal)(uN,$,LR.DiagAPI.instance())}active(){return this._getContextManager().active()}with($,X,Q,...J){return this._getContextManager().with($,X,Q,...J)}bind($,X){return this._getContextManager().bind($,X)}_getContextManager(){return(0,CN.getGlobal)(uN)||h4$}disable(){this._getContextManager().disable(),(0,CN.unregisterGlobal)(uN,LR.DiagAPI.instance())}}xU.ContextAPI=_N});var yN=N(($z)=>{Object.defineProperty($z,"__esModule",{value:!0});$z.TraceFlags=void 0;var l4$;(function($){$[$.NONE=0]="NONE",$[$.SAMPLED=1]="SAMPLED"})(l4$=$z.TraceFlags||($z.TraceFlags={}))});var bU=N((i0)=>{Object.defineProperty(i0,"__esModule",{value:!0});i0.INVALID_SPAN_CONTEXT=i0.INVALID_TRACEID=i0.INVALID_SPANID=void 0;var p4$=yN();i0.INVALID_SPANID="0000000000000000";i0.INVALID_TRACEID="00000000000000000000000000000000";i0.INVALID_SPAN_CONTEXT={traceId:i0.INVALID_TRACEID,spanId:i0.INVALID_SPANID,traceFlags:p4$.TraceFlags.NONE}});var RU=N((iU)=>{Object.defineProperty(iU,"__esModule",{value:!0});iU.NonRecordingSpan=void 0;var d4$=bU();class AR{constructor($=d4$.INVALID_SPAN_CONTEXT){this._spanContext=$}spanContext(){return this._spanContext}setAttribute($,X){return this}setAttributes($){return this}addEvent($,X){return this}addLink($){return this}addLinks($){return this}setStatus($){return this}updateName($){return this}end($){}isRecording(){return!1}recordException($,X){}}iU.NonRecordingSpan=AR});var nN=N((y6)=>{Object.defineProperty(y6,"__esModule",{value:!0});y6.getSpanContext=y6.setSpanContext=y6.deleteSpan=y6.setSpan=y6.getActiveSpan=y6.getSpan=void 0;var o4$=rY(),a4$=RU(),r4$=eY(),fN=(0,o4$.createContextKey)("OpenTelemetry Context Key SPAN");function kN($){return $.getValue(fN)||void 0}y6.getSpan=kN;function t4$(){return kN(r4$.ContextAPI.getInstance().active())}y6.getActiveSpan=t4$;function ZR($,X){return $.setValue(fN,X)}y6.setSpan=ZR;function s4$($){return $.deleteValue(fN)}y6.deleteSpan=s4$;function e4$($,X){return ZR($,new a4$.NonRecordingSpan(X))}y6.setSpanContext=e4$;function $6$($){var X;return(X=kN($))===null||X===void 0?void 0:X.spanContext()}y6.getSpanContext=$6$});var EU=N((R0)=>{Object.defineProperty(R0,"__esModule",{value:!0});R0.wrapSpanContext=R0.isSpanContextValid=R0.isValidSpanId=R0.isValidTraceId=void 0;var qR=bU(),X6$=RU(),Q6$=/^([0-9a-f]{32})$/i,v6$=/^[0-9a-f]{16}$/i;function IR($){return Q6$.test($)&&$!==qR.INVALID_TRACEID}R0.isValidTraceId=IR;function MR($){return v6$.test($)&&$!==qR.INVALID_SPANID}R0.isValidSpanId=MR;function J6$($){return IR($.traceId)&&MR($.spanId)}R0.isSpanContextValid=J6$;function Y6$($){return new X6$.NonRecordingSpan($)}R0.wrapSpanContext=Y6$});var mN=N((PU)=>{Object.defineProperty(PU,"__esModule",{value:!0});PU.NoopTracer=void 0;var z6$=eY(),xR=nN(),gN=RU(),W6$=EU(),cN=z6$.ContextAPI.getInstance();class bR{startSpan($,X,Q=cN.active()){if(Boolean(X===null||X===void 0?void 0:X.root))return new gN.NonRecordingSpan;let v=Q&&(0,xR.getSpanContext)(Q);if(G6$(v)&&(0,W6$.isSpanContextValid)(v))return new gN.NonRecordingSpan(v);else return new gN.NonRecordingSpan}startActiveSpan($,X,Q,J){let v,Y,z;if(arguments.length<2)return;else if(arguments.length===2)z=X;else if(arguments.length===3)v=X,z=Q;else v=X,Y=Q,z=J;let W=Y!==null&&Y!==void 0?Y:cN.active(),G=this.startSpan($,v,W),U=(0,xR.setSpan)(W,G);return cN.with(U,z,void 0,G)}}PU.NoopTracer=bR;function G6$($){return typeof $==="object"&&typeof $.spanId==="string"&&typeof $.traceId==="string"&&typeof $.traceFlags==="number"}});var hN=N((TU)=>{Object.defineProperty(TU,"__esModule",{value:!0});TU.ProxyTracer=void 0;var U6$=mN(),K6$=new U6$.NoopTracer;class iR{constructor($,X,Q,J){this._provider=$,this.name=X,this.version=Q,this.options=J}startSpan($,X,Q){return this._getTracer().startSpan($,X,Q)}startActiveSpan($,X,Q,J){let v=this._getTracer();return Reflect.apply(v.startActiveSpan,v,arguments)}_getTracer(){if(this._delegate)return this._delegate;let $=this._provider.getDelegateTracer(this.name,this.version,this.options);if(!$)return K6$;return this._delegate=$,this._delegate}}TU.ProxyTracer=iR});var ER=N((SU)=>{Object.defineProperty(SU,"__esModule",{value:!0});SU.NoopTracerProvider=void 0;var w6$=mN();class RR{getTracer($,X,Q){return new w6$.NoopTracer}}SU.NoopTracerProvider=RR});var lN=N((CU)=>{Object.defineProperty(CU,"__esModule",{value:!0});CU.ProxyTracerProvider=void 0;var H6$=hN(),j6$=ER(),V6$=new j6$.NoopTracerProvider;class PR{getTracer($,X,Q){var J;return(J=this.getDelegateTracer($,X,Q))!==null&&J!==void 0?J:new H6$.ProxyTracer(this,$,X,Q)}getDelegate(){var $;return($=this._delegate)!==null&&$!==void 0?$:V6$}setDelegate($){this._delegate=$}getDelegateTracer($,X,Q){var J;return(J=this._delegate)===null||J===void 0?void 0:J.getTracer($,X,Q)}}CU.ProxyTracerProvider=PR});var TR=N((Xz)=>{Object.defineProperty(Xz,"__esModule",{value:!0});Xz.SamplingDecision=void 0;var B6$;(function($){$[$.NOT_RECORD=0]="NOT_RECORD",$[$.RECORD=1]="RECORD",$[$.RECORD_AND_SAMPLED=2]="RECORD_AND_SAMPLED"})(B6$=Xz.SamplingDecision||(Xz.SamplingDecision={}))});var SR=N((Qz)=>{Object.defineProperty(Qz,"__esModule",{value:!0});Qz.SpanKind=void 0;var D6$;(function($){$[$.INTERNAL=0]="INTERNAL",$[$.SERVER=1]="SERVER",$[$.CLIENT=2]="CLIENT",$[$.PRODUCER=3]="PRODUCER",$[$.CONSUMER=4]="CONSUMER"})(D6$=Qz.SpanKind||(Qz.SpanKind={}))});var CR=N((vz)=>{Object.defineProperty(vz,"__esModule",{value:!0});vz.SpanStatusCode=void 0;var F6$;(function($){$[$.UNSET=0]="UNSET",$[$.OK=1]="OK",$[$.ERROR=2]="ERROR"})(F6$=vz.SpanStatusCode||(vz.SpanStatusCode={}))});var uR=N((rQ)=>{Object.defineProperty(rQ,"__esModule",{value:!0});rQ.validateValue=rQ.validateKey=void 0;var pN="[_0-9a-z-*/]",N6$=`[a-z]${pN}{0,255}`,O6$=`[a-z0-9]${pN}{0,240}@[a-z]${pN}{0,13}`,L6$=new RegExp(`^(?:${N6$}|${O6$})$`),A6$=/^[ -~]{0,255}[!-~]$/,Z6$=/,|=/;function q6$($){return L6$.test($)}rQ.validateKey=q6$;function I6$($){return A6$.test($)&&!Z6$.test($)}rQ.validateValue=I6$});var nR=N((uU)=>{Object.defineProperty(uU,"__esModule",{value:!0});uU.TraceStateImpl=void 0;var _R=uR(),yR=32,M6$=512,fR=",",kR="=";class dN{constructor($){if(this._internalState=new Map,$)this._parse($)}set($,X){let Q=this._clone();if(Q._internalState.has($))Q._internalState.delete($);return Q._internalState.set($,X),Q}unset($){let X=this._clone();return X._internalState.delete($),X}get($){return this._internalState.get($)}serialize(){return this._keys().reduce(($,X)=>{return $.push(X+kR+this.get(X)),$},[]).join(fR)}_parse($){if($.length>M6$)return;if(this._internalState=$.split(fR).reverse().reduce((X,Q)=>{let J=Q.trim(),v=J.indexOf(kR);if(v!==-1){let Y=J.slice(0,v),z=J.slice(v+1,Q.length);if((0,_R.validateKey)(Y)&&(0,_R.validateValue)(z))X.set(Y,z)}return X},new Map),this._internalState.size>yR)this._internalState=new Map(Array.from(this._internalState.entries()).reverse().slice(0,yR))}_keys(){return Array.from(this._internalState.keys()).reverse()}_clone(){let $=new dN;return $._internalState=new Map(this._internalState),$}}uU.TraceStateImpl=dN});var gR=N((_U)=>{Object.defineProperty(_U,"__esModule",{value:!0});_U.createTraceState=void 0;var x6$=nR();function b6$($){return new x6$.TraceStateImpl($)}_U.createTraceState=b6$});var cR=N((yU)=>{Object.defineProperty(yU,"__esModule",{value:!0});yU.context=void 0;var i6$=eY();yU.context=i6$.ContextAPI.getInstance()});var mR=N((fU)=>{Object.defineProperty(fU,"__esModule",{value:!0});fU.diag=void 0;var R6$=YX();fU.diag=R6$.DiagAPI.instance()});var hR=N((tQ)=>{Object.defineProperty(tQ,"__esModule",{value:!0});tQ.NOOP_METER_PROVIDER=tQ.NoopMeterProvider=void 0;var E6$=TN();class oN{getMeter($,X,Q){return E6$.NOOP_METER}}tQ.NoopMeterProvider=oN;tQ.NOOP_METER_PROVIDER=new oN});var pR=N((kU)=>{Object.defineProperty(kU,"__esModule",{value:!0});kU.MetricsAPI=void 0;var P6$=hR(),aN=JX(),lR=YX(),rN="metrics";class tN{constructor(){}static getInstance(){if(!this._instance)this._instance=new tN;return this._instance}setGlobalMeterProvider($){return(0,aN.registerGlobal)(rN,$,lR.DiagAPI.instance())}getMeterProvider(){return(0,aN.getGlobal)(rN)||P6$.NOOP_METER_PROVIDER}getMeter($,X,Q){return this.getMeterProvider().getMeter($,X,Q)}disable(){(0,aN.unregisterGlobal)(rN,lR.DiagAPI.instance())}}kU.MetricsAPI=tN});var dR=N((nU)=>{Object.defineProperty(nU,"__esModule",{value:!0});nU.metrics=void 0;var T6$=pR();nU.metrics=T6$.MetricsAPI.getInstance()});var aR=N((gU)=>{Object.defineProperty(gU,"__esModule",{value:!0});gU.NoopTextMapPropagator=void 0;class oR{inject($,X){}extract($,X){return $}fields(){return[]}}gU.NoopTextMapPropagator=oR});var tR=N((E0)=>{Object.defineProperty(E0,"__esModule",{value:!0});E0.deleteBaggage=E0.setBaggage=E0.getActiveBaggage=E0.getBaggage=void 0;var S6$=eY(),C6$=rY(),sN=(0,C6$.createContextKey)("OpenTelemetry Baggage Key");function rR($){return $.getValue(sN)||void 0}E0.getBaggage=rR;function u6$(){return rR(S6$.ContextAPI.getInstance().active())}E0.getActiveBaggage=u6$;function _6$($,X){return $.setValue(sN,X)}E0.setBaggage=_6$;function y6$($){return $.deleteValue(sN)}E0.deleteBaggage=y6$});var $E=N((mU)=>{Object.defineProperty(mU,"__esModule",{value:!0});mU.PropagationAPI=void 0;var eN=JX(),f6$=aR(),sR=SN(),cU=tR(),k6$=ZN(),eR=YX(),$O="propagation",n6$=new f6$.NoopTextMapPropagator;class XO{constructor(){this.createBaggage=k6$.createBaggage,this.getBaggage=cU.getBaggage,this.getActiveBaggage=cU.getActiveBaggage,this.setBaggage=cU.setBaggage,this.deleteBaggage=cU.deleteBaggage}static getInstance(){if(!this._instance)this._instance=new XO;return this._instance}setGlobalPropagator($){return(0,eN.registerGlobal)($O,$,eR.DiagAPI.instance())}inject($,X,Q=sR.defaultTextMapSetter){return this._getGlobalPropagator().inject($,X,Q)}extract($,X,Q=sR.defaultTextMapGetter){return this._getGlobalPropagator().extract($,X,Q)}fields(){return this._getGlobalPropagator().fields()}disable(){(0,eN.unregisterGlobal)($O,eR.DiagAPI.instance())}_getGlobalPropagator(){return(0,eN.getGlobal)($O)||n6$}}mU.PropagationAPI=XO});var XE=N((hU)=>{Object.defineProperty(hU,"__esModule",{value:!0});hU.propagation=void 0;var g6$=$E();hU.propagation=g6$.PropagationAPI.getInstance()});var YE=N((lU)=>{Object.defineProperty(lU,"__esModule",{value:!0});lU.TraceAPI=void 0;var QO=JX(),QE=lN(),vE=EU(),sQ=nN(),JE=YX(),vO="trace";class JO{constructor(){this._proxyTracerProvider=new QE.ProxyTracerProvider,this.wrapSpanContext=vE.wrapSpanContext,this.isSpanContextValid=vE.isSpanContextValid,this.deleteSpan=sQ.deleteSpan,this.getSpan=sQ.getSpan,this.getActiveSpan=sQ.getActiveSpan,this.getSpanContext=sQ.getSpanContext,this.setSpan=sQ.setSpan,this.setSpanContext=sQ.setSpanContext}static getInstance(){if(!this._instance)this._instance=new JO;return this._instance}setGlobalTracerProvider($){let X=(0,QO.registerGlobal)(vO,this._proxyTracerProvider,JE.DiagAPI.instance());if(X)this._proxyTracerProvider.setDelegate($);return X}getTracerProvider(){return(0,QO.getGlobal)(vO)||this._proxyTracerProvider}getTracer($,X){return this.getTracerProvider().getTracer($,X)}disable(){(0,QO.unregisterGlobal)(vO,JE.DiagAPI.instance()),this._proxyTracerProvider=new QE.ProxyTracerProvider}}lU.TraceAPI=JO});var zE=N((pU)=>{Object.defineProperty(pU,"__esModule",{value:!0});pU.trace=void 0;var c6$=YE();pU.trace=c6$.TraceAPI.getInstance()});var WO=N((r)=>{Object.defineProperty(r,"__esModule",{value:!0});r.trace=r.propagation=r.metrics=r.diag=r.context=r.INVALID_SPAN_CONTEXT=r.INVALID_TRACEID=r.INVALID_SPANID=r.isValidSpanId=r.isValidTraceId=r.isSpanContextValid=r.createTraceState=r.TraceFlags=r.SpanStatusCode=r.SpanKind=r.SamplingDecision=r.ProxyTracerProvider=r.ProxyTracer=r.defaultTextMapSetter=r.defaultTextMapGetter=r.ValueType=r.createNoopMeter=r.DiagLogLevel=r.DiagConsoleLogger=r.ROOT_CONTEXT=r.createContextKey=r.baggageEntryMetadataFromString=void 0;var m6$=ZN();Object.defineProperty(r,"baggageEntryMetadataFromString",{enumerable:!0,get:function(){return m6$.baggageEntryMetadataFromString}});var WE=rY();Object.defineProperty(r,"createContextKey",{enumerable:!0,get:function(){return WE.createContextKey}});Object.defineProperty(r,"ROOT_CONTEXT",{enumerable:!0,get:function(){return WE.ROOT_CONTEXT}});var h6$=DR();Object.defineProperty(r,"DiagConsoleLogger",{enumerable:!0,get:function(){return h6$.DiagConsoleLogger}});var l6$=FU();Object.defineProperty(r,"DiagLogLevel",{enumerable:!0,get:function(){return l6$.DiagLogLevel}});var p6$=TN();Object.defineProperty(r,"createNoopMeter",{enumerable:!0,get:function(){return p6$.createNoopMeter}});var d6$=FR();Object.defineProperty(r,"ValueType",{enumerable:!0,get:function(){return d6$.ValueType}});var GE=SN();Object.defineProperty(r,"defaultTextMapGetter",{enumerable:!0,get:function(){return GE.defaultTextMapGetter}});Object.defineProperty(r,"defaultTextMapSetter",{enumerable:!0,get:function(){return GE.defaultTextMapSetter}});var o6$=hN();Object.defineProperty(r,"ProxyTracer",{enumerable:!0,get:function(){return o6$.ProxyTracer}});var a6$=lN();Object.defineProperty(r,"ProxyTracerProvider",{enumerable:!0,get:function(){return a6$.ProxyTracerProvider}});var r6$=TR();Object.defineProperty(r,"SamplingDecision",{enumerable:!0,get:function(){return r6$.SamplingDecision}});var t6$=SR();Object.defineProperty(r,"SpanKind",{enumerable:!0,get:function(){return t6$.SpanKind}});var s6$=CR();Object.defineProperty(r,"SpanStatusCode",{enumerable:!0,get:function(){return s6$.SpanStatusCode}});var e6$=yN();Object.defineProperty(r,"TraceFlags",{enumerable:!0,get:function(){return e6$.TraceFlags}});var $1$=gR();Object.defineProperty(r,"createTraceState",{enumerable:!0,get:function(){return $1$.createTraceState}});var YO=EU();Object.defineProperty(r,"isSpanContextValid",{enumerable:!0,get:function(){return YO.isSpanContextValid}});Object.defineProperty(r,"isValidTraceId",{enumerable:!0,get:function(){return YO.isValidTraceId}});Object.defineProperty(r,"isValidSpanId",{enumerable:!0,get:function(){return YO.isValidSpanId}});var zO=bU();Object.defineProperty(r,"INVALID_SPANID",{enumerable:!0,get:function(){return zO.INVALID_SPANID}});Object.defineProperty(r,"INVALID_TRACEID",{enumerable:!0,get:function(){return zO.INVALID_TRACEID}});Object.defineProperty(r,"INVALID_SPAN_CONTEXT",{enumerable:!0,get:function(){return zO.INVALID_SPAN_CONTEXT}});var UE=cR();Object.defineProperty(r,"context",{enumerable:!0,get:function(){return UE.context}});var KE=mR();Object.defineProperty(r,"diag",{enumerable:!0,get:function(){return KE.diag}});var wE=dR();Object.defineProperty(r,"metrics",{enumerable:!0,get:function(){return wE.metrics}});var HE=XE();Object.defineProperty(r,"propagation",{enumerable:!0,get:function(){return HE.propagation}});var jE=zE();Object.defineProperty(r,"trace",{enumerable:!0,get:function(){return jE.trace}});r.default={context:UE.context,diag:KE.diag,metrics:wE.metrics,propagation:HE.propagation,trace:jE.trace}});var fS=N((Dx$,yS)=>{var jv=1000,Vv=jv*60,Bv=Vv*60,UX=Bv*24,D9$=UX*7,F9$=UX*365.25;yS.exports=function($,X){X=X||{};var Q=typeof $;if(Q==="string"&&$.length>0)return N9$($);else if(Q==="number"&&isFinite($))return X.long?L9$($):O9$($);throw Error("val is not a non-empty string or a valid number. val="+JSON.stringify($))};function N9$($){if($=String($),$.length>100)return;var X=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec($);if(!X)return;var Q=parseFloat(X[1]),J=(X[2]||"ms").toLowerCase();switch(J){case"years":case"year":case"yrs":case"yr":case"y":return Q*F9$;case"weeks":case"week":case"w":return Q*D9$;case"days":case"day":case"d":return Q*UX;case"hours":case"hour":case"hrs":case"hr":case"h":return Q*Bv;case"minutes":case"minute":case"mins":case"min":case"m":return Q*Vv;case"seconds":case"second":case"secs":case"sec":case"s":return Q*jv;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return Q;default:return}}function O9$($){var X=Math.abs($);if(X>=UX)return Math.round($/UX)+"d";if(X>=Bv)return Math.round($/Bv)+"h";if(X>=Vv)return Math.round($/Vv)+"m";if(X>=jv)return Math.round($/jv)+"s";return $+"ms"}function L9$($){var X=Math.abs($);if(X>=UX)return ww($,X,UX,"day");if(X>=Bv)return ww($,X,Bv,"hour");if(X>=Vv)return ww($,X,Vv,"minute");if(X>=jv)return ww($,X,jv,"second");return $+" ms"}function ww($,X,Q,J){var v=X>=Q*1.5;return Math.round($/Q)+" "+J+(v?"s":"")}});var $L=N((Fx$,kS)=>{function A9$($){Q.debug=Q,Q.default=Q,Q.coerce=G,Q.disable=z,Q.enable=v,Q.enabled=W,Q.humanize=fS(),Q.destroy=U,Object.keys($).forEach((w)=>{Q[w]=$[w]}),Q.names=[],Q.skips=[],Q.formatters={};function X(w){let H=0;for(let j=0;j{if(R==="%%")return"%";i++;let v$=Q.formatters[E];if(typeof v$==="function"){let q$=F[i];R=v$.call(L,q$),F.splice(i,1),i--}return R}),Q.formatArgs.call(L,F),(L.log||Q.log).apply(L,F)}if(D.namespace=w,D.useColors=Q.useColors(),D.color=Q.selectColor(w),D.extend=J,D.destroy=Q.destroy,Object.defineProperty(D,"enabled",{enumerable:!0,configurable:!1,get:()=>{if(j!==null)return j;if(V!==Q.namespaces)V=Q.namespaces,B=Q.enabled(w);return B},set:(F)=>{j=F}}),typeof Q.init==="function")Q.init(D);return D}function J(w,H){let j=Q(this.namespace+(typeof H>"u"?":":H)+w);return j.log=this.log,j}function v(w){Q.save(w),Q.namespaces=w,Q.names=[],Q.skips=[];let H=(typeof w==="string"?w:"").trim().replace(/\s+/g,",").split(",").filter(Boolean);for(let j of H)if(j[0]==="-")Q.skips.push(j.slice(1));else Q.names.push(j)}function Y(w,H){let j=0,V=0,B=-1,D=0;while(j"-"+H)].join(",");return Q.enable(""),w}function W(w){for(let H of Q.skips)if(Y(w,H))return!1;for(let H of Q.names)if(Y(w,H))return!0;return!1}function G(w){if(w instanceof Error)return w.stack||w.message;return w}function U(){console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.")}return Q.enable(Q.load()),Q}kS.exports=A9$});var nS=N((k6,Hw)=>{k6.formatArgs=q9$;k6.save=I9$;k6.load=M9$;k6.useColors=Z9$;k6.storage=x9$();k6.destroy=(()=>{let $=!1;return()=>{if(!$)$=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.")}})();k6.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"];function Z9$(){if(typeof window<"u"&&window.process&&(window.process.type==="renderer"||window.process.__nwjs))return!0;if(typeof navigator<"u"&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/))return!1;let $;return typeof document<"u"&&document.documentElement&&document.documentElement.style&&document.documentElement.style.WebkitAppearance||typeof window<"u"&&window.console&&(window.console.firebug||window.console.exception&&window.console.table)||typeof navigator<"u"&&navigator.userAgent&&($=navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/))&&parseInt($[1],10)>=31||typeof navigator<"u"&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/)}function q9$($){if($[0]=(this.useColors?"%c":"")+this.namespace+(this.useColors?" %c":" ")+$[0]+(this.useColors?"%c ":" ")+"+"+Hw.exports.humanize(this.diff),!this.useColors)return;let X="color: "+this.color;$.splice(1,0,X,"color: inherit");let Q=0,J=0;$[0].replace(/%[a-zA-Z%]/g,(v)=>{if(v==="%%")return;if(Q++,v==="%c")J=Q}),$.splice(J,0,X)}k6.log=console.debug||console.log||(()=>{});function I9$($){try{if($)k6.storage.setItem("debug",$);else k6.storage.removeItem("debug")}catch(X){}}function M9$(){let $;try{$=k6.storage.getItem("debug")||k6.storage.getItem("DEBUG")}catch(X){}if(!$&&typeof process<"u"&&"env"in process)$=process.env.DEBUG;return $}function x9$(){try{return localStorage}catch($){}}Hw.exports=$L()(k6);var{formatters:b9$}=Hw.exports;b9$.j=function($){try{return JSON.stringify($)}catch(X){return"[UnexpectedJSONParseError]: "+X.message}}});var cS=N((Nx$,gS)=>{gS.exports=($,X=process.argv)=>{let Q=$.startsWith("-")?"":$.length===1?"-":"--",J=X.indexOf(Q+$),v=X.indexOf("--");return J!==-1&&(v===-1||J{var i9$=require("os"),mS=require("tty"),O1=cS(),{env:P4}=process,jw;if(O1("no-color")||O1("no-colors")||O1("color=false")||O1("color=never"))jw=0;else if(O1("color")||O1("colors")||O1("color=true")||O1("color=always"))jw=1;function R9$(){if("FORCE_COLOR"in P4){if(P4.FORCE_COLOR==="true")return 1;if(P4.FORCE_COLOR==="false")return 0;return P4.FORCE_COLOR.length===0?1:Math.min(Number.parseInt(P4.FORCE_COLOR,10),3)}}function E9$($){if($===0)return!1;return{level:$,hasBasic:!0,has256:$>=2,has16m:$>=3}}function P9$($,{streamIsTTY:X,sniffFlags:Q=!0}={}){let J=R9$();if(J!==void 0)jw=J;let v=Q?jw:J;if(v===0)return 0;if(Q){if(O1("color=16m")||O1("color=full")||O1("color=truecolor"))return 3;if(O1("color=256"))return 2}if($&&!X&&v===void 0)return 0;let Y=v||0;if(P4.TERM==="dumb")return Y;if(process.platform==="win32"){let z=i9$.release().split(".");if(Number(z[0])>=10&&Number(z[2])>=10586)return Number(z[2])>=14931?3:2;return 1}if("CI"in P4){if(["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI","GITHUB_ACTIONS","BUILDKITE","DRONE"].some((z)=>(z in P4))||P4.CI_NAME==="codeship")return 1;return Y}if("TEAMCITY_VERSION"in P4)return/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(P4.TEAMCITY_VERSION)?1:0;if(P4.COLORTERM==="truecolor")return 3;if("TERM_PROGRAM"in P4){let z=Number.parseInt((P4.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(P4.TERM_PROGRAM){case"iTerm.app":return z>=3?3:2;case"Apple_Terminal":return 2}}if(/-256(color)?$/i.test(P4.TERM))return 2;if(/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(P4.TERM))return 1;if("COLORTERM"in P4)return 1;return Y}function XL($,X={}){let Q=P9$($,{streamIsTTY:$&&$.isTTY,...X});return E9$(Q)}hS.exports={supportsColor:XL,stdout:XL({isTTY:mS.isatty(1)}),stderr:XL({isTTY:mS.isatty(2)})}});var dS=N((T4,Bw)=>{var T9$=require("tty"),Vw=require("util");T4.init=k9$;T4.log=_9$;T4.formatArgs=C9$;T4.save=y9$;T4.load=f9$;T4.useColors=S9$;T4.destroy=Vw.deprecate(()=>{},"Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.");T4.colors=[6,2,3,4,5,1];try{let $=lS();if($&&($.stderr||$).level>=2)T4.colors=[20,21,26,27,32,33,38,39,40,41,42,43,44,45,56,57,62,63,68,69,74,75,76,77,78,79,80,81,92,93,98,99,112,113,128,129,134,135,148,149,160,161,162,163,164,165,166,167,168,169,170,171,172,173,178,179,184,185,196,197,198,199,200,201,202,203,204,205,206,207,208,209,214,215,220,221]}catch($){}T4.inspectOpts=Object.keys(process.env).filter(($)=>{return/^debug_/i.test($)}).reduce(($,X)=>{let Q=X.substring(6).toLowerCase().replace(/_([a-z])/g,(v,Y)=>{return Y.toUpperCase()}),J=process.env[X];if(/^(yes|on|true|enabled)$/i.test(J))J=!0;else if(/^(no|off|false|disabled)$/i.test(J))J=!1;else if(J==="null")J=null;else J=Number(J);return $[Q]=J,$},{});function S9$(){return"colors"in T4.inspectOpts?Boolean(T4.inspectOpts.colors):T9$.isatty(process.stderr.fd)}function C9$($){let{namespace:X,useColors:Q}=this;if(Q){let J=this.color,v="\x1B[3"+(J<8?J:"8;5;"+J),Y=` ${v};1m${X} \x1B[0m`;$[0]=Y+$[0].split(` +`).join(` +`+Y),$.push(v+"m+"+Bw.exports.humanize(this.diff)+"\x1B[0m")}else $[0]=u9$()+X+" "+$[0]}function u9$(){if(T4.inspectOpts.hideDate)return"";return new Date().toISOString()+" "}function _9$(...$){return process.stderr.write(Vw.formatWithOptions(T4.inspectOpts,...$)+` +`)}function y9$($){if($)process.env.DEBUG=$;else delete process.env.DEBUG}function f9$(){return process.env.DEBUG}function k9$($){$.inspectOpts={};let X=Object.keys(T4.inspectOpts);for(let Q=0;QX.trim()).join(" ")};pS.O=function($){return this.inspectOpts.colors=this.useColors,Vw.inspect($,this.inspectOpts)}});var W4=N((Lx$,QL)=>{if(typeof process>"u"||process.type==="renderer"||!1||process.__nwjs)QL.exports=nS();else QL.exports=dS()});var rS=N((n6)=>{var n9$=n6&&n6.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),g9$=n6&&n6.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),oS=n6&&n6.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))n9$(X,$,Q)}return g9$(X,$),X};Object.defineProperty(n6,"__esModule",{value:!0});n6.req=n6.json=n6.toBuffer=void 0;var c9$=oS(require("http")),m9$=oS(require("https"));async function aS($){let X=0,Q=[];for await(let J of $)X+=J.length,Q.push(J);return Buffer.concat(Q,X)}n6.toBuffer=aS;async function h9$($){let Q=(await aS($)).toString("utf8");try{return JSON.parse(Q)}catch(J){let v=J;throw v.message+=` (input: ${Q})`,v}}n6.json=h9$;function l9$($,X={}){let J=((typeof $==="string"?$:$.href).startsWith("https:")?m9$:c9$).request($,X),v=new Promise((Y,z)=>{J.once("response",Y).once("error",z).end()});return J.then=v.then.bind(v),J}n6.req=l9$});var Dw=N((z1)=>{var sS=z1&&z1.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),p9$=z1&&z1.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),eS=z1&&z1.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))sS(X,$,Q)}return p9$(X,$),X},d9$=z1&&z1.__exportStar||function($,X){for(var Q in $)if(Q!=="default"&&!Object.prototype.hasOwnProperty.call(X,Q))sS(X,$,Q)};Object.defineProperty(z1,"__esModule",{value:!0});z1.Agent=void 0;var o9$=eS(require("net")),tS=eS(require("http")),a9$=require("https");d9$(rS(),z1);var C0=Symbol("AgentBaseInternalState");class $C extends tS.Agent{constructor($){super($);this[C0]={}}isSecureEndpoint($){if($){if(typeof $.secureEndpoint==="boolean")return $.secureEndpoint;if(typeof $.protocol==="string")return $.protocol==="https:"}let{stack:X}=Error();if(typeof X!=="string")return!1;return X.split(` +`).some((Q)=>Q.indexOf("(https.js:")!==-1||Q.indexOf("node:https:")!==-1)}incrementSockets($){if(this.maxSockets===1/0&&this.maxTotalSockets===1/0)return null;if(!this.sockets[$])this.sockets[$]=[];let X=new o9$.Socket({writable:!1});return this.sockets[$].push(X),this.totalSocketCount++,X}decrementSockets($,X){if(!this.sockets[$]||X===null)return;let Q=this.sockets[$],J=Q.indexOf(X);if(J!==-1){if(Q.splice(J,1),this.totalSocketCount--,Q.length===0)delete this.sockets[$]}}getName($){if(this.isSecureEndpoint($))return a9$.Agent.prototype.getName.call(this,$);return super.getName($)}createSocket($,X,Q){let J={...X,secureEndpoint:this.isSecureEndpoint(X)},v=this.getName(J),Y=this.incrementSockets(v);Promise.resolve().then(()=>this.connect($,J)).then((z)=>{if(this.decrementSockets(v,Y),z instanceof tS.Agent)try{return z.addRequest($,J)}catch(W){return Q(W)}this[C0].currentSocket=z,super.createSocket($,X,Q)},(z)=>{this.decrementSockets(v,Y),Q(z)})}createConnection(){let $=this[C0].currentSocket;if(this[C0].currentSocket=void 0,!$)throw Error("No socket was returned in the `connect()` function");return $}get defaultPort(){return this[C0].defaultPort??(this.protocol==="https:"?443:80)}set defaultPort($){if(this[C0])this[C0].defaultPort=$}get protocol(){return this[C0].protocol??(this.isSecureEndpoint()?"https:":"http:")}set protocol($){if(this[C0])this[C0].protocol=$}}z1.Agent=$C});var vC=N((L1)=>{var r9$=L1&&L1.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),t9$=L1&&L1.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),QC=L1&&L1.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))r9$(X,$,Q)}return t9$(X,$),X},s9$=L1&&L1.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(L1,"__esModule",{value:!0});L1.HttpProxyAgent=void 0;var e9$=QC(require("net")),$X$=QC(require("tls")),XX$=s9$(W4()),QX$=require("events"),vX$=Dw(),XC=require("url"),Dv=(0,XX$.default)("http-proxy-agent");class vL extends vX$.Agent{constructor($,X){super(X);this.proxy=typeof $==="string"?new XC.URL($):$,this.proxyHeaders=X?.headers??{},Dv("Creating new HttpProxyAgent instance: %o",this.proxy.href);let Q=(this.proxy.hostname||this.proxy.host).replace(/^\[|\]$/g,""),J=this.proxy.port?parseInt(this.proxy.port,10):this.proxy.protocol==="https:"?443:80;this.connectOpts={...X?JX$(X,"headers"):null,host:Q,port:J}}addRequest($,X){$._header=null,this.setRequestProps($,X),super.addRequest($,X)}setRequestProps($,X){let{proxy:Q}=this,J=X.secureEndpoint?"https:":"http:",v=$.getHeader("host")||"localhost",Y=`${J}//${v}`,z=new XC.URL($.path,Y);if(X.port!==80)z.port=String(X.port);$.path=String(z);let W=typeof this.proxyHeaders==="function"?this.proxyHeaders():{...this.proxyHeaders};if(Q.username||Q.password){let G=`${decodeURIComponent(Q.username)}:${decodeURIComponent(Q.password)}`;W["Proxy-Authorization"]=`Basic ${Buffer.from(G).toString("base64")}`}if(!W["Proxy-Connection"])W["Proxy-Connection"]=this.keepAlive?"Keep-Alive":"close";for(let G of Object.keys(W)){let U=W[G];if(U)$.setHeader(G,U)}}async connect($,X){if($._header=null,!$.path.includes("://"))this.setRequestProps($,X);let Q,J;if(Dv("Regenerating stored HTTP header string for request"),$._implicitHeader(),$.outputData&&$.outputData.length>0)Dv("Patching connection write() output buffer with updated header"),Q=$.outputData[0].data,J=Q.indexOf(`\r +\r +`)+4,$.outputData[0].data=$._header+Q.substring(J),Dv("Output buffer: %o",$.outputData[0].data);let v;if(this.proxy.protocol==="https:")Dv("Creating `tls.Socket`: %o",this.connectOpts),v=$X$.connect(this.connectOpts);else Dv("Creating `net.Socket`: %o",this.connectOpts),v=e9$.connect(this.connectOpts);return await(0,QX$.once)(v,"connect"),v}}vL.protocols=["http","https"];L1.HttpProxyAgent=vL;function JX$($,...X){let Q={},J;for(J in $)if(!X.includes(J))Q[J]=$[J];return Q}});var JC=N((Fv)=>{var YX$=Fv&&Fv.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(Fv,"__esModule",{value:!0});Fv.parseProxyResponse=void 0;var zX$=YX$(W4()),Fw=(0,zX$.default)("https-proxy-agent:parse-proxy-response");function WX$($){return new Promise((X,Q)=>{let J=0,v=[];function Y(){let w=$.read();if(w)U(w);else $.once("readable",Y)}function z(){$.removeListener("end",W),$.removeListener("error",G),$.removeListener("readable",Y)}function W(){z(),Fw("onend"),Q(Error("Proxy connection ended before receiving CONNECT response"))}function G(w){z(),Fw("onerror %o",w),Q(w)}function U(w){v.push(w),J+=w.length;let H=Buffer.concat(v,J),j=H.indexOf(`\r +\r +`);if(j===-1){Fw("have not received end of HTTP headers yet..."),Y();return}let V=H.slice(0,j).toString("ascii").split(`\r +`),B=V.shift();if(!B)return $.destroy(),Q(Error("No header received from proxy CONNECT response"));let D=B.split(" "),F=+D[1],L=D.slice(2).join(" "),A={};for(let O of V){if(!O)continue;let i=O.indexOf(":");if(i===-1)return $.destroy(),Q(Error(`Invalid header from proxy CONNECT response: "${O}"`));let M=O.slice(0,i).toLowerCase(),R=O.slice(i+1).trimStart(),E=A[M];if(typeof E==="string")A[M]=[E,R];else if(Array.isArray(E))E.push(R);else A[M]=R}Fw("got proxy server response: %o %o",B,A),z(),X({connect:{statusCode:F,statusText:L,headers:A},buffered:H})}$.on("error",G),$.on("end",W),Y()})}Fv.parseProxyResponse=WX$});var KC=N((A1)=>{var GX$=A1&&A1.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),UX$=A1&&A1.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),GC=A1&&A1.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))GX$(X,$,Q)}return UX$(X,$),X},UC=A1&&A1.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(A1,"__esModule",{value:!0});A1.HttpsProxyAgent=void 0;var Nw=GC(require("net")),YC=GC(require("tls")),KX$=UC(require("assert")),wX$=UC(W4()),HX$=Dw(),jX$=require("url"),VX$=JC(),Dz=(0,wX$.default)("https-proxy-agent"),zC=($)=>{if($.servername===void 0&&$.host&&!Nw.isIP($.host))return{...$,servername:$.host};return $};class JL extends HX$.Agent{constructor($,X){super(X);this.options={path:void 0},this.proxy=typeof $==="string"?new jX$.URL($):$,this.proxyHeaders=X?.headers??{},Dz("Creating new HttpsProxyAgent instance: %o",this.proxy.href);let Q=(this.proxy.hostname||this.proxy.host).replace(/^\[|\]$/g,""),J=this.proxy.port?parseInt(this.proxy.port,10):this.proxy.protocol==="https:"?443:80;this.connectOpts={ALPNProtocols:["http/1.1"],...X?WC(X,"headers"):null,host:Q,port:J}}async connect($,X){let{proxy:Q}=this;if(!X.host)throw TypeError('No "host" provided');let J;if(Q.protocol==="https:")Dz("Creating `tls.Socket`: %o",this.connectOpts),J=YC.connect(zC(this.connectOpts));else Dz("Creating `net.Socket`: %o",this.connectOpts),J=Nw.connect(this.connectOpts);let v=typeof this.proxyHeaders==="function"?this.proxyHeaders():{...this.proxyHeaders},Y=Nw.isIPv6(X.host)?`[${X.host}]`:X.host,z=`CONNECT ${Y}:${X.port} HTTP/1.1\r +`;if(Q.username||Q.password){let H=`${decodeURIComponent(Q.username)}:${decodeURIComponent(Q.password)}`;v["Proxy-Authorization"]=`Basic ${Buffer.from(H).toString("base64")}`}if(v.Host=`${Y}:${X.port}`,!v["Proxy-Connection"])v["Proxy-Connection"]=this.keepAlive?"Keep-Alive":"close";for(let H of Object.keys(v))z+=`${H}: ${v[H]}\r +`;let W=(0,VX$.parseProxyResponse)(J);J.write(`${z}\r +`);let{connect:G,buffered:U}=await W;if($.emit("proxyConnect",G),this.emit("proxyConnect",G,$),G.statusCode===200){if($.once("socket",BX$),X.secureEndpoint)return Dz("Upgrading socket connection to TLS"),YC.connect({...WC(zC(X),"host","path","port"),socket:J});return J}J.destroy();let w=new Nw.Socket({writable:!1});return w.readable=!0,$.once("socket",(H)=>{Dz("Replaying proxy buffer for failed request"),(0,KX$.default)(H.listenerCount("data")>0),H.push(U),H.push(null)}),w}}JL.protocols=["http","https"];A1.HttpsProxyAgent=JL;function BX$($){$.resume()}function WC($,...X){let Q={},J;for(J in $)if(!X.includes(J))Q[J]=$[J];return Q}});var VC=N((K8)=>{Object.defineProperty(K8,"__esModule",{value:!0});var wC=require("buffer"),KX={INVALID_ENCODING:"Invalid encoding provided. Please specify a valid encoding the internal Node.js Buffer supports.",INVALID_SMARTBUFFER_SIZE:"Invalid size provided. Size must be a valid integer greater than zero.",INVALID_SMARTBUFFER_BUFFER:"Invalid Buffer provided in SmartBufferOptions.",INVALID_SMARTBUFFER_OBJECT:"Invalid SmartBufferOptions object supplied to SmartBuffer constructor or factory methods.",INVALID_OFFSET:"An invalid offset value was provided.",INVALID_OFFSET_NON_NUMBER:"An invalid offset value was provided. A numeric value is required.",INVALID_LENGTH:"An invalid length value was provided.",INVALID_LENGTH_NON_NUMBER:"An invalid length value was provived. A numeric value is required.",INVALID_TARGET_OFFSET:"Target offset is beyond the bounds of the internal SmartBuffer data.",INVALID_TARGET_LENGTH:"Specified length value moves cursor beyong the bounds of the internal SmartBuffer data.",INVALID_READ_BEYOND_BOUNDS:"Attempted to read beyond the bounds of the managed data.",INVALID_WRITE_BEYOND_BOUNDS:"Attempted to write beyond the bounds of the managed data."};K8.ERRORS=KX;function DX$($){if(!wC.Buffer.isEncoding($))throw Error(KX.INVALID_ENCODING)}K8.checkEncoding=DX$;function HC($){return typeof $==="number"&&isFinite($)&&LX$($)}K8.isFiniteInteger=HC;function jC($,X){if(typeof $==="number"){if(!HC($)||$<0)throw Error(X?KX.INVALID_OFFSET:KX.INVALID_LENGTH)}else throw Error(X?KX.INVALID_OFFSET_NON_NUMBER:KX.INVALID_LENGTH_NON_NUMBER)}function FX$($){jC($,!1)}K8.checkLengthValue=FX$;function NX$($){jC($,!0)}K8.checkOffsetValue=NX$;function OX$($,X){if($<0||$>X.length)throw Error(KX.INVALID_TARGET_OFFSET)}K8.checkTargetOffset=OX$;function LX$($){return typeof $==="number"&&isFinite($)&&Math.floor($)===$}function AX$($){if(typeof BigInt>"u")throw Error("Platform does not support JS BigInt type.");if(typeof wC.Buffer.prototype[$]>"u")throw Error(`Platform does not support Buffer.prototype.${$}.`)}K8.bigIntAndBufferInt64Check=AX$});var DC=N((zL)=>{Object.defineProperty(zL,"__esModule",{value:!0});var V$=VC(),BC=4096,ZX$="utf8";class YL{constructor($){if(this.length=0,this._encoding=ZX$,this._writeOffset=0,this._readOffset=0,YL.isSmartBufferOptions($)){if($.encoding)V$.checkEncoding($.encoding),this._encoding=$.encoding;if($.size)if(V$.isFiniteInteger($.size)&&$.size>0)this._buff=Buffer.allocUnsafe($.size);else throw Error(V$.ERRORS.INVALID_SMARTBUFFER_SIZE);else if($.buff)if(Buffer.isBuffer($.buff))this._buff=$.buff,this.length=$.buff.length;else throw Error(V$.ERRORS.INVALID_SMARTBUFFER_BUFFER);else this._buff=Buffer.allocUnsafe(BC)}else{if(typeof $<"u")throw Error(V$.ERRORS.INVALID_SMARTBUFFER_OBJECT);this._buff=Buffer.allocUnsafe(BC)}}static fromSize($,X){return new this({size:$,encoding:X})}static fromBuffer($,X){return new this({buff:$,encoding:X})}static fromOptions($){return new this($)}static isSmartBufferOptions($){let X=$;return X&&(X.encoding!==void 0||X.size!==void 0||X.buff!==void 0)}readInt8($){return this._readNumberValue(Buffer.prototype.readInt8,1,$)}readInt16BE($){return this._readNumberValue(Buffer.prototype.readInt16BE,2,$)}readInt16LE($){return this._readNumberValue(Buffer.prototype.readInt16LE,2,$)}readInt32BE($){return this._readNumberValue(Buffer.prototype.readInt32BE,4,$)}readInt32LE($){return this._readNumberValue(Buffer.prototype.readInt32LE,4,$)}readBigInt64BE($){return V$.bigIntAndBufferInt64Check("readBigInt64BE"),this._readNumberValue(Buffer.prototype.readBigInt64BE,8,$)}readBigInt64LE($){return V$.bigIntAndBufferInt64Check("readBigInt64LE"),this._readNumberValue(Buffer.prototype.readBigInt64LE,8,$)}writeInt8($,X){return this._writeNumberValue(Buffer.prototype.writeInt8,1,$,X),this}insertInt8($,X){return this._insertNumberValue(Buffer.prototype.writeInt8,1,$,X)}writeInt16BE($,X){return this._writeNumberValue(Buffer.prototype.writeInt16BE,2,$,X)}insertInt16BE($,X){return this._insertNumberValue(Buffer.prototype.writeInt16BE,2,$,X)}writeInt16LE($,X){return this._writeNumberValue(Buffer.prototype.writeInt16LE,2,$,X)}insertInt16LE($,X){return this._insertNumberValue(Buffer.prototype.writeInt16LE,2,$,X)}writeInt32BE($,X){return this._writeNumberValue(Buffer.prototype.writeInt32BE,4,$,X)}insertInt32BE($,X){return this._insertNumberValue(Buffer.prototype.writeInt32BE,4,$,X)}writeInt32LE($,X){return this._writeNumberValue(Buffer.prototype.writeInt32LE,4,$,X)}insertInt32LE($,X){return this._insertNumberValue(Buffer.prototype.writeInt32LE,4,$,X)}writeBigInt64BE($,X){return V$.bigIntAndBufferInt64Check("writeBigInt64BE"),this._writeNumberValue(Buffer.prototype.writeBigInt64BE,8,$,X)}insertBigInt64BE($,X){return V$.bigIntAndBufferInt64Check("writeBigInt64BE"),this._insertNumberValue(Buffer.prototype.writeBigInt64BE,8,$,X)}writeBigInt64LE($,X){return V$.bigIntAndBufferInt64Check("writeBigInt64LE"),this._writeNumberValue(Buffer.prototype.writeBigInt64LE,8,$,X)}insertBigInt64LE($,X){return V$.bigIntAndBufferInt64Check("writeBigInt64LE"),this._insertNumberValue(Buffer.prototype.writeBigInt64LE,8,$,X)}readUInt8($){return this._readNumberValue(Buffer.prototype.readUInt8,1,$)}readUInt16BE($){return this._readNumberValue(Buffer.prototype.readUInt16BE,2,$)}readUInt16LE($){return this._readNumberValue(Buffer.prototype.readUInt16LE,2,$)}readUInt32BE($){return this._readNumberValue(Buffer.prototype.readUInt32BE,4,$)}readUInt32LE($){return this._readNumberValue(Buffer.prototype.readUInt32LE,4,$)}readBigUInt64BE($){return V$.bigIntAndBufferInt64Check("readBigUInt64BE"),this._readNumberValue(Buffer.prototype.readBigUInt64BE,8,$)}readBigUInt64LE($){return V$.bigIntAndBufferInt64Check("readBigUInt64LE"),this._readNumberValue(Buffer.prototype.readBigUInt64LE,8,$)}writeUInt8($,X){return this._writeNumberValue(Buffer.prototype.writeUInt8,1,$,X)}insertUInt8($,X){return this._insertNumberValue(Buffer.prototype.writeUInt8,1,$,X)}writeUInt16BE($,X){return this._writeNumberValue(Buffer.prototype.writeUInt16BE,2,$,X)}insertUInt16BE($,X){return this._insertNumberValue(Buffer.prototype.writeUInt16BE,2,$,X)}writeUInt16LE($,X){return this._writeNumberValue(Buffer.prototype.writeUInt16LE,2,$,X)}insertUInt16LE($,X){return this._insertNumberValue(Buffer.prototype.writeUInt16LE,2,$,X)}writeUInt32BE($,X){return this._writeNumberValue(Buffer.prototype.writeUInt32BE,4,$,X)}insertUInt32BE($,X){return this._insertNumberValue(Buffer.prototype.writeUInt32BE,4,$,X)}writeUInt32LE($,X){return this._writeNumberValue(Buffer.prototype.writeUInt32LE,4,$,X)}insertUInt32LE($,X){return this._insertNumberValue(Buffer.prototype.writeUInt32LE,4,$,X)}writeBigUInt64BE($,X){return V$.bigIntAndBufferInt64Check("writeBigUInt64BE"),this._writeNumberValue(Buffer.prototype.writeBigUInt64BE,8,$,X)}insertBigUInt64BE($,X){return V$.bigIntAndBufferInt64Check("writeBigUInt64BE"),this._insertNumberValue(Buffer.prototype.writeBigUInt64BE,8,$,X)}writeBigUInt64LE($,X){return V$.bigIntAndBufferInt64Check("writeBigUInt64LE"),this._writeNumberValue(Buffer.prototype.writeBigUInt64LE,8,$,X)}insertBigUInt64LE($,X){return V$.bigIntAndBufferInt64Check("writeBigUInt64LE"),this._insertNumberValue(Buffer.prototype.writeBigUInt64LE,8,$,X)}readFloatBE($){return this._readNumberValue(Buffer.prototype.readFloatBE,4,$)}readFloatLE($){return this._readNumberValue(Buffer.prototype.readFloatLE,4,$)}writeFloatBE($,X){return this._writeNumberValue(Buffer.prototype.writeFloatBE,4,$,X)}insertFloatBE($,X){return this._insertNumberValue(Buffer.prototype.writeFloatBE,4,$,X)}writeFloatLE($,X){return this._writeNumberValue(Buffer.prototype.writeFloatLE,4,$,X)}insertFloatLE($,X){return this._insertNumberValue(Buffer.prototype.writeFloatLE,4,$,X)}readDoubleBE($){return this._readNumberValue(Buffer.prototype.readDoubleBE,8,$)}readDoubleLE($){return this._readNumberValue(Buffer.prototype.readDoubleLE,8,$)}writeDoubleBE($,X){return this._writeNumberValue(Buffer.prototype.writeDoubleBE,8,$,X)}insertDoubleBE($,X){return this._insertNumberValue(Buffer.prototype.writeDoubleBE,8,$,X)}writeDoubleLE($,X){return this._writeNumberValue(Buffer.prototype.writeDoubleLE,8,$,X)}insertDoubleLE($,X){return this._insertNumberValue(Buffer.prototype.writeDoubleLE,8,$,X)}readString($,X){let Q;if(typeof $==="number")V$.checkLengthValue($),Q=Math.min($,this.length-this._readOffset);else X=$,Q=this.length-this._readOffset;if(typeof X<"u")V$.checkEncoding(X);let J=this._buff.slice(this._readOffset,this._readOffset+Q).toString(X||this._encoding);return this._readOffset+=Q,J}insertString($,X,Q){return V$.checkOffsetValue(X),this._handleString($,!0,X,Q)}writeString($,X,Q){return this._handleString($,!1,X,Q)}readStringNT($){if(typeof $<"u")V$.checkEncoding($);let X=this.length;for(let J=this._readOffset;Jthis.length)throw Error(V$.ERRORS.INVALID_READ_BEYOND_BOUNDS)}ensureInsertable($,X){if(V$.checkOffsetValue(X),this._ensureCapacity(this.length+$),Xthis.length)this.length=X+$;else this.length+=$}_ensureWriteable($,X){let Q=typeof X==="number"?X:this._writeOffset;if(this._ensureCapacity(Q+$),Q+$>this.length)this.length=Q+$}_ensureCapacity($){let X=this._buff.length;if($>X){let Q=this._buff,J=X*3/2+1;if(J<$)J=$;this._buff=Buffer.allocUnsafe(J),Q.copy(this._buff,0,0,X)}}_readNumberValue($,X,Q){this.ensureReadable(X,Q);let J=$.call(this._buff,typeof Q==="number"?Q:this._readOffset);if(typeof Q>"u")this._readOffset+=X;return J}_insertNumberValue($,X,Q,J){return V$.checkOffsetValue(J),this.ensureInsertable(X,J),$.call(this._buff,Q,J),this._writeOffset+=X,this}_writeNumberValue($,X,Q,J){if(typeof J==="number"){if(J<0)throw Error(V$.ERRORS.INVALID_WRITE_BEYOND_BOUNDS);V$.checkOffsetValue(J)}let v=typeof J==="number"?J:this._writeOffset;if(this._ensureWriteable(X,v),$.call(this._buff,Q,v),typeof J==="number")this._writeOffset=Math.max(this._writeOffset,v+X);else this._writeOffset+=X;return this}}zL.SmartBuffer=YL});var WL=N((r$)=>{Object.defineProperty(r$,"__esModule",{value:!0});r$.SOCKS5_NO_ACCEPTABLE_AUTH=r$.SOCKS5_CUSTOM_AUTH_END=r$.SOCKS5_CUSTOM_AUTH_START=r$.SOCKS_INCOMING_PACKET_SIZES=r$.SocksClientState=r$.Socks5Response=r$.Socks5HostType=r$.Socks5Auth=r$.Socks4Response=r$.SocksCommand=r$.ERRORS=r$.DEFAULT_TIMEOUT=void 0;var qX$=30000;r$.DEFAULT_TIMEOUT=qX$;var IX$={InvalidSocksCommand:"An invalid SOCKS command was provided. Valid options are connect, bind, and associate.",InvalidSocksCommandForOperation:"An invalid SOCKS command was provided. Only a subset of commands are supported for this operation.",InvalidSocksCommandChain:"An invalid SOCKS command was provided. Chaining currently only supports the connect command.",InvalidSocksClientOptionsDestination:"An invalid destination host was provided.",InvalidSocksClientOptionsExistingSocket:"An invalid existing socket was provided. This should be an instance of stream.Duplex.",InvalidSocksClientOptionsProxy:"Invalid SOCKS proxy details were provided.",InvalidSocksClientOptionsTimeout:"An invalid timeout value was provided. Please enter a value above 0 (in ms).",InvalidSocksClientOptionsProxiesLength:"At least two socks proxies must be provided for chaining.",InvalidSocksClientOptionsCustomAuthRange:"Custom auth must be a value between 0x80 and 0xFE.",InvalidSocksClientOptionsCustomAuthOptions:"When a custom_auth_method is provided, custom_auth_request_handler, custom_auth_response_size, and custom_auth_response_handler must also be provided and valid.",NegotiationError:"Negotiation error",SocketClosed:"Socket closed",ProxyConnectionTimedOut:"Proxy connection timed out",InternalError:"SocksClient internal error (this should not happen)",InvalidSocks4HandshakeResponse:"Received invalid Socks4 handshake response",Socks4ProxyRejectedConnection:"Socks4 Proxy rejected connection",InvalidSocks4IncomingConnectionResponse:"Socks4 invalid incoming connection response",Socks4ProxyRejectedIncomingBoundConnection:"Socks4 Proxy rejected incoming bound connection",InvalidSocks5InitialHandshakeResponse:"Received invalid Socks5 initial handshake response",InvalidSocks5IntiailHandshakeSocksVersion:"Received invalid Socks5 initial handshake (invalid socks version)",InvalidSocks5InitialHandshakeNoAcceptedAuthType:"Received invalid Socks5 initial handshake (no accepted authentication type)",InvalidSocks5InitialHandshakeUnknownAuthType:"Received invalid Socks5 initial handshake (unknown authentication type)",Socks5AuthenticationFailed:"Socks5 Authentication failed",InvalidSocks5FinalHandshake:"Received invalid Socks5 final handshake response",InvalidSocks5FinalHandshakeRejected:"Socks5 proxy rejected connection",InvalidSocks5IncomingConnectionResponse:"Received invalid Socks5 incoming connection response",Socks5ProxyRejectedIncomingBoundConnection:"Socks5 Proxy rejected incoming bound connection"};r$.ERRORS=IX$;var MX$={Socks5InitialHandshakeResponse:2,Socks5UserPassAuthenticationResponse:2,Socks5ResponseHeader:5,Socks5ResponseIPv4:10,Socks5ResponseIPv6:22,Socks5ResponseHostname:($)=>$+7,Socks4Response:8};r$.SOCKS_INCOMING_PACKET_SIZES=MX$;var FC;(function($){$[$.connect=1]="connect",$[$.bind=2]="bind",$[$.associate=3]="associate"})(FC||(r$.SocksCommand=FC={}));var NC;(function($){$[$.Granted=90]="Granted",$[$.Failed=91]="Failed",$[$.Rejected=92]="Rejected",$[$.RejectedIdent=93]="RejectedIdent"})(NC||(r$.Socks4Response=NC={}));var OC;(function($){$[$.NoAuth=0]="NoAuth",$[$.GSSApi=1]="GSSApi",$[$.UserPass=2]="UserPass"})(OC||(r$.Socks5Auth=OC={}));var xX$=128;r$.SOCKS5_CUSTOM_AUTH_START=xX$;var bX$=254;r$.SOCKS5_CUSTOM_AUTH_END=bX$;var iX$=255;r$.SOCKS5_NO_ACCEPTABLE_AUTH=iX$;var LC;(function($){$[$.Granted=0]="Granted",$[$.Failure=1]="Failure",$[$.NotAllowed=2]="NotAllowed",$[$.NetworkUnreachable=3]="NetworkUnreachable",$[$.HostUnreachable=4]="HostUnreachable",$[$.ConnectionRefused=5]="ConnectionRefused",$[$.TTLExpired=6]="TTLExpired",$[$.CommandNotSupported=7]="CommandNotSupported",$[$.AddressNotSupported=8]="AddressNotSupported"})(LC||(r$.Socks5Response=LC={}));var AC;(function($){$[$.IPv4=1]="IPv4",$[$.Hostname=3]="Hostname",$[$.IPv6=4]="IPv6"})(AC||(r$.Socks5HostType=AC={}));var ZC;(function($){$[$.Created=0]="Created",$[$.Connecting=1]="Connecting",$[$.Connected=2]="Connected",$[$.SentInitialHandshake=3]="SentInitialHandshake",$[$.ReceivedInitialHandshakeResponse=4]="ReceivedInitialHandshakeResponse",$[$.SentAuthentication=5]="SentAuthentication",$[$.ReceivedAuthenticationResponse=6]="ReceivedAuthenticationResponse",$[$.SentFinalHandshake=7]="SentFinalHandshake",$[$.ReceivedFinalResponse=8]="ReceivedFinalResponse",$[$.BoundWaitingForConnection=9]="BoundWaitingForConnection",$[$.Established=10]="Established",$[$.Disconnected=11]="Disconnected",$[$.Error=99]="Error"})(ZC||(r$.SocksClientState=ZC={}))});var GL=N((Nv)=>{Object.defineProperty(Nv,"__esModule",{value:!0});Nv.shuffleArray=Nv.SocksClientError=void 0;class qC extends Error{constructor($,X){super($);this.options=X}}Nv.SocksClientError=qC;function RX$($){for(let X=$.length-1;X>0;X--){let Q=Math.floor(Math.random()*(X+1));[$[X],$[Q]]=[$[Q],$[X]]}}Nv.shuffleArray=RX$});var Ow=N((wX)=>{Object.defineProperty(wX,"__esModule",{value:!0});wX.isInSubnet=EX$;wX.isCorrect=PX$;wX.numberToPaddedHex=IC;wX.stringToPaddedHex=TX$;wX.testBit=SX$;function EX$($){if(this.subnetMask<$.subnetMask)return!1;if(this.mask($.subnetMask)===$.mask())return!0;return!1}function PX$($){return function(){if(this.addressMinusSuffix!==this.correctForm())return!1;if(this.subnetMask===$&&!this.parsedSubnet)return!0;return this.parsedSubnet===String(this.subnetMask)}}function IC($){return $.toString(16).padStart(2,"0")}function TX$($){return IC(parseInt($,10))}function SX$($,X){let{length:Q}=$;if(X>Q)return!1;let J=Q-X;return $.substring(J,J+1)==="1"}});var UL=N((u0)=>{Object.defineProperty(u0,"__esModule",{value:!0});u0.RE_SUBNET_STRING=u0.RE_ADDRESS=u0.GROUPS=u0.BITS=void 0;u0.BITS=32;u0.GROUPS=4;u0.RE_ADDRESS=/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/g;u0.RE_SUBNET_STRING=/\/\d{1,2}$/});var Aw=N((Lw)=>{Object.defineProperty(Lw,"__esModule",{value:!0});Lw.AddressError=void 0;class MC extends Error{constructor($,X){super($);this.name="AddressError",this.parseMessage=X}}Lw.AddressError=MC});var KL=N((_0)=>{var CX$=_0&&_0.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),uX$=_0&&_0.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),xC=_0&&_0.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))CX$(X,$,Q)}return uX$(X,$),X};Object.defineProperty(_0,"__esModule",{value:!0});_0.Address4=void 0;var Ov=xC(Ow()),e1=xC(UL()),Fz=Aw();class W1{constructor($){this.groups=e1.GROUPS,this.parsedAddress=[],this.parsedSubnet="",this.subnet="/32",this.subnetMask=32,this.v4=!0,this.isCorrect=Ov.isCorrect(e1.BITS),this.isInSubnet=Ov.isInSubnet,this.address=$;let X=e1.RE_SUBNET_STRING.exec($);if(X){if(this.parsedSubnet=X[0].replace("/",""),this.subnetMask=parseInt(this.parsedSubnet,10),this.subnet=`/${this.subnetMask}`,this.subnetMask<0||this.subnetMask>e1.BITS)throw new Fz.AddressError("Invalid subnet mask.");$=$.replace(e1.RE_SUBNET_STRING,"")}this.addressMinusSuffix=$,this.parsedAddress=this.parse($)}static isValid($){try{return new W1($),!0}catch(X){return!1}}parse($){let X=$.split(".");if(!$.match(e1.RE_ADDRESS))throw new Fz.AddressError("Invalid IPv4 address.");return X}correctForm(){return this.parsedAddress.map(($)=>parseInt($,10)).join(".")}static fromHex($){let X=$.replace(/:/g,"").padStart(8,"0"),Q=[],J;for(J=0;J<8;J+=2){let v=X.slice(J,J+2);Q.push(parseInt(v,16))}return new W1(Q.join("."))}static fromInteger($){return W1.fromHex($.toString(16))}static fromArpa($){let Q=$.replace(/(\.in-addr\.arpa)?\.$/,"").split(".").reverse().join(".");return new W1(Q)}toHex(){return this.parsedAddress.map(($)=>Ov.stringToPaddedHex($)).join(":")}toArray(){return this.parsedAddress.map(($)=>parseInt($,10))}toGroup6(){let $=[],X;for(X=0;XOv.stringToPaddedHex($)).join("")}`)}_startAddress(){return BigInt(`0b${this.mask()+"0".repeat(e1.BITS-this.subnetMask)}`)}startAddress(){return W1.fromBigInt(this._startAddress())}startAddressExclusive(){let $=BigInt("1");return W1.fromBigInt(this._startAddress()+$)}_endAddress(){return BigInt(`0b${this.mask()+"1".repeat(e1.BITS-this.subnetMask)}`)}endAddress(){return W1.fromBigInt(this._endAddress())}endAddressExclusive(){let $=BigInt("1");return W1.fromBigInt(this._endAddress()-$)}static fromBigInt($){return W1.fromHex($.toString(16))}static fromByteArray($){if($.length!==4)throw new Fz.AddressError("IPv4 addresses require exactly 4 bytes");for(let X=0;X<$.length;X++)if(!Number.isInteger($[X])||$[X]<0||$[X]>255)throw new Fz.AddressError("All bytes must be integers between 0 and 255");return this.fromUnsignedByteArray($)}static fromUnsignedByteArray($){if($.length!==4)throw new Fz.AddressError("IPv4 addresses require exactly 4 bytes");let X=$.join(".");return new W1(X)}mask($){if($===void 0)$=this.subnetMask;return this.getBitsBase2(0,$)}getBitsBase2($,X){return this.binaryZeroPad().slice($,X)}reverseForm($){if(!$)$={};let X=this.correctForm().split(".").reverse().join(".");if($.omitSuffix)return X;return`${X}.in-addr.arpa.`}isMulticast(){return this.isInSubnet(new W1("224.0.0.0/4"))}binaryZeroPad(){return this.bigInt().toString(2).padStart(e1.BITS,"0")}groupForV6(){let $=this.parsedAddress;return this.address.replace(e1.RE_ADDRESS,`${$.slice(0,2).join(".")}.${$.slice(2,4).join(".")}`)}}_0.Address4=W1});var wL=N((G4)=>{Object.defineProperty(G4,"__esModule",{value:!0});G4.RE_URL_WITH_PORT=G4.RE_URL=G4.RE_ZONE_STRING=G4.RE_SUBNET_STRING=G4.RE_BAD_ADDRESS=G4.RE_BAD_CHARACTERS=G4.TYPES=G4.SCOPES=G4.GROUPS=G4.BITS=void 0;G4.BITS=128;G4.GROUPS=8;G4.SCOPES={0:"Reserved",1:"Interface local",2:"Link local",4:"Admin local",5:"Site local",8:"Organization local",14:"Global",15:"Reserved"};G4.TYPES={"ff01::1/128":"Multicast (All nodes on this interface)","ff01::2/128":"Multicast (All routers on this interface)","ff02::1/128":"Multicast (All nodes on this link)","ff02::2/128":"Multicast (All routers on this link)","ff05::2/128":"Multicast (All routers in this site)","ff02::5/128":"Multicast (OSPFv3 AllSPF routers)","ff02::6/128":"Multicast (OSPFv3 AllDR routers)","ff02::9/128":"Multicast (RIP routers)","ff02::a/128":"Multicast (EIGRP routers)","ff02::d/128":"Multicast (PIM routers)","ff02::16/128":"Multicast (MLDv2 reports)","ff01::fb/128":"Multicast (mDNSv6)","ff02::fb/128":"Multicast (mDNSv6)","ff05::fb/128":"Multicast (mDNSv6)","ff02::1:2/128":"Multicast (All DHCP servers and relay agents on this link)","ff05::1:2/128":"Multicast (All DHCP servers and relay agents in this site)","ff02::1:3/128":"Multicast (All DHCP servers on this link)","ff05::1:3/128":"Multicast (All DHCP servers in this site)","::/128":"Unspecified","::1/128":"Loopback","ff00::/8":"Multicast","fe80::/10":"Link-local unicast"};G4.RE_BAD_CHARACTERS=/([^0-9a-f:/%])/gi;G4.RE_BAD_ADDRESS=/([0-9a-f]{5,}|:{3,}|[^:]:$|^:[^:]|\/$)/gi;G4.RE_SUBNET_STRING=/\/\d{1,3}(?=%|$)/;G4.RE_ZONE_STRING=/%.*$/;G4.RE_URL=/^\[{0,1}([0-9a-f:]+)\]{0,1}/;G4.RE_URL_WITH_PORT=/\[([0-9a-f:]+)\]:([0-9]{1,5})/});var HL=N((Lv)=>{Object.defineProperty(Lv,"__esModule",{value:!0});Lv.spanAllZeroes=bC;Lv.spanAll=_X$;Lv.spanLeadingZeroes=yX$;Lv.simpleGroup=fX$;function bC($){return $.replace(/(0+)/g,'$1')}function _X$($,X=0){return $.split("").map((J,v)=>`${bC(J)}`).join("")}function iC($){return $.replace(/^(0+)/,'$1')}function yX$($){return $.split(":").map((Q)=>iC(Q)).join(":")}function fX$($,X=0){return $.split(":").map((J,v)=>{if(/group-v4/.test(J))return J;return`${iC(J)}`})}});var RC=N((g6)=>{var kX$=g6&&g6.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),nX$=g6&&g6.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),gX$=g6&&g6.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))kX$(X,$,Q)}return nX$(X,$),X};Object.defineProperty(g6,"__esModule",{value:!0});g6.ADDRESS_BOUNDARY=void 0;g6.groupPossibilities=qw;g6.padGroup=Zw;g6.simpleRegularExpression=mX$;g6.possibleElisions=hX$;var cX$=gX$(wL());function qw($){return`(${$.join("|")})`}function Zw($){if($.length<4)return`0{0,${4-$.length}}${$}`;return $}g6.ADDRESS_BOUNDARY="[^A-Fa-f0-9:]";function mX$($){let X=[];$.forEach((J,v)=>{if(parseInt(J,16)===0)X.push(v)});let Q=X.map((J)=>$.map((v,Y)=>{if(Y===J){let z=Y===0||Y===cX$.GROUPS-1?":":"";return qw([Zw(v),z])}return Zw(v)}).join(":"));return Q.push($.map(Zw).join(":")),qw(Q)}function hX$($,X,Q){let J=X?"":":",v=Q?"":":",Y=[];if(!X&&!Q)Y.push("::");if(X&&Q)Y.push("");if(Q&&!X||!Q&&X)Y.push(":");Y.push(`${J}(:0{1,4}){1,${$-1}}`),Y.push(`(0{1,4}:){1,${$-1}}${v}`),Y.push(`(0{1,4}:){${$-1}}0{1,4}`);for(let z=1;z<$-1;z++)for(let W=1;W<$-z;W++)Y.push(`(0{1,4}:){${W}}:(0{1,4}:){${$-W-z-1}}0{1,4}`);return qw(Y)}});var SC=N((y0)=>{var lX$=y0&&y0.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),pX$=y0&&y0.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),xw=y0&&y0.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))lX$(X,$,Q)}return pX$(X,$),X};Object.defineProperty(y0,"__esModule",{value:!0});y0.Address6=void 0;var EC=xw(Ow()),jL=xw(UL()),t$=xw(wL()),VL=xw(HL()),HX=KL(),jX=RC(),w8=Aw(),Iw=Ow();function Mw($){if(!$)throw Error("Assertion failed.")}function dX$($){let X=/(\d+)(\d{3})/;while(X.test($))$=$.replace(X,"$1,$2");return $}function oX$($){return $=$.replace(/^(0{1,})([1-9]+)$/,'$1$2'),$=$.replace(/^(0{1,})(0)$/,'$1$2'),$}function aX$($,X){let Q=[],J=[],v;for(v=0;v<$.length;v++)if(vX[1])J.push($[v]);return Q.concat(["compact"]).concat(J)}function PC($){return parseInt($,16).toString(16).padStart(4,"0")}function TC($){return $&255}class S4{constructor($,X){if(this.addressMinusSuffix="",this.parsedSubnet="",this.subnet="/128",this.subnetMask=128,this.v4=!1,this.zone="",this.isInSubnet=EC.isInSubnet,this.isCorrect=EC.isCorrect(t$.BITS),X===void 0)this.groups=t$.GROUPS;else this.groups=X;this.address=$;let Q=t$.RE_SUBNET_STRING.exec($);if(Q){if(this.parsedSubnet=Q[0].replace("/",""),this.subnetMask=parseInt(this.parsedSubnet,10),this.subnet=`/${this.subnetMask}`,Number.isNaN(this.subnetMask)||this.subnetMask<0||this.subnetMask>t$.BITS)throw new w8.AddressError("Invalid subnet mask.");$=$.replace(t$.RE_SUBNET_STRING,"")}else if(/\//.test($))throw new w8.AddressError("Invalid subnet mask.");let J=t$.RE_ZONE_STRING.exec($);if(J)this.zone=J[0],$=$.replace(t$.RE_ZONE_STRING,"");this.addressMinusSuffix=$,this.parsedAddress=this.parse(this.addressMinusSuffix)}static isValid($){try{return new S4($),!0}catch(X){return!1}}static fromBigInt($){let X=$.toString(16).padStart(32,"0"),Q=[],J;for(J=0;J65536)Q=null}else Q=null;return{address:new S4(X),port:Q}}static fromAddress4($){let X=new HX.Address4($),Q=t$.BITS-(jL.BITS-X.subnetMask);return new S4(`::ffff:${X.correctForm()}/${Q}`)}static fromArpa($){let X=$.replace(/(\.ip6\.arpa)?\.$/,""),Q=7;if(X.length!==63)throw new w8.AddressError("Invalid 'ip6.arpa' form.");let J=X.split(".").reverse();for(let v=Q;v>0;v--){let Y=v*4;J.splice(Y,0,":")}return X=J.join(""),new S4(X)}microsoftTranscription(){return`${this.correctForm().replace(/:/g,"-")}.ipv6-literal.net`}mask($=this.subnetMask){return this.getBitsBase2(0,$)}possibleSubnets($=128){let X=t$.BITS-this.subnetMask,Q=Math.abs($-t$.BITS),J=X-Q;if(J<0)return"0";return dX$((BigInt("2")**BigInt(J)).toString(10))}_startAddress(){return BigInt(`0b${this.mask()+"0".repeat(t$.BITS-this.subnetMask)}`)}startAddress(){return S4.fromBigInt(this._startAddress())}startAddressExclusive(){let $=BigInt("1");return S4.fromBigInt(this._startAddress()+$)}_endAddress(){return BigInt(`0b${this.mask()+"1".repeat(t$.BITS-this.subnetMask)}`)}endAddress(){return S4.fromBigInt(this._endAddress())}endAddressExclusive(){let $=BigInt("1");return S4.fromBigInt(this._endAddress()-$)}getScope(){let $=t$.SCOPES[parseInt(this.getBits(12,16).toString(10),10)];if(this.getType()==="Global unicast"&&$!=="Link local")$="Global";return $||"Unknown"}getType(){for(let $ of Object.keys(t$.TYPES))if(this.isInSubnet(new S4($)))return t$.TYPES[$];return"Global unicast"}getBits($,X){return BigInt(`0b${this.getBitsBase2($,X)}`)}getBitsBase2($,X){return this.binaryZeroPad().slice($,X)}getBitsBase16($,X){let Q=X-$;if(Q%4!==0)throw Error("Length of bits to retrieve must be divisible by four");return this.getBits($,X).toString(16).padStart(Q/4,"0")}getBitsPastSubnet(){return this.getBitsBase2(this.subnetMask,t$.BITS)}reverseForm($){if(!$)$={};let X=Math.floor(this.subnetMask/4),Q=this.canonicalForm().replace(/:/g,"").split("").slice(0,X).reverse().join(".");if(X>0){if($.omitSuffix)return Q;return`${Q}.ip6.arpa.`}if($.omitSuffix)return"";return"ip6.arpa."}correctForm(){let $,X=[],Q=0,J=[];for($=0;$0){if(Q>1)J.push([$-Q,$-1]);Q=0}}if(Q>1)J.push([this.parsedAddress.length-Q,this.parsedAddress.length-1]);let v=J.map((z)=>z[1]-z[0]+1);if(J.length>0){let z=v.indexOf(Math.max(...v));X=aX$(this.parsedAddress,J[z])}else X=this.parsedAddress;for($=0;$1?"s":""} detected in address: ${X.join("")}`,$.replace(t$.RE_BAD_CHARACTERS,'$1'));let Q=$.match(t$.RE_BAD_ADDRESS);if(Q)throw new w8.AddressError(`Address failed regex: ${Q.join("")}`,$.replace(t$.RE_BAD_ADDRESS,'$1'));let J=[],v=$.split("::");if(v.length===2){let Y=v[0].split(":"),z=v[1].split(":");if(Y.length===1&&Y[0]==="")Y=[];if(z.length===1&&z[0]==="")z=[];let W=this.groups-(Y.length+z.length);if(!W)throw new w8.AddressError("Error parsing groups");this.elidedGroups=W,this.elisionBegin=Y.length,this.elisionEnd=Y.length+this.elidedGroups,J=J.concat(Y);for(let G=0;GparseInt(Y,16).toString(16)),J.length!==this.groups)throw new w8.AddressError("Incorrect number of groups found");return J}canonicalForm(){return this.parsedAddress.map(PC).join(":")}decimal(){return this.parsedAddress.map(($)=>parseInt($,16).toString(10).padStart(5,"0")).join(":")}bigInt(){return BigInt(`0x${this.parsedAddress.map(PC).join("")}`)}to4(){let $=this.binaryZeroPad().split("");return HX.Address4.fromHex(BigInt(`0b${$.slice(96,128).join("")}`).toString(16))}to4in6(){let $=this.to4(),Q=new S4(this.parsedAddress.slice(0,6).join(":"),6).correctForm(),J="";if(!/:$/.test(Q))J=":";return Q+J+$.address}inspectTeredo(){let $=this.getBitsBase16(0,32),Q=(this.getBits(80,96)^BigInt("0xffff")).toString(),J=HX.Address4.fromHex(this.getBitsBase16(32,64)),v=this.getBits(96,128),Y=HX.Address4.fromHex((v^BigInt("0xffffffff")).toString(16)),z=this.getBitsBase2(64,80),W=(0,Iw.testBit)(z,15),G=(0,Iw.testBit)(z,14),U=(0,Iw.testBit)(z,8),w=(0,Iw.testBit)(z,9),H=BigInt(`0b${z.slice(2,6)+z.slice(8,16)}`).toString(10);return{prefix:`${$.slice(0,4)}:${$.slice(4,8)}`,server4:J.address,client4:Y.address,flags:z,coneNat:W,microsoft:{reserved:G,universalLocal:w,groupIndividual:U,nonce:H},udpPort:Q}}inspect6to4(){let $=this.getBitsBase16(0,16),X=HX.Address4.fromHex(this.getBitsBase16(16,48));return{prefix:$.slice(0,4),gateway:X.address}}to6to4(){if(!this.is4())return null;let $=["2002",this.getBitsBase16(96,112),this.getBitsBase16(112,128),"","/16"].join(":");return new S4($)}toByteArray(){let $=this.bigInt().toString(16),Q=`${"0".repeat($.length%2)}${$}`,J=[];for(let v=0,Y=Q.length;v=0;v--)Q+=J*BigInt($[v].toString(10)),J*=X;return S4.fromBigInt(Q)}isCanonical(){return this.addressMinusSuffix===this.canonicalForm()}isLinkLocal(){if(this.getBitsBase2(0,64)==="1111111010000000000000000000000000000000000000000000000000000000")return!0;return!1}isMulticast(){return this.getType()==="Multicast"}is4(){return this.v4}isTeredo(){return this.isInSubnet(new S4("2001::/32"))}is6to4(){return this.isInSubnet(new S4("2002::/16"))}isLoopback(){return this.getType()==="Loopback"}href($){if($===void 0)$="";else $=`:${$}`;return`http://[${this.correctForm()}]${$}/`}link($){if(!$)$={};if($.className===void 0)$.className="";if($.prefix===void 0)$.prefix="/#address=";if($.v4===void 0)$.v4=!1;let X=this.correctForm;if($.v4)X=this.to4in6;let Q=X.call(this);if($.className)return`${Q}`;return`${Q}`}group(){if(this.elidedGroups===0)return VL.simpleGroup(this.address).join(":");Mw(typeof this.elidedGroups==="number"),Mw(typeof this.elisionBegin==="number");let $=[],[X,Q]=this.address.split("::");if(X.length)$.push(...VL.simpleGroup(X));else $.push("");let J=["hover-group"];for(let v=this.elisionBegin;v`),Q.length)$.push(...VL.simpleGroup(Q,this.elisionEnd));else $.push("");if(this.is4())Mw(this.address4 instanceof HX.Address4),$.pop(),$.push(this.address4.groupForV6());return $.join(":")}regularExpressionString($=!1){let X=[],Q=new S4(this.correctForm());if(Q.elidedGroups===0)X.push((0,jX.simpleRegularExpression)(Q.parsedAddress));else if(Q.elidedGroups===t$.GROUPS)X.push((0,jX.possibleElisions)(t$.GROUPS));else{let J=Q.address.split("::");if(J[0].length)X.push((0,jX.simpleRegularExpression)(J[0].split(":")));if(Mw(typeof Q.elidedGroups==="number"),X.push((0,jX.possibleElisions)(Q.elidedGroups,J[0].length!==0,J[1].length!==0)),J[1].length)X.push((0,jX.simpleRegularExpression)(J[1].split(":")));X=[X.join(":")]}if(!$)X=["(?=^|",jX.ADDRESS_BOUNDARY,"|[^\\w\\:])(",...X,")(?=[^\\w\\:]|",jX.ADDRESS_BOUNDARY,"|$)"];return X.join("")}regularExpression($=!1){return new RegExp(this.regularExpressionString($),"i")}}y0.Address6=S4});var BL=N((z6)=>{var rX$=z6&&z6.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),tX$=z6&&z6.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),sX$=z6&&z6.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))rX$(X,$,Q)}return tX$(X,$),X};Object.defineProperty(z6,"__esModule",{value:!0});z6.v6=z6.AddressError=z6.Address6=z6.Address4=void 0;var eX$=KL();Object.defineProperty(z6,"Address4",{enumerable:!0,get:function(){return eX$.Address4}});var $Q$=SC();Object.defineProperty(z6,"Address6",{enumerable:!0,get:function(){return $Q$.Address6}});var XQ$=Aw();Object.defineProperty(z6,"AddressError",{enumerable:!0,get:function(){return XQ$.AddressError}});var QQ$=sX$(HL());z6.v6={helpers:QQ$}});var kC=N((Z1)=>{Object.defineProperty(Z1,"__esModule",{value:!0});Z1.ipToBuffer=Z1.int32ToIpv4=Z1.ipv4ToInt32=Z1.validateSocksClientChainOptions=Z1.validateSocksClientOptions=void 0;var I6=GL(),C4=WL(),vQ$=require("stream"),DL=BL(),CC=require("net");function JQ$($,X=["connect","bind","associate"]){if(!C4.SocksCommand[$.command])throw new I6.SocksClientError(C4.ERRORS.InvalidSocksCommand,$);if(X.indexOf($.command)===-1)throw new I6.SocksClientError(C4.ERRORS.InvalidSocksCommandForOperation,$);if(!_C($.destination))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsDestination,$);if(!yC($.proxy))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsProxy,$);if(uC($.proxy,$),$.timeout&&!fC($.timeout))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsTimeout,$);if($.existing_socket&&!($.existing_socket instanceof vQ$.Duplex))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsExistingSocket,$)}Z1.validateSocksClientOptions=JQ$;function YQ$($){if($.command!=="connect")throw new I6.SocksClientError(C4.ERRORS.InvalidSocksCommandChain,$);if(!_C($.destination))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsDestination,$);if(!($.proxies&&Array.isArray($.proxies)&&$.proxies.length>=2))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsProxiesLength,$);if($.proxies.forEach((X)=>{if(!yC(X))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsProxy,$);uC(X,$)}),$.timeout&&!fC($.timeout))throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsTimeout,$)}Z1.validateSocksClientChainOptions=YQ$;function uC($,X){if($.custom_auth_method!==void 0){if($.custom_auth_methodC4.SOCKS5_CUSTOM_AUTH_END)throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsCustomAuthRange,X);if($.custom_auth_request_handler===void 0||typeof $.custom_auth_request_handler!=="function")throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsCustomAuthOptions,X);if($.custom_auth_response_size===void 0)throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsCustomAuthOptions,X);if($.custom_auth_response_handler===void 0||typeof $.custom_auth_response_handler!=="function")throw new I6.SocksClientError(C4.ERRORS.InvalidSocksClientOptionsCustomAuthOptions,X)}}function _C($){return $&&typeof $.host==="string"&&Buffer.byteLength($.host)<256&&typeof $.port==="number"&&$.port>=0&&$.port<=65535}function yC($){return $&&(typeof $.host==="string"||typeof $.ipaddress==="string")&&typeof $.port==="number"&&$.port>=0&&$.port<=65535&&($.type===4||$.type===5)}function fC($){return typeof $==="number"&&$>0}function zQ$($){return new DL.Address4($).toArray().reduce((Q,J)=>(Q<<8)+J,0)>>>0}Z1.ipv4ToInt32=zQ$;function WQ$($){let X=$>>>24&255,Q=$>>>16&255,J=$>>>8&255,v=$&255;return[X,Q,J,v].join(".")}Z1.int32ToIpv4=WQ$;function GQ$($){if(CC.isIPv4($)){let X=new DL.Address4($);return Buffer.from(X.toArray())}else if(CC.isIPv6($)){let X=new DL.Address6($);return Buffer.from(X.canonicalForm().split(":").map((Q)=>Q.padStart(4,"0")).join(""),"hex")}else throw Error("Invalid IP address format")}Z1.ipToBuffer=GQ$});var gC=N((bw)=>{Object.defineProperty(bw,"__esModule",{value:!0});bw.ReceiveBuffer=void 0;class nC{constructor($=4096){this.buffer=Buffer.allocUnsafe($),this.offset=0,this.originalSize=$}get length(){return this.offset}append($){if(!Buffer.isBuffer($))throw Error("Attempted to append a non-buffer instance to ReceiveBuffer.");if(this.offset+$.length>=this.buffer.length){let X=this.buffer;this.buffer=Buffer.allocUnsafe(Math.max(this.buffer.length+this.originalSize,this.buffer.length+$.length)),X.copy(this.buffer)}return $.copy(this.buffer,this.offset),this.offset+=$.length}peek($){if($>this.offset)throw Error("Attempted to read beyond the bounds of the managed internal data.");return this.buffer.slice(0,$)}get($){if($>this.offset)throw Error("Attempted to read beyond the bounds of the managed internal data.");let X=Buffer.allocUnsafe($);return this.buffer.slice(0,$).copy(X),this.buffer.copyWithin(0,$,$+this.offset-$),this.offset-=$,X}}bw.ReceiveBuffer=nC});var cC=N((Q9)=>{var Av=Q9&&Q9.__awaiter||function($,X,Q,J){function v(Y){return Y instanceof Q?Y:new Q(function(z){z(Y)})}return new(Q||(Q=Promise))(function(Y,z){function W(w){try{U(J.next(w))}catch(H){z(H)}}function G(w){try{U(J.throw(w))}catch(H){z(H)}}function U(w){w.done?Y(w.value):v(w.value).then(W,G)}U((J=J.apply($,X||[])).next())})};Object.defineProperty(Q9,"__esModule",{value:!0});Q9.SocksClientError=Q9.SocksClient=void 0;var UQ$=require("events"),Zv=require("net"),c6=DC(),_=WL(),G1=kC(),KQ$=gC(),NL=GL();Object.defineProperty(Q9,"SocksClientError",{enumerable:!0,get:function(){return NL.SocksClientError}});var FL=BL();class iw extends UQ$.EventEmitter{constructor($){super();this.options=Object.assign({},$),(0,G1.validateSocksClientOptions)($),this.setState(_.SocksClientState.Created)}static createConnection($,X){return new Promise((Q,J)=>{try{(0,G1.validateSocksClientOptions)($,["connect"])}catch(Y){if(typeof X==="function")return X(Y),Q(Y);else return J(Y)}let v=new iw($);v.connect($.existing_socket),v.once("established",(Y)=>{if(v.removeAllListeners(),typeof X==="function")X(null,Y),Q(Y);else Q(Y)}),v.once("error",(Y)=>{if(v.removeAllListeners(),typeof X==="function")X(Y),Q(Y);else J(Y)})})}static createConnectionChain($,X){return new Promise((Q,J)=>Av(this,void 0,void 0,function*(){try{(0,G1.validateSocksClientChainOptions)($)}catch(v){if(typeof X==="function")return X(v),Q(v);else return J(v)}if($.randomizeChain)(0,NL.shuffleArray)($.proxies);try{let v;for(let Y=0;Y<$.proxies.length;Y++){let z=$.proxies[Y],W=Y===$.proxies.length-1?$.destination:{host:$.proxies[Y+1].host||$.proxies[Y+1].ipaddress,port:$.proxies[Y+1].port},G=yield iw.createConnection({command:"connect",proxy:z,destination:W,existing_socket:v});v=v||G.socket}if(typeof X==="function")X(null,{socket:v}),Q({socket:v});else Q({socket:v})}catch(v){if(typeof X==="function")X(v),Q(v);else J(v)}}))}static createUDPFrame($){let X=new c6.SmartBuffer;if(X.writeUInt16BE(0),X.writeUInt8($.frameNumber||0),Zv.isIPv4($.remoteHost.host))X.writeUInt8(_.Socks5HostType.IPv4),X.writeUInt32BE((0,G1.ipv4ToInt32)($.remoteHost.host));else if(Zv.isIPv6($.remoteHost.host))X.writeUInt8(_.Socks5HostType.IPv6),X.writeBuffer((0,G1.ipToBuffer)($.remoteHost.host));else X.writeUInt8(_.Socks5HostType.Hostname),X.writeUInt8(Buffer.byteLength($.remoteHost.host)),X.writeString($.remoteHost.host);return X.writeUInt16BE($.remoteHost.port),X.writeBuffer($.data),X.toBuffer()}static parseUDPFrame($){let X=c6.SmartBuffer.fromBuffer($);X.readOffset=2;let Q=X.readUInt8(),J=X.readUInt8(),v;if(J===_.Socks5HostType.IPv4)v=(0,G1.int32ToIpv4)(X.readUInt32BE());else if(J===_.Socks5HostType.IPv6)v=FL.Address6.fromByteArray(Array.from(X.readBuffer(16))).canonicalForm();else v=X.readString(X.readUInt8());let Y=X.readUInt16BE();return{frameNumber:Q,remoteHost:{host:v,port:Y},data:X.readBuffer()}}setState($){if(this.state!==_.SocksClientState.Error)this.state=$}connect($){this.onDataReceived=(Q)=>this.onDataReceivedHandler(Q),this.onClose=()=>this.onCloseHandler(),this.onError=(Q)=>this.onErrorHandler(Q),this.onConnect=()=>this.onConnectHandler();let X=setTimeout(()=>this.onEstablishedTimeout(),this.options.timeout||_.DEFAULT_TIMEOUT);if(X.unref&&typeof X.unref==="function")X.unref();if($)this.socket=$;else this.socket=new Zv.Socket;if(this.socket.once("close",this.onClose),this.socket.once("error",this.onError),this.socket.once("connect",this.onConnect),this.socket.on("data",this.onDataReceived),this.setState(_.SocksClientState.Connecting),this.receiveBuffer=new KQ$.ReceiveBuffer,$)this.socket.emit("connect");else if(this.socket.connect(this.getSocketOptions()),this.options.set_tcp_nodelay!==void 0&&this.options.set_tcp_nodelay!==null)this.socket.setNoDelay(!!this.options.set_tcp_nodelay);this.prependOnceListener("established",(Q)=>{setImmediate(()=>{if(this.receiveBuffer.length>0){let J=this.receiveBuffer.get(this.receiveBuffer.length);Q.socket.emit("data",J)}Q.socket.resume()})})}getSocketOptions(){return Object.assign(Object.assign({},this.options.socket_options),{host:this.options.proxy.host||this.options.proxy.ipaddress,port:this.options.proxy.port})}onEstablishedTimeout(){if(this.state!==_.SocksClientState.Established&&this.state!==_.SocksClientState.BoundWaitingForConnection)this.closeSocket(_.ERRORS.ProxyConnectionTimedOut)}onConnectHandler(){if(this.setState(_.SocksClientState.Connected),this.options.proxy.type===4)this.sendSocks4InitialHandshake();else this.sendSocks5InitialHandshake();this.setState(_.SocksClientState.SentInitialHandshake)}onDataReceivedHandler($){this.receiveBuffer.append($),this.processData()}processData(){while(this.state!==_.SocksClientState.Established&&this.state!==_.SocksClientState.Error&&this.receiveBuffer.length>=this.nextRequiredPacketBufferSize)if(this.state===_.SocksClientState.SentInitialHandshake)if(this.options.proxy.type===4)this.handleSocks4FinalHandshakeResponse();else this.handleInitialSocks5HandshakeResponse();else if(this.state===_.SocksClientState.SentAuthentication)this.handleInitialSocks5AuthenticationHandshakeResponse();else if(this.state===_.SocksClientState.SentFinalHandshake)this.handleSocks5FinalHandshakeResponse();else if(this.state===_.SocksClientState.BoundWaitingForConnection)if(this.options.proxy.type===4)this.handleSocks4IncomingConnectionResponse();else this.handleSocks5IncomingConnectionResponse();else{this.closeSocket(_.ERRORS.InternalError);break}}onCloseHandler(){this.closeSocket(_.ERRORS.SocketClosed)}onErrorHandler($){this.closeSocket($.message)}removeInternalSocketHandlers(){this.socket.pause(),this.socket.removeListener("data",this.onDataReceived),this.socket.removeListener("close",this.onClose),this.socket.removeListener("error",this.onError),this.socket.removeListener("connect",this.onConnect)}closeSocket($){if(this.state!==_.SocksClientState.Error)this.setState(_.SocksClientState.Error),this.socket.destroy(),this.removeInternalSocketHandlers(),this.emit("error",new NL.SocksClientError($,this.options))}sendSocks4InitialHandshake(){let $=this.options.proxy.userId||"",X=new c6.SmartBuffer;if(X.writeUInt8(4),X.writeUInt8(_.SocksCommand[this.options.command]),X.writeUInt16BE(this.options.destination.port),Zv.isIPv4(this.options.destination.host))X.writeBuffer((0,G1.ipToBuffer)(this.options.destination.host)),X.writeStringNT($);else X.writeUInt8(0),X.writeUInt8(0),X.writeUInt8(0),X.writeUInt8(1),X.writeStringNT($),X.writeStringNT(this.options.destination.host);this.nextRequiredPacketBufferSize=_.SOCKS_INCOMING_PACKET_SIZES.Socks4Response,this.socket.write(X.toBuffer())}handleSocks4FinalHandshakeResponse(){let $=this.receiveBuffer.get(8);if($[1]!==_.Socks4Response.Granted)this.closeSocket(`${_.ERRORS.Socks4ProxyRejectedConnection} - (${_.Socks4Response[$[1]]})`);else if(_.SocksCommand[this.options.command]===_.SocksCommand.bind){let X=c6.SmartBuffer.fromBuffer($);X.readOffset=2;let Q={port:X.readUInt16BE(),host:(0,G1.int32ToIpv4)(X.readUInt32BE())};if(Q.host==="0.0.0.0")Q.host=this.options.proxy.ipaddress;this.setState(_.SocksClientState.BoundWaitingForConnection),this.emit("bound",{remoteHost:Q,socket:this.socket})}else this.setState(_.SocksClientState.Established),this.removeInternalSocketHandlers(),this.emit("established",{socket:this.socket})}handleSocks4IncomingConnectionResponse(){let $=this.receiveBuffer.get(8);if($[1]!==_.Socks4Response.Granted)this.closeSocket(`${_.ERRORS.Socks4ProxyRejectedIncomingBoundConnection} - (${_.Socks4Response[$[1]]})`);else{let X=c6.SmartBuffer.fromBuffer($);X.readOffset=2;let Q={port:X.readUInt16BE(),host:(0,G1.int32ToIpv4)(X.readUInt32BE())};this.setState(_.SocksClientState.Established),this.removeInternalSocketHandlers(),this.emit("established",{remoteHost:Q,socket:this.socket})}}sendSocks5InitialHandshake(){let $=new c6.SmartBuffer,X=[_.Socks5Auth.NoAuth];if(this.options.proxy.userId||this.options.proxy.password)X.push(_.Socks5Auth.UserPass);if(this.options.proxy.custom_auth_method!==void 0)X.push(this.options.proxy.custom_auth_method);$.writeUInt8(5),$.writeUInt8(X.length);for(let Q of X)$.writeUInt8(Q);this.nextRequiredPacketBufferSize=_.SOCKS_INCOMING_PACKET_SIZES.Socks5InitialHandshakeResponse,this.socket.write($.toBuffer()),this.setState(_.SocksClientState.SentInitialHandshake)}handleInitialSocks5HandshakeResponse(){let $=this.receiveBuffer.get(2);if($[0]!==5)this.closeSocket(_.ERRORS.InvalidSocks5IntiailHandshakeSocksVersion);else if($[1]===_.SOCKS5_NO_ACCEPTABLE_AUTH)this.closeSocket(_.ERRORS.InvalidSocks5InitialHandshakeNoAcceptedAuthType);else if($[1]===_.Socks5Auth.NoAuth)this.socks5ChosenAuthType=_.Socks5Auth.NoAuth,this.sendSocks5CommandRequest();else if($[1]===_.Socks5Auth.UserPass)this.socks5ChosenAuthType=_.Socks5Auth.UserPass,this.sendSocks5UserPassAuthentication();else if($[1]===this.options.proxy.custom_auth_method)this.socks5ChosenAuthType=this.options.proxy.custom_auth_method,this.sendSocks5CustomAuthentication();else this.closeSocket(_.ERRORS.InvalidSocks5InitialHandshakeUnknownAuthType)}sendSocks5UserPassAuthentication(){let $=this.options.proxy.userId||"",X=this.options.proxy.password||"",Q=new c6.SmartBuffer;Q.writeUInt8(1),Q.writeUInt8(Buffer.byteLength($)),Q.writeString($),Q.writeUInt8(Buffer.byteLength(X)),Q.writeString(X),this.nextRequiredPacketBufferSize=_.SOCKS_INCOMING_PACKET_SIZES.Socks5UserPassAuthenticationResponse,this.socket.write(Q.toBuffer()),this.setState(_.SocksClientState.SentAuthentication)}sendSocks5CustomAuthentication(){return Av(this,void 0,void 0,function*(){this.nextRequiredPacketBufferSize=this.options.proxy.custom_auth_response_size,this.socket.write(yield this.options.proxy.custom_auth_request_handler()),this.setState(_.SocksClientState.SentAuthentication)})}handleSocks5CustomAuthHandshakeResponse($){return Av(this,void 0,void 0,function*(){return yield this.options.proxy.custom_auth_response_handler($)})}handleSocks5AuthenticationNoAuthHandshakeResponse($){return Av(this,void 0,void 0,function*(){return $[1]===0})}handleSocks5AuthenticationUserPassHandshakeResponse($){return Av(this,void 0,void 0,function*(){return $[1]===0})}handleInitialSocks5AuthenticationHandshakeResponse(){return Av(this,void 0,void 0,function*(){this.setState(_.SocksClientState.ReceivedAuthenticationResponse);let $=!1;if(this.socks5ChosenAuthType===_.Socks5Auth.NoAuth)$=yield this.handleSocks5AuthenticationNoAuthHandshakeResponse(this.receiveBuffer.get(2));else if(this.socks5ChosenAuthType===_.Socks5Auth.UserPass)$=yield this.handleSocks5AuthenticationUserPassHandshakeResponse(this.receiveBuffer.get(2));else if(this.socks5ChosenAuthType===this.options.proxy.custom_auth_method)$=yield this.handleSocks5CustomAuthHandshakeResponse(this.receiveBuffer.get(this.options.proxy.custom_auth_response_size));if(!$)this.closeSocket(_.ERRORS.Socks5AuthenticationFailed);else this.sendSocks5CommandRequest()})}sendSocks5CommandRequest(){let $=new c6.SmartBuffer;if($.writeUInt8(5),$.writeUInt8(_.SocksCommand[this.options.command]),$.writeUInt8(0),Zv.isIPv4(this.options.destination.host))$.writeUInt8(_.Socks5HostType.IPv4),$.writeBuffer((0,G1.ipToBuffer)(this.options.destination.host));else if(Zv.isIPv6(this.options.destination.host))$.writeUInt8(_.Socks5HostType.IPv6),$.writeBuffer((0,G1.ipToBuffer)(this.options.destination.host));else $.writeUInt8(_.Socks5HostType.Hostname),$.writeUInt8(this.options.destination.host.length),$.writeString(this.options.destination.host);$.writeUInt16BE(this.options.destination.port),this.nextRequiredPacketBufferSize=_.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseHeader,this.socket.write($.toBuffer()),this.setState(_.SocksClientState.SentFinalHandshake)}handleSocks5FinalHandshakeResponse(){let $=this.receiveBuffer.peek(5);if($[0]!==5||$[1]!==_.Socks5Response.Granted)this.closeSocket(`${_.ERRORS.InvalidSocks5FinalHandshakeRejected} - ${_.Socks5Response[$[1]]}`);else{let X=$[3],Q,J;if(X===_.Socks5HostType.IPv4){let v=_.SOCKS_INCOMING_PACKET_SIZES.Socks5ResponseIPv4;if(this.receiveBuffer.length{var wQ$=VX&&VX.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),HQ$=VX&&VX.__exportStar||function($,X){for(var Q in $)if(Q!=="default"&&!Object.prototype.hasOwnProperty.call(X,Q))wQ$(X,$,Q)};Object.defineProperty(VX,"__esModule",{value:!0});HQ$(cC(),VX)});var hC=N((q1)=>{var jQ$=q1&&q1.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),VQ$=q1&&q1.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),OL=q1&&q1.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))jQ$(X,$,Q)}return VQ$(X,$),X},BQ$=q1&&q1.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(q1,"__esModule",{value:!0});q1.SocksProxyAgent=void 0;var DQ$=mC(),FQ$=Dw(),NQ$=BQ$(W4()),OQ$=OL(require("dns")),LQ$=OL(require("net")),AQ$=OL(require("tls")),ZQ$=require("url"),Rw=(0,NQ$.default)("socks-proxy-agent"),qQ$=($)=>{if($.servername===void 0&&$.host&&!LQ$.isIP($.host))return{...$,servername:$.host};return $};function IQ$($){let X=!1,Q=5,J=$.hostname,v=parseInt($.port,10)||1080;switch($.protocol.replace(":","")){case"socks4":X=!0,Q=4;break;case"socks4a":Q=4;break;case"socks5":X=!0,Q=5;break;case"socks":Q=5;break;case"socks5h":Q=5;break;default:throw TypeError(`A "socks" protocol must be specified! Got: ${String($.protocol)}`)}let Y={host:J,port:v,type:Q};if($.username)Object.defineProperty(Y,"userId",{value:decodeURIComponent($.username),enumerable:!1});if($.password!=null)Object.defineProperty(Y,"password",{value:decodeURIComponent($.password),enumerable:!1});return{lookup:X,proxy:Y}}class LL extends FQ$.Agent{constructor($,X){super(X);let Q=typeof $==="string"?new ZQ$.URL($):$,{proxy:J,lookup:v}=IQ$(Q);this.shouldLookup=v,this.proxy=J,this.timeout=X?.timeout??null,this.socketOptions=X?.socketOptions??null}async connect($,X){let{shouldLookup:Q,proxy:J,timeout:v}=this;if(!X.host)throw Error("No `host` defined!");let{host:Y}=X,{port:z,lookup:W=OQ$.lookup}=X;if(Q)Y=await new Promise((H,j)=>{W(Y,{},(V,B)=>{if(V)j(V);else H(B)})});let G={proxy:J,destination:{host:Y,port:typeof z==="number"?z:parseInt(z,10)},command:"connect",timeout:v??void 0,socket_options:this.socketOptions??void 0},U=(H)=>{if($.destroy(),w.destroy(),H)H.destroy()};Rw("Creating socks proxy connection: %o",G);let{socket:w}=await DQ$.SocksClient.createConnection(G);if(Rw("Successfully created socks proxy connection"),v!==null)w.setTimeout(v),w.on("timeout",()=>U());if(X.secureEndpoint){Rw("Upgrading socket connection to TLS");let H=AQ$.connect({...MQ$(qQ$(X),"host","path","port"),socket:w});return H.once("error",(j)=>{Rw("Socket TLS error",j.message),U(H)}),H}return w}}LL.protocols=["socks","socks4","socks4a","socks5","socks5h"];q1.SocksProxyAgent=LL;function MQ$($,...X){let Q={},J;for(J in $)if(!X.includes(J))Q[J]=$[J];return Q}});var nu=N((NE$,gL)=>{gL.exports=ku;gL.exports.preferredCharsets=ku;var av$=/^\s*([^\s;]+)\s*(?:;(.*))?$/;function rv$($){var X=$.split(",");for(var Q=0,J=0;Q0}});var lu=N((OE$,cL)=>{cL.exports=hu;cL.exports.preferredEncodings=hu;var XJ$=/^\s*([^\s;]+)\s*(?:;(.*))?$/;function QJ$($){var X=$.split(","),Q=!1,J=1;for(var v=0,Y=0;v0}});var ru=N((LE$,mL)=>{mL.exports=au;mL.exports.preferredLanguages=au;var zJ$=/^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/;function WJ$($){var X=$.split(",");for(var Q=0,J=0;Q0}});var Q_=N((AE$,hL)=>{hL.exports=$_;hL.exports.preferredMediaTypes=$_;var wJ$=/^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/;function HJ$($){var X=FJ$($);for(var Q=0,J=0;Q0)if(Y.every(function(z){return X.params[z]=="*"||(X.params[z]||"").toLowerCase()==(J.params[z]||"").toLowerCase()}))v|=1;else return null;return{i:Q,o:X.i,q:X.q,s:v}}function $_($,X){var Q=HJ$($===void 0?"*/*":$||"");if(!X)return Q.filter(su).sort(tu).map(BJ$);var J=X.map(function(Y,z){return jJ$(Y,Q,z)});return J.filter(su).sort(tu).map(function(Y){return X[J.indexOf(Y)]})}function tu($,X){return X.q-$.q||X.s-$.s||$.o-X.o||$.i-X.i||0}function BJ$($){return $.type+"/"+$.subtype}function su($){return $.q>0}function X_($){var X=0,Q=0;while((Q=$.indexOf('"',Q))!==-1)X++,Q++;return X}function DJ$($){var X=$.indexOf("="),Q,J;if(X===-1)Q=$;else Q=$.substr(0,X),J=$.substr(X+1);return[Q,J]}function FJ$($){var X=$.split(",");for(var Q=1,J=0;Q{/*! + * negotiator + * Copyright(c) 2012 Federico Romero + * Copyright(c) 2012-2014 Isaac Z. Schlueter + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */var OJ$=nu(),LJ$=lu(),AJ$=ru(),ZJ$=Q_();lL.exports=n$;lL.exports.Negotiator=n$;function n$($){if(!(this instanceof n$))return new n$($);this.request=$}n$.prototype.charset=function(X){var Q=this.charsets(X);return Q&&Q[0]};n$.prototype.charsets=function(X){return OJ$(this.request.headers["accept-charset"],X)};n$.prototype.encoding=function(X){var Q=this.encodings(X);return Q&&Q[0]};n$.prototype.encodings=function(X){return LJ$(this.request.headers["accept-encoding"],X)};n$.prototype.language=function(X){var Q=this.languages(X);return Q&&Q[0]};n$.prototype.languages=function(X){return AJ$(this.request.headers["accept-language"],X)};n$.prototype.mediaType=function(X){var Q=this.mediaTypes(X);return Q&&Q[0]};n$.prototype.mediaTypes=function(X){return ZJ$(this.request.headers.accept,X)};n$.prototype.preferredCharset=n$.prototype.charset;n$.prototype.preferredCharsets=n$.prototype.charsets;n$.prototype.preferredEncoding=n$.prototype.encoding;n$.prototype.preferredEncodings=n$.prototype.encodings;n$.prototype.preferredLanguage=n$.prototype.language;n$.prototype.preferredLanguages=n$.prototype.languages;n$.prototype.preferredMediaType=n$.prototype.mediaType;n$.prototype.preferredMediaTypes=n$.prototype.mediaTypes});var J_=N((qE$,qJ$)=>{qJ$.exports={"application/1d-interleaved-parityfec":{source:"iana"},"application/3gpdash-qoe-report+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/3gpp-ims+xml":{source:"iana",compressible:!0},"application/3gpphal+json":{source:"iana",compressible:!0},"application/3gpphalforms+json":{source:"iana",compressible:!0},"application/a2l":{source:"iana"},"application/ace+cbor":{source:"iana"},"application/activemessage":{source:"iana"},"application/activity+json":{source:"iana",compressible:!0},"application/alto-costmap+json":{source:"iana",compressible:!0},"application/alto-costmapfilter+json":{source:"iana",compressible:!0},"application/alto-directory+json":{source:"iana",compressible:!0},"application/alto-endpointcost+json":{source:"iana",compressible:!0},"application/alto-endpointcostparams+json":{source:"iana",compressible:!0},"application/alto-endpointprop+json":{source:"iana",compressible:!0},"application/alto-endpointpropparams+json":{source:"iana",compressible:!0},"application/alto-error+json":{source:"iana",compressible:!0},"application/alto-networkmap+json":{source:"iana",compressible:!0},"application/alto-networkmapfilter+json":{source:"iana",compressible:!0},"application/alto-updatestreamcontrol+json":{source:"iana",compressible:!0},"application/alto-updatestreamparams+json":{source:"iana",compressible:!0},"application/aml":{source:"iana"},"application/andrew-inset":{source:"iana",extensions:["ez"]},"application/applefile":{source:"iana"},"application/applixware":{source:"apache",extensions:["aw"]},"application/at+jwt":{source:"iana"},"application/atf":{source:"iana"},"application/atfx":{source:"iana"},"application/atom+xml":{source:"iana",compressible:!0,extensions:["atom"]},"application/atomcat+xml":{source:"iana",compressible:!0,extensions:["atomcat"]},"application/atomdeleted+xml":{source:"iana",compressible:!0,extensions:["atomdeleted"]},"application/atomicmail":{source:"iana"},"application/atomsvc+xml":{source:"iana",compressible:!0,extensions:["atomsvc"]},"application/atsc-dwd+xml":{source:"iana",compressible:!0,extensions:["dwd"]},"application/atsc-dynamic-event-message":{source:"iana"},"application/atsc-held+xml":{source:"iana",compressible:!0,extensions:["held"]},"application/atsc-rdt+json":{source:"iana",compressible:!0},"application/atsc-rsat+xml":{source:"iana",compressible:!0,extensions:["rsat"]},"application/atxml":{source:"iana"},"application/auth-policy+xml":{source:"iana",compressible:!0},"application/bacnet-xdd+zip":{source:"iana",compressible:!1},"application/batch-smtp":{source:"iana"},"application/bdoc":{compressible:!1,extensions:["bdoc"]},"application/beep+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/calendar+json":{source:"iana",compressible:!0},"application/calendar+xml":{source:"iana",compressible:!0,extensions:["xcs"]},"application/call-completion":{source:"iana"},"application/cals-1840":{source:"iana"},"application/captive+json":{source:"iana",compressible:!0},"application/cbor":{source:"iana"},"application/cbor-seq":{source:"iana"},"application/cccex":{source:"iana"},"application/ccmp+xml":{source:"iana",compressible:!0},"application/ccxml+xml":{source:"iana",compressible:!0,extensions:["ccxml"]},"application/cdfx+xml":{source:"iana",compressible:!0,extensions:["cdfx"]},"application/cdmi-capability":{source:"iana",extensions:["cdmia"]},"application/cdmi-container":{source:"iana",extensions:["cdmic"]},"application/cdmi-domain":{source:"iana",extensions:["cdmid"]},"application/cdmi-object":{source:"iana",extensions:["cdmio"]},"application/cdmi-queue":{source:"iana",extensions:["cdmiq"]},"application/cdni":{source:"iana"},"application/cea":{source:"iana"},"application/cea-2018+xml":{source:"iana",compressible:!0},"application/cellml+xml":{source:"iana",compressible:!0},"application/cfw":{source:"iana"},"application/city+json":{source:"iana",compressible:!0},"application/clr":{source:"iana"},"application/clue+xml":{source:"iana",compressible:!0},"application/clue_info+xml":{source:"iana",compressible:!0},"application/cms":{source:"iana"},"application/cnrp+xml":{source:"iana",compressible:!0},"application/coap-group+json":{source:"iana",compressible:!0},"application/coap-payload":{source:"iana"},"application/commonground":{source:"iana"},"application/conference-info+xml":{source:"iana",compressible:!0},"application/cose":{source:"iana"},"application/cose-key":{source:"iana"},"application/cose-key-set":{source:"iana"},"application/cpl+xml":{source:"iana",compressible:!0,extensions:["cpl"]},"application/csrattrs":{source:"iana"},"application/csta+xml":{source:"iana",compressible:!0},"application/cstadata+xml":{source:"iana",compressible:!0},"application/csvm+json":{source:"iana",compressible:!0},"application/cu-seeme":{source:"apache",extensions:["cu"]},"application/cwt":{source:"iana"},"application/cybercash":{source:"iana"},"application/dart":{compressible:!0},"application/dash+xml":{source:"iana",compressible:!0,extensions:["mpd"]},"application/dash-patch+xml":{source:"iana",compressible:!0,extensions:["mpp"]},"application/dashdelta":{source:"iana"},"application/davmount+xml":{source:"iana",compressible:!0,extensions:["davmount"]},"application/dca-rft":{source:"iana"},"application/dcd":{source:"iana"},"application/dec-dx":{source:"iana"},"application/dialog-info+xml":{source:"iana",compressible:!0},"application/dicom":{source:"iana"},"application/dicom+json":{source:"iana",compressible:!0},"application/dicom+xml":{source:"iana",compressible:!0},"application/dii":{source:"iana"},"application/dit":{source:"iana"},"application/dns":{source:"iana"},"application/dns+json":{source:"iana",compressible:!0},"application/dns-message":{source:"iana"},"application/docbook+xml":{source:"apache",compressible:!0,extensions:["dbk"]},"application/dots+cbor":{source:"iana"},"application/dskpp+xml":{source:"iana",compressible:!0},"application/dssc+der":{source:"iana",extensions:["dssc"]},"application/dssc+xml":{source:"iana",compressible:!0,extensions:["xdssc"]},"application/dvcs":{source:"iana"},"application/ecmascript":{source:"iana",compressible:!0,extensions:["es","ecma"]},"application/edi-consent":{source:"iana"},"application/edi-x12":{source:"iana",compressible:!1},"application/edifact":{source:"iana",compressible:!1},"application/efi":{source:"iana"},"application/elm+json":{source:"iana",charset:"UTF-8",compressible:!0},"application/elm+xml":{source:"iana",compressible:!0},"application/emergencycalldata.cap+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/emergencycalldata.comment+xml":{source:"iana",compressible:!0},"application/emergencycalldata.control+xml":{source:"iana",compressible:!0},"application/emergencycalldata.deviceinfo+xml":{source:"iana",compressible:!0},"application/emergencycalldata.ecall.msd":{source:"iana"},"application/emergencycalldata.providerinfo+xml":{source:"iana",compressible:!0},"application/emergencycalldata.serviceinfo+xml":{source:"iana",compressible:!0},"application/emergencycalldata.subscriberinfo+xml":{source:"iana",compressible:!0},"application/emergencycalldata.veds+xml":{source:"iana",compressible:!0},"application/emma+xml":{source:"iana",compressible:!0,extensions:["emma"]},"application/emotionml+xml":{source:"iana",compressible:!0,extensions:["emotionml"]},"application/encaprtp":{source:"iana"},"application/epp+xml":{source:"iana",compressible:!0},"application/epub+zip":{source:"iana",compressible:!1,extensions:["epub"]},"application/eshop":{source:"iana"},"application/exi":{source:"iana",extensions:["exi"]},"application/expect-ct-report+json":{source:"iana",compressible:!0},"application/express":{source:"iana",extensions:["exp"]},"application/fastinfoset":{source:"iana"},"application/fastsoap":{source:"iana"},"application/fdt+xml":{source:"iana",compressible:!0,extensions:["fdt"]},"application/fhir+json":{source:"iana",charset:"UTF-8",compressible:!0},"application/fhir+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/fido.trusted-apps+json":{compressible:!0},"application/fits":{source:"iana"},"application/flexfec":{source:"iana"},"application/font-sfnt":{source:"iana"},"application/font-tdpfr":{source:"iana",extensions:["pfr"]},"application/font-woff":{source:"iana",compressible:!1},"application/framework-attributes+xml":{source:"iana",compressible:!0},"application/geo+json":{source:"iana",compressible:!0,extensions:["geojson"]},"application/geo+json-seq":{source:"iana"},"application/geopackage+sqlite3":{source:"iana"},"application/geoxacml+xml":{source:"iana",compressible:!0},"application/gltf-buffer":{source:"iana"},"application/gml+xml":{source:"iana",compressible:!0,extensions:["gml"]},"application/gpx+xml":{source:"apache",compressible:!0,extensions:["gpx"]},"application/gxf":{source:"apache",extensions:["gxf"]},"application/gzip":{source:"iana",compressible:!1,extensions:["gz"]},"application/h224":{source:"iana"},"application/held+xml":{source:"iana",compressible:!0},"application/hjson":{extensions:["hjson"]},"application/http":{source:"iana"},"application/hyperstudio":{source:"iana",extensions:["stk"]},"application/ibe-key-request+xml":{source:"iana",compressible:!0},"application/ibe-pkg-reply+xml":{source:"iana",compressible:!0},"application/ibe-pp-data":{source:"iana"},"application/iges":{source:"iana"},"application/im-iscomposing+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/index":{source:"iana"},"application/index.cmd":{source:"iana"},"application/index.obj":{source:"iana"},"application/index.response":{source:"iana"},"application/index.vnd":{source:"iana"},"application/inkml+xml":{source:"iana",compressible:!0,extensions:["ink","inkml"]},"application/iotp":{source:"iana"},"application/ipfix":{source:"iana",extensions:["ipfix"]},"application/ipp":{source:"iana"},"application/isup":{source:"iana"},"application/its+xml":{source:"iana",compressible:!0,extensions:["its"]},"application/java-archive":{source:"apache",compressible:!1,extensions:["jar","war","ear"]},"application/java-serialized-object":{source:"apache",compressible:!1,extensions:["ser"]},"application/java-vm":{source:"apache",compressible:!1,extensions:["class"]},"application/javascript":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["js","mjs"]},"application/jf2feed+json":{source:"iana",compressible:!0},"application/jose":{source:"iana"},"application/jose+json":{source:"iana",compressible:!0},"application/jrd+json":{source:"iana",compressible:!0},"application/jscalendar+json":{source:"iana",compressible:!0},"application/json":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["json","map"]},"application/json-patch+json":{source:"iana",compressible:!0},"application/json-seq":{source:"iana"},"application/json5":{extensions:["json5"]},"application/jsonml+json":{source:"apache",compressible:!0,extensions:["jsonml"]},"application/jwk+json":{source:"iana",compressible:!0},"application/jwk-set+json":{source:"iana",compressible:!0},"application/jwt":{source:"iana"},"application/kpml-request+xml":{source:"iana",compressible:!0},"application/kpml-response+xml":{source:"iana",compressible:!0},"application/ld+json":{source:"iana",compressible:!0,extensions:["jsonld"]},"application/lgr+xml":{source:"iana",compressible:!0,extensions:["lgr"]},"application/link-format":{source:"iana"},"application/load-control+xml":{source:"iana",compressible:!0},"application/lost+xml":{source:"iana",compressible:!0,extensions:["lostxml"]},"application/lostsync+xml":{source:"iana",compressible:!0},"application/lpf+zip":{source:"iana",compressible:!1},"application/lxf":{source:"iana"},"application/mac-binhex40":{source:"iana",extensions:["hqx"]},"application/mac-compactpro":{source:"apache",extensions:["cpt"]},"application/macwriteii":{source:"iana"},"application/mads+xml":{source:"iana",compressible:!0,extensions:["mads"]},"application/manifest+json":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["webmanifest"]},"application/marc":{source:"iana",extensions:["mrc"]},"application/marcxml+xml":{source:"iana",compressible:!0,extensions:["mrcx"]},"application/mathematica":{source:"iana",extensions:["ma","nb","mb"]},"application/mathml+xml":{source:"iana",compressible:!0,extensions:["mathml"]},"application/mathml-content+xml":{source:"iana",compressible:!0},"application/mathml-presentation+xml":{source:"iana",compressible:!0},"application/mbms-associated-procedure-description+xml":{source:"iana",compressible:!0},"application/mbms-deregister+xml":{source:"iana",compressible:!0},"application/mbms-envelope+xml":{source:"iana",compressible:!0},"application/mbms-msk+xml":{source:"iana",compressible:!0},"application/mbms-msk-response+xml":{source:"iana",compressible:!0},"application/mbms-protection-description+xml":{source:"iana",compressible:!0},"application/mbms-reception-report+xml":{source:"iana",compressible:!0},"application/mbms-register+xml":{source:"iana",compressible:!0},"application/mbms-register-response+xml":{source:"iana",compressible:!0},"application/mbms-schedule+xml":{source:"iana",compressible:!0},"application/mbms-user-service-description+xml":{source:"iana",compressible:!0},"application/mbox":{source:"iana",extensions:["mbox"]},"application/media-policy-dataset+xml":{source:"iana",compressible:!0,extensions:["mpf"]},"application/media_control+xml":{source:"iana",compressible:!0},"application/mediaservercontrol+xml":{source:"iana",compressible:!0,extensions:["mscml"]},"application/merge-patch+json":{source:"iana",compressible:!0},"application/metalink+xml":{source:"apache",compressible:!0,extensions:["metalink"]},"application/metalink4+xml":{source:"iana",compressible:!0,extensions:["meta4"]},"application/mets+xml":{source:"iana",compressible:!0,extensions:["mets"]},"application/mf4":{source:"iana"},"application/mikey":{source:"iana"},"application/mipc":{source:"iana"},"application/missing-blocks+cbor-seq":{source:"iana"},"application/mmt-aei+xml":{source:"iana",compressible:!0,extensions:["maei"]},"application/mmt-usd+xml":{source:"iana",compressible:!0,extensions:["musd"]},"application/mods+xml":{source:"iana",compressible:!0,extensions:["mods"]},"application/moss-keys":{source:"iana"},"application/moss-signature":{source:"iana"},"application/mosskey-data":{source:"iana"},"application/mosskey-request":{source:"iana"},"application/mp21":{source:"iana",extensions:["m21","mp21"]},"application/mp4":{source:"iana",extensions:["mp4s","m4p"]},"application/mpeg4-generic":{source:"iana"},"application/mpeg4-iod":{source:"iana"},"application/mpeg4-iod-xmt":{source:"iana"},"application/mrb-consumer+xml":{source:"iana",compressible:!0},"application/mrb-publish+xml":{source:"iana",compressible:!0},"application/msc-ivr+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/msc-mixer+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/msword":{source:"iana",compressible:!1,extensions:["doc","dot"]},"application/mud+json":{source:"iana",compressible:!0},"application/multipart-core":{source:"iana"},"application/mxf":{source:"iana",extensions:["mxf"]},"application/n-quads":{source:"iana",extensions:["nq"]},"application/n-triples":{source:"iana",extensions:["nt"]},"application/nasdata":{source:"iana"},"application/news-checkgroups":{source:"iana",charset:"US-ASCII"},"application/news-groupinfo":{source:"iana",charset:"US-ASCII"},"application/news-transmission":{source:"iana"},"application/nlsml+xml":{source:"iana",compressible:!0},"application/node":{source:"iana",extensions:["cjs"]},"application/nss":{source:"iana"},"application/oauth-authz-req+jwt":{source:"iana"},"application/oblivious-dns-message":{source:"iana"},"application/ocsp-request":{source:"iana"},"application/ocsp-response":{source:"iana"},"application/octet-stream":{source:"iana",compressible:!1,extensions:["bin","dms","lrf","mar","so","dist","distz","pkg","bpk","dump","elc","deploy","exe","dll","deb","dmg","iso","img","msi","msp","msm","buffer"]},"application/oda":{source:"iana",extensions:["oda"]},"application/odm+xml":{source:"iana",compressible:!0},"application/odx":{source:"iana"},"application/oebps-package+xml":{source:"iana",compressible:!0,extensions:["opf"]},"application/ogg":{source:"iana",compressible:!1,extensions:["ogx"]},"application/omdoc+xml":{source:"apache",compressible:!0,extensions:["omdoc"]},"application/onenote":{source:"apache",extensions:["onetoc","onetoc2","onetmp","onepkg"]},"application/opc-nodeset+xml":{source:"iana",compressible:!0},"application/oscore":{source:"iana"},"application/oxps":{source:"iana",extensions:["oxps"]},"application/p21":{source:"iana"},"application/p21+zip":{source:"iana",compressible:!1},"application/p2p-overlay+xml":{source:"iana",compressible:!0,extensions:["relo"]},"application/parityfec":{source:"iana"},"application/passport":{source:"iana"},"application/patch-ops-error+xml":{source:"iana",compressible:!0,extensions:["xer"]},"application/pdf":{source:"iana",compressible:!1,extensions:["pdf"]},"application/pdx":{source:"iana"},"application/pem-certificate-chain":{source:"iana"},"application/pgp-encrypted":{source:"iana",compressible:!1,extensions:["pgp"]},"application/pgp-keys":{source:"iana",extensions:["asc"]},"application/pgp-signature":{source:"iana",extensions:["asc","sig"]},"application/pics-rules":{source:"apache",extensions:["prf"]},"application/pidf+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/pidf-diff+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/pkcs10":{source:"iana",extensions:["p10"]},"application/pkcs12":{source:"iana"},"application/pkcs7-mime":{source:"iana",extensions:["p7m","p7c"]},"application/pkcs7-signature":{source:"iana",extensions:["p7s"]},"application/pkcs8":{source:"iana",extensions:["p8"]},"application/pkcs8-encrypted":{source:"iana"},"application/pkix-attr-cert":{source:"iana",extensions:["ac"]},"application/pkix-cert":{source:"iana",extensions:["cer"]},"application/pkix-crl":{source:"iana",extensions:["crl"]},"application/pkix-pkipath":{source:"iana",extensions:["pkipath"]},"application/pkixcmp":{source:"iana",extensions:["pki"]},"application/pls+xml":{source:"iana",compressible:!0,extensions:["pls"]},"application/poc-settings+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/postscript":{source:"iana",compressible:!0,extensions:["ai","eps","ps"]},"application/ppsp-tracker+json":{source:"iana",compressible:!0},"application/problem+json":{source:"iana",compressible:!0},"application/problem+xml":{source:"iana",compressible:!0},"application/provenance+xml":{source:"iana",compressible:!0,extensions:["provx"]},"application/prs.alvestrand.titrax-sheet":{source:"iana"},"application/prs.cww":{source:"iana",extensions:["cww"]},"application/prs.cyn":{source:"iana",charset:"7-BIT"},"application/prs.hpub+zip":{source:"iana",compressible:!1},"application/prs.nprend":{source:"iana"},"application/prs.plucker":{source:"iana"},"application/prs.rdf-xml-crypt":{source:"iana"},"application/prs.xsf+xml":{source:"iana",compressible:!0},"application/pskc+xml":{source:"iana",compressible:!0,extensions:["pskcxml"]},"application/pvd+json":{source:"iana",compressible:!0},"application/qsig":{source:"iana"},"application/raml+yaml":{compressible:!0,extensions:["raml"]},"application/raptorfec":{source:"iana"},"application/rdap+json":{source:"iana",compressible:!0},"application/rdf+xml":{source:"iana",compressible:!0,extensions:["rdf","owl"]},"application/reginfo+xml":{source:"iana",compressible:!0,extensions:["rif"]},"application/relax-ng-compact-syntax":{source:"iana",extensions:["rnc"]},"application/remote-printing":{source:"iana"},"application/reputon+json":{source:"iana",compressible:!0},"application/resource-lists+xml":{source:"iana",compressible:!0,extensions:["rl"]},"application/resource-lists-diff+xml":{source:"iana",compressible:!0,extensions:["rld"]},"application/rfc+xml":{source:"iana",compressible:!0},"application/riscos":{source:"iana"},"application/rlmi+xml":{source:"iana",compressible:!0},"application/rls-services+xml":{source:"iana",compressible:!0,extensions:["rs"]},"application/route-apd+xml":{source:"iana",compressible:!0,extensions:["rapd"]},"application/route-s-tsid+xml":{source:"iana",compressible:!0,extensions:["sls"]},"application/route-usd+xml":{source:"iana",compressible:!0,extensions:["rusd"]},"application/rpki-ghostbusters":{source:"iana",extensions:["gbr"]},"application/rpki-manifest":{source:"iana",extensions:["mft"]},"application/rpki-publication":{source:"iana"},"application/rpki-roa":{source:"iana",extensions:["roa"]},"application/rpki-updown":{source:"iana"},"application/rsd+xml":{source:"apache",compressible:!0,extensions:["rsd"]},"application/rss+xml":{source:"apache",compressible:!0,extensions:["rss"]},"application/rtf":{source:"iana",compressible:!0,extensions:["rtf"]},"application/rtploopback":{source:"iana"},"application/rtx":{source:"iana"},"application/samlassertion+xml":{source:"iana",compressible:!0},"application/samlmetadata+xml":{source:"iana",compressible:!0},"application/sarif+json":{source:"iana",compressible:!0},"application/sarif-external-properties+json":{source:"iana",compressible:!0},"application/sbe":{source:"iana"},"application/sbml+xml":{source:"iana",compressible:!0,extensions:["sbml"]},"application/scaip+xml":{source:"iana",compressible:!0},"application/scim+json":{source:"iana",compressible:!0},"application/scvp-cv-request":{source:"iana",extensions:["scq"]},"application/scvp-cv-response":{source:"iana",extensions:["scs"]},"application/scvp-vp-request":{source:"iana",extensions:["spq"]},"application/scvp-vp-response":{source:"iana",extensions:["spp"]},"application/sdp":{source:"iana",extensions:["sdp"]},"application/secevent+jwt":{source:"iana"},"application/senml+cbor":{source:"iana"},"application/senml+json":{source:"iana",compressible:!0},"application/senml+xml":{source:"iana",compressible:!0,extensions:["senmlx"]},"application/senml-etch+cbor":{source:"iana"},"application/senml-etch+json":{source:"iana",compressible:!0},"application/senml-exi":{source:"iana"},"application/sensml+cbor":{source:"iana"},"application/sensml+json":{source:"iana",compressible:!0},"application/sensml+xml":{source:"iana",compressible:!0,extensions:["sensmlx"]},"application/sensml-exi":{source:"iana"},"application/sep+xml":{source:"iana",compressible:!0},"application/sep-exi":{source:"iana"},"application/session-info":{source:"iana"},"application/set-payment":{source:"iana"},"application/set-payment-initiation":{source:"iana",extensions:["setpay"]},"application/set-registration":{source:"iana"},"application/set-registration-initiation":{source:"iana",extensions:["setreg"]},"application/sgml":{source:"iana"},"application/sgml-open-catalog":{source:"iana"},"application/shf+xml":{source:"iana",compressible:!0,extensions:["shf"]},"application/sieve":{source:"iana",extensions:["siv","sieve"]},"application/simple-filter+xml":{source:"iana",compressible:!0},"application/simple-message-summary":{source:"iana"},"application/simplesymbolcontainer":{source:"iana"},"application/sipc":{source:"iana"},"application/slate":{source:"iana"},"application/smil":{source:"iana"},"application/smil+xml":{source:"iana",compressible:!0,extensions:["smi","smil"]},"application/smpte336m":{source:"iana"},"application/soap+fastinfoset":{source:"iana"},"application/soap+xml":{source:"iana",compressible:!0},"application/sparql-query":{source:"iana",extensions:["rq"]},"application/sparql-results+xml":{source:"iana",compressible:!0,extensions:["srx"]},"application/spdx+json":{source:"iana",compressible:!0},"application/spirits-event+xml":{source:"iana",compressible:!0},"application/sql":{source:"iana"},"application/srgs":{source:"iana",extensions:["gram"]},"application/srgs+xml":{source:"iana",compressible:!0,extensions:["grxml"]},"application/sru+xml":{source:"iana",compressible:!0,extensions:["sru"]},"application/ssdl+xml":{source:"apache",compressible:!0,extensions:["ssdl"]},"application/ssml+xml":{source:"iana",compressible:!0,extensions:["ssml"]},"application/stix+json":{source:"iana",compressible:!0},"application/swid+xml":{source:"iana",compressible:!0,extensions:["swidtag"]},"application/tamp-apex-update":{source:"iana"},"application/tamp-apex-update-confirm":{source:"iana"},"application/tamp-community-update":{source:"iana"},"application/tamp-community-update-confirm":{source:"iana"},"application/tamp-error":{source:"iana"},"application/tamp-sequence-adjust":{source:"iana"},"application/tamp-sequence-adjust-confirm":{source:"iana"},"application/tamp-status-query":{source:"iana"},"application/tamp-status-response":{source:"iana"},"application/tamp-update":{source:"iana"},"application/tamp-update-confirm":{source:"iana"},"application/tar":{compressible:!0},"application/taxii+json":{source:"iana",compressible:!0},"application/td+json":{source:"iana",compressible:!0},"application/tei+xml":{source:"iana",compressible:!0,extensions:["tei","teicorpus"]},"application/tetra_isi":{source:"iana"},"application/thraud+xml":{source:"iana",compressible:!0,extensions:["tfi"]},"application/timestamp-query":{source:"iana"},"application/timestamp-reply":{source:"iana"},"application/timestamped-data":{source:"iana",extensions:["tsd"]},"application/tlsrpt+gzip":{source:"iana"},"application/tlsrpt+json":{source:"iana",compressible:!0},"application/tnauthlist":{source:"iana"},"application/token-introspection+jwt":{source:"iana"},"application/toml":{compressible:!0,extensions:["toml"]},"application/trickle-ice-sdpfrag":{source:"iana"},"application/trig":{source:"iana",extensions:["trig"]},"application/ttml+xml":{source:"iana",compressible:!0,extensions:["ttml"]},"application/tve-trigger":{source:"iana"},"application/tzif":{source:"iana"},"application/tzif-leap":{source:"iana"},"application/ubjson":{compressible:!1,extensions:["ubj"]},"application/ulpfec":{source:"iana"},"application/urc-grpsheet+xml":{source:"iana",compressible:!0},"application/urc-ressheet+xml":{source:"iana",compressible:!0,extensions:["rsheet"]},"application/urc-targetdesc+xml":{source:"iana",compressible:!0,extensions:["td"]},"application/urc-uisocketdesc+xml":{source:"iana",compressible:!0},"application/vcard+json":{source:"iana",compressible:!0},"application/vcard+xml":{source:"iana",compressible:!0},"application/vemmi":{source:"iana"},"application/vividence.scriptfile":{source:"apache"},"application/vnd.1000minds.decision-model+xml":{source:"iana",compressible:!0,extensions:["1km"]},"application/vnd.3gpp-prose+xml":{source:"iana",compressible:!0},"application/vnd.3gpp-prose-pc3ch+xml":{source:"iana",compressible:!0},"application/vnd.3gpp-v2x-local-service-information":{source:"iana"},"application/vnd.3gpp.5gnas":{source:"iana"},"application/vnd.3gpp.access-transfer-events+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.bsf+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.gmop+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.gtpc":{source:"iana"},"application/vnd.3gpp.interworking-data":{source:"iana"},"application/vnd.3gpp.lpp":{source:"iana"},"application/vnd.3gpp.mc-signalling-ear":{source:"iana"},"application/vnd.3gpp.mcdata-affiliation-command+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcdata-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcdata-payload":{source:"iana"},"application/vnd.3gpp.mcdata-service-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcdata-signalling":{source:"iana"},"application/vnd.3gpp.mcdata-ue-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcdata-user-profile+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-affiliation-command+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-floor-request+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-location-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-mbms-usage-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-service-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-signed+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-ue-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-ue-init-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcptt-user-profile+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-affiliation-command+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-affiliation-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-location-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-mbms-usage-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-service-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-transmission-request+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-ue-config+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mcvideo-user-profile+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.mid-call+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.ngap":{source:"iana"},"application/vnd.3gpp.pfcp":{source:"iana"},"application/vnd.3gpp.pic-bw-large":{source:"iana",extensions:["plb"]},"application/vnd.3gpp.pic-bw-small":{source:"iana",extensions:["psb"]},"application/vnd.3gpp.pic-bw-var":{source:"iana",extensions:["pvb"]},"application/vnd.3gpp.s1ap":{source:"iana"},"application/vnd.3gpp.sms":{source:"iana"},"application/vnd.3gpp.sms+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.srvcc-ext+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.srvcc-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.state-and-event-info+xml":{source:"iana",compressible:!0},"application/vnd.3gpp.ussd+xml":{source:"iana",compressible:!0},"application/vnd.3gpp2.bcmcsinfo+xml":{source:"iana",compressible:!0},"application/vnd.3gpp2.sms":{source:"iana"},"application/vnd.3gpp2.tcap":{source:"iana",extensions:["tcap"]},"application/vnd.3lightssoftware.imagescal":{source:"iana"},"application/vnd.3m.post-it-notes":{source:"iana",extensions:["pwn"]},"application/vnd.accpac.simply.aso":{source:"iana",extensions:["aso"]},"application/vnd.accpac.simply.imp":{source:"iana",extensions:["imp"]},"application/vnd.acucobol":{source:"iana",extensions:["acu"]},"application/vnd.acucorp":{source:"iana",extensions:["atc","acutc"]},"application/vnd.adobe.air-application-installer-package+zip":{source:"apache",compressible:!1,extensions:["air"]},"application/vnd.adobe.flash.movie":{source:"iana"},"application/vnd.adobe.formscentral.fcdt":{source:"iana",extensions:["fcdt"]},"application/vnd.adobe.fxp":{source:"iana",extensions:["fxp","fxpl"]},"application/vnd.adobe.partial-upload":{source:"iana"},"application/vnd.adobe.xdp+xml":{source:"iana",compressible:!0,extensions:["xdp"]},"application/vnd.adobe.xfdf":{source:"iana",extensions:["xfdf"]},"application/vnd.aether.imp":{source:"iana"},"application/vnd.afpc.afplinedata":{source:"iana"},"application/vnd.afpc.afplinedata-pagedef":{source:"iana"},"application/vnd.afpc.cmoca-cmresource":{source:"iana"},"application/vnd.afpc.foca-charset":{source:"iana"},"application/vnd.afpc.foca-codedfont":{source:"iana"},"application/vnd.afpc.foca-codepage":{source:"iana"},"application/vnd.afpc.modca":{source:"iana"},"application/vnd.afpc.modca-cmtable":{source:"iana"},"application/vnd.afpc.modca-formdef":{source:"iana"},"application/vnd.afpc.modca-mediummap":{source:"iana"},"application/vnd.afpc.modca-objectcontainer":{source:"iana"},"application/vnd.afpc.modca-overlay":{source:"iana"},"application/vnd.afpc.modca-pagesegment":{source:"iana"},"application/vnd.age":{source:"iana",extensions:["age"]},"application/vnd.ah-barcode":{source:"iana"},"application/vnd.ahead.space":{source:"iana",extensions:["ahead"]},"application/vnd.airzip.filesecure.azf":{source:"iana",extensions:["azf"]},"application/vnd.airzip.filesecure.azs":{source:"iana",extensions:["azs"]},"application/vnd.amadeus+json":{source:"iana",compressible:!0},"application/vnd.amazon.ebook":{source:"apache",extensions:["azw"]},"application/vnd.amazon.mobi8-ebook":{source:"iana"},"application/vnd.americandynamics.acc":{source:"iana",extensions:["acc"]},"application/vnd.amiga.ami":{source:"iana",extensions:["ami"]},"application/vnd.amundsen.maze+xml":{source:"iana",compressible:!0},"application/vnd.android.ota":{source:"iana"},"application/vnd.android.package-archive":{source:"apache",compressible:!1,extensions:["apk"]},"application/vnd.anki":{source:"iana"},"application/vnd.anser-web-certificate-issue-initiation":{source:"iana",extensions:["cii"]},"application/vnd.anser-web-funds-transfer-initiation":{source:"apache",extensions:["fti"]},"application/vnd.antix.game-component":{source:"iana",extensions:["atx"]},"application/vnd.apache.arrow.file":{source:"iana"},"application/vnd.apache.arrow.stream":{source:"iana"},"application/vnd.apache.thrift.binary":{source:"iana"},"application/vnd.apache.thrift.compact":{source:"iana"},"application/vnd.apache.thrift.json":{source:"iana"},"application/vnd.api+json":{source:"iana",compressible:!0},"application/vnd.aplextor.warrp+json":{source:"iana",compressible:!0},"application/vnd.apothekende.reservation+json":{source:"iana",compressible:!0},"application/vnd.apple.installer+xml":{source:"iana",compressible:!0,extensions:["mpkg"]},"application/vnd.apple.keynote":{source:"iana",extensions:["key"]},"application/vnd.apple.mpegurl":{source:"iana",extensions:["m3u8"]},"application/vnd.apple.numbers":{source:"iana",extensions:["numbers"]},"application/vnd.apple.pages":{source:"iana",extensions:["pages"]},"application/vnd.apple.pkpass":{compressible:!1,extensions:["pkpass"]},"application/vnd.arastra.swi":{source:"iana"},"application/vnd.aristanetworks.swi":{source:"iana",extensions:["swi"]},"application/vnd.artisan+json":{source:"iana",compressible:!0},"application/vnd.artsquare":{source:"iana"},"application/vnd.astraea-software.iota":{source:"iana",extensions:["iota"]},"application/vnd.audiograph":{source:"iana",extensions:["aep"]},"application/vnd.autopackage":{source:"iana"},"application/vnd.avalon+json":{source:"iana",compressible:!0},"application/vnd.avistar+xml":{source:"iana",compressible:!0},"application/vnd.balsamiq.bmml+xml":{source:"iana",compressible:!0,extensions:["bmml"]},"application/vnd.balsamiq.bmpr":{source:"iana"},"application/vnd.banana-accounting":{source:"iana"},"application/vnd.bbf.usp.error":{source:"iana"},"application/vnd.bbf.usp.msg":{source:"iana"},"application/vnd.bbf.usp.msg+json":{source:"iana",compressible:!0},"application/vnd.bekitzur-stech+json":{source:"iana",compressible:!0},"application/vnd.bint.med-content":{source:"iana"},"application/vnd.biopax.rdf+xml":{source:"iana",compressible:!0},"application/vnd.blink-idb-value-wrapper":{source:"iana"},"application/vnd.blueice.multipass":{source:"iana",extensions:["mpm"]},"application/vnd.bluetooth.ep.oob":{source:"iana"},"application/vnd.bluetooth.le.oob":{source:"iana"},"application/vnd.bmi":{source:"iana",extensions:["bmi"]},"application/vnd.bpf":{source:"iana"},"application/vnd.bpf3":{source:"iana"},"application/vnd.businessobjects":{source:"iana",extensions:["rep"]},"application/vnd.byu.uapi+json":{source:"iana",compressible:!0},"application/vnd.cab-jscript":{source:"iana"},"application/vnd.canon-cpdl":{source:"iana"},"application/vnd.canon-lips":{source:"iana"},"application/vnd.capasystems-pg+json":{source:"iana",compressible:!0},"application/vnd.cendio.thinlinc.clientconf":{source:"iana"},"application/vnd.century-systems.tcp_stream":{source:"iana"},"application/vnd.chemdraw+xml":{source:"iana",compressible:!0,extensions:["cdxml"]},"application/vnd.chess-pgn":{source:"iana"},"application/vnd.chipnuts.karaoke-mmd":{source:"iana",extensions:["mmd"]},"application/vnd.ciedi":{source:"iana"},"application/vnd.cinderella":{source:"iana",extensions:["cdy"]},"application/vnd.cirpack.isdn-ext":{source:"iana"},"application/vnd.citationstyles.style+xml":{source:"iana",compressible:!0,extensions:["csl"]},"application/vnd.claymore":{source:"iana",extensions:["cla"]},"application/vnd.cloanto.rp9":{source:"iana",extensions:["rp9"]},"application/vnd.clonk.c4group":{source:"iana",extensions:["c4g","c4d","c4f","c4p","c4u"]},"application/vnd.cluetrust.cartomobile-config":{source:"iana",extensions:["c11amc"]},"application/vnd.cluetrust.cartomobile-config-pkg":{source:"iana",extensions:["c11amz"]},"application/vnd.coffeescript":{source:"iana"},"application/vnd.collabio.xodocuments.document":{source:"iana"},"application/vnd.collabio.xodocuments.document-template":{source:"iana"},"application/vnd.collabio.xodocuments.presentation":{source:"iana"},"application/vnd.collabio.xodocuments.presentation-template":{source:"iana"},"application/vnd.collabio.xodocuments.spreadsheet":{source:"iana"},"application/vnd.collabio.xodocuments.spreadsheet-template":{source:"iana"},"application/vnd.collection+json":{source:"iana",compressible:!0},"application/vnd.collection.doc+json":{source:"iana",compressible:!0},"application/vnd.collection.next+json":{source:"iana",compressible:!0},"application/vnd.comicbook+zip":{source:"iana",compressible:!1},"application/vnd.comicbook-rar":{source:"iana"},"application/vnd.commerce-battelle":{source:"iana"},"application/vnd.commonspace":{source:"iana",extensions:["csp"]},"application/vnd.contact.cmsg":{source:"iana",extensions:["cdbcmsg"]},"application/vnd.coreos.ignition+json":{source:"iana",compressible:!0},"application/vnd.cosmocaller":{source:"iana",extensions:["cmc"]},"application/vnd.crick.clicker":{source:"iana",extensions:["clkx"]},"application/vnd.crick.clicker.keyboard":{source:"iana",extensions:["clkk"]},"application/vnd.crick.clicker.palette":{source:"iana",extensions:["clkp"]},"application/vnd.crick.clicker.template":{source:"iana",extensions:["clkt"]},"application/vnd.crick.clicker.wordbank":{source:"iana",extensions:["clkw"]},"application/vnd.criticaltools.wbs+xml":{source:"iana",compressible:!0,extensions:["wbs"]},"application/vnd.cryptii.pipe+json":{source:"iana",compressible:!0},"application/vnd.crypto-shade-file":{source:"iana"},"application/vnd.cryptomator.encrypted":{source:"iana"},"application/vnd.cryptomator.vault":{source:"iana"},"application/vnd.ctc-posml":{source:"iana",extensions:["pml"]},"application/vnd.ctct.ws+xml":{source:"iana",compressible:!0},"application/vnd.cups-pdf":{source:"iana"},"application/vnd.cups-postscript":{source:"iana"},"application/vnd.cups-ppd":{source:"iana",extensions:["ppd"]},"application/vnd.cups-raster":{source:"iana"},"application/vnd.cups-raw":{source:"iana"},"application/vnd.curl":{source:"iana"},"application/vnd.curl.car":{source:"apache",extensions:["car"]},"application/vnd.curl.pcurl":{source:"apache",extensions:["pcurl"]},"application/vnd.cyan.dean.root+xml":{source:"iana",compressible:!0},"application/vnd.cybank":{source:"iana"},"application/vnd.cyclonedx+json":{source:"iana",compressible:!0},"application/vnd.cyclonedx+xml":{source:"iana",compressible:!0},"application/vnd.d2l.coursepackage1p0+zip":{source:"iana",compressible:!1},"application/vnd.d3m-dataset":{source:"iana"},"application/vnd.d3m-problem":{source:"iana"},"application/vnd.dart":{source:"iana",compressible:!0,extensions:["dart"]},"application/vnd.data-vision.rdz":{source:"iana",extensions:["rdz"]},"application/vnd.datapackage+json":{source:"iana",compressible:!0},"application/vnd.dataresource+json":{source:"iana",compressible:!0},"application/vnd.dbf":{source:"iana",extensions:["dbf"]},"application/vnd.debian.binary-package":{source:"iana"},"application/vnd.dece.data":{source:"iana",extensions:["uvf","uvvf","uvd","uvvd"]},"application/vnd.dece.ttml+xml":{source:"iana",compressible:!0,extensions:["uvt","uvvt"]},"application/vnd.dece.unspecified":{source:"iana",extensions:["uvx","uvvx"]},"application/vnd.dece.zip":{source:"iana",extensions:["uvz","uvvz"]},"application/vnd.denovo.fcselayout-link":{source:"iana",extensions:["fe_launch"]},"application/vnd.desmume.movie":{source:"iana"},"application/vnd.dir-bi.plate-dl-nosuffix":{source:"iana"},"application/vnd.dm.delegation+xml":{source:"iana",compressible:!0},"application/vnd.dna":{source:"iana",extensions:["dna"]},"application/vnd.document+json":{source:"iana",compressible:!0},"application/vnd.dolby.mlp":{source:"apache",extensions:["mlp"]},"application/vnd.dolby.mobile.1":{source:"iana"},"application/vnd.dolby.mobile.2":{source:"iana"},"application/vnd.doremir.scorecloud-binary-document":{source:"iana"},"application/vnd.dpgraph":{source:"iana",extensions:["dpg"]},"application/vnd.dreamfactory":{source:"iana",extensions:["dfac"]},"application/vnd.drive+json":{source:"iana",compressible:!0},"application/vnd.ds-keypoint":{source:"apache",extensions:["kpxx"]},"application/vnd.dtg.local":{source:"iana"},"application/vnd.dtg.local.flash":{source:"iana"},"application/vnd.dtg.local.html":{source:"iana"},"application/vnd.dvb.ait":{source:"iana",extensions:["ait"]},"application/vnd.dvb.dvbisl+xml":{source:"iana",compressible:!0},"application/vnd.dvb.dvbj":{source:"iana"},"application/vnd.dvb.esgcontainer":{source:"iana"},"application/vnd.dvb.ipdcdftnotifaccess":{source:"iana"},"application/vnd.dvb.ipdcesgaccess":{source:"iana"},"application/vnd.dvb.ipdcesgaccess2":{source:"iana"},"application/vnd.dvb.ipdcesgpdd":{source:"iana"},"application/vnd.dvb.ipdcroaming":{source:"iana"},"application/vnd.dvb.iptv.alfec-base":{source:"iana"},"application/vnd.dvb.iptv.alfec-enhancement":{source:"iana"},"application/vnd.dvb.notif-aggregate-root+xml":{source:"iana",compressible:!0},"application/vnd.dvb.notif-container+xml":{source:"iana",compressible:!0},"application/vnd.dvb.notif-generic+xml":{source:"iana",compressible:!0},"application/vnd.dvb.notif-ia-msglist+xml":{source:"iana",compressible:!0},"application/vnd.dvb.notif-ia-registration-request+xml":{source:"iana",compressible:!0},"application/vnd.dvb.notif-ia-registration-response+xml":{source:"iana",compressible:!0},"application/vnd.dvb.notif-init+xml":{source:"iana",compressible:!0},"application/vnd.dvb.pfr":{source:"iana"},"application/vnd.dvb.service":{source:"iana",extensions:["svc"]},"application/vnd.dxr":{source:"iana"},"application/vnd.dynageo":{source:"iana",extensions:["geo"]},"application/vnd.dzr":{source:"iana"},"application/vnd.easykaraoke.cdgdownload":{source:"iana"},"application/vnd.ecdis-update":{source:"iana"},"application/vnd.ecip.rlp":{source:"iana"},"application/vnd.eclipse.ditto+json":{source:"iana",compressible:!0},"application/vnd.ecowin.chart":{source:"iana",extensions:["mag"]},"application/vnd.ecowin.filerequest":{source:"iana"},"application/vnd.ecowin.fileupdate":{source:"iana"},"application/vnd.ecowin.series":{source:"iana"},"application/vnd.ecowin.seriesrequest":{source:"iana"},"application/vnd.ecowin.seriesupdate":{source:"iana"},"application/vnd.efi.img":{source:"iana"},"application/vnd.efi.iso":{source:"iana"},"application/vnd.emclient.accessrequest+xml":{source:"iana",compressible:!0},"application/vnd.enliven":{source:"iana",extensions:["nml"]},"application/vnd.enphase.envoy":{source:"iana"},"application/vnd.eprints.data+xml":{source:"iana",compressible:!0},"application/vnd.epson.esf":{source:"iana",extensions:["esf"]},"application/vnd.epson.msf":{source:"iana",extensions:["msf"]},"application/vnd.epson.quickanime":{source:"iana",extensions:["qam"]},"application/vnd.epson.salt":{source:"iana",extensions:["slt"]},"application/vnd.epson.ssf":{source:"iana",extensions:["ssf"]},"application/vnd.ericsson.quickcall":{source:"iana"},"application/vnd.espass-espass+zip":{source:"iana",compressible:!1},"application/vnd.eszigno3+xml":{source:"iana",compressible:!0,extensions:["es3","et3"]},"application/vnd.etsi.aoc+xml":{source:"iana",compressible:!0},"application/vnd.etsi.asic-e+zip":{source:"iana",compressible:!1},"application/vnd.etsi.asic-s+zip":{source:"iana",compressible:!1},"application/vnd.etsi.cug+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvcommand+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvdiscovery+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvprofile+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvsad-bc+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvsad-cod+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvsad-npvr+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvservice+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvsync+xml":{source:"iana",compressible:!0},"application/vnd.etsi.iptvueprofile+xml":{source:"iana",compressible:!0},"application/vnd.etsi.mcid+xml":{source:"iana",compressible:!0},"application/vnd.etsi.mheg5":{source:"iana"},"application/vnd.etsi.overload-control-policy-dataset+xml":{source:"iana",compressible:!0},"application/vnd.etsi.pstn+xml":{source:"iana",compressible:!0},"application/vnd.etsi.sci+xml":{source:"iana",compressible:!0},"application/vnd.etsi.simservs+xml":{source:"iana",compressible:!0},"application/vnd.etsi.timestamp-token":{source:"iana"},"application/vnd.etsi.tsl+xml":{source:"iana",compressible:!0},"application/vnd.etsi.tsl.der":{source:"iana"},"application/vnd.eu.kasparian.car+json":{source:"iana",compressible:!0},"application/vnd.eudora.data":{source:"iana"},"application/vnd.evolv.ecig.profile":{source:"iana"},"application/vnd.evolv.ecig.settings":{source:"iana"},"application/vnd.evolv.ecig.theme":{source:"iana"},"application/vnd.exstream-empower+zip":{source:"iana",compressible:!1},"application/vnd.exstream-package":{source:"iana"},"application/vnd.ezpix-album":{source:"iana",extensions:["ez2"]},"application/vnd.ezpix-package":{source:"iana",extensions:["ez3"]},"application/vnd.f-secure.mobile":{source:"iana"},"application/vnd.familysearch.gedcom+zip":{source:"iana",compressible:!1},"application/vnd.fastcopy-disk-image":{source:"iana"},"application/vnd.fdf":{source:"iana",extensions:["fdf"]},"application/vnd.fdsn.mseed":{source:"iana",extensions:["mseed"]},"application/vnd.fdsn.seed":{source:"iana",extensions:["seed","dataless"]},"application/vnd.ffsns":{source:"iana"},"application/vnd.ficlab.flb+zip":{source:"iana",compressible:!1},"application/vnd.filmit.zfc":{source:"iana"},"application/vnd.fints":{source:"iana"},"application/vnd.firemonkeys.cloudcell":{source:"iana"},"application/vnd.flographit":{source:"iana",extensions:["gph"]},"application/vnd.fluxtime.clip":{source:"iana",extensions:["ftc"]},"application/vnd.font-fontforge-sfd":{source:"iana"},"application/vnd.framemaker":{source:"iana",extensions:["fm","frame","maker","book"]},"application/vnd.frogans.fnc":{source:"iana",extensions:["fnc"]},"application/vnd.frogans.ltf":{source:"iana",extensions:["ltf"]},"application/vnd.fsc.weblaunch":{source:"iana",extensions:["fsc"]},"application/vnd.fujifilm.fb.docuworks":{source:"iana"},"application/vnd.fujifilm.fb.docuworks.binder":{source:"iana"},"application/vnd.fujifilm.fb.docuworks.container":{source:"iana"},"application/vnd.fujifilm.fb.jfi+xml":{source:"iana",compressible:!0},"application/vnd.fujitsu.oasys":{source:"iana",extensions:["oas"]},"application/vnd.fujitsu.oasys2":{source:"iana",extensions:["oa2"]},"application/vnd.fujitsu.oasys3":{source:"iana",extensions:["oa3"]},"application/vnd.fujitsu.oasysgp":{source:"iana",extensions:["fg5"]},"application/vnd.fujitsu.oasysprs":{source:"iana",extensions:["bh2"]},"application/vnd.fujixerox.art-ex":{source:"iana"},"application/vnd.fujixerox.art4":{source:"iana"},"application/vnd.fujixerox.ddd":{source:"iana",extensions:["ddd"]},"application/vnd.fujixerox.docuworks":{source:"iana",extensions:["xdw"]},"application/vnd.fujixerox.docuworks.binder":{source:"iana",extensions:["xbd"]},"application/vnd.fujixerox.docuworks.container":{source:"iana"},"application/vnd.fujixerox.hbpl":{source:"iana"},"application/vnd.fut-misnet":{source:"iana"},"application/vnd.futoin+cbor":{source:"iana"},"application/vnd.futoin+json":{source:"iana",compressible:!0},"application/vnd.fuzzysheet":{source:"iana",extensions:["fzs"]},"application/vnd.genomatix.tuxedo":{source:"iana",extensions:["txd"]},"application/vnd.gentics.grd+json":{source:"iana",compressible:!0},"application/vnd.geo+json":{source:"iana",compressible:!0},"application/vnd.geocube+xml":{source:"iana",compressible:!0},"application/vnd.geogebra.file":{source:"iana",extensions:["ggb"]},"application/vnd.geogebra.slides":{source:"iana"},"application/vnd.geogebra.tool":{source:"iana",extensions:["ggt"]},"application/vnd.geometry-explorer":{source:"iana",extensions:["gex","gre"]},"application/vnd.geonext":{source:"iana",extensions:["gxt"]},"application/vnd.geoplan":{source:"iana",extensions:["g2w"]},"application/vnd.geospace":{source:"iana",extensions:["g3w"]},"application/vnd.gerber":{source:"iana"},"application/vnd.globalplatform.card-content-mgt":{source:"iana"},"application/vnd.globalplatform.card-content-mgt-response":{source:"iana"},"application/vnd.gmx":{source:"iana",extensions:["gmx"]},"application/vnd.google-apps.document":{compressible:!1,extensions:["gdoc"]},"application/vnd.google-apps.presentation":{compressible:!1,extensions:["gslides"]},"application/vnd.google-apps.spreadsheet":{compressible:!1,extensions:["gsheet"]},"application/vnd.google-earth.kml+xml":{source:"iana",compressible:!0,extensions:["kml"]},"application/vnd.google-earth.kmz":{source:"iana",compressible:!1,extensions:["kmz"]},"application/vnd.gov.sk.e-form+xml":{source:"iana",compressible:!0},"application/vnd.gov.sk.e-form+zip":{source:"iana",compressible:!1},"application/vnd.gov.sk.xmldatacontainer+xml":{source:"iana",compressible:!0},"application/vnd.grafeq":{source:"iana",extensions:["gqf","gqs"]},"application/vnd.gridmp":{source:"iana"},"application/vnd.groove-account":{source:"iana",extensions:["gac"]},"application/vnd.groove-help":{source:"iana",extensions:["ghf"]},"application/vnd.groove-identity-message":{source:"iana",extensions:["gim"]},"application/vnd.groove-injector":{source:"iana",extensions:["grv"]},"application/vnd.groove-tool-message":{source:"iana",extensions:["gtm"]},"application/vnd.groove-tool-template":{source:"iana",extensions:["tpl"]},"application/vnd.groove-vcard":{source:"iana",extensions:["vcg"]},"application/vnd.hal+json":{source:"iana",compressible:!0},"application/vnd.hal+xml":{source:"iana",compressible:!0,extensions:["hal"]},"application/vnd.handheld-entertainment+xml":{source:"iana",compressible:!0,extensions:["zmm"]},"application/vnd.hbci":{source:"iana",extensions:["hbci"]},"application/vnd.hc+json":{source:"iana",compressible:!0},"application/vnd.hcl-bireports":{source:"iana"},"application/vnd.hdt":{source:"iana"},"application/vnd.heroku+json":{source:"iana",compressible:!0},"application/vnd.hhe.lesson-player":{source:"iana",extensions:["les"]},"application/vnd.hl7cda+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/vnd.hl7v2+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/vnd.hp-hpgl":{source:"iana",extensions:["hpgl"]},"application/vnd.hp-hpid":{source:"iana",extensions:["hpid"]},"application/vnd.hp-hps":{source:"iana",extensions:["hps"]},"application/vnd.hp-jlyt":{source:"iana",extensions:["jlt"]},"application/vnd.hp-pcl":{source:"iana",extensions:["pcl"]},"application/vnd.hp-pclxl":{source:"iana",extensions:["pclxl"]},"application/vnd.httphone":{source:"iana"},"application/vnd.hydrostatix.sof-data":{source:"iana",extensions:["sfd-hdstx"]},"application/vnd.hyper+json":{source:"iana",compressible:!0},"application/vnd.hyper-item+json":{source:"iana",compressible:!0},"application/vnd.hyperdrive+json":{source:"iana",compressible:!0},"application/vnd.hzn-3d-crossword":{source:"iana"},"application/vnd.ibm.afplinedata":{source:"iana"},"application/vnd.ibm.electronic-media":{source:"iana"},"application/vnd.ibm.minipay":{source:"iana",extensions:["mpy"]},"application/vnd.ibm.modcap":{source:"iana",extensions:["afp","listafp","list3820"]},"application/vnd.ibm.rights-management":{source:"iana",extensions:["irm"]},"application/vnd.ibm.secure-container":{source:"iana",extensions:["sc"]},"application/vnd.iccprofile":{source:"iana",extensions:["icc","icm"]},"application/vnd.ieee.1905":{source:"iana"},"application/vnd.igloader":{source:"iana",extensions:["igl"]},"application/vnd.imagemeter.folder+zip":{source:"iana",compressible:!1},"application/vnd.imagemeter.image+zip":{source:"iana",compressible:!1},"application/vnd.immervision-ivp":{source:"iana",extensions:["ivp"]},"application/vnd.immervision-ivu":{source:"iana",extensions:["ivu"]},"application/vnd.ims.imsccv1p1":{source:"iana"},"application/vnd.ims.imsccv1p2":{source:"iana"},"application/vnd.ims.imsccv1p3":{source:"iana"},"application/vnd.ims.lis.v2.result+json":{source:"iana",compressible:!0},"application/vnd.ims.lti.v2.toolconsumerprofile+json":{source:"iana",compressible:!0},"application/vnd.ims.lti.v2.toolproxy+json":{source:"iana",compressible:!0},"application/vnd.ims.lti.v2.toolproxy.id+json":{source:"iana",compressible:!0},"application/vnd.ims.lti.v2.toolsettings+json":{source:"iana",compressible:!0},"application/vnd.ims.lti.v2.toolsettings.simple+json":{source:"iana",compressible:!0},"application/vnd.informedcontrol.rms+xml":{source:"iana",compressible:!0},"application/vnd.informix-visionary":{source:"iana"},"application/vnd.infotech.project":{source:"iana"},"application/vnd.infotech.project+xml":{source:"iana",compressible:!0},"application/vnd.innopath.wamp.notification":{source:"iana"},"application/vnd.insors.igm":{source:"iana",extensions:["igm"]},"application/vnd.intercon.formnet":{source:"iana",extensions:["xpw","xpx"]},"application/vnd.intergeo":{source:"iana",extensions:["i2g"]},"application/vnd.intertrust.digibox":{source:"iana"},"application/vnd.intertrust.nncp":{source:"iana"},"application/vnd.intu.qbo":{source:"iana",extensions:["qbo"]},"application/vnd.intu.qfx":{source:"iana",extensions:["qfx"]},"application/vnd.iptc.g2.catalogitem+xml":{source:"iana",compressible:!0},"application/vnd.iptc.g2.conceptitem+xml":{source:"iana",compressible:!0},"application/vnd.iptc.g2.knowledgeitem+xml":{source:"iana",compressible:!0},"application/vnd.iptc.g2.newsitem+xml":{source:"iana",compressible:!0},"application/vnd.iptc.g2.newsmessage+xml":{source:"iana",compressible:!0},"application/vnd.iptc.g2.packageitem+xml":{source:"iana",compressible:!0},"application/vnd.iptc.g2.planningitem+xml":{source:"iana",compressible:!0},"application/vnd.ipunplugged.rcprofile":{source:"iana",extensions:["rcprofile"]},"application/vnd.irepository.package+xml":{source:"iana",compressible:!0,extensions:["irp"]},"application/vnd.is-xpr":{source:"iana",extensions:["xpr"]},"application/vnd.isac.fcs":{source:"iana",extensions:["fcs"]},"application/vnd.iso11783-10+zip":{source:"iana",compressible:!1},"application/vnd.jam":{source:"iana",extensions:["jam"]},"application/vnd.japannet-directory-service":{source:"iana"},"application/vnd.japannet-jpnstore-wakeup":{source:"iana"},"application/vnd.japannet-payment-wakeup":{source:"iana"},"application/vnd.japannet-registration":{source:"iana"},"application/vnd.japannet-registration-wakeup":{source:"iana"},"application/vnd.japannet-setstore-wakeup":{source:"iana"},"application/vnd.japannet-verification":{source:"iana"},"application/vnd.japannet-verification-wakeup":{source:"iana"},"application/vnd.jcp.javame.midlet-rms":{source:"iana",extensions:["rms"]},"application/vnd.jisp":{source:"iana",extensions:["jisp"]},"application/vnd.joost.joda-archive":{source:"iana",extensions:["joda"]},"application/vnd.jsk.isdn-ngn":{source:"iana"},"application/vnd.kahootz":{source:"iana",extensions:["ktz","ktr"]},"application/vnd.kde.karbon":{source:"iana",extensions:["karbon"]},"application/vnd.kde.kchart":{source:"iana",extensions:["chrt"]},"application/vnd.kde.kformula":{source:"iana",extensions:["kfo"]},"application/vnd.kde.kivio":{source:"iana",extensions:["flw"]},"application/vnd.kde.kontour":{source:"iana",extensions:["kon"]},"application/vnd.kde.kpresenter":{source:"iana",extensions:["kpr","kpt"]},"application/vnd.kde.kspread":{source:"iana",extensions:["ksp"]},"application/vnd.kde.kword":{source:"iana",extensions:["kwd","kwt"]},"application/vnd.kenameaapp":{source:"iana",extensions:["htke"]},"application/vnd.kidspiration":{source:"iana",extensions:["kia"]},"application/vnd.kinar":{source:"iana",extensions:["kne","knp"]},"application/vnd.koan":{source:"iana",extensions:["skp","skd","skt","skm"]},"application/vnd.kodak-descriptor":{source:"iana",extensions:["sse"]},"application/vnd.las":{source:"iana"},"application/vnd.las.las+json":{source:"iana",compressible:!0},"application/vnd.las.las+xml":{source:"iana",compressible:!0,extensions:["lasxml"]},"application/vnd.laszip":{source:"iana"},"application/vnd.leap+json":{source:"iana",compressible:!0},"application/vnd.liberty-request+xml":{source:"iana",compressible:!0},"application/vnd.llamagraphics.life-balance.desktop":{source:"iana",extensions:["lbd"]},"application/vnd.llamagraphics.life-balance.exchange+xml":{source:"iana",compressible:!0,extensions:["lbe"]},"application/vnd.logipipe.circuit+zip":{source:"iana",compressible:!1},"application/vnd.loom":{source:"iana"},"application/vnd.lotus-1-2-3":{source:"iana",extensions:["123"]},"application/vnd.lotus-approach":{source:"iana",extensions:["apr"]},"application/vnd.lotus-freelance":{source:"iana",extensions:["pre"]},"application/vnd.lotus-notes":{source:"iana",extensions:["nsf"]},"application/vnd.lotus-organizer":{source:"iana",extensions:["org"]},"application/vnd.lotus-screencam":{source:"iana",extensions:["scm"]},"application/vnd.lotus-wordpro":{source:"iana",extensions:["lwp"]},"application/vnd.macports.portpkg":{source:"iana",extensions:["portpkg"]},"application/vnd.mapbox-vector-tile":{source:"iana",extensions:["mvt"]},"application/vnd.marlin.drm.actiontoken+xml":{source:"iana",compressible:!0},"application/vnd.marlin.drm.conftoken+xml":{source:"iana",compressible:!0},"application/vnd.marlin.drm.license+xml":{source:"iana",compressible:!0},"application/vnd.marlin.drm.mdcf":{source:"iana"},"application/vnd.mason+json":{source:"iana",compressible:!0},"application/vnd.maxar.archive.3tz+zip":{source:"iana",compressible:!1},"application/vnd.maxmind.maxmind-db":{source:"iana"},"application/vnd.mcd":{source:"iana",extensions:["mcd"]},"application/vnd.medcalcdata":{source:"iana",extensions:["mc1"]},"application/vnd.mediastation.cdkey":{source:"iana",extensions:["cdkey"]},"application/vnd.meridian-slingshot":{source:"iana"},"application/vnd.mfer":{source:"iana",extensions:["mwf"]},"application/vnd.mfmp":{source:"iana",extensions:["mfm"]},"application/vnd.micro+json":{source:"iana",compressible:!0},"application/vnd.micrografx.flo":{source:"iana",extensions:["flo"]},"application/vnd.micrografx.igx":{source:"iana",extensions:["igx"]},"application/vnd.microsoft.portable-executable":{source:"iana"},"application/vnd.microsoft.windows.thumbnail-cache":{source:"iana"},"application/vnd.miele+json":{source:"iana",compressible:!0},"application/vnd.mif":{source:"iana",extensions:["mif"]},"application/vnd.minisoft-hp3000-save":{source:"iana"},"application/vnd.mitsubishi.misty-guard.trustweb":{source:"iana"},"application/vnd.mobius.daf":{source:"iana",extensions:["daf"]},"application/vnd.mobius.dis":{source:"iana",extensions:["dis"]},"application/vnd.mobius.mbk":{source:"iana",extensions:["mbk"]},"application/vnd.mobius.mqy":{source:"iana",extensions:["mqy"]},"application/vnd.mobius.msl":{source:"iana",extensions:["msl"]},"application/vnd.mobius.plc":{source:"iana",extensions:["plc"]},"application/vnd.mobius.txf":{source:"iana",extensions:["txf"]},"application/vnd.mophun.application":{source:"iana",extensions:["mpn"]},"application/vnd.mophun.certificate":{source:"iana",extensions:["mpc"]},"application/vnd.motorola.flexsuite":{source:"iana"},"application/vnd.motorola.flexsuite.adsi":{source:"iana"},"application/vnd.motorola.flexsuite.fis":{source:"iana"},"application/vnd.motorola.flexsuite.gotap":{source:"iana"},"application/vnd.motorola.flexsuite.kmr":{source:"iana"},"application/vnd.motorola.flexsuite.ttc":{source:"iana"},"application/vnd.motorola.flexsuite.wem":{source:"iana"},"application/vnd.motorola.iprm":{source:"iana"},"application/vnd.mozilla.xul+xml":{source:"iana",compressible:!0,extensions:["xul"]},"application/vnd.ms-3mfdocument":{source:"iana"},"application/vnd.ms-artgalry":{source:"iana",extensions:["cil"]},"application/vnd.ms-asf":{source:"iana"},"application/vnd.ms-cab-compressed":{source:"iana",extensions:["cab"]},"application/vnd.ms-color.iccprofile":{source:"apache"},"application/vnd.ms-excel":{source:"iana",compressible:!1,extensions:["xls","xlm","xla","xlc","xlt","xlw"]},"application/vnd.ms-excel.addin.macroenabled.12":{source:"iana",extensions:["xlam"]},"application/vnd.ms-excel.sheet.binary.macroenabled.12":{source:"iana",extensions:["xlsb"]},"application/vnd.ms-excel.sheet.macroenabled.12":{source:"iana",extensions:["xlsm"]},"application/vnd.ms-excel.template.macroenabled.12":{source:"iana",extensions:["xltm"]},"application/vnd.ms-fontobject":{source:"iana",compressible:!0,extensions:["eot"]},"application/vnd.ms-htmlhelp":{source:"iana",extensions:["chm"]},"application/vnd.ms-ims":{source:"iana",extensions:["ims"]},"application/vnd.ms-lrm":{source:"iana",extensions:["lrm"]},"application/vnd.ms-office.activex+xml":{source:"iana",compressible:!0},"application/vnd.ms-officetheme":{source:"iana",extensions:["thmx"]},"application/vnd.ms-opentype":{source:"apache",compressible:!0},"application/vnd.ms-outlook":{compressible:!1,extensions:["msg"]},"application/vnd.ms-package.obfuscated-opentype":{source:"apache"},"application/vnd.ms-pki.seccat":{source:"apache",extensions:["cat"]},"application/vnd.ms-pki.stl":{source:"apache",extensions:["stl"]},"application/vnd.ms-playready.initiator+xml":{source:"iana",compressible:!0},"application/vnd.ms-powerpoint":{source:"iana",compressible:!1,extensions:["ppt","pps","pot"]},"application/vnd.ms-powerpoint.addin.macroenabled.12":{source:"iana",extensions:["ppam"]},"application/vnd.ms-powerpoint.presentation.macroenabled.12":{source:"iana",extensions:["pptm"]},"application/vnd.ms-powerpoint.slide.macroenabled.12":{source:"iana",extensions:["sldm"]},"application/vnd.ms-powerpoint.slideshow.macroenabled.12":{source:"iana",extensions:["ppsm"]},"application/vnd.ms-powerpoint.template.macroenabled.12":{source:"iana",extensions:["potm"]},"application/vnd.ms-printdevicecapabilities+xml":{source:"iana",compressible:!0},"application/vnd.ms-printing.printticket+xml":{source:"apache",compressible:!0},"application/vnd.ms-printschematicket+xml":{source:"iana",compressible:!0},"application/vnd.ms-project":{source:"iana",extensions:["mpp","mpt"]},"application/vnd.ms-tnef":{source:"iana"},"application/vnd.ms-windows.devicepairing":{source:"iana"},"application/vnd.ms-windows.nwprinting.oob":{source:"iana"},"application/vnd.ms-windows.printerpairing":{source:"iana"},"application/vnd.ms-windows.wsd.oob":{source:"iana"},"application/vnd.ms-wmdrm.lic-chlg-req":{source:"iana"},"application/vnd.ms-wmdrm.lic-resp":{source:"iana"},"application/vnd.ms-wmdrm.meter-chlg-req":{source:"iana"},"application/vnd.ms-wmdrm.meter-resp":{source:"iana"},"application/vnd.ms-word.document.macroenabled.12":{source:"iana",extensions:["docm"]},"application/vnd.ms-word.template.macroenabled.12":{source:"iana",extensions:["dotm"]},"application/vnd.ms-works":{source:"iana",extensions:["wps","wks","wcm","wdb"]},"application/vnd.ms-wpl":{source:"iana",extensions:["wpl"]},"application/vnd.ms-xpsdocument":{source:"iana",compressible:!1,extensions:["xps"]},"application/vnd.msa-disk-image":{source:"iana"},"application/vnd.mseq":{source:"iana",extensions:["mseq"]},"application/vnd.msign":{source:"iana"},"application/vnd.multiad.creator":{source:"iana"},"application/vnd.multiad.creator.cif":{source:"iana"},"application/vnd.music-niff":{source:"iana"},"application/vnd.musician":{source:"iana",extensions:["mus"]},"application/vnd.muvee.style":{source:"iana",extensions:["msty"]},"application/vnd.mynfc":{source:"iana",extensions:["taglet"]},"application/vnd.nacamar.ybrid+json":{source:"iana",compressible:!0},"application/vnd.ncd.control":{source:"iana"},"application/vnd.ncd.reference":{source:"iana"},"application/vnd.nearst.inv+json":{source:"iana",compressible:!0},"application/vnd.nebumind.line":{source:"iana"},"application/vnd.nervana":{source:"iana"},"application/vnd.netfpx":{source:"iana"},"application/vnd.neurolanguage.nlu":{source:"iana",extensions:["nlu"]},"application/vnd.nimn":{source:"iana"},"application/vnd.nintendo.nitro.rom":{source:"iana"},"application/vnd.nintendo.snes.rom":{source:"iana"},"application/vnd.nitf":{source:"iana",extensions:["ntf","nitf"]},"application/vnd.noblenet-directory":{source:"iana",extensions:["nnd"]},"application/vnd.noblenet-sealer":{source:"iana",extensions:["nns"]},"application/vnd.noblenet-web":{source:"iana",extensions:["nnw"]},"application/vnd.nokia.catalogs":{source:"iana"},"application/vnd.nokia.conml+wbxml":{source:"iana"},"application/vnd.nokia.conml+xml":{source:"iana",compressible:!0},"application/vnd.nokia.iptv.config+xml":{source:"iana",compressible:!0},"application/vnd.nokia.isds-radio-presets":{source:"iana"},"application/vnd.nokia.landmark+wbxml":{source:"iana"},"application/vnd.nokia.landmark+xml":{source:"iana",compressible:!0},"application/vnd.nokia.landmarkcollection+xml":{source:"iana",compressible:!0},"application/vnd.nokia.n-gage.ac+xml":{source:"iana",compressible:!0,extensions:["ac"]},"application/vnd.nokia.n-gage.data":{source:"iana",extensions:["ngdat"]},"application/vnd.nokia.n-gage.symbian.install":{source:"iana",extensions:["n-gage"]},"application/vnd.nokia.ncd":{source:"iana"},"application/vnd.nokia.pcd+wbxml":{source:"iana"},"application/vnd.nokia.pcd+xml":{source:"iana",compressible:!0},"application/vnd.nokia.radio-preset":{source:"iana",extensions:["rpst"]},"application/vnd.nokia.radio-presets":{source:"iana",extensions:["rpss"]},"application/vnd.novadigm.edm":{source:"iana",extensions:["edm"]},"application/vnd.novadigm.edx":{source:"iana",extensions:["edx"]},"application/vnd.novadigm.ext":{source:"iana",extensions:["ext"]},"application/vnd.ntt-local.content-share":{source:"iana"},"application/vnd.ntt-local.file-transfer":{source:"iana"},"application/vnd.ntt-local.ogw_remote-access":{source:"iana"},"application/vnd.ntt-local.sip-ta_remote":{source:"iana"},"application/vnd.ntt-local.sip-ta_tcp_stream":{source:"iana"},"application/vnd.oasis.opendocument.chart":{source:"iana",extensions:["odc"]},"application/vnd.oasis.opendocument.chart-template":{source:"iana",extensions:["otc"]},"application/vnd.oasis.opendocument.database":{source:"iana",extensions:["odb"]},"application/vnd.oasis.opendocument.formula":{source:"iana",extensions:["odf"]},"application/vnd.oasis.opendocument.formula-template":{source:"iana",extensions:["odft"]},"application/vnd.oasis.opendocument.graphics":{source:"iana",compressible:!1,extensions:["odg"]},"application/vnd.oasis.opendocument.graphics-template":{source:"iana",extensions:["otg"]},"application/vnd.oasis.opendocument.image":{source:"iana",extensions:["odi"]},"application/vnd.oasis.opendocument.image-template":{source:"iana",extensions:["oti"]},"application/vnd.oasis.opendocument.presentation":{source:"iana",compressible:!1,extensions:["odp"]},"application/vnd.oasis.opendocument.presentation-template":{source:"iana",extensions:["otp"]},"application/vnd.oasis.opendocument.spreadsheet":{source:"iana",compressible:!1,extensions:["ods"]},"application/vnd.oasis.opendocument.spreadsheet-template":{source:"iana",extensions:["ots"]},"application/vnd.oasis.opendocument.text":{source:"iana",compressible:!1,extensions:["odt"]},"application/vnd.oasis.opendocument.text-master":{source:"iana",extensions:["odm"]},"application/vnd.oasis.opendocument.text-template":{source:"iana",extensions:["ott"]},"application/vnd.oasis.opendocument.text-web":{source:"iana",extensions:["oth"]},"application/vnd.obn":{source:"iana"},"application/vnd.ocf+cbor":{source:"iana"},"application/vnd.oci.image.manifest.v1+json":{source:"iana",compressible:!0},"application/vnd.oftn.l10n+json":{source:"iana",compressible:!0},"application/vnd.oipf.contentaccessdownload+xml":{source:"iana",compressible:!0},"application/vnd.oipf.contentaccessstreaming+xml":{source:"iana",compressible:!0},"application/vnd.oipf.cspg-hexbinary":{source:"iana"},"application/vnd.oipf.dae.svg+xml":{source:"iana",compressible:!0},"application/vnd.oipf.dae.xhtml+xml":{source:"iana",compressible:!0},"application/vnd.oipf.mippvcontrolmessage+xml":{source:"iana",compressible:!0},"application/vnd.oipf.pae.gem":{source:"iana"},"application/vnd.oipf.spdiscovery+xml":{source:"iana",compressible:!0},"application/vnd.oipf.spdlist+xml":{source:"iana",compressible:!0},"application/vnd.oipf.ueprofile+xml":{source:"iana",compressible:!0},"application/vnd.oipf.userprofile+xml":{source:"iana",compressible:!0},"application/vnd.olpc-sugar":{source:"iana",extensions:["xo"]},"application/vnd.oma-scws-config":{source:"iana"},"application/vnd.oma-scws-http-request":{source:"iana"},"application/vnd.oma-scws-http-response":{source:"iana"},"application/vnd.oma.bcast.associated-procedure-parameter+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.drm-trigger+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.imd+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.ltkm":{source:"iana"},"application/vnd.oma.bcast.notification+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.provisioningtrigger":{source:"iana"},"application/vnd.oma.bcast.sgboot":{source:"iana"},"application/vnd.oma.bcast.sgdd+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.sgdu":{source:"iana"},"application/vnd.oma.bcast.simple-symbol-container":{source:"iana"},"application/vnd.oma.bcast.smartcard-trigger+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.sprov+xml":{source:"iana",compressible:!0},"application/vnd.oma.bcast.stkm":{source:"iana"},"application/vnd.oma.cab-address-book+xml":{source:"iana",compressible:!0},"application/vnd.oma.cab-feature-handler+xml":{source:"iana",compressible:!0},"application/vnd.oma.cab-pcc+xml":{source:"iana",compressible:!0},"application/vnd.oma.cab-subs-invite+xml":{source:"iana",compressible:!0},"application/vnd.oma.cab-user-prefs+xml":{source:"iana",compressible:!0},"application/vnd.oma.dcd":{source:"iana"},"application/vnd.oma.dcdc":{source:"iana"},"application/vnd.oma.dd2+xml":{source:"iana",compressible:!0,extensions:["dd2"]},"application/vnd.oma.drm.risd+xml":{source:"iana",compressible:!0},"application/vnd.oma.group-usage-list+xml":{source:"iana",compressible:!0},"application/vnd.oma.lwm2m+cbor":{source:"iana"},"application/vnd.oma.lwm2m+json":{source:"iana",compressible:!0},"application/vnd.oma.lwm2m+tlv":{source:"iana"},"application/vnd.oma.pal+xml":{source:"iana",compressible:!0},"application/vnd.oma.poc.detailed-progress-report+xml":{source:"iana",compressible:!0},"application/vnd.oma.poc.final-report+xml":{source:"iana",compressible:!0},"application/vnd.oma.poc.groups+xml":{source:"iana",compressible:!0},"application/vnd.oma.poc.invocation-descriptor+xml":{source:"iana",compressible:!0},"application/vnd.oma.poc.optimized-progress-report+xml":{source:"iana",compressible:!0},"application/vnd.oma.push":{source:"iana"},"application/vnd.oma.scidm.messages+xml":{source:"iana",compressible:!0},"application/vnd.oma.xcap-directory+xml":{source:"iana",compressible:!0},"application/vnd.omads-email+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/vnd.omads-file+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/vnd.omads-folder+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/vnd.omaloc-supl-init":{source:"iana"},"application/vnd.onepager":{source:"iana"},"application/vnd.onepagertamp":{source:"iana"},"application/vnd.onepagertamx":{source:"iana"},"application/vnd.onepagertat":{source:"iana"},"application/vnd.onepagertatp":{source:"iana"},"application/vnd.onepagertatx":{source:"iana"},"application/vnd.openblox.game+xml":{source:"iana",compressible:!0,extensions:["obgx"]},"application/vnd.openblox.game-binary":{source:"iana"},"application/vnd.openeye.oeb":{source:"iana"},"application/vnd.openofficeorg.extension":{source:"apache",extensions:["oxt"]},"application/vnd.openstreetmap.data+xml":{source:"iana",compressible:!0,extensions:["osm"]},"application/vnd.opentimestamps.ots":{source:"iana"},"application/vnd.openxmlformats-officedocument.custom-properties+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.customxmlproperties+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawing+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawingml.chart+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.extended-properties+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.comments+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.presentation":{source:"iana",compressible:!1,extensions:["pptx"]},"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.presprops+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.slide":{source:"iana",extensions:["sldx"]},"application/vnd.openxmlformats-officedocument.presentationml.slide+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.slideshow":{source:"iana",extensions:["ppsx"]},"application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.tags+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.template":{source:"iana",extensions:["potx"]},"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{source:"iana",compressible:!1,extensions:["xlsx"]},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.template":{source:"iana",extensions:["xltx"]},"application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.theme+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.themeoverride+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.vmldrawing":{source:"iana"},"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.document":{source:"iana",compressible:!1,extensions:["docx"]},"application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.template":{source:"iana",extensions:["dotx"]},"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-package.core-properties+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml":{source:"iana",compressible:!0},"application/vnd.openxmlformats-package.relationships+xml":{source:"iana",compressible:!0},"application/vnd.oracle.resource+json":{source:"iana",compressible:!0},"application/vnd.orange.indata":{source:"iana"},"application/vnd.osa.netdeploy":{source:"iana"},"application/vnd.osgeo.mapguide.package":{source:"iana",extensions:["mgp"]},"application/vnd.osgi.bundle":{source:"iana"},"application/vnd.osgi.dp":{source:"iana",extensions:["dp"]},"application/vnd.osgi.subsystem":{source:"iana",extensions:["esa"]},"application/vnd.otps.ct-kip+xml":{source:"iana",compressible:!0},"application/vnd.oxli.countgraph":{source:"iana"},"application/vnd.pagerduty+json":{source:"iana",compressible:!0},"application/vnd.palm":{source:"iana",extensions:["pdb","pqa","oprc"]},"application/vnd.panoply":{source:"iana"},"application/vnd.paos.xml":{source:"iana"},"application/vnd.patentdive":{source:"iana"},"application/vnd.patientecommsdoc":{source:"iana"},"application/vnd.pawaafile":{source:"iana",extensions:["paw"]},"application/vnd.pcos":{source:"iana"},"application/vnd.pg.format":{source:"iana",extensions:["str"]},"application/vnd.pg.osasli":{source:"iana",extensions:["ei6"]},"application/vnd.piaccess.application-licence":{source:"iana"},"application/vnd.picsel":{source:"iana",extensions:["efif"]},"application/vnd.pmi.widget":{source:"iana",extensions:["wg"]},"application/vnd.poc.group-advertisement+xml":{source:"iana",compressible:!0},"application/vnd.pocketlearn":{source:"iana",extensions:["plf"]},"application/vnd.powerbuilder6":{source:"iana",extensions:["pbd"]},"application/vnd.powerbuilder6-s":{source:"iana"},"application/vnd.powerbuilder7":{source:"iana"},"application/vnd.powerbuilder7-s":{source:"iana"},"application/vnd.powerbuilder75":{source:"iana"},"application/vnd.powerbuilder75-s":{source:"iana"},"application/vnd.preminet":{source:"iana"},"application/vnd.previewsystems.box":{source:"iana",extensions:["box"]},"application/vnd.proteus.magazine":{source:"iana",extensions:["mgz"]},"application/vnd.psfs":{source:"iana"},"application/vnd.publishare-delta-tree":{source:"iana",extensions:["qps"]},"application/vnd.pvi.ptid1":{source:"iana",extensions:["ptid"]},"application/vnd.pwg-multiplexed":{source:"iana"},"application/vnd.pwg-xhtml-print+xml":{source:"iana",compressible:!0},"application/vnd.qualcomm.brew-app-res":{source:"iana"},"application/vnd.quarantainenet":{source:"iana"},"application/vnd.quark.quarkxpress":{source:"iana",extensions:["qxd","qxt","qwd","qwt","qxl","qxb"]},"application/vnd.quobject-quoxdocument":{source:"iana"},"application/vnd.radisys.moml+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-audit+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-audit-conf+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-audit-conn+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-audit-dialog+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-audit-stream+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-conf+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog-base+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog-fax-detect+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog-fax-sendrecv+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog-group+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog-speech+xml":{source:"iana",compressible:!0},"application/vnd.radisys.msml-dialog-transform+xml":{source:"iana",compressible:!0},"application/vnd.rainstor.data":{source:"iana"},"application/vnd.rapid":{source:"iana"},"application/vnd.rar":{source:"iana",extensions:["rar"]},"application/vnd.realvnc.bed":{source:"iana",extensions:["bed"]},"application/vnd.recordare.musicxml":{source:"iana",extensions:["mxl"]},"application/vnd.recordare.musicxml+xml":{source:"iana",compressible:!0,extensions:["musicxml"]},"application/vnd.renlearn.rlprint":{source:"iana"},"application/vnd.resilient.logic":{source:"iana"},"application/vnd.restful+json":{source:"iana",compressible:!0},"application/vnd.rig.cryptonote":{source:"iana",extensions:["cryptonote"]},"application/vnd.rim.cod":{source:"apache",extensions:["cod"]},"application/vnd.rn-realmedia":{source:"apache",extensions:["rm"]},"application/vnd.rn-realmedia-vbr":{source:"apache",extensions:["rmvb"]},"application/vnd.route66.link66+xml":{source:"iana",compressible:!0,extensions:["link66"]},"application/vnd.rs-274x":{source:"iana"},"application/vnd.ruckus.download":{source:"iana"},"application/vnd.s3sms":{source:"iana"},"application/vnd.sailingtracker.track":{source:"iana",extensions:["st"]},"application/vnd.sar":{source:"iana"},"application/vnd.sbm.cid":{source:"iana"},"application/vnd.sbm.mid2":{source:"iana"},"application/vnd.scribus":{source:"iana"},"application/vnd.sealed.3df":{source:"iana"},"application/vnd.sealed.csf":{source:"iana"},"application/vnd.sealed.doc":{source:"iana"},"application/vnd.sealed.eml":{source:"iana"},"application/vnd.sealed.mht":{source:"iana"},"application/vnd.sealed.net":{source:"iana"},"application/vnd.sealed.ppt":{source:"iana"},"application/vnd.sealed.tiff":{source:"iana"},"application/vnd.sealed.xls":{source:"iana"},"application/vnd.sealedmedia.softseal.html":{source:"iana"},"application/vnd.sealedmedia.softseal.pdf":{source:"iana"},"application/vnd.seemail":{source:"iana",extensions:["see"]},"application/vnd.seis+json":{source:"iana",compressible:!0},"application/vnd.sema":{source:"iana",extensions:["sema"]},"application/vnd.semd":{source:"iana",extensions:["semd"]},"application/vnd.semf":{source:"iana",extensions:["semf"]},"application/vnd.shade-save-file":{source:"iana"},"application/vnd.shana.informed.formdata":{source:"iana",extensions:["ifm"]},"application/vnd.shana.informed.formtemplate":{source:"iana",extensions:["itp"]},"application/vnd.shana.informed.interchange":{source:"iana",extensions:["iif"]},"application/vnd.shana.informed.package":{source:"iana",extensions:["ipk"]},"application/vnd.shootproof+json":{source:"iana",compressible:!0},"application/vnd.shopkick+json":{source:"iana",compressible:!0},"application/vnd.shp":{source:"iana"},"application/vnd.shx":{source:"iana"},"application/vnd.sigrok.session":{source:"iana"},"application/vnd.simtech-mindmapper":{source:"iana",extensions:["twd","twds"]},"application/vnd.siren+json":{source:"iana",compressible:!0},"application/vnd.smaf":{source:"iana",extensions:["mmf"]},"application/vnd.smart.notebook":{source:"iana"},"application/vnd.smart.teacher":{source:"iana",extensions:["teacher"]},"application/vnd.snesdev-page-table":{source:"iana"},"application/vnd.software602.filler.form+xml":{source:"iana",compressible:!0,extensions:["fo"]},"application/vnd.software602.filler.form-xml-zip":{source:"iana"},"application/vnd.solent.sdkm+xml":{source:"iana",compressible:!0,extensions:["sdkm","sdkd"]},"application/vnd.spotfire.dxp":{source:"iana",extensions:["dxp"]},"application/vnd.spotfire.sfs":{source:"iana",extensions:["sfs"]},"application/vnd.sqlite3":{source:"iana"},"application/vnd.sss-cod":{source:"iana"},"application/vnd.sss-dtf":{source:"iana"},"application/vnd.sss-ntf":{source:"iana"},"application/vnd.stardivision.calc":{source:"apache",extensions:["sdc"]},"application/vnd.stardivision.draw":{source:"apache",extensions:["sda"]},"application/vnd.stardivision.impress":{source:"apache",extensions:["sdd"]},"application/vnd.stardivision.math":{source:"apache",extensions:["smf"]},"application/vnd.stardivision.writer":{source:"apache",extensions:["sdw","vor"]},"application/vnd.stardivision.writer-global":{source:"apache",extensions:["sgl"]},"application/vnd.stepmania.package":{source:"iana",extensions:["smzip"]},"application/vnd.stepmania.stepchart":{source:"iana",extensions:["sm"]},"application/vnd.street-stream":{source:"iana"},"application/vnd.sun.wadl+xml":{source:"iana",compressible:!0,extensions:["wadl"]},"application/vnd.sun.xml.calc":{source:"apache",extensions:["sxc"]},"application/vnd.sun.xml.calc.template":{source:"apache",extensions:["stc"]},"application/vnd.sun.xml.draw":{source:"apache",extensions:["sxd"]},"application/vnd.sun.xml.draw.template":{source:"apache",extensions:["std"]},"application/vnd.sun.xml.impress":{source:"apache",extensions:["sxi"]},"application/vnd.sun.xml.impress.template":{source:"apache",extensions:["sti"]},"application/vnd.sun.xml.math":{source:"apache",extensions:["sxm"]},"application/vnd.sun.xml.writer":{source:"apache",extensions:["sxw"]},"application/vnd.sun.xml.writer.global":{source:"apache",extensions:["sxg"]},"application/vnd.sun.xml.writer.template":{source:"apache",extensions:["stw"]},"application/vnd.sus-calendar":{source:"iana",extensions:["sus","susp"]},"application/vnd.svd":{source:"iana",extensions:["svd"]},"application/vnd.swiftview-ics":{source:"iana"},"application/vnd.sycle+xml":{source:"iana",compressible:!0},"application/vnd.syft+json":{source:"iana",compressible:!0},"application/vnd.symbian.install":{source:"apache",extensions:["sis","sisx"]},"application/vnd.syncml+xml":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["xsm"]},"application/vnd.syncml.dm+wbxml":{source:"iana",charset:"UTF-8",extensions:["bdm"]},"application/vnd.syncml.dm+xml":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["xdm"]},"application/vnd.syncml.dm.notification":{source:"iana"},"application/vnd.syncml.dmddf+wbxml":{source:"iana"},"application/vnd.syncml.dmddf+xml":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["ddf"]},"application/vnd.syncml.dmtnds+wbxml":{source:"iana"},"application/vnd.syncml.dmtnds+xml":{source:"iana",charset:"UTF-8",compressible:!0},"application/vnd.syncml.ds.notification":{source:"iana"},"application/vnd.tableschema+json":{source:"iana",compressible:!0},"application/vnd.tao.intent-module-archive":{source:"iana",extensions:["tao"]},"application/vnd.tcpdump.pcap":{source:"iana",extensions:["pcap","cap","dmp"]},"application/vnd.think-cell.ppttc+json":{source:"iana",compressible:!0},"application/vnd.tmd.mediaflex.api+xml":{source:"iana",compressible:!0},"application/vnd.tml":{source:"iana"},"application/vnd.tmobile-livetv":{source:"iana",extensions:["tmo"]},"application/vnd.tri.onesource":{source:"iana"},"application/vnd.trid.tpt":{source:"iana",extensions:["tpt"]},"application/vnd.triscape.mxs":{source:"iana",extensions:["mxs"]},"application/vnd.trueapp":{source:"iana",extensions:["tra"]},"application/vnd.truedoc":{source:"iana"},"application/vnd.ubisoft.webplayer":{source:"iana"},"application/vnd.ufdl":{source:"iana",extensions:["ufd","ufdl"]},"application/vnd.uiq.theme":{source:"iana",extensions:["utz"]},"application/vnd.umajin":{source:"iana",extensions:["umj"]},"application/vnd.unity":{source:"iana",extensions:["unityweb"]},"application/vnd.uoml+xml":{source:"iana",compressible:!0,extensions:["uoml"]},"application/vnd.uplanet.alert":{source:"iana"},"application/vnd.uplanet.alert-wbxml":{source:"iana"},"application/vnd.uplanet.bearer-choice":{source:"iana"},"application/vnd.uplanet.bearer-choice-wbxml":{source:"iana"},"application/vnd.uplanet.cacheop":{source:"iana"},"application/vnd.uplanet.cacheop-wbxml":{source:"iana"},"application/vnd.uplanet.channel":{source:"iana"},"application/vnd.uplanet.channel-wbxml":{source:"iana"},"application/vnd.uplanet.list":{source:"iana"},"application/vnd.uplanet.list-wbxml":{source:"iana"},"application/vnd.uplanet.listcmd":{source:"iana"},"application/vnd.uplanet.listcmd-wbxml":{source:"iana"},"application/vnd.uplanet.signal":{source:"iana"},"application/vnd.uri-map":{source:"iana"},"application/vnd.valve.source.material":{source:"iana"},"application/vnd.vcx":{source:"iana",extensions:["vcx"]},"application/vnd.vd-study":{source:"iana"},"application/vnd.vectorworks":{source:"iana"},"application/vnd.vel+json":{source:"iana",compressible:!0},"application/vnd.verimatrix.vcas":{source:"iana"},"application/vnd.veritone.aion+json":{source:"iana",compressible:!0},"application/vnd.veryant.thin":{source:"iana"},"application/vnd.ves.encrypted":{source:"iana"},"application/vnd.vidsoft.vidconference":{source:"iana"},"application/vnd.visio":{source:"iana",extensions:["vsd","vst","vss","vsw"]},"application/vnd.visionary":{source:"iana",extensions:["vis"]},"application/vnd.vividence.scriptfile":{source:"iana"},"application/vnd.vsf":{source:"iana",extensions:["vsf"]},"application/vnd.wap.sic":{source:"iana"},"application/vnd.wap.slc":{source:"iana"},"application/vnd.wap.wbxml":{source:"iana",charset:"UTF-8",extensions:["wbxml"]},"application/vnd.wap.wmlc":{source:"iana",extensions:["wmlc"]},"application/vnd.wap.wmlscriptc":{source:"iana",extensions:["wmlsc"]},"application/vnd.webturbo":{source:"iana",extensions:["wtb"]},"application/vnd.wfa.dpp":{source:"iana"},"application/vnd.wfa.p2p":{source:"iana"},"application/vnd.wfa.wsc":{source:"iana"},"application/vnd.windows.devicepairing":{source:"iana"},"application/vnd.wmc":{source:"iana"},"application/vnd.wmf.bootstrap":{source:"iana"},"application/vnd.wolfram.mathematica":{source:"iana"},"application/vnd.wolfram.mathematica.package":{source:"iana"},"application/vnd.wolfram.player":{source:"iana",extensions:["nbp"]},"application/vnd.wordperfect":{source:"iana",extensions:["wpd"]},"application/vnd.wqd":{source:"iana",extensions:["wqd"]},"application/vnd.wrq-hp3000-labelled":{source:"iana"},"application/vnd.wt.stf":{source:"iana",extensions:["stf"]},"application/vnd.wv.csp+wbxml":{source:"iana"},"application/vnd.wv.csp+xml":{source:"iana",compressible:!0},"application/vnd.wv.ssp+xml":{source:"iana",compressible:!0},"application/vnd.xacml+json":{source:"iana",compressible:!0},"application/vnd.xara":{source:"iana",extensions:["xar"]},"application/vnd.xfdl":{source:"iana",extensions:["xfdl"]},"application/vnd.xfdl.webform":{source:"iana"},"application/vnd.xmi+xml":{source:"iana",compressible:!0},"application/vnd.xmpie.cpkg":{source:"iana"},"application/vnd.xmpie.dpkg":{source:"iana"},"application/vnd.xmpie.plan":{source:"iana"},"application/vnd.xmpie.ppkg":{source:"iana"},"application/vnd.xmpie.xlim":{source:"iana"},"application/vnd.yamaha.hv-dic":{source:"iana",extensions:["hvd"]},"application/vnd.yamaha.hv-script":{source:"iana",extensions:["hvs"]},"application/vnd.yamaha.hv-voice":{source:"iana",extensions:["hvp"]},"application/vnd.yamaha.openscoreformat":{source:"iana",extensions:["osf"]},"application/vnd.yamaha.openscoreformat.osfpvg+xml":{source:"iana",compressible:!0,extensions:["osfpvg"]},"application/vnd.yamaha.remote-setup":{source:"iana"},"application/vnd.yamaha.smaf-audio":{source:"iana",extensions:["saf"]},"application/vnd.yamaha.smaf-phrase":{source:"iana",extensions:["spf"]},"application/vnd.yamaha.through-ngn":{source:"iana"},"application/vnd.yamaha.tunnel-udpencap":{source:"iana"},"application/vnd.yaoweme":{source:"iana"},"application/vnd.yellowriver-custom-menu":{source:"iana",extensions:["cmp"]},"application/vnd.youtube.yt":{source:"iana"},"application/vnd.zul":{source:"iana",extensions:["zir","zirz"]},"application/vnd.zzazz.deck+xml":{source:"iana",compressible:!0,extensions:["zaz"]},"application/voicexml+xml":{source:"iana",compressible:!0,extensions:["vxml"]},"application/voucher-cms+json":{source:"iana",compressible:!0},"application/vq-rtcpxr":{source:"iana"},"application/wasm":{source:"iana",compressible:!0,extensions:["wasm"]},"application/watcherinfo+xml":{source:"iana",compressible:!0,extensions:["wif"]},"application/webpush-options+json":{source:"iana",compressible:!0},"application/whoispp-query":{source:"iana"},"application/whoispp-response":{source:"iana"},"application/widget":{source:"iana",extensions:["wgt"]},"application/winhlp":{source:"apache",extensions:["hlp"]},"application/wita":{source:"iana"},"application/wordperfect5.1":{source:"iana"},"application/wsdl+xml":{source:"iana",compressible:!0,extensions:["wsdl"]},"application/wspolicy+xml":{source:"iana",compressible:!0,extensions:["wspolicy"]},"application/x-7z-compressed":{source:"apache",compressible:!1,extensions:["7z"]},"application/x-abiword":{source:"apache",extensions:["abw"]},"application/x-ace-compressed":{source:"apache",extensions:["ace"]},"application/x-amf":{source:"apache"},"application/x-apple-diskimage":{source:"apache",extensions:["dmg"]},"application/x-arj":{compressible:!1,extensions:["arj"]},"application/x-authorware-bin":{source:"apache",extensions:["aab","x32","u32","vox"]},"application/x-authorware-map":{source:"apache",extensions:["aam"]},"application/x-authorware-seg":{source:"apache",extensions:["aas"]},"application/x-bcpio":{source:"apache",extensions:["bcpio"]},"application/x-bdoc":{compressible:!1,extensions:["bdoc"]},"application/x-bittorrent":{source:"apache",extensions:["torrent"]},"application/x-blorb":{source:"apache",extensions:["blb","blorb"]},"application/x-bzip":{source:"apache",compressible:!1,extensions:["bz"]},"application/x-bzip2":{source:"apache",compressible:!1,extensions:["bz2","boz"]},"application/x-cbr":{source:"apache",extensions:["cbr","cba","cbt","cbz","cb7"]},"application/x-cdlink":{source:"apache",extensions:["vcd"]},"application/x-cfs-compressed":{source:"apache",extensions:["cfs"]},"application/x-chat":{source:"apache",extensions:["chat"]},"application/x-chess-pgn":{source:"apache",extensions:["pgn"]},"application/x-chrome-extension":{extensions:["crx"]},"application/x-cocoa":{source:"nginx",extensions:["cco"]},"application/x-compress":{source:"apache"},"application/x-conference":{source:"apache",extensions:["nsc"]},"application/x-cpio":{source:"apache",extensions:["cpio"]},"application/x-csh":{source:"apache",extensions:["csh"]},"application/x-deb":{compressible:!1},"application/x-debian-package":{source:"apache",extensions:["deb","udeb"]},"application/x-dgc-compressed":{source:"apache",extensions:["dgc"]},"application/x-director":{source:"apache",extensions:["dir","dcr","dxr","cst","cct","cxt","w3d","fgd","swa"]},"application/x-doom":{source:"apache",extensions:["wad"]},"application/x-dtbncx+xml":{source:"apache",compressible:!0,extensions:["ncx"]},"application/x-dtbook+xml":{source:"apache",compressible:!0,extensions:["dtb"]},"application/x-dtbresource+xml":{source:"apache",compressible:!0,extensions:["res"]},"application/x-dvi":{source:"apache",compressible:!1,extensions:["dvi"]},"application/x-envoy":{source:"apache",extensions:["evy"]},"application/x-eva":{source:"apache",extensions:["eva"]},"application/x-font-bdf":{source:"apache",extensions:["bdf"]},"application/x-font-dos":{source:"apache"},"application/x-font-framemaker":{source:"apache"},"application/x-font-ghostscript":{source:"apache",extensions:["gsf"]},"application/x-font-libgrx":{source:"apache"},"application/x-font-linux-psf":{source:"apache",extensions:["psf"]},"application/x-font-pcf":{source:"apache",extensions:["pcf"]},"application/x-font-snf":{source:"apache",extensions:["snf"]},"application/x-font-speedo":{source:"apache"},"application/x-font-sunos-news":{source:"apache"},"application/x-font-type1":{source:"apache",extensions:["pfa","pfb","pfm","afm"]},"application/x-font-vfont":{source:"apache"},"application/x-freearc":{source:"apache",extensions:["arc"]},"application/x-futuresplash":{source:"apache",extensions:["spl"]},"application/x-gca-compressed":{source:"apache",extensions:["gca"]},"application/x-glulx":{source:"apache",extensions:["ulx"]},"application/x-gnumeric":{source:"apache",extensions:["gnumeric"]},"application/x-gramps-xml":{source:"apache",extensions:["gramps"]},"application/x-gtar":{source:"apache",extensions:["gtar"]},"application/x-gzip":{source:"apache"},"application/x-hdf":{source:"apache",extensions:["hdf"]},"application/x-httpd-php":{compressible:!0,extensions:["php"]},"application/x-install-instructions":{source:"apache",extensions:["install"]},"application/x-iso9660-image":{source:"apache",extensions:["iso"]},"application/x-iwork-keynote-sffkey":{extensions:["key"]},"application/x-iwork-numbers-sffnumbers":{extensions:["numbers"]},"application/x-iwork-pages-sffpages":{extensions:["pages"]},"application/x-java-archive-diff":{source:"nginx",extensions:["jardiff"]},"application/x-java-jnlp-file":{source:"apache",compressible:!1,extensions:["jnlp"]},"application/x-javascript":{compressible:!0},"application/x-keepass2":{extensions:["kdbx"]},"application/x-latex":{source:"apache",compressible:!1,extensions:["latex"]},"application/x-lua-bytecode":{extensions:["luac"]},"application/x-lzh-compressed":{source:"apache",extensions:["lzh","lha"]},"application/x-makeself":{source:"nginx",extensions:["run"]},"application/x-mie":{source:"apache",extensions:["mie"]},"application/x-mobipocket-ebook":{source:"apache",extensions:["prc","mobi"]},"application/x-mpegurl":{compressible:!1},"application/x-ms-application":{source:"apache",extensions:["application"]},"application/x-ms-shortcut":{source:"apache",extensions:["lnk"]},"application/x-ms-wmd":{source:"apache",extensions:["wmd"]},"application/x-ms-wmz":{source:"apache",extensions:["wmz"]},"application/x-ms-xbap":{source:"apache",extensions:["xbap"]},"application/x-msaccess":{source:"apache",extensions:["mdb"]},"application/x-msbinder":{source:"apache",extensions:["obd"]},"application/x-mscardfile":{source:"apache",extensions:["crd"]},"application/x-msclip":{source:"apache",extensions:["clp"]},"application/x-msdos-program":{extensions:["exe"]},"application/x-msdownload":{source:"apache",extensions:["exe","dll","com","bat","msi"]},"application/x-msmediaview":{source:"apache",extensions:["mvb","m13","m14"]},"application/x-msmetafile":{source:"apache",extensions:["wmf","wmz","emf","emz"]},"application/x-msmoney":{source:"apache",extensions:["mny"]},"application/x-mspublisher":{source:"apache",extensions:["pub"]},"application/x-msschedule":{source:"apache",extensions:["scd"]},"application/x-msterminal":{source:"apache",extensions:["trm"]},"application/x-mswrite":{source:"apache",extensions:["wri"]},"application/x-netcdf":{source:"apache",extensions:["nc","cdf"]},"application/x-ns-proxy-autoconfig":{compressible:!0,extensions:["pac"]},"application/x-nzb":{source:"apache",extensions:["nzb"]},"application/x-perl":{source:"nginx",extensions:["pl","pm"]},"application/x-pilot":{source:"nginx",extensions:["prc","pdb"]},"application/x-pkcs12":{source:"apache",compressible:!1,extensions:["p12","pfx"]},"application/x-pkcs7-certificates":{source:"apache",extensions:["p7b","spc"]},"application/x-pkcs7-certreqresp":{source:"apache",extensions:["p7r"]},"application/x-pki-message":{source:"iana"},"application/x-rar-compressed":{source:"apache",compressible:!1,extensions:["rar"]},"application/x-redhat-package-manager":{source:"nginx",extensions:["rpm"]},"application/x-research-info-systems":{source:"apache",extensions:["ris"]},"application/x-sea":{source:"nginx",extensions:["sea"]},"application/x-sh":{source:"apache",compressible:!0,extensions:["sh"]},"application/x-shar":{source:"apache",extensions:["shar"]},"application/x-shockwave-flash":{source:"apache",compressible:!1,extensions:["swf"]},"application/x-silverlight-app":{source:"apache",extensions:["xap"]},"application/x-sql":{source:"apache",extensions:["sql"]},"application/x-stuffit":{source:"apache",compressible:!1,extensions:["sit"]},"application/x-stuffitx":{source:"apache",extensions:["sitx"]},"application/x-subrip":{source:"apache",extensions:["srt"]},"application/x-sv4cpio":{source:"apache",extensions:["sv4cpio"]},"application/x-sv4crc":{source:"apache",extensions:["sv4crc"]},"application/x-t3vm-image":{source:"apache",extensions:["t3"]},"application/x-tads":{source:"apache",extensions:["gam"]},"application/x-tar":{source:"apache",compressible:!0,extensions:["tar"]},"application/x-tcl":{source:"apache",extensions:["tcl","tk"]},"application/x-tex":{source:"apache",extensions:["tex"]},"application/x-tex-tfm":{source:"apache",extensions:["tfm"]},"application/x-texinfo":{source:"apache",extensions:["texinfo","texi"]},"application/x-tgif":{source:"apache",extensions:["obj"]},"application/x-ustar":{source:"apache",extensions:["ustar"]},"application/x-virtualbox-hdd":{compressible:!0,extensions:["hdd"]},"application/x-virtualbox-ova":{compressible:!0,extensions:["ova"]},"application/x-virtualbox-ovf":{compressible:!0,extensions:["ovf"]},"application/x-virtualbox-vbox":{compressible:!0,extensions:["vbox"]},"application/x-virtualbox-vbox-extpack":{compressible:!1,extensions:["vbox-extpack"]},"application/x-virtualbox-vdi":{compressible:!0,extensions:["vdi"]},"application/x-virtualbox-vhd":{compressible:!0,extensions:["vhd"]},"application/x-virtualbox-vmdk":{compressible:!0,extensions:["vmdk"]},"application/x-wais-source":{source:"apache",extensions:["src"]},"application/x-web-app-manifest+json":{compressible:!0,extensions:["webapp"]},"application/x-www-form-urlencoded":{source:"iana",compressible:!0},"application/x-x509-ca-cert":{source:"iana",extensions:["der","crt","pem"]},"application/x-x509-ca-ra-cert":{source:"iana"},"application/x-x509-next-ca-cert":{source:"iana"},"application/x-xfig":{source:"apache",extensions:["fig"]},"application/x-xliff+xml":{source:"apache",compressible:!0,extensions:["xlf"]},"application/x-xpinstall":{source:"apache",compressible:!1,extensions:["xpi"]},"application/x-xz":{source:"apache",extensions:["xz"]},"application/x-zmachine":{source:"apache",extensions:["z1","z2","z3","z4","z5","z6","z7","z8"]},"application/x400-bp":{source:"iana"},"application/xacml+xml":{source:"iana",compressible:!0},"application/xaml+xml":{source:"apache",compressible:!0,extensions:["xaml"]},"application/xcap-att+xml":{source:"iana",compressible:!0,extensions:["xav"]},"application/xcap-caps+xml":{source:"iana",compressible:!0,extensions:["xca"]},"application/xcap-diff+xml":{source:"iana",compressible:!0,extensions:["xdf"]},"application/xcap-el+xml":{source:"iana",compressible:!0,extensions:["xel"]},"application/xcap-error+xml":{source:"iana",compressible:!0},"application/xcap-ns+xml":{source:"iana",compressible:!0,extensions:["xns"]},"application/xcon-conference-info+xml":{source:"iana",compressible:!0},"application/xcon-conference-info-diff+xml":{source:"iana",compressible:!0},"application/xenc+xml":{source:"iana",compressible:!0,extensions:["xenc"]},"application/xhtml+xml":{source:"iana",compressible:!0,extensions:["xhtml","xht"]},"application/xhtml-voice+xml":{source:"apache",compressible:!0},"application/xliff+xml":{source:"iana",compressible:!0,extensions:["xlf"]},"application/xml":{source:"iana",compressible:!0,extensions:["xml","xsl","xsd","rng"]},"application/xml-dtd":{source:"iana",compressible:!0,extensions:["dtd"]},"application/xml-external-parsed-entity":{source:"iana"},"application/xml-patch+xml":{source:"iana",compressible:!0},"application/xmpp+xml":{source:"iana",compressible:!0},"application/xop+xml":{source:"iana",compressible:!0,extensions:["xop"]},"application/xproc+xml":{source:"apache",compressible:!0,extensions:["xpl"]},"application/xslt+xml":{source:"iana",compressible:!0,extensions:["xsl","xslt"]},"application/xspf+xml":{source:"apache",compressible:!0,extensions:["xspf"]},"application/xv+xml":{source:"iana",compressible:!0,extensions:["mxml","xhvml","xvml","xvm"]},"application/yang":{source:"iana",extensions:["yang"]},"application/yang-data+json":{source:"iana",compressible:!0},"application/yang-data+xml":{source:"iana",compressible:!0},"application/yang-patch+json":{source:"iana",compressible:!0},"application/yang-patch+xml":{source:"iana",compressible:!0},"application/yin+xml":{source:"iana",compressible:!0,extensions:["yin"]},"application/zip":{source:"iana",compressible:!1,extensions:["zip"]},"application/zlib":{source:"iana"},"application/zstd":{source:"iana"},"audio/1d-interleaved-parityfec":{source:"iana"},"audio/32kadpcm":{source:"iana"},"audio/3gpp":{source:"iana",compressible:!1,extensions:["3gpp"]},"audio/3gpp2":{source:"iana"},"audio/aac":{source:"iana"},"audio/ac3":{source:"iana"},"audio/adpcm":{source:"apache",extensions:["adp"]},"audio/amr":{source:"iana",extensions:["amr"]},"audio/amr-wb":{source:"iana"},"audio/amr-wb+":{source:"iana"},"audio/aptx":{source:"iana"},"audio/asc":{source:"iana"},"audio/atrac-advanced-lossless":{source:"iana"},"audio/atrac-x":{source:"iana"},"audio/atrac3":{source:"iana"},"audio/basic":{source:"iana",compressible:!1,extensions:["au","snd"]},"audio/bv16":{source:"iana"},"audio/bv32":{source:"iana"},"audio/clearmode":{source:"iana"},"audio/cn":{source:"iana"},"audio/dat12":{source:"iana"},"audio/dls":{source:"iana"},"audio/dsr-es201108":{source:"iana"},"audio/dsr-es202050":{source:"iana"},"audio/dsr-es202211":{source:"iana"},"audio/dsr-es202212":{source:"iana"},"audio/dv":{source:"iana"},"audio/dvi4":{source:"iana"},"audio/eac3":{source:"iana"},"audio/encaprtp":{source:"iana"},"audio/evrc":{source:"iana"},"audio/evrc-qcp":{source:"iana"},"audio/evrc0":{source:"iana"},"audio/evrc1":{source:"iana"},"audio/evrcb":{source:"iana"},"audio/evrcb0":{source:"iana"},"audio/evrcb1":{source:"iana"},"audio/evrcnw":{source:"iana"},"audio/evrcnw0":{source:"iana"},"audio/evrcnw1":{source:"iana"},"audio/evrcwb":{source:"iana"},"audio/evrcwb0":{source:"iana"},"audio/evrcwb1":{source:"iana"},"audio/evs":{source:"iana"},"audio/flexfec":{source:"iana"},"audio/fwdred":{source:"iana"},"audio/g711-0":{source:"iana"},"audio/g719":{source:"iana"},"audio/g722":{source:"iana"},"audio/g7221":{source:"iana"},"audio/g723":{source:"iana"},"audio/g726-16":{source:"iana"},"audio/g726-24":{source:"iana"},"audio/g726-32":{source:"iana"},"audio/g726-40":{source:"iana"},"audio/g728":{source:"iana"},"audio/g729":{source:"iana"},"audio/g7291":{source:"iana"},"audio/g729d":{source:"iana"},"audio/g729e":{source:"iana"},"audio/gsm":{source:"iana"},"audio/gsm-efr":{source:"iana"},"audio/gsm-hr-08":{source:"iana"},"audio/ilbc":{source:"iana"},"audio/ip-mr_v2.5":{source:"iana"},"audio/isac":{source:"apache"},"audio/l16":{source:"iana"},"audio/l20":{source:"iana"},"audio/l24":{source:"iana",compressible:!1},"audio/l8":{source:"iana"},"audio/lpc":{source:"iana"},"audio/melp":{source:"iana"},"audio/melp1200":{source:"iana"},"audio/melp2400":{source:"iana"},"audio/melp600":{source:"iana"},"audio/mhas":{source:"iana"},"audio/midi":{source:"apache",extensions:["mid","midi","kar","rmi"]},"audio/mobile-xmf":{source:"iana",extensions:["mxmf"]},"audio/mp3":{compressible:!1,extensions:["mp3"]},"audio/mp4":{source:"iana",compressible:!1,extensions:["m4a","mp4a"]},"audio/mp4a-latm":{source:"iana"},"audio/mpa":{source:"iana"},"audio/mpa-robust":{source:"iana"},"audio/mpeg":{source:"iana",compressible:!1,extensions:["mpga","mp2","mp2a","mp3","m2a","m3a"]},"audio/mpeg4-generic":{source:"iana"},"audio/musepack":{source:"apache"},"audio/ogg":{source:"iana",compressible:!1,extensions:["oga","ogg","spx","opus"]},"audio/opus":{source:"iana"},"audio/parityfec":{source:"iana"},"audio/pcma":{source:"iana"},"audio/pcma-wb":{source:"iana"},"audio/pcmu":{source:"iana"},"audio/pcmu-wb":{source:"iana"},"audio/prs.sid":{source:"iana"},"audio/qcelp":{source:"iana"},"audio/raptorfec":{source:"iana"},"audio/red":{source:"iana"},"audio/rtp-enc-aescm128":{source:"iana"},"audio/rtp-midi":{source:"iana"},"audio/rtploopback":{source:"iana"},"audio/rtx":{source:"iana"},"audio/s3m":{source:"apache",extensions:["s3m"]},"audio/scip":{source:"iana"},"audio/silk":{source:"apache",extensions:["sil"]},"audio/smv":{source:"iana"},"audio/smv-qcp":{source:"iana"},"audio/smv0":{source:"iana"},"audio/sofa":{source:"iana"},"audio/sp-midi":{source:"iana"},"audio/speex":{source:"iana"},"audio/t140c":{source:"iana"},"audio/t38":{source:"iana"},"audio/telephone-event":{source:"iana"},"audio/tetra_acelp":{source:"iana"},"audio/tetra_acelp_bb":{source:"iana"},"audio/tone":{source:"iana"},"audio/tsvcis":{source:"iana"},"audio/uemclip":{source:"iana"},"audio/ulpfec":{source:"iana"},"audio/usac":{source:"iana"},"audio/vdvi":{source:"iana"},"audio/vmr-wb":{source:"iana"},"audio/vnd.3gpp.iufp":{source:"iana"},"audio/vnd.4sb":{source:"iana"},"audio/vnd.audiokoz":{source:"iana"},"audio/vnd.celp":{source:"iana"},"audio/vnd.cisco.nse":{source:"iana"},"audio/vnd.cmles.radio-events":{source:"iana"},"audio/vnd.cns.anp1":{source:"iana"},"audio/vnd.cns.inf1":{source:"iana"},"audio/vnd.dece.audio":{source:"iana",extensions:["uva","uvva"]},"audio/vnd.digital-winds":{source:"iana",extensions:["eol"]},"audio/vnd.dlna.adts":{source:"iana"},"audio/vnd.dolby.heaac.1":{source:"iana"},"audio/vnd.dolby.heaac.2":{source:"iana"},"audio/vnd.dolby.mlp":{source:"iana"},"audio/vnd.dolby.mps":{source:"iana"},"audio/vnd.dolby.pl2":{source:"iana"},"audio/vnd.dolby.pl2x":{source:"iana"},"audio/vnd.dolby.pl2z":{source:"iana"},"audio/vnd.dolby.pulse.1":{source:"iana"},"audio/vnd.dra":{source:"iana",extensions:["dra"]},"audio/vnd.dts":{source:"iana",extensions:["dts"]},"audio/vnd.dts.hd":{source:"iana",extensions:["dtshd"]},"audio/vnd.dts.uhd":{source:"iana"},"audio/vnd.dvb.file":{source:"iana"},"audio/vnd.everad.plj":{source:"iana"},"audio/vnd.hns.audio":{source:"iana"},"audio/vnd.lucent.voice":{source:"iana",extensions:["lvp"]},"audio/vnd.ms-playready.media.pya":{source:"iana",extensions:["pya"]},"audio/vnd.nokia.mobile-xmf":{source:"iana"},"audio/vnd.nortel.vbk":{source:"iana"},"audio/vnd.nuera.ecelp4800":{source:"iana",extensions:["ecelp4800"]},"audio/vnd.nuera.ecelp7470":{source:"iana",extensions:["ecelp7470"]},"audio/vnd.nuera.ecelp9600":{source:"iana",extensions:["ecelp9600"]},"audio/vnd.octel.sbc":{source:"iana"},"audio/vnd.presonus.multitrack":{source:"iana"},"audio/vnd.qcelp":{source:"iana"},"audio/vnd.rhetorex.32kadpcm":{source:"iana"},"audio/vnd.rip":{source:"iana",extensions:["rip"]},"audio/vnd.rn-realaudio":{compressible:!1},"audio/vnd.sealedmedia.softseal.mpeg":{source:"iana"},"audio/vnd.vmx.cvsd":{source:"iana"},"audio/vnd.wave":{compressible:!1},"audio/vorbis":{source:"iana",compressible:!1},"audio/vorbis-config":{source:"iana"},"audio/wav":{compressible:!1,extensions:["wav"]},"audio/wave":{compressible:!1,extensions:["wav"]},"audio/webm":{source:"apache",compressible:!1,extensions:["weba"]},"audio/x-aac":{source:"apache",compressible:!1,extensions:["aac"]},"audio/x-aiff":{source:"apache",extensions:["aif","aiff","aifc"]},"audio/x-caf":{source:"apache",compressible:!1,extensions:["caf"]},"audio/x-flac":{source:"apache",extensions:["flac"]},"audio/x-m4a":{source:"nginx",extensions:["m4a"]},"audio/x-matroska":{source:"apache",extensions:["mka"]},"audio/x-mpegurl":{source:"apache",extensions:["m3u"]},"audio/x-ms-wax":{source:"apache",extensions:["wax"]},"audio/x-ms-wma":{source:"apache",extensions:["wma"]},"audio/x-pn-realaudio":{source:"apache",extensions:["ram","ra"]},"audio/x-pn-realaudio-plugin":{source:"apache",extensions:["rmp"]},"audio/x-realaudio":{source:"nginx",extensions:["ra"]},"audio/x-tta":{source:"apache"},"audio/x-wav":{source:"apache",extensions:["wav"]},"audio/xm":{source:"apache",extensions:["xm"]},"chemical/x-cdx":{source:"apache",extensions:["cdx"]},"chemical/x-cif":{source:"apache",extensions:["cif"]},"chemical/x-cmdf":{source:"apache",extensions:["cmdf"]},"chemical/x-cml":{source:"apache",extensions:["cml"]},"chemical/x-csml":{source:"apache",extensions:["csml"]},"chemical/x-pdb":{source:"apache"},"chemical/x-xyz":{source:"apache",extensions:["xyz"]},"font/collection":{source:"iana",extensions:["ttc"]},"font/otf":{source:"iana",compressible:!0,extensions:["otf"]},"font/sfnt":{source:"iana"},"font/ttf":{source:"iana",compressible:!0,extensions:["ttf"]},"font/woff":{source:"iana",extensions:["woff"]},"font/woff2":{source:"iana",extensions:["woff2"]},"image/aces":{source:"iana",extensions:["exr"]},"image/apng":{compressible:!1,extensions:["apng"]},"image/avci":{source:"iana",extensions:["avci"]},"image/avcs":{source:"iana",extensions:["avcs"]},"image/avif":{source:"iana",compressible:!1,extensions:["avif"]},"image/bmp":{source:"iana",compressible:!0,extensions:["bmp"]},"image/cgm":{source:"iana",extensions:["cgm"]},"image/dicom-rle":{source:"iana",extensions:["drle"]},"image/emf":{source:"iana",extensions:["emf"]},"image/fits":{source:"iana",extensions:["fits"]},"image/g3fax":{source:"iana",extensions:["g3"]},"image/gif":{source:"iana",compressible:!1,extensions:["gif"]},"image/heic":{source:"iana",extensions:["heic"]},"image/heic-sequence":{source:"iana",extensions:["heics"]},"image/heif":{source:"iana",extensions:["heif"]},"image/heif-sequence":{source:"iana",extensions:["heifs"]},"image/hej2k":{source:"iana",extensions:["hej2"]},"image/hsj2":{source:"iana",extensions:["hsj2"]},"image/ief":{source:"iana",extensions:["ief"]},"image/jls":{source:"iana",extensions:["jls"]},"image/jp2":{source:"iana",compressible:!1,extensions:["jp2","jpg2"]},"image/jpeg":{source:"iana",compressible:!1,extensions:["jpeg","jpg","jpe"]},"image/jph":{source:"iana",extensions:["jph"]},"image/jphc":{source:"iana",extensions:["jhc"]},"image/jpm":{source:"iana",compressible:!1,extensions:["jpm"]},"image/jpx":{source:"iana",compressible:!1,extensions:["jpx","jpf"]},"image/jxr":{source:"iana",extensions:["jxr"]},"image/jxra":{source:"iana",extensions:["jxra"]},"image/jxrs":{source:"iana",extensions:["jxrs"]},"image/jxs":{source:"iana",extensions:["jxs"]},"image/jxsc":{source:"iana",extensions:["jxsc"]},"image/jxsi":{source:"iana",extensions:["jxsi"]},"image/jxss":{source:"iana",extensions:["jxss"]},"image/ktx":{source:"iana",extensions:["ktx"]},"image/ktx2":{source:"iana",extensions:["ktx2"]},"image/naplps":{source:"iana"},"image/pjpeg":{compressible:!1},"image/png":{source:"iana",compressible:!1,extensions:["png"]},"image/prs.btif":{source:"iana",extensions:["btif"]},"image/prs.pti":{source:"iana",extensions:["pti"]},"image/pwg-raster":{source:"iana"},"image/sgi":{source:"apache",extensions:["sgi"]},"image/svg+xml":{source:"iana",compressible:!0,extensions:["svg","svgz"]},"image/t38":{source:"iana",extensions:["t38"]},"image/tiff":{source:"iana",compressible:!1,extensions:["tif","tiff"]},"image/tiff-fx":{source:"iana",extensions:["tfx"]},"image/vnd.adobe.photoshop":{source:"iana",compressible:!0,extensions:["psd"]},"image/vnd.airzip.accelerator.azv":{source:"iana",extensions:["azv"]},"image/vnd.cns.inf2":{source:"iana"},"image/vnd.dece.graphic":{source:"iana",extensions:["uvi","uvvi","uvg","uvvg"]},"image/vnd.djvu":{source:"iana",extensions:["djvu","djv"]},"image/vnd.dvb.subtitle":{source:"iana",extensions:["sub"]},"image/vnd.dwg":{source:"iana",extensions:["dwg"]},"image/vnd.dxf":{source:"iana",extensions:["dxf"]},"image/vnd.fastbidsheet":{source:"iana",extensions:["fbs"]},"image/vnd.fpx":{source:"iana",extensions:["fpx"]},"image/vnd.fst":{source:"iana",extensions:["fst"]},"image/vnd.fujixerox.edmics-mmr":{source:"iana",extensions:["mmr"]},"image/vnd.fujixerox.edmics-rlc":{source:"iana",extensions:["rlc"]},"image/vnd.globalgraphics.pgb":{source:"iana"},"image/vnd.microsoft.icon":{source:"iana",compressible:!0,extensions:["ico"]},"image/vnd.mix":{source:"iana"},"image/vnd.mozilla.apng":{source:"iana"},"image/vnd.ms-dds":{compressible:!0,extensions:["dds"]},"image/vnd.ms-modi":{source:"iana",extensions:["mdi"]},"image/vnd.ms-photo":{source:"apache",extensions:["wdp"]},"image/vnd.net-fpx":{source:"iana",extensions:["npx"]},"image/vnd.pco.b16":{source:"iana",extensions:["b16"]},"image/vnd.radiance":{source:"iana"},"image/vnd.sealed.png":{source:"iana"},"image/vnd.sealedmedia.softseal.gif":{source:"iana"},"image/vnd.sealedmedia.softseal.jpg":{source:"iana"},"image/vnd.svf":{source:"iana"},"image/vnd.tencent.tap":{source:"iana",extensions:["tap"]},"image/vnd.valve.source.texture":{source:"iana",extensions:["vtf"]},"image/vnd.wap.wbmp":{source:"iana",extensions:["wbmp"]},"image/vnd.xiff":{source:"iana",extensions:["xif"]},"image/vnd.zbrush.pcx":{source:"iana",extensions:["pcx"]},"image/webp":{source:"apache",extensions:["webp"]},"image/wmf":{source:"iana",extensions:["wmf"]},"image/x-3ds":{source:"apache",extensions:["3ds"]},"image/x-cmu-raster":{source:"apache",extensions:["ras"]},"image/x-cmx":{source:"apache",extensions:["cmx"]},"image/x-freehand":{source:"apache",extensions:["fh","fhc","fh4","fh5","fh7"]},"image/x-icon":{source:"apache",compressible:!0,extensions:["ico"]},"image/x-jng":{source:"nginx",extensions:["jng"]},"image/x-mrsid-image":{source:"apache",extensions:["sid"]},"image/x-ms-bmp":{source:"nginx",compressible:!0,extensions:["bmp"]},"image/x-pcx":{source:"apache",extensions:["pcx"]},"image/x-pict":{source:"apache",extensions:["pic","pct"]},"image/x-portable-anymap":{source:"apache",extensions:["pnm"]},"image/x-portable-bitmap":{source:"apache",extensions:["pbm"]},"image/x-portable-graymap":{source:"apache",extensions:["pgm"]},"image/x-portable-pixmap":{source:"apache",extensions:["ppm"]},"image/x-rgb":{source:"apache",extensions:["rgb"]},"image/x-tga":{source:"apache",extensions:["tga"]},"image/x-xbitmap":{source:"apache",extensions:["xbm"]},"image/x-xcf":{compressible:!1},"image/x-xpixmap":{source:"apache",extensions:["xpm"]},"image/x-xwindowdump":{source:"apache",extensions:["xwd"]},"message/cpim":{source:"iana"},"message/delivery-status":{source:"iana"},"message/disposition-notification":{source:"iana",extensions:["disposition-notification"]},"message/external-body":{source:"iana"},"message/feedback-report":{source:"iana"},"message/global":{source:"iana",extensions:["u8msg"]},"message/global-delivery-status":{source:"iana",extensions:["u8dsn"]},"message/global-disposition-notification":{source:"iana",extensions:["u8mdn"]},"message/global-headers":{source:"iana",extensions:["u8hdr"]},"message/http":{source:"iana",compressible:!1},"message/imdn+xml":{source:"iana",compressible:!0},"message/news":{source:"iana"},"message/partial":{source:"iana",compressible:!1},"message/rfc822":{source:"iana",compressible:!0,extensions:["eml","mime"]},"message/s-http":{source:"iana"},"message/sip":{source:"iana"},"message/sipfrag":{source:"iana"},"message/tracking-status":{source:"iana"},"message/vnd.si.simp":{source:"iana"},"message/vnd.wfa.wsc":{source:"iana",extensions:["wsc"]},"model/3mf":{source:"iana",extensions:["3mf"]},"model/e57":{source:"iana"},"model/gltf+json":{source:"iana",compressible:!0,extensions:["gltf"]},"model/gltf-binary":{source:"iana",compressible:!0,extensions:["glb"]},"model/iges":{source:"iana",compressible:!1,extensions:["igs","iges"]},"model/mesh":{source:"iana",compressible:!1,extensions:["msh","mesh","silo"]},"model/mtl":{source:"iana",extensions:["mtl"]},"model/obj":{source:"iana",extensions:["obj"]},"model/step":{source:"iana"},"model/step+xml":{source:"iana",compressible:!0,extensions:["stpx"]},"model/step+zip":{source:"iana",compressible:!1,extensions:["stpz"]},"model/step-xml+zip":{source:"iana",compressible:!1,extensions:["stpxz"]},"model/stl":{source:"iana",extensions:["stl"]},"model/vnd.collada+xml":{source:"iana",compressible:!0,extensions:["dae"]},"model/vnd.dwf":{source:"iana",extensions:["dwf"]},"model/vnd.flatland.3dml":{source:"iana"},"model/vnd.gdl":{source:"iana",extensions:["gdl"]},"model/vnd.gs-gdl":{source:"apache"},"model/vnd.gs.gdl":{source:"iana"},"model/vnd.gtw":{source:"iana",extensions:["gtw"]},"model/vnd.moml+xml":{source:"iana",compressible:!0},"model/vnd.mts":{source:"iana",extensions:["mts"]},"model/vnd.opengex":{source:"iana",extensions:["ogex"]},"model/vnd.parasolid.transmit.binary":{source:"iana",extensions:["x_b"]},"model/vnd.parasolid.transmit.text":{source:"iana",extensions:["x_t"]},"model/vnd.pytha.pyox":{source:"iana"},"model/vnd.rosette.annotated-data-model":{source:"iana"},"model/vnd.sap.vds":{source:"iana",extensions:["vds"]},"model/vnd.usdz+zip":{source:"iana",compressible:!1,extensions:["usdz"]},"model/vnd.valve.source.compiled-map":{source:"iana",extensions:["bsp"]},"model/vnd.vtu":{source:"iana",extensions:["vtu"]},"model/vrml":{source:"iana",compressible:!1,extensions:["wrl","vrml"]},"model/x3d+binary":{source:"apache",compressible:!1,extensions:["x3db","x3dbz"]},"model/x3d+fastinfoset":{source:"iana",extensions:["x3db"]},"model/x3d+vrml":{source:"apache",compressible:!1,extensions:["x3dv","x3dvz"]},"model/x3d+xml":{source:"iana",compressible:!0,extensions:["x3d","x3dz"]},"model/x3d-vrml":{source:"iana",extensions:["x3dv"]},"multipart/alternative":{source:"iana",compressible:!1},"multipart/appledouble":{source:"iana"},"multipart/byteranges":{source:"iana"},"multipart/digest":{source:"iana"},"multipart/encrypted":{source:"iana",compressible:!1},"multipart/form-data":{source:"iana",compressible:!1},"multipart/header-set":{source:"iana"},"multipart/mixed":{source:"iana"},"multipart/multilingual":{source:"iana"},"multipart/parallel":{source:"iana"},"multipart/related":{source:"iana",compressible:!1},"multipart/report":{source:"iana"},"multipart/signed":{source:"iana",compressible:!1},"multipart/vnd.bint.med-plus":{source:"iana"},"multipart/voice-message":{source:"iana"},"multipart/x-mixed-replace":{source:"iana"},"text/1d-interleaved-parityfec":{source:"iana"},"text/cache-manifest":{source:"iana",compressible:!0,extensions:["appcache","manifest"]},"text/calendar":{source:"iana",extensions:["ics","ifb"]},"text/calender":{compressible:!0},"text/cmd":{compressible:!0},"text/coffeescript":{extensions:["coffee","litcoffee"]},"text/cql":{source:"iana"},"text/cql-expression":{source:"iana"},"text/cql-identifier":{source:"iana"},"text/css":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["css"]},"text/csv":{source:"iana",compressible:!0,extensions:["csv"]},"text/csv-schema":{source:"iana"},"text/directory":{source:"iana"},"text/dns":{source:"iana"},"text/ecmascript":{source:"iana"},"text/encaprtp":{source:"iana"},"text/enriched":{source:"iana"},"text/fhirpath":{source:"iana"},"text/flexfec":{source:"iana"},"text/fwdred":{source:"iana"},"text/gff3":{source:"iana"},"text/grammar-ref-list":{source:"iana"},"text/html":{source:"iana",compressible:!0,extensions:["html","htm","shtml"]},"text/jade":{extensions:["jade"]},"text/javascript":{source:"iana",compressible:!0},"text/jcr-cnd":{source:"iana"},"text/jsx":{compressible:!0,extensions:["jsx"]},"text/less":{compressible:!0,extensions:["less"]},"text/markdown":{source:"iana",compressible:!0,extensions:["markdown","md"]},"text/mathml":{source:"nginx",extensions:["mml"]},"text/mdx":{compressible:!0,extensions:["mdx"]},"text/mizar":{source:"iana"},"text/n3":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["n3"]},"text/parameters":{source:"iana",charset:"UTF-8"},"text/parityfec":{source:"iana"},"text/plain":{source:"iana",compressible:!0,extensions:["txt","text","conf","def","list","log","in","ini"]},"text/provenance-notation":{source:"iana",charset:"UTF-8"},"text/prs.fallenstein.rst":{source:"iana"},"text/prs.lines.tag":{source:"iana",extensions:["dsc"]},"text/prs.prop.logic":{source:"iana"},"text/raptorfec":{source:"iana"},"text/red":{source:"iana"},"text/rfc822-headers":{source:"iana"},"text/richtext":{source:"iana",compressible:!0,extensions:["rtx"]},"text/rtf":{source:"iana",compressible:!0,extensions:["rtf"]},"text/rtp-enc-aescm128":{source:"iana"},"text/rtploopback":{source:"iana"},"text/rtx":{source:"iana"},"text/sgml":{source:"iana",extensions:["sgml","sgm"]},"text/shaclc":{source:"iana"},"text/shex":{source:"iana",extensions:["shex"]},"text/slim":{extensions:["slim","slm"]},"text/spdx":{source:"iana",extensions:["spdx"]},"text/strings":{source:"iana"},"text/stylus":{extensions:["stylus","styl"]},"text/t140":{source:"iana"},"text/tab-separated-values":{source:"iana",compressible:!0,extensions:["tsv"]},"text/troff":{source:"iana",extensions:["t","tr","roff","man","me","ms"]},"text/turtle":{source:"iana",charset:"UTF-8",extensions:["ttl"]},"text/ulpfec":{source:"iana"},"text/uri-list":{source:"iana",compressible:!0,extensions:["uri","uris","urls"]},"text/vcard":{source:"iana",compressible:!0,extensions:["vcard"]},"text/vnd.a":{source:"iana"},"text/vnd.abc":{source:"iana"},"text/vnd.ascii-art":{source:"iana"},"text/vnd.curl":{source:"iana",extensions:["curl"]},"text/vnd.curl.dcurl":{source:"apache",extensions:["dcurl"]},"text/vnd.curl.mcurl":{source:"apache",extensions:["mcurl"]},"text/vnd.curl.scurl":{source:"apache",extensions:["scurl"]},"text/vnd.debian.copyright":{source:"iana",charset:"UTF-8"},"text/vnd.dmclientscript":{source:"iana"},"text/vnd.dvb.subtitle":{source:"iana",extensions:["sub"]},"text/vnd.esmertec.theme-descriptor":{source:"iana",charset:"UTF-8"},"text/vnd.familysearch.gedcom":{source:"iana",extensions:["ged"]},"text/vnd.ficlab.flt":{source:"iana"},"text/vnd.fly":{source:"iana",extensions:["fly"]},"text/vnd.fmi.flexstor":{source:"iana",extensions:["flx"]},"text/vnd.gml":{source:"iana"},"text/vnd.graphviz":{source:"iana",extensions:["gv"]},"text/vnd.hans":{source:"iana"},"text/vnd.hgl":{source:"iana"},"text/vnd.in3d.3dml":{source:"iana",extensions:["3dml"]},"text/vnd.in3d.spot":{source:"iana",extensions:["spot"]},"text/vnd.iptc.newsml":{source:"iana"},"text/vnd.iptc.nitf":{source:"iana"},"text/vnd.latex-z":{source:"iana"},"text/vnd.motorola.reflex":{source:"iana"},"text/vnd.ms-mediapackage":{source:"iana"},"text/vnd.net2phone.commcenter.command":{source:"iana"},"text/vnd.radisys.msml-basic-layout":{source:"iana"},"text/vnd.senx.warpscript":{source:"iana"},"text/vnd.si.uricatalogue":{source:"iana"},"text/vnd.sosi":{source:"iana"},"text/vnd.sun.j2me.app-descriptor":{source:"iana",charset:"UTF-8",extensions:["jad"]},"text/vnd.trolltech.linguist":{source:"iana",charset:"UTF-8"},"text/vnd.wap.si":{source:"iana"},"text/vnd.wap.sl":{source:"iana"},"text/vnd.wap.wml":{source:"iana",extensions:["wml"]},"text/vnd.wap.wmlscript":{source:"iana",extensions:["wmls"]},"text/vtt":{source:"iana",charset:"UTF-8",compressible:!0,extensions:["vtt"]},"text/x-asm":{source:"apache",extensions:["s","asm"]},"text/x-c":{source:"apache",extensions:["c","cc","cxx","cpp","h","hh","dic"]},"text/x-component":{source:"nginx",extensions:["htc"]},"text/x-fortran":{source:"apache",extensions:["f","for","f77","f90"]},"text/x-gwt-rpc":{compressible:!0},"text/x-handlebars-template":{extensions:["hbs"]},"text/x-java-source":{source:"apache",extensions:["java"]},"text/x-jquery-tmpl":{compressible:!0},"text/x-lua":{extensions:["lua"]},"text/x-markdown":{compressible:!0,extensions:["mkd"]},"text/x-nfo":{source:"apache",extensions:["nfo"]},"text/x-opml":{source:"apache",extensions:["opml"]},"text/x-org":{compressible:!0,extensions:["org"]},"text/x-pascal":{source:"apache",extensions:["p","pas"]},"text/x-processing":{compressible:!0,extensions:["pde"]},"text/x-sass":{extensions:["sass"]},"text/x-scss":{extensions:["scss"]},"text/x-setext":{source:"apache",extensions:["etx"]},"text/x-sfv":{source:"apache",extensions:["sfv"]},"text/x-suse-ymp":{compressible:!0,extensions:["ymp"]},"text/x-uuencode":{source:"apache",extensions:["uu"]},"text/x-vcalendar":{source:"apache",extensions:["vcs"]},"text/x-vcard":{source:"apache",extensions:["vcf"]},"text/xml":{source:"iana",compressible:!0,extensions:["xml"]},"text/xml-external-parsed-entity":{source:"iana"},"text/yaml":{compressible:!0,extensions:["yaml","yml"]},"video/1d-interleaved-parityfec":{source:"iana"},"video/3gpp":{source:"iana",extensions:["3gp","3gpp"]},"video/3gpp-tt":{source:"iana"},"video/3gpp2":{source:"iana",extensions:["3g2"]},"video/av1":{source:"iana"},"video/bmpeg":{source:"iana"},"video/bt656":{source:"iana"},"video/celb":{source:"iana"},"video/dv":{source:"iana"},"video/encaprtp":{source:"iana"},"video/ffv1":{source:"iana"},"video/flexfec":{source:"iana"},"video/h261":{source:"iana",extensions:["h261"]},"video/h263":{source:"iana",extensions:["h263"]},"video/h263-1998":{source:"iana"},"video/h263-2000":{source:"iana"},"video/h264":{source:"iana",extensions:["h264"]},"video/h264-rcdo":{source:"iana"},"video/h264-svc":{source:"iana"},"video/h265":{source:"iana"},"video/iso.segment":{source:"iana",extensions:["m4s"]},"video/jpeg":{source:"iana",extensions:["jpgv"]},"video/jpeg2000":{source:"iana"},"video/jpm":{source:"apache",extensions:["jpm","jpgm"]},"video/jxsv":{source:"iana"},"video/mj2":{source:"iana",extensions:["mj2","mjp2"]},"video/mp1s":{source:"iana"},"video/mp2p":{source:"iana"},"video/mp2t":{source:"iana",extensions:["ts"]},"video/mp4":{source:"iana",compressible:!1,extensions:["mp4","mp4v","mpg4"]},"video/mp4v-es":{source:"iana"},"video/mpeg":{source:"iana",compressible:!1,extensions:["mpeg","mpg","mpe","m1v","m2v"]},"video/mpeg4-generic":{source:"iana"},"video/mpv":{source:"iana"},"video/nv":{source:"iana"},"video/ogg":{source:"iana",compressible:!1,extensions:["ogv"]},"video/parityfec":{source:"iana"},"video/pointer":{source:"iana"},"video/quicktime":{source:"iana",compressible:!1,extensions:["qt","mov"]},"video/raptorfec":{source:"iana"},"video/raw":{source:"iana"},"video/rtp-enc-aescm128":{source:"iana"},"video/rtploopback":{source:"iana"},"video/rtx":{source:"iana"},"video/scip":{source:"iana"},"video/smpte291":{source:"iana"},"video/smpte292m":{source:"iana"},"video/ulpfec":{source:"iana"},"video/vc1":{source:"iana"},"video/vc2":{source:"iana"},"video/vnd.cctv":{source:"iana"},"video/vnd.dece.hd":{source:"iana",extensions:["uvh","uvvh"]},"video/vnd.dece.mobile":{source:"iana",extensions:["uvm","uvvm"]},"video/vnd.dece.mp4":{source:"iana"},"video/vnd.dece.pd":{source:"iana",extensions:["uvp","uvvp"]},"video/vnd.dece.sd":{source:"iana",extensions:["uvs","uvvs"]},"video/vnd.dece.video":{source:"iana",extensions:["uvv","uvvv"]},"video/vnd.directv.mpeg":{source:"iana"},"video/vnd.directv.mpeg-tts":{source:"iana"},"video/vnd.dlna.mpeg-tts":{source:"iana"},"video/vnd.dvb.file":{source:"iana",extensions:["dvb"]},"video/vnd.fvt":{source:"iana",extensions:["fvt"]},"video/vnd.hns.video":{source:"iana"},"video/vnd.iptvforum.1dparityfec-1010":{source:"iana"},"video/vnd.iptvforum.1dparityfec-2005":{source:"iana"},"video/vnd.iptvforum.2dparityfec-1010":{source:"iana"},"video/vnd.iptvforum.2dparityfec-2005":{source:"iana"},"video/vnd.iptvforum.ttsavc":{source:"iana"},"video/vnd.iptvforum.ttsmpeg2":{source:"iana"},"video/vnd.motorola.video":{source:"iana"},"video/vnd.motorola.videop":{source:"iana"},"video/vnd.mpegurl":{source:"iana",extensions:["mxu","m4u"]},"video/vnd.ms-playready.media.pyv":{source:"iana",extensions:["pyv"]},"video/vnd.nokia.interleaved-multimedia":{source:"iana"},"video/vnd.nokia.mp4vr":{source:"iana"},"video/vnd.nokia.videovoip":{source:"iana"},"video/vnd.objectvideo":{source:"iana"},"video/vnd.radgamettools.bink":{source:"iana"},"video/vnd.radgamettools.smacker":{source:"iana"},"video/vnd.sealed.mpeg1":{source:"iana"},"video/vnd.sealed.mpeg4":{source:"iana"},"video/vnd.sealed.swf":{source:"iana"},"video/vnd.sealedmedia.softseal.mov":{source:"iana"},"video/vnd.uvvu.mp4":{source:"iana",extensions:["uvu","uvvu"]},"video/vnd.vivo":{source:"iana",extensions:["viv"]},"video/vnd.youtube.yt":{source:"iana"},"video/vp8":{source:"iana"},"video/vp9":{source:"iana"},"video/webm":{source:"apache",compressible:!1,extensions:["webm"]},"video/x-f4v":{source:"apache",extensions:["f4v"]},"video/x-fli":{source:"apache",extensions:["fli"]},"video/x-flv":{source:"apache",compressible:!1,extensions:["flv"]},"video/x-m4v":{source:"apache",extensions:["m4v"]},"video/x-matroska":{source:"apache",compressible:!1,extensions:["mkv","mk3d","mks"]},"video/x-mng":{source:"apache",extensions:["mng"]},"video/x-ms-asf":{source:"apache",extensions:["asf","asx"]},"video/x-ms-vob":{source:"apache",extensions:["vob"]},"video/x-ms-wm":{source:"apache",extensions:["wm"]},"video/x-ms-wmv":{source:"apache",compressible:!1,extensions:["wmv"]},"video/x-ms-wmx":{source:"apache",extensions:["wmx"]},"video/x-ms-wvx":{source:"apache",extensions:["wvx"]},"video/x-msvideo":{source:"apache",extensions:["avi"]},"video/x-sgi-movie":{source:"apache",extensions:["movie"]},"video/x-smv":{source:"apache",extensions:["smv"]},"x-conference/x-cooltalk":{source:"apache",extensions:["ice"]},"x-shader/x-fragment":{compressible:!0},"x-shader/x-vertex":{compressible:!0}}});var z_=N((IE$,Y_)=>{/*! + * mime-db + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015-2022 Douglas Christopher Wilson + * MIT Licensed + */Y_.exports=J_()});var U_=N((m6)=>{/*! + * mime-types + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */var UH=z_(),IJ$=require("path").extname,W_=/^\s*([^;\s]*)(?:;|\s|$)/,MJ$=/^text\//i;m6.charset=G_;m6.charsets={lookup:G_};m6.contentType=xJ$;m6.extension=bJ$;m6.extensions=Object.create(null);m6.lookup=iJ$;m6.types=Object.create(null);RJ$(m6.extensions,m6.types);function G_($){if(!$||typeof $!=="string")return!1;var X=W_.exec($),Q=X&&UH[X[1].toLowerCase()];if(Q&&Q.charset)return Q.charset;if(X&&MJ$.test(X[1]))return"UTF-8";return!1}function xJ$($){if(!$||typeof $!=="string")return!1;var X=$.indexOf("/")===-1?m6.lookup($):$;if(!X)return!1;if(X.indexOf("charset")===-1){var Q=m6.charset(X);if(Q)X+="; charset="+Q.toLowerCase()}return X}function bJ$($){if(!$||typeof $!=="string")return!1;var X=W_.exec($),Q=X&&m6.extensions[X[1].toLowerCase()];if(!Q||!Q.length)return!1;return Q[0]}function iJ$($){if(!$||typeof $!=="string")return!1;var X=IJ$("x."+$).toLowerCase().substr(1);if(!X)return!1;return m6.types[X]||!1}function RJ$($,X){var Q=["nginx","apache",void 0,"iana"];Object.keys(UH).forEach(function(v){var Y=UH[v],z=Y.extensions;if(!z||!z.length)return;$[v]=z;for(var W=0;Ww||U===w&&X[G].substr(0,12)==="application/"))continue}X[G]=v}})}});var KH=N((xE$,K_)=>{/*! + * accepts + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */var EJ$=v_(),PJ$=U_();K_.exports=U1;function U1($){if(!(this instanceof U1))return new U1($);this.headers=$.headers,this.negotiator=new EJ$($)}U1.prototype.type=U1.prototype.types=function($){var X=$;if(X&&!Array.isArray(X)){X=Array(arguments.length);for(var Q=0;Q{/*! + * base64id v0.1.0 + */var Q7=require("crypto"),pL=function(){};pL.prototype.getRandomBytes=function($){var X=4096,Q=this;if($=$||12,$>X)return Q7.randomBytes($);var J=parseInt(X/$),v=parseInt(J*0.85);if(!v)return Q7.randomBytes($);if(this.bytesBufferIndex==null)this.bytesBufferIndex=-1;if(this.bytesBufferIndex==J)this.bytesBuffer=null,this.bytesBufferIndex=-1;if(this.bytesBufferIndex==-1||this.bytesBufferIndex>v){if(!this.isGeneratingBytes)this.isGeneratingBytes=!0,Q7.randomBytes(X,function(z,W){Q.bytesBuffer=W,Q.bytesBufferIndex=0,Q.isGeneratingBytes=!1});if(this.bytesBufferIndex==-1)return Q7.randomBytes($)}var Y=this.bytesBuffer.slice($*this.bytesBufferIndex,$*(this.bytesBufferIndex+1));return this.bytesBufferIndex++,Y};pL.prototype.generateId=function(){var $=Buffer.alloc(15);if(!$.writeInt32BE)return Math.abs(Math.random()*Math.random()*Date.now()|0).toString()+Math.abs(Math.random()*Math.random()*Date.now()|0).toString();if(this.sequenceNumber=this.sequenceNumber+1|0,$.writeInt32BE(this.sequenceNumber,11),Q7.randomBytes)this.getRandomBytes(12).copy($);else[0,4,8].forEach(function(X){$.writeInt32BE(Math.random()*Math.pow(2,32)|0,X)});return $.toString("base64").replace(/\//g,"_").replace(/\+/g,"-")};w_=H_.exports=new pL});var wH=N((J9)=>{Object.defineProperty(J9,"__esModule",{value:!0});J9.ERROR_PACKET=J9.PACKET_TYPES_REVERSE=J9.PACKET_TYPES=void 0;var f0=Object.create(null);J9.PACKET_TYPES=f0;f0.open="0";f0.close="1";f0.ping="2";f0.pong="3";f0.message="4";f0.upgrade="5";f0.noop="6";var j_=Object.create(null);J9.PACKET_TYPES_REVERSE=j_;Object.keys(f0).forEach(($)=>{j_[f0[$]]=$});var CJ$={type:"error",data:"parser error"};J9.ERROR_PACKET=CJ$});var B_=N((iv)=>{Object.defineProperty(iv,"__esModule",{value:!0});iv.encodePacket=void 0;iv.encodePacketToBinary=yJ$;var uJ$=wH(),_J$=({type:$,data:X},Q,J)=>{if(X instanceof ArrayBuffer||ArrayBuffer.isView(X))return J(Q?X:"b"+V_(X,!0).toString("base64"));return J(uJ$.PACKET_TYPES[$]+(X||""))};iv.encodePacket=_J$;var V_=($,X)=>{if(Buffer.isBuffer($)||$ instanceof Uint8Array&&!X)return $;else if($ instanceof ArrayBuffer)return Buffer.from($);else return Buffer.from($.buffer,$.byteOffset,$.byteLength)},oL;function yJ$($,X){if($.data instanceof ArrayBuffer||ArrayBuffer.isView($.data))return X(V_($.data,!1));(0,iv.encodePacket)($,!0,(Q)=>{if(!oL)oL=new TextEncoder;X(oL.encode(Q))})}});var F_=N((jH)=>{Object.defineProperty(jH,"__esModule",{value:!0});jH.decodePacket=void 0;var HH=wH(),fJ$=($,X)=>{if(typeof $!=="string")return{type:"message",data:D_($,X)};let Q=$.charAt(0);if(Q==="b"){let J=Buffer.from($.substring(1),"base64");return{type:"message",data:D_(J,X)}}if(!HH.PACKET_TYPES_REVERSE[Q])return HH.ERROR_PACKET;return $.length>1?{type:HH.PACKET_TYPES_REVERSE[Q],data:$.substring(1)}:{type:HH.PACKET_TYPES_REVERSE[Q]}};jH.decodePacket=fJ$;var D_=($,X)=>{switch(X){case"arraybuffer":if($ instanceof ArrayBuffer)return $;else if(Buffer.isBuffer($))return $.buffer.slice($.byteOffset,$.byteOffset+$.byteLength);else return $.buffer;case"nodebuffer":default:if(Buffer.isBuffer($))return $;else return Buffer.from($)}}});var v7=N((h6)=>{Object.defineProperty(h6,"__esModule",{value:!0});h6.decodePayload=h6.decodePacket=h6.encodePayload=h6.encodePacket=h6.protocol=void 0;h6.createPacketEncoderStream=gJ$;h6.createPacketDecoderStream=cJ$;var rL=B_();Object.defineProperty(h6,"encodePacket",{enumerable:!0,get:function(){return rL.encodePacket}});var tL=F_();Object.defineProperty(h6,"decodePacket",{enumerable:!0,get:function(){return tL.decodePacket}});var N_=wH(),O_=String.fromCharCode(30),kJ$=($,X)=>{let Q=$.length,J=Array(Q),v=0;$.forEach((Y,z)=>{(0,rL.encodePacket)(Y,!1,(W)=>{if(J[z]=W,++v===Q)X(J.join(O_))})})};h6.encodePayload=kJ$;var nJ$=($,X)=>{let Q=$.split(O_),J=[];for(let v=0;v{let J=Q.length,v;if(J<126)v=new Uint8Array(1),new DataView(v.buffer).setUint8(0,J);else if(J<65536){v=new Uint8Array(3);let Y=new DataView(v.buffer);Y.setUint8(0,126),Y.setUint16(1,J)}else{v=new Uint8Array(9);let Y=new DataView(v.buffer);Y.setUint8(0,127),Y.setBigUint64(1,BigInt(J))}if($.data&&typeof $.data!=="string")v[0]|=128;X.enqueue(v),X.enqueue(Q)})}})}var aL;function VH($){return $.reduce((X,Q)=>X+Q.length,0)}function BH($,X){if($[0].length===X)return $.shift();let Q=new Uint8Array(X),J=0;for(let v=0;vMath.pow(2,21)-1){W.enqueue(N_.ERROR_PACKET);break}v=w*Math.pow(2,32)+U.getUint32(4),J=3}else{if(VH(Q)$){W.enqueue(N_.ERROR_PACKET);break}}}})}h6.protocol=4});var q_=N((PE$,Z_)=>{/*! https://mths.be/utf8js v2.1.2 by @mathias */var Y9=String.fromCharCode;function L_($){var X=[],Q=0,J=$.length,v,Y;while(Q=55296&&v<=56319&&Q65535)J-=65536,v+=Y9(J>>>10&1023|55296),J=56320|J&1023;v+=Y9(J)}return v}function A_($,X){if($>=55296&&$<=57343){if(X)throw Error("Lone surrogate U+"+$.toString(16).toUpperCase()+" is not a scalar value");return!1}return!0}function sL($,X){return Y9($>>X&63|128)}function hJ$($,X){if(($&4294967168)==0)return Y9($);var Q="";if(($&4294965248)==0)Q=Y9($>>6&31|192);else if(($&4294901760)==0){if(!A_($,X))$=65533;Q=Y9($>>12&15|224),Q+=sL($,6)}else if(($&4292870144)==0)Q=Y9($>>18&7|240),Q+=sL($,12),Q+=sL($,6);return Q+=Y9($&63|128),Q}function lJ$($,X){X=X||{};var Q=X.strict!==!1,J=L_($),v=J.length,Y=-1,z,W="";while(++Y=FH)throw Error("Invalid byte index");var $=DH[z9]&255;if(z9++,($&192)==128)return $&63;throw Error("Invalid continuation byte")}function pJ$($){var X,Q,J,v,Y;if(z9>FH)throw Error("Invalid byte index");if(z9==FH)return!1;if(X=DH[z9]&255,z9++,(X&128)==0)return X;if((X&224)==192)if(Q=Rv(),Y=(X&31)<<6|Q,Y>=128)return Y;else throw Error("Invalid continuation byte");if((X&240)==224)if(Q=Rv(),J=Rv(),Y=(X&15)<<12|Q<<6|J,Y>=2048)return A_(Y,$)?Y:65533;else throw Error("Invalid continuation byte");if((X&248)==240){if(Q=Rv(),J=Rv(),v=Rv(),Y=(X&7)<<18|Q<<12|J<<6|v,Y>=65536&&Y<=1114111)return Y}throw Error("Invalid UTF-8 detected")}var DH,FH,z9;function dJ$($,X){X=X||{};var Q=X.strict!==!1;DH=L_($),FH=DH.length,z9=0;var J=[],v;while((v=pJ$(Q))!==!1)J.push(v);return mJ$(J)}Z_.exports={version:"2.1.2",encode:lJ$,decode:dJ$}});var P_=N((u4)=>{Object.defineProperty(u4,"__esModule",{value:!0});u4.packets=u4.protocol=void 0;u4.encodePacket=eL;u4.encodeBase64Packet=x_;u4.decodePacket=$A;u4.decodeBase64Packet=b_;u4.encodePayload=tJ$;u4.decodePayload=eJ$;u4.encodePayloadAsBinary=R_;u4.decodePayloadAsBinary=E_;var M_=q_();u4.protocol=3;var oJ$=($)=>{for(let X of $)if(X.data instanceof ArrayBuffer||ArrayBuffer.isView(X.data))return!0;return!1};u4.packets={open:0,close:1,ping:2,pong:3,message:4,upgrade:5,noop:6};var Ev=Object.keys(u4.packets),$0={type:"error",data:"parser error"},aJ$=Buffer.concat([]);function eL($,X,Q,J){if(typeof X==="function")J=X,X=null;if(typeof Q==="function")J=Q,Q=null;if(Buffer.isBuffer($.data))return I_($,X,J);else if($.data&&($.data.buffer||$.data)instanceof ArrayBuffer)return I_({type:$.type,data:XA($.data)},X,J);var v=u4.packets[$.type];if($.data!==void 0)v+=Q?M_.encode(String($.data),{strict:!1}):String($.data);return J(""+v)}function I_($,X,Q){if(!X)return x_($,Q);var J=$.data,v=Buffer.allocUnsafe(1);return v[0]=u4.packets[$.type],Q(Buffer.concat([v,J]))}function x_($,X){var Q=Buffer.isBuffer($.data)?$.data:XA($.data),J="b"+u4.packets[$.type];return J+=Q.toString("base64"),X(J)}function $A($,X,Q){if($===void 0)return $0;let J;if(typeof $==="string"){if(J=$.charAt(0),J==="b")return b_($.slice(1),X);if(Q){if($=rJ$($),$===!1)return $0}if(Number(J)!=J||!Ev[J])return $0;if($.length>1)return{type:Ev[J],data:$.slice(1)};else return{type:Ev[J]}}if(X==="arraybuffer"){var v=new Uint8Array($);return J=v[0],{type:Ev[J],data:v.buffer.slice(1)}}if($ instanceof ArrayBuffer)$=XA($);return J=$[0],{type:Ev[J],data:$.slice(1)}}function rJ$($){try{$=M_.decode($,{strict:!1})}catch(X){return!1}return $}function b_($,X){var Q=Ev[$.charAt(0)],J=Buffer.from($.slice(1),"base64");if(X==="arraybuffer"){var v=new Uint8Array(J.length);for(var Y=0;Y{if(J[Y]=W,++v===$.length)Q(null,J)})}function eJ$($,X,Q){if(typeof $!=="string")return E_($,X,Q);if(typeof X==="function")Q=X,X=null;if($==="")return Q($0,0,1);var J="",v,Y,z;for(var W=0,G=$.length;W0){var z="",W=J[0]===0;for(Y=1;;Y++){if(J[Y]===255)break;if(z.length>310)return Q($0,0,1);z+=""+J[Y]}J=J.slice(z.length+1);var G=parseInt(z,10),U=J.slice(1,G+1);if(W)U=$Y$(U);v.push(U),J=J.slice(G+1)}var w=v.length;for(Y=0;Y{Object.defineProperty(NH,"__esModule",{value:!0});NH.Transport=void 0;var vY$=require("events"),JY$=v7(),YY$=P_(),zY$=W4(),T_=(0,zY$.default)("engine:transport");function WY$(){}class QA extends vY$.EventEmitter{get readyState(){return this._readyState}set readyState($){T_("readyState updated from %s to %s (%s)",this._readyState,$,this.name),this._readyState=$}constructor($){super();this.writable=!1,this._readyState="open",this.discarded=!1,this.protocol=$._query.EIO==="4"?4:3,this.parser=this.protocol===4?JY$:YY$,this.supportsBinary=!($._query&&$._query.b64)}discard(){this.discarded=!0}onRequest($){}close($){if(this.readyState==="closed"||this.readyState==="closing")return;this.readyState="closing",this.doClose($||WY$)}onError($,X){if(this.listeners("error").length){let Q=Error($);Q.type="TransportError",Q.description=X,this.emit("error",Q)}else T_("ignored transport error %s (%s)",$,X)}onPacket($){this.emit("packet",$)}onData($){this.onPacket(this.parser.decodePacket($))}onClose(){this.readyState="closed",this.emit("close")}}NH.Transport=QA;QA.upgradesTo=[]});var vA=N((OH)=>{Object.defineProperty(OH,"__esModule",{value:!0});OH.Polling=void 0;var GY$=DX(),S_=require("zlib"),UY$=KH(),KY$=W4(),K1=(0,KY$.default)("engine:polling"),wY$={gzip:S_.createGzip,deflate:S_.createDeflate};class C_ extends GY$.Transport{constructor($){super($);this.closeTimeout=30000}get name(){return"polling"}onRequest($){let X=$.res;if($.res=null,$.method==="GET")this.onPollRequest($,X);else if($.method==="POST")this.onDataRequest($,X);else X.writeHead(500),X.end()}onPollRequest($,X){if(this.req){K1("request overlap"),this.onError("overlap from client"),X.writeHead(400),X.end();return}K1("setting request"),this.req=$,this.res=X;let Q=()=>{this.onError("poll connection closed prematurely")},J=()=>{$.removeListener("close",Q),this.req=this.res=null};if($.cleanup=J,$.on("close",Q),this.writable=!0,this.emit("ready"),this.writable&&this.shouldClose)K1("triggering empty send to append close packet"),this.send([{type:"noop"}])}onDataRequest($,X){if(this.dataReq){this.onError("data request overlap from client"),X.writeHead(400),X.end();return}let Q=$.headers["content-type"]==="application/octet-stream";if(Q&&this.protocol===4)return this.onError("invalid content");this.dataReq=$,this.dataRes=X;let J=Q?Buffer.concat([]):"",v=()=>{$.removeListener("data",z),$.removeListener("end",W),$.removeListener("close",Y),this.dataReq=this.dataRes=J=null},Y=()=>{v(),this.onError("data request connection closed prematurely")},z=(G)=>{let U;if(Q)J=Buffer.concat([J,G]),U=J.length;else J+=G,U=Buffer.byteLength(J);if(U>this.maxHttpBufferSize)X.writeHead(413).end(),v()},W=()=>{this.onData(J);let G={"Content-Type":"text/html","Content-Length":"2"};X.writeHead(200,this.headers($,G)),X.end("ok"),v()};if($.on("close",Y),!Q)$.setEncoding("utf8");$.on("data",z),$.on("end",W)}onData($){K1('received "%s"',$);let X=(Q)=>{if(Q.type==="close")return K1("got xhr close packet"),this.onClose(),!1;this.onPacket(Q)};if(this.protocol===3)this.parser.decodePayload($,X);else this.parser.decodePayload($).forEach(X)}onClose(){if(this.writable)this.send([{type:"noop"}]);super.onClose()}send($){if(this.writable=!1,this.shouldClose)K1("appending close packet to payload"),$.push({type:"close"}),this.shouldClose(),this.shouldClose=null;let X=(Q)=>{let J=$.some((v)=>{return v.options&&v.options.compress});this.write(Q,{compress:J})};if(this.protocol===3)this.parser.encodePayload($,this.supportsBinary,X);else this.parser.encodePayload($,X)}write($,X){K1('writing "%s"',$),this.doWrite($,X,()=>{this.req.cleanup(),this.emit("drain")})}doWrite($,X,Q){let J=typeof $==="string",Y={"Content-Type":J?"text/plain; charset=UTF-8":"application/octet-stream"},z=(U)=>{Y["Content-Length"]=typeof U==="string"?Buffer.byteLength(U):U.length,this.res.writeHead(200,this.headers(this.req,Y)),this.res.end(U),Q()};if(!this.httpCompression||!X.compress){z($);return}if((J?Buffer.byteLength($):$.length){if(U){this.res.writeHead(500),this.res.end(),Q(U);return}Y["Content-Encoding"]=G,z(w)})}compress($,X,Q){K1("compressing");let J=[],v=0;wY$[X](this.httpCompression).on("error",Q).on("data",function(Y){J.push(Y),v+=Y.length}).on("end",function(){Q(null,Buffer.concat(J,v))}).end($)}doClose($){K1("closing");let X;if(this.dataReq)K1("aborting ongoing data request"),this.dataReq.destroy();let Q=()=>{clearTimeout(X),$(),this.onClose()};if(this.writable)K1("transport writable - closing right away"),this.send([{type:"close"}]),Q();else if(this.discarded)K1("transport discarded - closing right away"),Q();else K1("transport not writable - buffering orderly close"),this.shouldClose=Q,X=setTimeout(Q,this.closeTimeout)}headers($,X={}){let Q=$.headers["user-agent"];if(Q&&(~Q.indexOf(";MSIE")||~Q.indexOf("Trident/")))X["X-XSS-Protection"]="0";return X["cache-control"]="no-store",this.emit("headers",X,$),X}}OH.Polling=C_});var __=N((LH)=>{Object.defineProperty(LH,"__esModule",{value:!0});LH.JSONP=void 0;var HY$=vA(),jY$=require("querystring"),VY$=/\\\\n/g,BY$=/(\\)?\\n/g;class u_ extends HY$.Polling{constructor($){super($);this.head="___eio["+($._query.j||"").replace(/[^0-9]/g,"")+"](",this.foot=");"}onData($){if($=jY$.parse($).d,typeof $==="string")$=$.replace(BY$,function(X,Q){return Q?X:` +`}),super.onData($.replace(VY$,"\\n"))}doWrite($,X,Q){let J=JSON.stringify($).replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029");$=this.head+J+this.foot,super.doWrite($,X,Q)}}LH.JSONP=u_});var k_=N((AH)=>{Object.defineProperty(AH,"__esModule",{value:!0});AH.WebSocket=void 0;var DY$=DX(),FY$=W4(),y_=(0,FY$.default)("engine:ws");class f_ extends DY$.Transport{constructor($){super($);this._doSend=(X)=>{this.socket.send(X,this._onSent)},this._doSendLast=(X)=>{this.socket.send(X,this._onSentLast)},this._onSent=(X)=>{if(X)this.onError("write error",X.stack)},this._onSentLast=(X)=>{if(X)this.onError("write error",X.stack);else this.emit("drain"),this.writable=!0,this.emit("ready")},this.socket=$.websocket,this.socket.on("message",(X,Q)=>{let J=Q?X:X.toString();y_('received "%s"',J),super.onData(J)}),this.socket.once("close",this.onClose.bind(this)),this.socket.on("error",this.onError.bind(this)),this.writable=!0,this.perMessageDeflate=null}get name(){return"websocket"}get handlesUpgrades(){return!0}send($){this.writable=!1;for(let X=0;X<$.length;X++){let Q=$[X],J=X+1===$.length;if(this._canSendPreEncodedFrame(Q))this.socket._sender.sendFrame(Q.options.wsPreEncodedFrame,J?this._onSentLast:this._onSent);else this.parser.encodePacket(Q,this.supportsBinary,J?this._doSendLast:this._doSend)}}_canSendPreEncodedFrame($){var X,Q,J;return!this.perMessageDeflate&&typeof((Q=(X=this.socket)===null||X===void 0?void 0:X._sender)===null||Q===void 0?void 0:Q.sendFrame)==="function"&&((J=$.options)===null||J===void 0?void 0:J.wsPreEncodedFrame)!==void 0}doClose($){y_("closing"),this.socket.close(),$&&$()}}AH.WebSocket=f_});var JA=N((ZH)=>{Object.defineProperty(ZH,"__esModule",{value:!0});ZH.WebTransport=void 0;var NY$=DX(),OY$=W4(),LY$=v7(),Pv=(0,OY$.default)("engine:webtransport");class n_ extends NY$.Transport{constructor($,X,Q){super({_query:{EIO:"4"}});this.session=$;let J=(0,LY$.createPacketEncoderStream)();J.readable.pipeTo(X.writable).catch(()=>{Pv("the stream was closed")}),this.writer=J.writable.getWriter(),(async()=>{try{while(!0){let{value:v,done:Y}=await Q.read();if(Y){Pv("session is closed");break}Pv("received chunk: %o",v),this.onPacket(v)}}catch(v){Pv("error while reading: %s",v.message)}})(),$.closed.then(()=>this.onClose()),this.writable=!0}get name(){return"webtransport"}async send($){this.writable=!1;try{for(let X=0;X<$.length;X++){let Q=$[X];await this.writer.write(Q)}}catch(X){Pv("error while writing: %s",X.message)}this.emit("drain"),this.writable=!0,this.emit("ready")}doClose($){Pv("closing WebTransport session"),this.session.close(),$&&$()}}ZH.WebTransport=n_});var zA=N((YA)=>{Object.defineProperty(YA,"__esModule",{value:!0});var AY$=vA(),ZY$=__(),qY$=k_(),IY$=JA();YA.default={polling:g_,websocket:qY$.WebSocket,webtransport:IY$.WebTransport};function g_($){if(typeof $._query.j==="string")return new ZY$.JSONP($);else return new AY$.Polling($)}g_.upgradesTo=["websocket","webtransport"]});var WA=N((qH)=>{Object.defineProperty(qH,"__esModule",{value:!0});qH.Socket=void 0;var MY$=require("events"),xY$=W4(),j8=require("timers"),V4=(0,xY$.default)("engine:socket");class c_ extends MY$.EventEmitter{get readyState(){return this._readyState}set readyState($){V4("readyState updated from %s to %s",this._readyState,$),this._readyState=$}constructor($,X,Q,J,v){super();if(this._readyState="opening",this.upgrading=!1,this.upgraded=!1,this.writeBuffer=[],this.packetsFn=[],this.sentCallbackFn=[],this.cleanupFn=[],this.id=$,this.server=X,this.request=J,this.protocol=v,J)if(J.websocket&&J.websocket._socket)this.remoteAddress=J.websocket._socket.remoteAddress;else this.remoteAddress=J.connection.remoteAddress;this.pingTimeoutTimer=null,this.pingIntervalTimer=null,this.setTransport(Q),this.onOpen()}onOpen(){if(this.readyState="open",this.transport.sid=this.id,this.sendPacket("open",JSON.stringify({sid:this.id,upgrades:this.getAvailableUpgrades(),pingInterval:this.server.opts.pingInterval,pingTimeout:this.server.opts.pingTimeout,maxPayload:this.server.opts.maxHttpBufferSize})),this.server.opts.initialPacket)this.sendPacket("message",this.server.opts.initialPacket);if(this.emit("open"),this.protocol===3)this.resetPingTimeout();else this.schedulePing()}onPacket($){if(this.readyState!=="open")return V4("packet received with closed socket");switch(V4(`received packet ${$.type}`),this.emit("packet",$),$.type){case"ping":if(this.transport.protocol!==3){this.onError(Error("invalid heartbeat direction"));return}V4("got ping"),this.pingTimeoutTimer.refresh(),this.sendPacket("pong"),this.emit("heartbeat");break;case"pong":if(this.transport.protocol===3){this.onError(Error("invalid heartbeat direction"));return}V4("got pong"),(0,j8.clearTimeout)(this.pingTimeoutTimer),this.pingIntervalTimer.refresh(),this.emit("heartbeat");break;case"error":this.onClose("parse error");break;case"message":this.emit("data",$.data),this.emit("message",$.data);break}}onError($){V4("transport error"),this.onClose("transport error",$)}schedulePing(){this.pingIntervalTimer=(0,j8.setTimeout)(()=>{V4("writing ping packet - expecting pong within %sms",this.server.opts.pingTimeout),this.sendPacket("ping"),this.resetPingTimeout()},this.server.opts.pingInterval)}resetPingTimeout(){(0,j8.clearTimeout)(this.pingTimeoutTimer),this.pingTimeoutTimer=(0,j8.setTimeout)(()=>{if(this.readyState==="closed")return;this.onClose("ping timeout")},this.protocol===3?this.server.opts.pingInterval+this.server.opts.pingTimeout:this.server.opts.pingTimeout)}setTransport($){let X=this.onError.bind(this),Q=()=>this.flush(),J=this.onPacket.bind(this),v=this.onDrain.bind(this),Y=this.onClose.bind(this,"transport close");this.transport=$,this.transport.once("error",X),this.transport.on("ready",Q),this.transport.on("packet",J),this.transport.on("drain",v),this.transport.once("close",Y),this.cleanupFn.push(function(){$.removeListener("error",X),$.removeListener("ready",Q),$.removeListener("packet",J),$.removeListener("drain",v),$.removeListener("close",Y)})}onDrain(){if(this.sentCallbackFn.length>0){V4("executing batch send callback");let $=this.sentCallbackFn.shift();if($)for(let X=0;X<$.length;X++)$[X](this.transport)}}_maybeUpgrade($){V4('might upgrade socket transport from "%s" to "%s"',this.transport.name,$.name),this.upgrading=!0;let X=(0,j8.setTimeout)(()=>{if(V4("client did not complete upgrade - closing transport"),Y(),$.readyState==="open")$.close()},this.server.opts.upgradeTimeout),Q,J=(U)=>{if(U.type==="ping"&&U.data==="probe")V4("got probe ping packet, sending pong"),$.send([{type:"pong",data:"probe"}]),this.emit("upgrading",$),clearInterval(Q),Q=setInterval(v,100);else if(U.type==="upgrade"&&this.readyState!=="closed"){if(V4("got upgrade packet - upgrading"),Y(),this.transport.discard(),this.upgraded=!0,this.clearTransport(),this.setTransport($),this.emit("upgrade",$),this.flush(),this.readyState==="closing")$.close(()=>{this.onClose("forced close")})}else Y(),$.close()},v=()=>{if(this.transport.name==="polling"&&this.transport.writable)V4("writing a noop packet to polling for fast upgrade"),this.transport.send([{type:"noop"}])},Y=()=>{this.upgrading=!1,clearInterval(Q),(0,j8.clearTimeout)(X),$.removeListener("packet",J),$.removeListener("close",W),$.removeListener("error",z),this.removeListener("close",G)},z=(U)=>{V4("client did not complete upgrade - %s",U),Y(),$.close(),$=null},W=()=>{z("transport closed")},G=()=>{z("socket closed")};$.on("packet",J),$.once("close",W),$.once("error",z),this.once("close",G)}clearTransport(){let $,X=this.cleanupFn.length;for(let Q=0;Q{this.writeBuffer=[]}),this.packetsFn=[],this.sentCallbackFn=[],this.clearTransport(),this.emit("close",$,X)}send($,X,Q){return this.sendPacket("message",$,X,Q),this}write($,X,Q){return this.sendPacket("message",$,X,Q),this}sendPacket($,X,Q={},J){if(typeof Q==="function")J=Q,Q={};if(this.readyState!=="closing"&&this.readyState!=="closed"){V4('sending packet "%s" (%s)',$,X),Q.compress=Q.compress!==!1;let v={type:$,options:Q};if(X)v.data=X;if(this.emit("packetCreate",v),this.writeBuffer.push(v),typeof J==="function")this.packetsFn.push(J);this.flush()}}flush(){if(this.readyState!=="closed"&&this.transport.writable&&this.writeBuffer.length){V4("flushing buffer to transport"),this.emit("flush",this.writeBuffer),this.server.emit("flush",this,this.writeBuffer);let $=this.writeBuffer;if(this.writeBuffer=[],this.packetsFn.length)this.sentCallbackFn.push(this.packetsFn),this.packetsFn=[];else this.sentCallbackFn.push(null);this.transport.send($),this.emit("drain"),this.server.emit("drain",this)}}getAvailableUpgrades(){let $=[],X=this.server.upgrades(this.transport.name);for(let Q=0;Q{V4("all packets have been sent, closing the transport"),this.closeTransport($)});return}V4("the buffer is empty, closing the transport right away"),this.closeTransport($)}closeTransport($){if(V4("closing the transport (discard? %s)",!!$),$)this.transport.discard();this.transport.close(this.onClose.bind(this,"forced close"))}}qH.Socket=c_});var l_=N((GA)=>{/*! + * cookie + * Copyright(c) 2012-2014 Roman Shtylman + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */GA.parse=SY$;GA.serialize=CY$;var bY$=Object.prototype.toString,iY$=Object.prototype.hasOwnProperty,RY$=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/,EY$=/^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/,PY$=/^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i,TY$=/^[\u0020-\u003A\u003D-\u007E]*$/;function SY$($,X){if(typeof $!=="string")throw TypeError("argument str must be a string");var Q={},J=$.length;if(J<2)return Q;var v=X&&X.decode||uY$,Y=0,z=0,W=0;do{if(z=$.indexOf("=",Y),z===-1)break;if(W=$.indexOf(";",Y),W===-1)W=J;else if(z>W){Y=$.lastIndexOf(";",z-1)+1;continue}var G=m_($,Y,z),U=h_($,z,G),w=$.slice(G,U);if(!iY$.call(Q,w)){var H=m_($,z+1,W),j=h_($,W,H);if($.charCodeAt(H)===34&&$.charCodeAt(j-1)===34)H++,j--;var V=$.slice(H,j);Q[w]=yY$(V,v)}Y=W+1}while(YQ){var J=$.charCodeAt(--X);if(J!==32&&J!==9)return X+1}return Q}function CY$($,X,Q){var J=Q&&Q.encode||encodeURIComponent;if(typeof J!=="function")throw TypeError("option encode is invalid");if(!RY$.test($))throw TypeError("argument name is invalid");var v=J(X);if(!EY$.test(v))throw TypeError("argument val is invalid");var Y=$+"="+v;if(!Q)return Y;if(Q.maxAge!=null){var z=Math.floor(Q.maxAge);if(!isFinite(z))throw TypeError("option maxAge is invalid");Y+="; Max-Age="+z}if(Q.domain){if(!PY$.test(Q.domain))throw TypeError("option domain is invalid");Y+="; Domain="+Q.domain}if(Q.path){if(!TY$.test(Q.path))throw TypeError("option path is invalid");Y+="; Path="+Q.path}if(Q.expires){var W=Q.expires;if(!_Y$(W)||isNaN(W.valueOf()))throw TypeError("option expires is invalid");Y+="; Expires="+W.toUTCString()}if(Q.httpOnly)Y+="; HttpOnly";if(Q.secure)Y+="; Secure";if(Q.partitioned)Y+="; Partitioned";if(Q.priority){var G=typeof Q.priority==="string"?Q.priority.toLowerCase():Q.priority;switch(G){case"low":Y+="; Priority=Low";break;case"medium":Y+="; Priority=Medium";break;case"high":Y+="; Priority=High";break;default:throw TypeError("option priority is invalid")}}if(Q.sameSite){var U=typeof Q.sameSite==="string"?Q.sameSite.toLowerCase():Q.sameSite;switch(U){case!0:Y+="; SameSite=Strict";break;case"lax":Y+="; SameSite=Lax";break;case"strict":Y+="; SameSite=Strict";break;case"none":Y+="; SameSite=None";break;default:throw TypeError("option sameSite is invalid")}}return Y}function uY$($){return $.indexOf("%")!==-1?decodeURIComponent($):$}function _Y$($){return bY$.call($)==="[object Date]"}function yY$($,X){try{return X($)}catch(Q){return $}}});var V8=N((gE$,o_)=>{var p_=["nodebuffer","arraybuffer","fragments"],d_=typeof Blob<"u";if(d_)p_.push("blob");o_.exports={BINARY_TYPES:p_,EMPTY_BUFFER:Buffer.alloc(0),GUID:"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",hasBlob:d_,kForOnEventAttribute:Symbol("kIsForOnEventAttribute"),kListener:Symbol("kListener"),kStatusCode:Symbol("status-code"),kWebSocket:Symbol("websocket"),NOOP:()=>{}}});var J7=N((cE$,IH)=>{var{EMPTY_BUFFER:fY$}=V8(),UA=Buffer[Symbol.species];function kY$($,X){if($.length===0)return fY$;if($.length===1)return $[0];let Q=Buffer.allocUnsafe(X),J=0;for(let v=0;v<$.length;v++){let Y=$[v];Q.set(Y,J),J+=Y.length}if(J{throw new Error("Cannot require module "+"bufferutil");})();IH.exports.mask=function(X,Q,J,v,Y){if(Y<48)a_(X,Q,J,v,Y);else $.mask(X,Q,J,v,Y)},IH.exports.unmask=function(X,Q){if(X.length<32)r_(X,Q);else $.unmask(X,Q)}}catch($){}});var $y=N((mE$,e_)=>{var t_=Symbol("kDone"),wA=Symbol("kRun");class s_{constructor($){this[t_]=()=>{this.pending--,this[wA]()},this.concurrency=$||1/0,this.jobs=[],this.pending=0}add($){this.jobs.push($),this[wA]()}[wA](){if(this.pending===this.concurrency)return;if(this.jobs.length){let $=this.jobs.shift();this.pending++,$(this[t_])}}}e_.exports=s_});var z7=N((hE$,Yy)=>{var Y7=require("zlib"),Xy=J7(),gY$=$y(),{kStatusCode:Qy}=V8(),cY$=Buffer[Symbol.species],mY$=Buffer.from([0,0,255,255]),xH=Symbol("permessage-deflate"),B8=Symbol("total-length"),Tv=Symbol("callback"),W9=Symbol("buffers"),Sv=Symbol("error"),MH;class vy{constructor($,X,Q){if(this._maxPayload=Q|0,this._options=$||{},this._threshold=this._options.threshold!==void 0?this._options.threshold:1024,this._isServer=!!X,this._deflate=null,this._inflate=null,this.params=null,!MH){let J=this._options.concurrencyLimit!==void 0?this._options.concurrencyLimit:10;MH=new gY$(J)}}static get extensionName(){return"permessage-deflate"}offer(){let $={};if(this._options.serverNoContextTakeover)$.server_no_context_takeover=!0;if(this._options.clientNoContextTakeover)$.client_no_context_takeover=!0;if(this._options.serverMaxWindowBits)$.server_max_window_bits=this._options.serverMaxWindowBits;if(this._options.clientMaxWindowBits)$.client_max_window_bits=this._options.clientMaxWindowBits;else if(this._options.clientMaxWindowBits==null)$.client_max_window_bits=!0;return $}accept($){return $=this.normalizeParams($),this.params=this._isServer?this.acceptAsServer($):this.acceptAsClient($),this.params}cleanup(){if(this._inflate)this._inflate.close(),this._inflate=null;if(this._deflate){let $=this._deflate[Tv];if(this._deflate.close(),this._deflate=null,$)$(Error("The deflate stream was closed while data was being processed"))}}acceptAsServer($){let X=this._options,Q=$.find((J)=>{if(X.serverNoContextTakeover===!1&&J.server_no_context_takeover||J.server_max_window_bits&&(X.serverMaxWindowBits===!1||typeof X.serverMaxWindowBits==="number"&&X.serverMaxWindowBits>J.server_max_window_bits)||typeof X.clientMaxWindowBits==="number"&&!J.client_max_window_bits)return!1;return!0});if(!Q)throw Error("None of the extension offers can be accepted");if(X.serverNoContextTakeover)Q.server_no_context_takeover=!0;if(X.clientNoContextTakeover)Q.client_no_context_takeover=!0;if(typeof X.serverMaxWindowBits==="number")Q.server_max_window_bits=X.serverMaxWindowBits;if(typeof X.clientMaxWindowBits==="number")Q.client_max_window_bits=X.clientMaxWindowBits;else if(Q.client_max_window_bits===!0||X.clientMaxWindowBits===!1)delete Q.client_max_window_bits;return Q}acceptAsClient($){let X=$[0];if(this._options.clientNoContextTakeover===!1&&X.client_no_context_takeover)throw Error('Unexpected parameter "client_no_context_takeover"');if(!X.client_max_window_bits){if(typeof this._options.clientMaxWindowBits==="number")X.client_max_window_bits=this._options.clientMaxWindowBits}else if(this._options.clientMaxWindowBits===!1||typeof this._options.clientMaxWindowBits==="number"&&X.client_max_window_bits>this._options.clientMaxWindowBits)throw Error('Unexpected or invalid parameter "client_max_window_bits"');return X}normalizeParams($){return $.forEach((X)=>{Object.keys(X).forEach((Q)=>{let J=X[Q];if(J.length>1)throw Error(`Parameter "${Q}" must have only a single value`);if(J=J[0],Q==="client_max_window_bits"){if(J!==!0){let v=+J;if(!Number.isInteger(v)||v<8||v>15)throw TypeError(`Invalid value for parameter "${Q}": ${J}`);J=v}else if(!this._isServer)throw TypeError(`Invalid value for parameter "${Q}": ${J}`)}else if(Q==="server_max_window_bits"){let v=+J;if(!Number.isInteger(v)||v<8||v>15)throw TypeError(`Invalid value for parameter "${Q}": ${J}`);J=v}else if(Q==="client_no_context_takeover"||Q==="server_no_context_takeover"){if(J!==!0)throw TypeError(`Invalid value for parameter "${Q}": ${J}`)}else throw Error(`Unknown parameter "${Q}"`);X[Q]=J})}),$}decompress($,X,Q){MH.add((J)=>{this._decompress($,X,(v,Y)=>{J(),Q(v,Y)})})}compress($,X,Q){MH.add((J)=>{this._compress($,X,(v,Y)=>{J(),Q(v,Y)})})}_decompress($,X,Q){let J=this._isServer?"client":"server";if(!this._inflate){let v=`${J}_max_window_bits`,Y=typeof this.params[v]!=="number"?Y7.Z_DEFAULT_WINDOWBITS:this.params[v];this._inflate=Y7.createInflateRaw({...this._options.zlibInflateOptions,windowBits:Y}),this._inflate[xH]=this,this._inflate[B8]=0,this._inflate[W9]=[],this._inflate.on("error",lY$),this._inflate.on("data",Jy)}if(this._inflate[Tv]=Q,this._inflate.write($),X)this._inflate.write(mY$);this._inflate.flush(()=>{let v=this._inflate[Sv];if(v){this._inflate.close(),this._inflate=null,Q(v);return}let Y=Xy.concat(this._inflate[W9],this._inflate[B8]);if(this._inflate._readableState.endEmitted)this._inflate.close(),this._inflate=null;else if(this._inflate[B8]=0,this._inflate[W9]=[],X&&this.params[`${J}_no_context_takeover`])this._inflate.reset();Q(null,Y)})}_compress($,X,Q){let J=this._isServer?"server":"client";if(!this._deflate){let v=`${J}_max_window_bits`,Y=typeof this.params[v]!=="number"?Y7.Z_DEFAULT_WINDOWBITS:this.params[v];this._deflate=Y7.createDeflateRaw({...this._options.zlibDeflateOptions,windowBits:Y}),this._deflate[B8]=0,this._deflate[W9]=[],this._deflate.on("data",hY$)}this._deflate[Tv]=Q,this._deflate.write($),this._deflate.flush(Y7.Z_SYNC_FLUSH,()=>{if(!this._deflate)return;let v=Xy.concat(this._deflate[W9],this._deflate[B8]);if(X)v=new cY$(v.buffer,v.byteOffset,v.length-4);if(this._deflate[Tv]=null,this._deflate[B8]=0,this._deflate[W9]=[],X&&this.params[`${J}_no_context_takeover`])this._deflate.reset();Q(null,v)})}}Yy.exports=vy;function hY$($){this[W9].push($),this[B8]+=$.length}function Jy($){if(this[B8]+=$.length,this[xH]._maxPayload<1||this[B8]<=this[xH]._maxPayload){this[W9].push($);return}this[Sv]=RangeError("Max payload size exceeded"),this[Sv].code="WS_ERR_UNSUPPORTED_MESSAGE_LENGTH",this[Sv][Qy]=1009,this.removeListener("data",Jy),this.reset()}function lY$($){if(this[xH]._inflate=null,this[Sv]){this[Tv](this[Sv]);return}$[Qy]=1007,this[Tv]($)}});var Cv=N((lE$,bH)=>{var{isUtf8:zy}=require("buffer"),{hasBlob:pY$}=V8(),dY$=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,1,1,1,1,0,0,1,1,0,1,1,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,0];function oY$($){return $>=1000&&$<=1014&&$!==1004&&$!==1005&&$!==1006||$>=3000&&$<=4999}function HA($){let X=$.length,Q=0;while(Q=X||($[Q+1]&192)!==128||($[Q+2]&192)!==128||$[Q]===224&&($[Q+1]&224)===128||$[Q]===237&&($[Q+1]&224)===160)return!1;Q+=3}else if(($[Q]&248)===240){if(Q+3>=X||($[Q+1]&192)!==128||($[Q+2]&192)!==128||($[Q+3]&192)!==128||$[Q]===240&&($[Q+1]&240)===128||$[Q]===244&&$[Q+1]>143||$[Q]>244)return!1;Q+=4}else return!1;return!0}function aY$($){return pY$&&typeof $==="object"&&typeof $.arrayBuffer==="function"&&typeof $.type==="string"&&typeof $.stream==="function"&&($[Symbol.toStringTag]==="Blob"||$[Symbol.toStringTag]==="File")}bH.exports={isBlob:aY$,isValidStatusCode:oY$,isValidUTF8:HA,tokenChars:dY$};if(zy)bH.exports.isValidUTF8=function($){return $.length<24?HA($):zy($)};else if(!process.env.WS_NO_UTF_8_VALIDATE)try{let $=(()=>{throw new Error("Cannot require module "+"utf-8-validate");})();bH.exports.isValidUTF8=function(X){return X.length<32?HA(X):$(X)}}catch($){}});var VA=N((pE$,wy)=>{var{Writable:rY$}=require("stream"),Wy=z7(),{BINARY_TYPES:tY$,EMPTY_BUFFER:Gy,kStatusCode:sY$,kWebSocket:eY$}=V8(),{concat:jA,toArrayBuffer:$z$,unmask:Xz$}=J7(),{isValidStatusCode:Qz$,isValidUTF8:Uy}=Cv(),iH=Buffer[Symbol.species];class Ky extends rY${constructor($={}){super();this._allowSynchronousEvents=$.allowSynchronousEvents!==void 0?$.allowSynchronousEvents:!0,this._binaryType=$.binaryType||tY$[0],this._extensions=$.extensions||{},this._isServer=!!$.isServer,this._maxPayload=$.maxPayload|0,this._skipUTF8Validation=!!$.skipUTF8Validation,this[eY$]=void 0,this._bufferedBytes=0,this._buffers=[],this._compressed=!1,this._payloadLength=0,this._mask=void 0,this._fragmented=0,this._masked=!1,this._fin=!1,this._opcode=0,this._totalPayloadLength=0,this._messageLength=0,this._fragments=[],this._errored=!1,this._loop=!1,this._state=0}_write($,X,Q){if(this._opcode===8&&this._state==0)return Q();this._bufferedBytes+=$.length,this._buffers.push($),this.startLoop(Q)}consume($){if(this._bufferedBytes-=$,$===this._buffers[0].length)return this._buffers.shift();if($=Q.length)X.set(this._buffers.shift(),J);else X.set(new Uint8Array(Q.buffer,Q.byteOffset,$),J),this._buffers[0]=new iH(Q.buffer,Q.byteOffset+$,Q.length-$);$-=Q.length}while($>0);return X}startLoop($){this._loop=!0;do switch(this._state){case 0:this.getInfo($);break;case 1:this.getPayloadLength16($);break;case 2:this.getPayloadLength64($);break;case 3:this.getMask();break;case 4:this.getData($);break;case 5:case 6:this._loop=!1;return}while(this._loop);if(!this._errored)$()}getInfo($){if(this._bufferedBytes<2){this._loop=!1;return}let X=this.consume(2);if((X[0]&48)!==0){let J=this.createError(RangeError,"RSV2 and RSV3 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_2_3");$(J);return}let Q=(X[0]&64)===64;if(Q&&!this._extensions[Wy.extensionName]){let J=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");$(J);return}if(this._fin=(X[0]&128)===128,this._opcode=X[0]&15,this._payloadLength=X[1]&127,this._opcode===0){if(Q){let J=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");$(J);return}if(!this._fragmented){let J=this.createError(RangeError,"invalid opcode 0",!0,1002,"WS_ERR_INVALID_OPCODE");$(J);return}this._opcode=this._fragmented}else if(this._opcode===1||this._opcode===2){if(this._fragmented){let J=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");$(J);return}this._compressed=Q}else if(this._opcode>7&&this._opcode<11){if(!this._fin){let J=this.createError(RangeError,"FIN must be set",!0,1002,"WS_ERR_EXPECTED_FIN");$(J);return}if(Q){let J=this.createError(RangeError,"RSV1 must be clear",!0,1002,"WS_ERR_UNEXPECTED_RSV_1");$(J);return}if(this._payloadLength>125||this._opcode===8&&this._payloadLength===1){let J=this.createError(RangeError,`invalid payload length ${this._payloadLength}`,!0,1002,"WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH");$(J);return}}else{let J=this.createError(RangeError,`invalid opcode ${this._opcode}`,!0,1002,"WS_ERR_INVALID_OPCODE");$(J);return}if(!this._fin&&!this._fragmented)this._fragmented=this._opcode;if(this._masked=(X[1]&128)===128,this._isServer){if(!this._masked){let J=this.createError(RangeError,"MASK must be set",!0,1002,"WS_ERR_EXPECTED_MASK");$(J);return}}else if(this._masked){let J=this.createError(RangeError,"MASK must be clear",!0,1002,"WS_ERR_UNEXPECTED_MASK");$(J);return}if(this._payloadLength===126)this._state=1;else if(this._payloadLength===127)this._state=2;else this.haveLength($)}getPayloadLength16($){if(this._bufferedBytes<2){this._loop=!1;return}this._payloadLength=this.consume(2).readUInt16BE(0),this.haveLength($)}getPayloadLength64($){if(this._bufferedBytes<8){this._loop=!1;return}let X=this.consume(8),Q=X.readUInt32BE(0);if(Q>Math.pow(2,21)-1){let J=this.createError(RangeError,"Unsupported WebSocket frame: payload length > 2^53 - 1",!1,1009,"WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH");$(J);return}this._payloadLength=Q*Math.pow(2,32)+X.readUInt32BE(4),this.haveLength($)}haveLength($){if(this._payloadLength&&this._opcode<8){if(this._totalPayloadLength+=this._payloadLength,this._totalPayloadLength>this._maxPayload&&this._maxPayload>0){let X=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");$(X);return}}if(this._masked)this._state=3;else this._state=4}getMask(){if(this._bufferedBytes<4){this._loop=!1;return}this._mask=this.consume(4),this._state=4}getData($){let X=Gy;if(this._payloadLength){if(this._bufferedBytes7){this.controlMessage(X,$);return}if(this._compressed){this._state=5,this.decompress(X,$);return}if(X.length)this._messageLength=this._totalPayloadLength,this._fragments.push(X);this.dataMessage($)}decompress($,X){this._extensions[Wy.extensionName].decompress($,this._fin,(J,v)=>{if(J)return X(J);if(v.length){if(this._messageLength+=v.length,this._messageLength>this._maxPayload&&this._maxPayload>0){let Y=this.createError(RangeError,"Max payload size exceeded",!1,1009,"WS_ERR_UNSUPPORTED_MESSAGE_LENGTH");X(Y);return}this._fragments.push(v)}if(this.dataMessage(X),this._state===0)this.startLoop(X)})}dataMessage($){if(!this._fin){this._state=0;return}let X=this._messageLength,Q=this._fragments;if(this._totalPayloadLength=0,this._messageLength=0,this._fragmented=0,this._fragments=[],this._opcode===2){let J;if(this._binaryType==="nodebuffer")J=jA(Q,X);else if(this._binaryType==="arraybuffer")J=$z$(jA(Q,X));else if(this._binaryType==="blob")J=new Blob(Q);else J=Q;if(this._allowSynchronousEvents)this.emit("message",J,!0),this._state=0;else this._state=6,setImmediate(()=>{this.emit("message",J,!0),this._state=0,this.startLoop($)})}else{let J=jA(Q,X);if(!this._skipUTF8Validation&&!Uy(J)){let v=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");$(v);return}if(this._state===5||this._allowSynchronousEvents)this.emit("message",J,!1),this._state=0;else this._state=6,setImmediate(()=>{this.emit("message",J,!1),this._state=0,this.startLoop($)})}}controlMessage($,X){if(this._opcode===8){if($.length===0)this._loop=!1,this.emit("conclude",1005,Gy),this.end();else{let Q=$.readUInt16BE(0);if(!Qz$(Q)){let v=this.createError(RangeError,`invalid status code ${Q}`,!0,1002,"WS_ERR_INVALID_CLOSE_CODE");X(v);return}let J=new iH($.buffer,$.byteOffset+2,$.length-2);if(!this._skipUTF8Validation&&!Uy(J)){let v=this.createError(Error,"invalid UTF-8 sequence",!0,1007,"WS_ERR_INVALID_UTF8");X(v);return}this._loop=!1,this.emit("conclude",Q,J),this.end()}this._state=0;return}if(this._allowSynchronousEvents)this.emit(this._opcode===9?"ping":"pong",$),this._state=0;else this._state=6,setImmediate(()=>{this.emit(this._opcode===9?"ping":"pong",$),this._state=0,this.startLoop(X)})}createError($,X,Q,J,v){this._loop=!1,this._errored=!0;let Y=new $(Q?`Invalid WebSocket frame: ${X}`:X);return Error.captureStackTrace(Y,this.createError),Y.code=v,Y[sY$]=J,Y}}wy.exports=Ky});var DA=N((oE$,Vy)=>{var{Duplex:dE$}=require("stream"),{randomFillSync:vz$}=require("crypto"),Hy=z7(),{EMPTY_BUFFER:Jz$,kWebSocket:Yz$,NOOP:zz$}=V8(),{isBlob:uv,isValidStatusCode:Wz$}=Cv(),{mask:jy,toBuffer:FX}=J7(),I1=Symbol("kByteLength"),Gz$=Buffer.alloc(4),NX,_v=8192,X0=0,Uz$=1,Kz$=2;class G9{constructor($,X,Q){if(this._extensions=X||{},Q)this._generateMask=Q,this._maskBuffer=Buffer.alloc(4);this._socket=$,this._firstFragment=!0,this._compress=!1,this._bufferedBytes=0,this._queue=[],this._state=X0,this.onerror=zz$,this[Yz$]=void 0}static frame($,X){let Q,J=!1,v=2,Y=!1;if(X.mask){if(Q=X.maskBuffer||Gz$,X.generateMask)X.generateMask(Q);else{if(_v===8192){if(NX===void 0)NX=Buffer.alloc(8192);vz$(NX,0,8192),_v=0}Q[0]=NX[_v++],Q[1]=NX[_v++],Q[2]=NX[_v++],Q[3]=NX[_v++]}Y=(Q[0]|Q[1]|Q[2]|Q[3])===0,v=6}let z;if(typeof $==="string")if((!X.mask||Y)&&X[I1]!==void 0)z=X[I1];else $=Buffer.from($),z=$.length;else z=$.length,J=X.mask&&X.readOnly&&!Y;let W=z;if(z>=65536)v+=8,W=127;else if(z>125)v+=2,W=126;let G=Buffer.allocUnsafe(J?z+v:v);if(G[0]=X.fin?X.opcode|128:X.opcode,X.rsv1)G[0]|=64;if(G[1]=W,W===126)G.writeUInt16BE(z,2);else if(W===127)G[2]=G[3]=0,G.writeUIntBE(z,4,6);if(!X.mask)return[G,$];if(G[1]|=128,G[v-4]=Q[0],G[v-3]=Q[1],G[v-2]=Q[2],G[v-1]=Q[3],Y)return[G,$];if(J)return jy($,Q,G,v,z),[G];return jy($,Q,$,0,z),[G,$]}close($,X,Q,J){let v;if($===void 0)v=Jz$;else if(typeof $!=="number"||!Wz$($))throw TypeError("First argument must be a valid error code number");else if(X===void 0||!X.length)v=Buffer.allocUnsafe(2),v.writeUInt16BE($,0);else{let z=Buffer.byteLength(X);if(z>123)throw RangeError("The message must not be greater than 123 bytes");if(v=Buffer.allocUnsafe(2+z),v.writeUInt16BE($,0),typeof X==="string")v.write(X,2);else v.set(X,2)}let Y={[I1]:v.length,fin:!0,generateMask:this._generateMask,mask:Q,maskBuffer:this._maskBuffer,opcode:8,readOnly:!1,rsv1:!1};if(this._state!==X0)this.enqueue([this.dispatch,v,!1,Y,J]);else this.sendFrame(G9.frame(v,Y),J)}ping($,X,Q){let J,v;if(typeof $==="string")J=Buffer.byteLength($),v=!1;else if(uv($))J=$.size,v=!1;else $=FX($),J=$.length,v=FX.readOnly;if(J>125)throw RangeError("The data size must not be greater than 125 bytes");let Y={[I1]:J,fin:!0,generateMask:this._generateMask,mask:X,maskBuffer:this._maskBuffer,opcode:9,readOnly:v,rsv1:!1};if(uv($))if(this._state!==X0)this.enqueue([this.getBlobData,$,!1,Y,Q]);else this.getBlobData($,!1,Y,Q);else if(this._state!==X0)this.enqueue([this.dispatch,$,!1,Y,Q]);else this.sendFrame(G9.frame($,Y),Q)}pong($,X,Q){let J,v;if(typeof $==="string")J=Buffer.byteLength($),v=!1;else if(uv($))J=$.size,v=!1;else $=FX($),J=$.length,v=FX.readOnly;if(J>125)throw RangeError("The data size must not be greater than 125 bytes");let Y={[I1]:J,fin:!0,generateMask:this._generateMask,mask:X,maskBuffer:this._maskBuffer,opcode:10,readOnly:v,rsv1:!1};if(uv($))if(this._state!==X0)this.enqueue([this.getBlobData,$,!1,Y,Q]);else this.getBlobData($,!1,Y,Q);else if(this._state!==X0)this.enqueue([this.dispatch,$,!1,Y,Q]);else this.sendFrame(G9.frame($,Y),Q)}send($,X,Q){let J=this._extensions[Hy.extensionName],v=X.binary?2:1,Y=X.compress,z,W;if(typeof $==="string")z=Buffer.byteLength($),W=!1;else if(uv($))z=$.size,W=!1;else $=FX($),z=$.length,W=FX.readOnly;if(this._firstFragment){if(this._firstFragment=!1,Y&&J&&J.params[J._isServer?"server_no_context_takeover":"client_no_context_takeover"])Y=z>=J._threshold;this._compress=Y}else Y=!1,v=0;if(X.fin)this._firstFragment=!0;let G={[I1]:z,fin:X.fin,generateMask:this._generateMask,mask:X.mask,maskBuffer:this._maskBuffer,opcode:v,readOnly:W,rsv1:Y};if(uv($))if(this._state!==X0)this.enqueue([this.getBlobData,$,this._compress,G,Q]);else this.getBlobData($,this._compress,G,Q);else if(this._state!==X0)this.enqueue([this.dispatch,$,this._compress,G,Q]);else this.dispatch($,this._compress,G,Q)}getBlobData($,X,Q,J){this._bufferedBytes+=Q[I1],this._state=Kz$,$.arrayBuffer().then((v)=>{if(this._socket.destroyed){let z=Error("The socket was closed while the blob was being read");process.nextTick(BA,this,z,J);return}this._bufferedBytes-=Q[I1];let Y=FX(v);if(!X)this._state=X0,this.sendFrame(G9.frame(Y,Q),J),this.dequeue();else this.dispatch(Y,X,Q,J)}).catch((v)=>{process.nextTick(wz$,this,v,J)})}dispatch($,X,Q,J){if(!X){this.sendFrame(G9.frame($,Q),J);return}let v=this._extensions[Hy.extensionName];this._bufferedBytes+=Q[I1],this._state=Uz$,v.compress($,Q.fin,(Y,z)=>{if(this._socket.destroyed){let W=Error("The socket was closed while data was being compressed");BA(this,W,J);return}this._bufferedBytes-=Q[I1],this._state=X0,Q.readOnly=!1,this.sendFrame(G9.frame(z,Q),J),this.dequeue()})}dequeue(){while(this._state===X0&&this._queue.length){let $=this._queue.shift();this._bufferedBytes-=$[3][I1],Reflect.apply($[0],this,$.slice(1))}}enqueue($){this._bufferedBytes+=$[3][I1],this._queue.push($)}sendFrame($,X){if($.length===2)this._socket.cork(),this._socket.write($[0]),this._socket.write($[1],X),this._socket.uncork();else this._socket.write($[0],X)}}Vy.exports=G9;function BA($,X,Q){if(typeof Q==="function")Q(X);for(let J=0;J<$._queue.length;J++){let v=$._queue[J],Y=v[v.length-1];if(typeof Y==="function")Y(X)}}function wz$($,X,Q){BA($,X,Q),$.onerror(X)}});var qy=N((aE$,Zy)=>{var{kForOnEventAttribute:W7,kListener:FA}=V8(),By=Symbol("kCode"),Dy=Symbol("kData"),Fy=Symbol("kError"),Ny=Symbol("kMessage"),Oy=Symbol("kReason"),yv=Symbol("kTarget"),Ly=Symbol("kType"),Ay=Symbol("kWasClean");class U9{constructor($){this[yv]=null,this[Ly]=$}get target(){return this[yv]}get type(){return this[Ly]}}Object.defineProperty(U9.prototype,"target",{enumerable:!0});Object.defineProperty(U9.prototype,"type",{enumerable:!0});class fv extends U9{constructor($,X={}){super($);this[By]=X.code===void 0?0:X.code,this[Oy]=X.reason===void 0?"":X.reason,this[Ay]=X.wasClean===void 0?!1:X.wasClean}get code(){return this[By]}get reason(){return this[Oy]}get wasClean(){return this[Ay]}}Object.defineProperty(fv.prototype,"code",{enumerable:!0});Object.defineProperty(fv.prototype,"reason",{enumerable:!0});Object.defineProperty(fv.prototype,"wasClean",{enumerable:!0});class G7 extends U9{constructor($,X={}){super($);this[Fy]=X.error===void 0?null:X.error,this[Ny]=X.message===void 0?"":X.message}get error(){return this[Fy]}get message(){return this[Ny]}}Object.defineProperty(G7.prototype,"error",{enumerable:!0});Object.defineProperty(G7.prototype,"message",{enumerable:!0});class EH extends U9{constructor($,X={}){super($);this[Dy]=X.data===void 0?null:X.data}get data(){return this[Dy]}}Object.defineProperty(EH.prototype,"data",{enumerable:!0});var Hz$={addEventListener($,X,Q={}){for(let v of this.listeners($))if(!Q[W7]&&v[FA]===X&&!v[W7])return;let J;if($==="message")J=function(Y,z){let W=new EH("message",{data:z?Y:Y.toString()});W[yv]=this,RH(X,this,W)};else if($==="close")J=function(Y,z){let W=new fv("close",{code:Y,reason:z.toString(),wasClean:this._closeFrameReceived&&this._closeFrameSent});W[yv]=this,RH(X,this,W)};else if($==="error")J=function(Y){let z=new G7("error",{error:Y,message:Y.message});z[yv]=this,RH(X,this,z)};else if($==="open")J=function(){let Y=new U9("open");Y[yv]=this,RH(X,this,Y)};else return;if(J[W7]=!!Q[W7],J[FA]=X,Q.once)this.once($,J);else this.on($,J)},removeEventListener($,X){for(let Q of this.listeners($))if(Q[FA]===X&&!Q[W7]){this.removeListener($,Q);break}}};Zy.exports={CloseEvent:fv,ErrorEvent:G7,Event:U9,EventTarget:Hz$,MessageEvent:EH};function RH($,X,Q){if(typeof $==="object"&&$.handleEvent)$.handleEvent.call($,Q);else $.call(X,Q)}});var NA=N((rE$,Iy)=>{var{tokenChars:U7}=Cv();function k0($,X,Q){if($[X]===void 0)$[X]=[Q];else $[X].push(Q)}function jz$($){let X=Object.create(null),Q=Object.create(null),J=!1,v=!1,Y=!1,z,W,G=-1,U=-1,w=-1,H=0;for(;H<$.length;H++)if(U=$.charCodeAt(H),z===void 0)if(w===-1&&U7[U]===1){if(G===-1)G=H}else if(H!==0&&(U===32||U===9)){if(w===-1&&G!==-1)w=H}else if(U===59||U===44){if(G===-1)throw SyntaxError(`Unexpected character at index ${H}`);if(w===-1)w=H;let V=$.slice(G,w);if(U===44)k0(X,V,Q),Q=Object.create(null);else z=V;G=w=-1}else throw SyntaxError(`Unexpected character at index ${H}`);else if(W===void 0)if(w===-1&&U7[U]===1){if(G===-1)G=H}else if(U===32||U===9){if(w===-1&&G!==-1)w=H}else if(U===59||U===44){if(G===-1)throw SyntaxError(`Unexpected character at index ${H}`);if(w===-1)w=H;if(k0(Q,$.slice(G,w),!0),U===44)k0(X,z,Q),Q=Object.create(null),z=void 0;G=w=-1}else if(U===61&&G!==-1&&w===-1)W=$.slice(G,H),G=w=-1;else throw SyntaxError(`Unexpected character at index ${H}`);else if(v){if(U7[U]!==1)throw SyntaxError(`Unexpected character at index ${H}`);if(G===-1)G=H;else if(!J)J=!0;v=!1}else if(Y)if(U7[U]===1){if(G===-1)G=H}else if(U===34&&G!==-1)Y=!1,w=H;else if(U===92)v=!0;else throw SyntaxError(`Unexpected character at index ${H}`);else if(U===34&&$.charCodeAt(H-1)===61)Y=!0;else if(w===-1&&U7[U]===1){if(G===-1)G=H}else if(G!==-1&&(U===32||U===9)){if(w===-1)w=H}else if(U===59||U===44){if(G===-1)throw SyntaxError(`Unexpected character at index ${H}`);if(w===-1)w=H;let V=$.slice(G,w);if(J)V=V.replace(/\\/g,""),J=!1;if(k0(Q,W,V),U===44)k0(X,z,Q),Q=Object.create(null),z=void 0;W=void 0,G=w=-1}else throw SyntaxError(`Unexpected character at index ${H}`);if(G===-1||Y||U===32||U===9)throw SyntaxError("Unexpected end of input");if(w===-1)w=H;let j=$.slice(G,w);if(z===void 0)k0(X,j,Q);else{if(W===void 0)k0(Q,j,!0);else if(J)k0(Q,W,j.replace(/\\/g,""));else k0(Q,W,j);k0(X,z,Q)}return X}function Vz$($){return Object.keys($).map((X)=>{let Q=$[X];if(!Array.isArray(Q))Q=[Q];return Q.map((J)=>{return[X].concat(Object.keys(J).map((v)=>{let Y=J[v];if(!Array.isArray(Y))Y=[Y];return Y.map((z)=>z===!0?v:`${v}=${z}`).join("; ")})).join("; ")}).join(", ")}).join(", ")}Iy.exports={format:Vz$,parse:jz$}});var CH=N((eE$,_y)=>{var Bz$=require("events"),Dz$=require("https"),Fz$=require("http"),by=require("net"),Nz$=require("tls"),{randomBytes:Oz$,createHash:Lz$}=require("crypto"),{Duplex:tE$,Readable:sE$}=require("stream"),{URL:OA}=require("url"),K9=z7(),Az$=VA(),Zz$=DA(),{isBlob:qz$}=Cv(),{BINARY_TYPES:My,EMPTY_BUFFER:PH,GUID:Iz$,kForOnEventAttribute:LA,kListener:Mz$,kStatusCode:xz$,kWebSocket:_4,NOOP:iy}=V8(),{EventTarget:{addEventListener:bz$,removeEventListener:iz$}}=qy(),{format:Rz$,parse:Ez$}=NA(),{toBuffer:Pz$}=J7(),Ry=Symbol("kAborted"),AA=[8,13],D8=["CONNECTING","OPEN","CLOSING","CLOSED"],Tz$=/^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/;class G$ extends Bz${constructor($,X,Q){super();if(this._binaryType=My[0],this._closeCode=1006,this._closeFrameReceived=!1,this._closeFrameSent=!1,this._closeMessage=PH,this._closeTimer=null,this._errorEmitted=!1,this._extensions={},this._paused=!1,this._protocol="",this._readyState=G$.CONNECTING,this._receiver=null,this._sender=null,this._socket=null,$!==null){if(this._bufferedAmount=0,this._isServer=!1,this._redirects=0,X===void 0)X=[];else if(!Array.isArray(X))if(typeof X==="object"&&X!==null)Q=X,X=[];else X=[X];Ey(this,$,X,Q)}else this._autoPong=Q.autoPong,this._isServer=!0}get binaryType(){return this._binaryType}set binaryType($){if(!My.includes($))return;if(this._binaryType=$,this._receiver)this._receiver._binaryType=$}get bufferedAmount(){if(!this._socket)return this._bufferedAmount;return this._socket._writableState.length+this._sender._bufferedBytes}get extensions(){return Object.keys(this._extensions).join()}get isPaused(){return this._paused}get onclose(){return null}get onerror(){return null}get onopen(){return null}get onmessage(){return null}get protocol(){return this._protocol}get readyState(){return this._readyState}get url(){return this._url}setSocket($,X,Q){let J=new Az$({allowSynchronousEvents:Q.allowSynchronousEvents,binaryType:this.binaryType,extensions:this._extensions,isServer:this._isServer,maxPayload:Q.maxPayload,skipUTF8Validation:Q.skipUTF8Validation}),v=new Zz$($,this._extensions,Q.generateMask);if(this._receiver=J,this._sender=v,this._socket=$,J[_4]=this,v[_4]=this,$[_4]=this,J.on("conclude",uz$),J.on("drain",_z$),J.on("error",yz$),J.on("message",fz$),J.on("ping",kz$),J.on("pong",nz$),v.onerror=gz$,$.setTimeout)$.setTimeout(0);if($.setNoDelay)$.setNoDelay();if(X.length>0)$.unshift(X);$.on("close",Sy),$.on("data",SH),$.on("end",Cy),$.on("error",uy),this._readyState=G$.OPEN,this.emit("open")}emitClose(){if(!this._socket){this._readyState=G$.CLOSED,this.emit("close",this._closeCode,this._closeMessage);return}if(this._extensions[K9.extensionName])this._extensions[K9.extensionName].cleanup();this._receiver.removeAllListeners(),this._readyState=G$.CLOSED,this.emit("close",this._closeCode,this._closeMessage)}close($,X){if(this.readyState===G$.CLOSED)return;if(this.readyState===G$.CONNECTING){w1(this,this._req,"WebSocket was closed before the connection was established");return}if(this.readyState===G$.CLOSING){if(this._closeFrameSent&&(this._closeFrameReceived||this._receiver._writableState.errorEmitted))this._socket.end();return}this._readyState=G$.CLOSING,this._sender.close($,X,!this._isServer,(Q)=>{if(Q)return;if(this._closeFrameSent=!0,this._closeFrameReceived||this._receiver._writableState.errorEmitted)this._socket.end()}),Ty(this)}pause(){if(this.readyState===G$.CONNECTING||this.readyState===G$.CLOSED)return;this._paused=!0,this._socket.pause()}ping($,X,Q){if(this.readyState===G$.CONNECTING)throw Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof $==="function")Q=$,$=X=void 0;else if(typeof X==="function")Q=X,X=void 0;if(typeof $==="number")$=$.toString();if(this.readyState!==G$.OPEN){ZA(this,$,Q);return}if(X===void 0)X=!this._isServer;this._sender.ping($||PH,X,Q)}pong($,X,Q){if(this.readyState===G$.CONNECTING)throw Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof $==="function")Q=$,$=X=void 0;else if(typeof X==="function")Q=X,X=void 0;if(typeof $==="number")$=$.toString();if(this.readyState!==G$.OPEN){ZA(this,$,Q);return}if(X===void 0)X=!this._isServer;this._sender.pong($||PH,X,Q)}resume(){if(this.readyState===G$.CONNECTING||this.readyState===G$.CLOSED)return;if(this._paused=!1,!this._receiver._writableState.needDrain)this._socket.resume()}send($,X,Q){if(this.readyState===G$.CONNECTING)throw Error("WebSocket is not open: readyState 0 (CONNECTING)");if(typeof X==="function")Q=X,X={};if(typeof $==="number")$=$.toString();if(this.readyState!==G$.OPEN){ZA(this,$,Q);return}let J={binary:typeof $!=="string",mask:!this._isServer,compress:!0,fin:!0,...X};if(!this._extensions[K9.extensionName])J.compress=!1;this._sender.send($||PH,J,Q)}terminate(){if(this.readyState===G$.CLOSED)return;if(this.readyState===G$.CONNECTING){w1(this,this._req,"WebSocket was closed before the connection was established");return}if(this._socket)this._readyState=G$.CLOSING,this._socket.destroy()}}Object.defineProperty(G$,"CONNECTING",{enumerable:!0,value:D8.indexOf("CONNECTING")});Object.defineProperty(G$.prototype,"CONNECTING",{enumerable:!0,value:D8.indexOf("CONNECTING")});Object.defineProperty(G$,"OPEN",{enumerable:!0,value:D8.indexOf("OPEN")});Object.defineProperty(G$.prototype,"OPEN",{enumerable:!0,value:D8.indexOf("OPEN")});Object.defineProperty(G$,"CLOSING",{enumerable:!0,value:D8.indexOf("CLOSING")});Object.defineProperty(G$.prototype,"CLOSING",{enumerable:!0,value:D8.indexOf("CLOSING")});Object.defineProperty(G$,"CLOSED",{enumerable:!0,value:D8.indexOf("CLOSED")});Object.defineProperty(G$.prototype,"CLOSED",{enumerable:!0,value:D8.indexOf("CLOSED")});["binaryType","bufferedAmount","extensions","isPaused","protocol","readyState","url"].forEach(($)=>{Object.defineProperty(G$.prototype,$,{enumerable:!0})});["open","error","close","message"].forEach(($)=>{Object.defineProperty(G$.prototype,`on${$}`,{enumerable:!0,get(){for(let X of this.listeners($))if(X[LA])return X[Mz$];return null},set(X){for(let Q of this.listeners($))if(Q[LA]){this.removeListener($,Q);break}if(typeof X!=="function")return;this.addEventListener($,X,{[LA]:!0})}})});G$.prototype.addEventListener=bz$;G$.prototype.removeEventListener=iz$;_y.exports=G$;function Ey($,X,Q,J){let v={allowSynchronousEvents:!0,autoPong:!0,protocolVersion:AA[1],maxPayload:104857600,skipUTF8Validation:!1,perMessageDeflate:!0,followRedirects:!1,maxRedirects:10,...J,socketPath:void 0,hostname:void 0,protocol:void 0,timeout:void 0,method:"GET",host:void 0,path:void 0,port:void 0};if($._autoPong=v.autoPong,!AA.includes(v.protocolVersion))throw RangeError(`Unsupported protocol version: ${v.protocolVersion} (supported versions: ${AA.join(", ")})`);let Y;if(X instanceof OA)Y=X;else try{Y=new OA(X)}catch(D){throw SyntaxError(`Invalid URL: ${X}`)}if(Y.protocol==="http:")Y.protocol="ws:";else if(Y.protocol==="https:")Y.protocol="wss:";$._url=Y.href;let z=Y.protocol==="wss:",W=Y.protocol==="ws+unix:",G;if(Y.protocol!=="ws:"&&!z&&!W)G=`The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`;else if(W&&!Y.pathname)G="The URL's pathname is empty";else if(Y.hash)G="The URL contains a fragment identifier";if(G){let D=SyntaxError(G);if($._redirects===0)throw D;else{TH($,D);return}}let U=z?443:80,w=Oz$(16).toString("base64"),H=z?Dz$.request:Fz$.request,j=new Set,V;if(v.createConnection=v.createConnection||(z?Cz$:Sz$),v.defaultPort=v.defaultPort||U,v.port=Y.port||U,v.host=Y.hostname.startsWith("[")?Y.hostname.slice(1,-1):Y.hostname,v.headers={...v.headers,"Sec-WebSocket-Version":v.protocolVersion,"Sec-WebSocket-Key":w,Connection:"Upgrade",Upgrade:"websocket"},v.path=Y.pathname+Y.search,v.timeout=v.handshakeTimeout,v.perMessageDeflate)V=new K9(v.perMessageDeflate!==!0?v.perMessageDeflate:{},!1,v.maxPayload),v.headers["Sec-WebSocket-Extensions"]=Rz$({[K9.extensionName]:V.offer()});if(Q.length){for(let D of Q){if(typeof D!=="string"||!Tz$.test(D)||j.has(D))throw SyntaxError("An invalid or duplicated subprotocol was specified");j.add(D)}v.headers["Sec-WebSocket-Protocol"]=Q.join(",")}if(v.origin)if(v.protocolVersion<13)v.headers["Sec-WebSocket-Origin"]=v.origin;else v.headers.Origin=v.origin;if(Y.username||Y.password)v.auth=`${Y.username}:${Y.password}`;if(W){let D=v.path.split(":");v.socketPath=D[0],v.path=D[1]}let B;if(v.followRedirects){if($._redirects===0){$._originalIpc=W,$._originalSecure=z,$._originalHostOrSocketPath=W?v.socketPath:Y.host;let D=J&&J.headers;if(J={...J,headers:{}},D)for(let[F,L]of Object.entries(D))J.headers[F.toLowerCase()]=L}else if($.listenerCount("redirect")===0){let D=W?$._originalIpc?v.socketPath===$._originalHostOrSocketPath:!1:$._originalIpc?!1:Y.host===$._originalHostOrSocketPath;if(!D||$._originalSecure&&!z){if(delete v.headers.authorization,delete v.headers.cookie,!D)delete v.headers.host;v.auth=void 0}}if(v.auth&&!J.headers.authorization)J.headers.authorization="Basic "+Buffer.from(v.auth).toString("base64");if(B=$._req=H(v),$._redirects)$.emit("redirect",$.url,B)}else B=$._req=H(v);if(v.timeout)B.on("timeout",()=>{w1($,B,"Opening handshake has timed out")});if(B.on("error",(D)=>{if(B===null||B[Ry])return;B=$._req=null,TH($,D)}),B.on("response",(D)=>{let F=D.headers.location,L=D.statusCode;if(F&&v.followRedirects&&L>=300&&L<400){if(++$._redirects>v.maxRedirects){w1($,B,"Maximum redirects exceeded");return}B.abort();let A;try{A=new OA(F,X)}catch(O){let i=SyntaxError(`Invalid URL: ${F}`);TH($,i);return}Ey($,A,Q,J)}else if(!$.emit("unexpected-response",B,D))w1($,B,`Unexpected server response: ${D.statusCode}`)}),B.on("upgrade",(D,F,L)=>{if($.emit("upgrade",D),$.readyState!==G$.CONNECTING)return;B=$._req=null;let A=D.headers.upgrade;if(A===void 0||A.toLowerCase()!=="websocket"){w1($,F,"Invalid Upgrade header");return}let O=Lz$("sha1").update(w+Iz$).digest("base64");if(D.headers["sec-websocket-accept"]!==O){w1($,F,"Invalid Sec-WebSocket-Accept header");return}let i=D.headers["sec-websocket-protocol"],M;if(i!==void 0){if(!j.size)M="Server sent a subprotocol but none was requested";else if(!j.has(i))M="Server sent an invalid subprotocol"}else if(j.size)M="Server sent no subprotocol";if(M){w1($,F,M);return}if(i)$._protocol=i;let R=D.headers["sec-websocket-extensions"];if(R!==void 0){if(!V){w1($,F,"Server sent a Sec-WebSocket-Extensions header but no extension was requested");return}let E;try{E=Ez$(R)}catch(q$){w1($,F,"Invalid Sec-WebSocket-Extensions header");return}let v$=Object.keys(E);if(v$.length!==1||v$[0]!==K9.extensionName){w1($,F,"Server indicated an extension that was not requested");return}try{V.accept(E[K9.extensionName])}catch(q$){w1($,F,"Invalid Sec-WebSocket-Extensions header");return}$._extensions[K9.extensionName]=V}$.setSocket(F,L,{allowSynchronousEvents:v.allowSynchronousEvents,generateMask:v.generateMask,maxPayload:v.maxPayload,skipUTF8Validation:v.skipUTF8Validation})}),v.finishRequest)v.finishRequest(B,$);else B.end()}function TH($,X){$._readyState=G$.CLOSING,$._errorEmitted=!0,$.emit("error",X),$.emitClose()}function Sz$($){return $.path=$.socketPath,by.connect($)}function Cz$($){if($.path=void 0,!$.servername&&$.servername!=="")$.servername=by.isIP($.host)?"":$.host;return Nz$.connect($)}function w1($,X,Q){$._readyState=G$.CLOSING;let J=Error(Q);if(Error.captureStackTrace(J,w1),X.setHeader){if(X[Ry]=!0,X.abort(),X.socket&&!X.socket.destroyed)X.socket.destroy();process.nextTick(TH,$,J)}else X.destroy(J),X.once("error",$.emit.bind($,"error")),X.once("close",$.emitClose.bind($))}function ZA($,X,Q){if(X){let J=qz$(X)?X.size:Pz$(X).length;if($._socket)$._sender._bufferedBytes+=J;else $._bufferedAmount+=J}if(Q){let J=Error(`WebSocket is not open: readyState ${$.readyState} (${D8[$.readyState]})`);process.nextTick(Q,J)}}function uz$($,X){let Q=this[_4];if(Q._closeFrameReceived=!0,Q._closeMessage=X,Q._closeCode=$,Q._socket[_4]===void 0)return;if(Q._socket.removeListener("data",SH),process.nextTick(Py,Q._socket),$===1005)Q.close();else Q.close($,X)}function _z$(){let $=this[_4];if(!$.isPaused)$._socket.resume()}function yz$($){let X=this[_4];if(X._socket[_4]!==void 0)X._socket.removeListener("data",SH),process.nextTick(Py,X._socket),X.close($[xz$]);if(!X._errorEmitted)X._errorEmitted=!0,X.emit("error",$)}function xy(){this[_4].emitClose()}function fz$($,X){this[_4].emit("message",$,X)}function kz$($){let X=this[_4];if(X._autoPong)X.pong($,!this._isServer,iy);X.emit("ping",$)}function nz$($){this[_4].emit("pong",$)}function Py($){$.resume()}function gz$($){let X=this[_4];if(X.readyState===G$.CLOSED)return;if(X.readyState===G$.OPEN)X._readyState=G$.CLOSING,Ty(X);if(this._socket.end(),!X._errorEmitted)X._errorEmitted=!0,X.emit("error",$)}function Ty($){$._closeTimer=setTimeout($._socket.destroy.bind($._socket),30000)}function Sy(){let $=this[_4];this.removeListener("close",Sy),this.removeListener("data",SH),this.removeListener("end",Cy),$._readyState=G$.CLOSING;let X;if(!this._readableState.endEmitted&&!$._closeFrameReceived&&!$._receiver._writableState.errorEmitted&&(X=$._socket.read())!==null)$._receiver.write(X);if($._receiver.end(),this[_4]=void 0,clearTimeout($._closeTimer),$._receiver._writableState.finished||$._receiver._writableState.errorEmitted)$.emitClose();else $._receiver.on("error",xy),$._receiver.on("finish",xy)}function SH($){if(!this[_4]._receiver.write($))this.pause()}function Cy(){let $=this[_4];$._readyState=G$.CLOSING,$._receiver.end(),this.end()}function uy(){let $=this[_4];if(this.removeListener("error",uy),this.on("error",iy),$)$._readyState=G$.CLOSING,this.destroy()}});var ny=N((XP$,ky)=>{var $P$=CH(),{Duplex:cz$}=require("stream");function yy($){$.emit("close")}function mz$(){if(!this.destroyed&&this._writableState.finished)this.destroy()}function fy($){if(this.removeListener("error",fy),this.destroy(),this.listenerCount("error")===0)this.emit("error",$)}function hz$($,X){let Q=!0,J=new cz$({...X,autoDestroy:!1,emitClose:!1,objectMode:!1,writableObjectMode:!1});return $.on("message",function(Y,z){let W=!z&&J._readableState.objectMode?Y.toString():Y;if(!J.push(W))$.pause()}),$.once("error",function(Y){if(J.destroyed)return;Q=!1,J.destroy(Y)}),$.once("close",function(){if(J.destroyed)return;J.push(null)}),J._destroy=function(v,Y){if($.readyState===$.CLOSED){Y(v),process.nextTick(yy,J);return}let z=!1;if($.once("error",function(G){z=!0,Y(G)}),$.once("close",function(){if(!z)Y(v);process.nextTick(yy,J)}),Q)$.terminate()},J._final=function(v){if($.readyState===$.CONNECTING){$.once("open",function(){J._final(v)});return}if($._socket===null)return;if($._socket._writableState.finished){if(v(),J._readableState.endEmitted)J.destroy()}else $._socket.once("finish",function(){v()}),$.close()},J._read=function(){if($.isPaused)$.resume()},J._write=function(v,Y,z){if($.readyState===$.CONNECTING){$.once("open",function(){J._write(v,Y,z)});return}$.send(v,z)},J.on("end",mz$),J.on("error",fy),J}ky.exports=hz$});var cy=N((QP$,gy)=>{var{tokenChars:lz$}=Cv();function pz$($){let X=new Set,Q=-1,J=-1,v=0;for(v;v<$.length;v++){let z=$.charCodeAt(v);if(J===-1&&lz$[z]===1){if(Q===-1)Q=v}else if(v!==0&&(z===32||z===9)){if(J===-1&&Q!==-1)J=v}else if(z===44){if(Q===-1)throw SyntaxError(`Unexpected character at index ${v}`);if(J===-1)J=v;let W=$.slice(Q,J);if(X.has(W))throw SyntaxError(`The "${W}" subprotocol is duplicated`);X.add(W),Q=J=-1}else throw SyntaxError(`Unexpected character at index ${v}`)}if(Q===-1||J!==-1)throw SyntaxError("Unexpected end of input");let Y=$.slice(Q,v);if(X.has(Y))throw SyntaxError(`The "${Y}" subprotocol is duplicated`);return X.add(Y),X}gy.exports={parse:pz$}});var dy=N((JP$,py)=>{var dz$=require("events"),uH=require("http"),{Duplex:vP$}=require("stream"),{createHash:oz$}=require("crypto"),my=NA(),OX=z7(),az$=cy(),rz$=CH(),{GUID:tz$,kWebSocket:sz$}=V8(),ez$=/^[+/0-9A-Za-z]{22}==$/;class ly extends dz${constructor($,X){super();if($={allowSynchronousEvents:!0,autoPong:!0,maxPayload:104857600,skipUTF8Validation:!1,perMessageDeflate:!1,handleProtocols:null,clientTracking:!0,verifyClient:null,noServer:!1,backlog:null,server:null,host:null,path:null,port:null,WebSocket:rz$,...$},$.port==null&&!$.server&&!$.noServer||$.port!=null&&($.server||$.noServer)||$.server&&$.noServer)throw TypeError('One and only one of the "port", "server", or "noServer" options must be specified');if($.port!=null)this._server=uH.createServer((Q,J)=>{let v=uH.STATUS_CODES[426];J.writeHead(426,{"Content-Length":v.length,"Content-Type":"text/plain"}),J.end(v)}),this._server.listen($.port,$.host,$.backlog,X);else if($.server)this._server=$.server;if(this._server){let Q=this.emit.bind(this,"connection");this._removeListeners=$7$(this._server,{listening:this.emit.bind(this,"listening"),error:this.emit.bind(this,"error"),upgrade:(J,v,Y)=>{this.handleUpgrade(J,v,Y,Q)}})}if($.perMessageDeflate===!0)$.perMessageDeflate={};if($.clientTracking)this.clients=new Set,this._shouldEmitClose=!1;this.options=$,this._state=0}address(){if(this.options.noServer)throw Error('The server is operating in "noServer" mode');if(!this._server)return null;return this._server.address()}close($){if(this._state===2){if($)this.once("close",()=>{$(Error("The server is not running"))});process.nextTick(K7,this);return}if($)this.once("close",$);if(this._state===1)return;if(this._state=1,this.options.noServer||this.options.server){if(this._server)this._removeListeners(),this._removeListeners=this._server=null;if(this.clients)if(!this.clients.size)process.nextTick(K7,this);else this._shouldEmitClose=!0;else process.nextTick(K7,this)}else{let X=this._server;this._removeListeners(),this._removeListeners=this._server=null,X.close(()=>{K7(this)})}}shouldHandle($){if(this.options.path){let X=$.url.indexOf("?");if((X!==-1?$.url.slice(0,X):$.url)!==this.options.path)return!1}return!0}handleUpgrade($,X,Q,J){X.on("error",hy);let v=$.headers["sec-websocket-key"],Y=$.headers.upgrade,z=+$.headers["sec-websocket-version"];if($.method!=="GET"){LX(this,$,X,405,"Invalid HTTP method");return}if(Y===void 0||Y.toLowerCase()!=="websocket"){LX(this,$,X,400,"Invalid Upgrade header");return}if(v===void 0||!ez$.test(v)){LX(this,$,X,400,"Missing or invalid Sec-WebSocket-Key header");return}if(z!==13&&z!==8){LX(this,$,X,400,"Missing or invalid Sec-WebSocket-Version header",{"Sec-WebSocket-Version":"13, 8"});return}if(!this.shouldHandle($)){w7(X,400);return}let W=$.headers["sec-websocket-protocol"],G=new Set;if(W!==void 0)try{G=az$.parse(W)}catch(H){LX(this,$,X,400,"Invalid Sec-WebSocket-Protocol header");return}let U=$.headers["sec-websocket-extensions"],w={};if(this.options.perMessageDeflate&&U!==void 0){let H=new OX(this.options.perMessageDeflate,!0,this.options.maxPayload);try{let j=my.parse(U);if(j[OX.extensionName])H.accept(j[OX.extensionName]),w[OX.extensionName]=H}catch(j){LX(this,$,X,400,"Invalid or unacceptable Sec-WebSocket-Extensions header");return}}if(this.options.verifyClient){let H={origin:$.headers[`${z===8?"sec-websocket-origin":"origin"}`],secure:!!($.socket.authorized||$.socket.encrypted),req:$};if(this.options.verifyClient.length===2){this.options.verifyClient(H,(j,V,B,D)=>{if(!j)return w7(X,V||401,B,D);this.completeUpgrade(w,v,G,$,X,Q,J)});return}if(!this.options.verifyClient(H))return w7(X,401)}this.completeUpgrade(w,v,G,$,X,Q,J)}completeUpgrade($,X,Q,J,v,Y,z){if(!v.readable||!v.writable)return v.destroy();if(v[sz$])throw Error("server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration");if(this._state>0)return w7(v,503);let G=["HTTP/1.1 101 Switching Protocols","Upgrade: websocket","Connection: Upgrade",`Sec-WebSocket-Accept: ${oz$("sha1").update(X+tz$).digest("base64")}`],U=new this.options.WebSocket(null,void 0,this.options);if(Q.size){let w=this.options.handleProtocols?this.options.handleProtocols(Q,J):Q.values().next().value;if(w)G.push(`Sec-WebSocket-Protocol: ${w}`),U._protocol=w}if($[OX.extensionName]){let w=$[OX.extensionName].params,H=my.format({[OX.extensionName]:[w]});G.push(`Sec-WebSocket-Extensions: ${H}`),U._extensions=$}if(this.emit("headers",G,J),v.write(G.concat(`\r +`).join(`\r +`)),v.removeListener("error",hy),U.setSocket(v,Y,{allowSynchronousEvents:this.options.allowSynchronousEvents,maxPayload:this.options.maxPayload,skipUTF8Validation:this.options.skipUTF8Validation}),this.clients)this.clients.add(U),U.on("close",()=>{if(this.clients.delete(U),this._shouldEmitClose&&!this.clients.size)process.nextTick(K7,this)});z(U,J)}}py.exports=ly;function $7$($,X){for(let Q of Object.keys(X))$.on(Q,X[Q]);return function(){for(let J of Object.keys(X))$.removeListener(J,X[J])}}function K7($){$._state=2,$.emit("close")}function hy(){this.destroy()}function w7($,X,Q,J){Q=Q||uH.STATUS_CODES[X],J={Connection:"close","Content-Type":"text/html","Content-Length":Buffer.byteLength(Q),...J},$.once("finish",$.destroy),$.end(`HTTP/1.1 ${X} ${uH.STATUS_CODES[X]}\r +`+Object.keys(J).map((v)=>`${v}: ${J[v]}`).join(`\r +`)+`\r +\r +`+Q)}function LX($,X,Q,J,v,Y){if($.listenerCount("wsClientError")){let z=Error(v);Error.captureStackTrace(z,LX),$.emit("wsClientError",z,Q,X)}else w7(Q,J,v,Y)}});var qA=N((YP$,oy)=>{var F8=CH();F8.createWebSocketStream=ny();F8.Server=dy();F8.Receiver=VA();F8.Sender=DA();F8.WebSocket=F8;F8.WebSocketServer=F8.Server;oy.exports=F8});var ty=N((zP$,ry)=>{var ay=Object.getOwnPropertySymbols,X7$=Object.prototype.hasOwnProperty,Q7$=Object.prototype.propertyIsEnumerable;function v7$($){if($===null||$===void 0)throw TypeError("Object.assign cannot be called with null or undefined");return Object($)}function J7$(){try{if(!Object.assign)return!1;var $=new String("abc");if($[5]="de",Object.getOwnPropertyNames($)[0]==="5")return!1;var X={};for(var Q=0;Q<10;Q++)X["_"+String.fromCharCode(Q)]=Q;var J=Object.getOwnPropertyNames(X).map(function(Y){return X[Y]});if(J.join("")!=="0123456789")return!1;var v={};if("abcdefghijklmnopqrst".split("").forEach(function(Y){v[Y]=Y}),Object.keys(Object.assign({},v)).join("")!=="abcdefghijklmnopqrst")return!1;return!0}catch(Y){return!1}}ry.exports=J7$()?Object.assign:function($,X){var Q,J=v7$($),v;for(var Y=1;Y{/*! + * vary + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */IA.exports=z7$;IA.exports.append=ey;var Y7$=/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;function ey($,X){if(typeof $!=="string")throw TypeError("header argument is required");if(!X)throw TypeError("field argument is required");var Q=!Array.isArray(X)?sy(String(X)):X;for(var J=0;J{(function(){var $=ty(),X=$f(),Q={origin:"*",methods:"GET,HEAD,PUT,PATCH,POST,DELETE",preflightContinue:!1,optionsSuccessStatus:204};function J(B){return typeof B==="string"||B instanceof String}function v(B,D){if(Array.isArray(D)){for(var F=0;F{Object.defineProperty(nv,"__esModule",{value:!0});nv.Server=nv.BaseServer=void 0;var Qf=dL(),_H=zA(),W7$=require("events"),vf=WA(),G7$=W4(),U7$=l_(),K7$=qA(),Jf=JA(),w7$=v7(),B$=(0,G7$.default)("engine"),kv=Symbol("responseHeaders");function H7$($){try{let X=JSON.parse($);if(typeof X.sid==="string")return X.sid}catch(X){}}class j7 extends W7$.EventEmitter{constructor($={}){super();if(this.middlewares=[],this.clients={},this.clientsCount=0,this.opts=Object.assign({wsEngine:K7$.Server,pingTimeout:20000,pingInterval:25000,upgradeTimeout:1e4,maxHttpBufferSize:1e6,transports:["polling","websocket"],allowUpgrades:!0,httpCompression:{threshold:1024},cors:!1,allowEIO3:!1},$),$.cookie)this.opts.cookie=Object.assign({name:"io",path:"/",httpOnly:$.cookie.path!==!1,sameSite:"lax"},$.cookie);if(this.opts.cors)this.use(MA()(this.opts.cors));if($.perMessageDeflate)this.opts.perMessageDeflate=Object.assign({threshold:1024},$.perMessageDeflate);this.init()}_computePath($){let X=($.path||"/engine.io").replace(/\/$/,"");if($.addTrailingSlash!==!1)X+="/";return X}upgrades($){if(!this.opts.allowUpgrades)return[];return _H.default[$].upgradesTo||[]}verify($,X,Q){let J=$._query.transport;if(!~this.opts.transports.indexOf(J)||J==="webtransport")return B$('unknown transport "%s"',J),Q(g$.errors.UNKNOWN_TRANSPORT,{transport:J});if(j7$($.headers.origin)){let z=$.headers.origin;return $.headers.origin=null,B$("origin header invalid"),Q(g$.errors.BAD_REQUEST,{name:"INVALID_ORIGIN",origin:z})}let Y=$._query.sid;if(Y){if(!this.clients.hasOwnProperty(Y))return B$('unknown sid "%s"',Y),Q(g$.errors.UNKNOWN_SID,{sid:Y});let z=this.clients[Y].transport.name;if(!X&&z!==J)return B$("bad request: unexpected transport without upgrade"),Q(g$.errors.BAD_REQUEST,{name:"TRANSPORT_MISMATCH",transport:J,previousTransport:z})}else{if($.method!=="GET")return Q(g$.errors.BAD_HANDSHAKE_METHOD,{method:$.method});if(J==="websocket"&&!X)return B$("invalid transport upgrade"),Q(g$.errors.BAD_REQUEST,{name:"TRANSPORT_HANDSHAKE_ERROR"});if(!this.opts.allowRequest)return Q();return this.opts.allowRequest($,(z,W)=>{if(!W)return Q(g$.errors.FORBIDDEN,{message:z});Q()})}Q()}use($){this.middlewares.push($)}_applyMiddlewares($,X,Q){if(this.middlewares.length===0)return B$("no middleware to apply, skipping"),Q();let J=(v)=>{B$("applying middleware n°%d",v+1),this.middlewares[v]($,X,(Y)=>{if(Y)return Q(Y);if(v+1{if(!G._query.sid){if(this.opts.cookie)W["Set-Cookie"]=[(0,U7$.serialize)(this.opts.cookie.name,v,this.opts.cookie)];this.emit("initial_headers",W,G)}this.emit("headers",W,G)}),Y.onRequest(X),this.clients[v]=z,this.clientsCount++,z.once("close",()=>{delete this.clients[v],this.clientsCount--}),this.emit("connection",z),Y}async onWebTransportSession($){let X=setTimeout(()=>{B$("the client failed to establish a bidirectional stream in the given period"),$.close()},this.opts.upgradeTimeout),J=await $.incomingBidirectionalStreams.getReader().read();if(J.done){B$("session is closed");return}let v=J.value,Y=(0,w7$.createPacketDecoderStream)(this.opts.maxHttpBufferSize,"nodebuffer"),z=v.readable.pipeThrough(Y).getReader(),{value:W,done:G}=await z.read();if(G){B$("stream is closed");return}if(clearTimeout(X),W.type!=="open")return B$("invalid WebTransport handshake"),$.close();if(W.data===void 0){let H=new Jf.WebTransport($,v,z),j=Qf.generateId();B$('handshaking client "%s" (WebTransport)',j);let V=new vf.Socket(j,this,H,null,4);this.clients[j]=V,this.clientsCount++,V.once("close",()=>{delete this.clients[j],this.clientsCount--}),this.emit("connection",V);return}let U=H7$(W.data);if(!U)return B$("invalid WebTransport handshake"),$.close();let w=this.clients[U];if(!w)B$("upgrade attempt for closed client"),$.close();else if(w.upgrading)B$("transport has already been trying to upgrade"),$.close();else if(w.upgraded)B$("transport had already been upgraded"),$.close();else{B$("upgrading existing transport");let H=new Jf.WebTransport($,v,z);w._maybeUpgrade(H)}}}nv.BaseServer=j7;j7.errors={UNKNOWN_TRANSPORT:0,UNKNOWN_SID:1,BAD_HANDSHAKE_METHOD:2,BAD_REQUEST:3,FORBIDDEN:4,UNSUPPORTED_PROTOCOL_VERSION:5};j7.errorMessages={0:"Transport unknown",1:"Session ID unknown",2:"Bad handshake method",3:"Bad request",4:"Forbidden",5:"Unsupported protocol version"};class Wf{constructor($,X){this.req=$,this.socket=X,$[kv]={}}setHeader($,X){this.req[kv][$]=X}getHeader($){return this.req[kv][$]}removeHeader($){delete this.req[kv][$]}write(){}writeHead(){}end(){this.socket.destroy()}}class g$ extends j7{init(){if(!~this.opts.transports.indexOf("websocket"))return;if(this.ws)this.ws.close();if(this.ws=new this.opts.wsEngine({noServer:!0,clientTracking:!1,perMessageDeflate:this.opts.perMessageDeflate,maxPayload:this.opts.maxHttpBufferSize}),typeof this.ws.on==="function")this.ws.on("headers",($,X)=>{let Q=X[kv]||{};if(delete X[kv],!X._query.sid)this.emit("initial_headers",Q,X);this.emit("headers",Q,X),B$("writing headers: %j",Q),Object.keys(Q).forEach((v)=>{$.push(`${v}: ${Q[v]}`)})})}cleanup(){if(this.ws)B$("closing webSocketServer"),this.ws.close()}prepare($){if(!$._query){let X=new URL($.url,"https://socket.io");$._query=Object.fromEntries(X.searchParams.entries())}}createTransport($,X){return new _H.default[$](X)}handleRequest($,X){B$('handling "%s" http request "%s"',$.method,$.url),this.prepare($),$.res=X;let Q=(J,v)=>{if(J!==void 0){this.emit("connection_error",{req:$,code:J,message:g$.errorMessages[J],context:v}),Yf(X,J,v);return}if($._query.sid)B$("setting new request for existing client"),this.clients[$._query.sid].transport.onRequest($);else{let Y=(z,W)=>Yf(X,z,W);this.handshake($._query.transport,$,Y)}};this._applyMiddlewares($,X,(J)=>{if(J)Q(g$.errors.BAD_REQUEST,{name:"MIDDLEWARE_FAILURE"});else this.verify($,!1,Q)})}handleUpgrade($,X,Q){this.prepare($);let J=new Wf($,X),v=(Y,z)=>{if(Y!==void 0){this.emit("connection_error",{req:$,code:Y,message:g$.errorMessages[Y],context:z}),zf(X,Y,z);return}let W=Buffer.from(Q);Q=null,J.writeHead(),this.ws.handleUpgrade($,X,W,(G)=>{this.onWebSocket($,X,G)})};this._applyMiddlewares($,J,(Y)=>{if(Y)v(g$.errors.BAD_REQUEST,{name:"MIDDLEWARE_FAILURE"});else this.verify($,!0,v)})}onWebSocket($,X,Q){if(Q.on("error",v),_H.default[$._query.transport]!==void 0&&!_H.default[$._query.transport].prototype.handlesUpgrades){B$("transport doesnt handle upgraded requests"),Q.close();return}let J=$._query.sid;if($.websocket=Q,J){let Y=this.clients[J];if(!Y)B$("upgrade attempt for closed client"),Q.close();else if(Y.upgrading)B$("transport has already been trying to upgrade"),Q.close();else if(Y.upgraded)B$("transport had already been upgraded"),Q.close();else{B$("upgrading existing transport"),Q.removeListener("error",v);let z=this.createTransport($._query.transport,$);z.perMessageDeflate=this.opts.perMessageDeflate,Y._maybeUpgrade(z)}}else{let Y=(z,W)=>zf(X,z,W);this.handshake($._query.transport,$,Y)}function v(){B$("websocket error before upgrade")}}attach($,X={}){let Q=this._computePath(X),J=X.destroyUpgradeTimeout||1000;function v(z){return Q===z.url.slice(0,Q.length)}let Y=$.listeners("request").slice(0);if($.removeAllListeners("request"),$.on("close",this.close.bind(this)),$.on("listening",this.init.bind(this)),$.on("request",(z,W)=>{if(v(z))B$('intercepting request for path "%s"',Q),this.handleRequest(z,W);else{let G=0,U=Y.length;for(;G{if(v(z))this.handleUpgrade(z,W,G);else if(X.destroyUpgrade!==!1)setTimeout(function(){if(W.writable&&W.bytesWritten<=0)return W.on("error",(U)=>{B$("error while destroying upgrade: %s",U.message)}),W.end()},J)})}}nv.Server=g$;function Yf($,X,Q){let J=X===g$.errors.FORBIDDEN?403:400,v=Q&&Q.message?Q.message:g$.errorMessages[X];$.writeHead(J,{"Content-Type":"application/json"}),$.end(JSON.stringify({code:X,message:v}))}function zf($,X,Q={}){if($.on("error",()=>{B$("ignoring error from closed connection")}),$.writable){let J=Q.message||g$.errorMessages[X],v=Buffer.byteLength(J);$.write(`HTTP/1.1 400 Bad Request\r +Connection: close\r +Content-type: text/html\r +Content-Length: `+v+`\r +\r +`+J)}$.destroy()}var H7=[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1];function j7$($){if($+="",$.length<1)return!1;if(!H7[$.charCodeAt(0)])return B$('invalid header, index 0, char "%s"',$.charCodeAt(0)),!0;if($.length<2)return!1;if(!H7[$.charCodeAt(1)])return B$('invalid header, index 1, char "%s"',$.charCodeAt(1)),!0;if($.length<3)return!1;if(!H7[$.charCodeAt(2)])return B$('invalid header, index 2, char "%s"',$.charCodeAt(2)),!0;if($.length<4)return!1;if(!H7[$.charCodeAt(3)])return B$('invalid header, index 3, char "%s"',$.charCodeAt(3)),!0;for(let X=4;X<$.length;++X)if(!H7[$.charCodeAt(X)])return B$('invalid header, index "%i", char "%s"',X,$.charCodeAt(X)),!0;return!1}});var Kf=N((yH)=>{Object.defineProperty(yH,"__esModule",{value:!0});yH.Polling=void 0;var V7$=DX(),Gf=require("zlib"),B7$=KH(),D7$=W4(),M1=(0,D7$.default)("engine:polling"),F7$={gzip:Gf.createGzip,deflate:Gf.createDeflate};class Uf extends V7$.Transport{constructor($){super($);this.closeTimeout=30000}get name(){return"polling"}onRequest($){let X=$.res;if($.res=null,$.getMethod()==="get")this.onPollRequest($,X);else if($.getMethod()==="post")this.onDataRequest($,X);else X.writeStatus("500 Internal Server Error"),X.end()}onPollRequest($,X){if(this.req){M1("request overlap"),this.onError("overlap from client"),X.writeStatus("500 Internal Server Error"),X.end();return}M1("setting request"),this.req=$,this.res=X;let Q=()=>{this.writable=!1,this.onError("poll connection closed prematurely")},J=()=>{this.req=this.res=null};if($.cleanup=J,X.onAborted(Q),this.writable=!0,this.emit("ready"),this.writable&&this.shouldClose)M1("triggering empty send to append close packet"),this.send([{type:"noop"}])}onDataRequest($,X){if(this.dataReq){this.onError("data request overlap from client"),X.writeStatus("500 Internal Server Error"),X.end();return}let Q=Number($.headers["content-length"]);if(!Q){this.onError("content-length header required"),X.writeStatus("411 Length Required").end();return}if(Q>this.maxHttpBufferSize){this.onError("payload too large"),X.writeStatus("413 Payload Too Large").end();return}if($.headers["content-type"]==="application/octet-stream"&&this.protocol===4)return this.onError("invalid content");this.dataReq=$,this.dataRes=X;let v,Y=0,z={"Content-Type":"text/html"};this.headers($,z);for(let G in z)X.writeHeader(G,String(z[G]));let W=(G)=>{this.onData(G.toString()),this.onDataRequestCleanup(),X.cork(()=>{X.end("ok")})};X.onAborted(()=>{this.onDataRequestCleanup(),this.onError("data request connection closed prematurely")}),X.onData((G,U)=>{let w=Y+G.byteLength;if(w>Q){this.onError("content-length mismatch"),X.close();return}if(!v){if(U){W(Buffer.from(G));return}v=Buffer.allocUnsafe(Q)}if(Buffer.from(G).copy(v,Y),U){if(w!=Q){this.onError("content-length mismatch"),X.writeStatus("400 Content-Length Mismatch").end(),this.onDataRequestCleanup();return}W(v);return}Y=w})}onDataRequestCleanup(){this.dataReq=this.dataRes=null}onData($){M1('received "%s"',$);let X=(Q)=>{if(Q.type==="close")return M1("got xhr close packet"),this.onClose(),!1;this.onPacket(Q)};if(this.protocol===3)this.parser.decodePayload($,X);else this.parser.decodePayload($).forEach(X)}onClose(){if(this.writable)this.send([{type:"noop"}]);super.onClose()}send($){if(this.writable=!1,this.shouldClose)M1("appending close packet to payload"),$.push({type:"close"}),this.shouldClose(),this.shouldClose=null;let X=(Q)=>{let J=$.some((v)=>{return v.options&&v.options.compress});this.write(Q,{compress:J})};if(this.protocol===3)this.parser.encodePayload($,this.supportsBinary,X);else this.parser.encodePayload($,X)}write($,X){M1('writing "%s"',$),this.doWrite($,X,()=>{this.req.cleanup(),this.emit("drain")})}doWrite($,X,Q){let J=typeof $==="string",Y={"Content-Type":J?"text/plain; charset=UTF-8":"application/octet-stream"},z=(U)=>{this.headers(this.req,Y),this.res.cork(()=>{Object.keys(Y).forEach((w)=>{this.res.writeHeader(w,String(Y[w]))}),this.res.end(U)}),Q()};if(!this.httpCompression||!X.compress){z($);return}if((J?Buffer.byteLength($):$.length){if(U){this.res.writeStatus("500 Internal Server Error"),this.res.end(),Q(U);return}Y["Content-Encoding"]=G,z(w)})}compress($,X,Q){M1("compressing");let J=[],v=0;F7$[X](this.httpCompression).on("error",Q).on("data",function(Y){J.push(Y),v+=Y.length}).on("end",function(){Q(null,Buffer.concat(J,v))}).end($)}doClose($){M1("closing");let X,Q=()=>{clearTimeout(X),$(),this.onClose()};if(this.writable)M1("transport writable - closing right away"),this.send([{type:"close"}]),Q();else if(this.discarded)M1("transport discarded - closing right away"),Q();else M1("transport not writable - buffering orderly close"),this.shouldClose=Q,X=setTimeout(Q,this.closeTimeout)}headers($,X){X=X||{};let Q=$.headers["user-agent"];if(Q&&(~Q.indexOf(";MSIE")||~Q.indexOf("Trident/")))X["X-XSS-Protection"]="0";return X["cache-control"]="no-store",this.emit("headers",X,$),X}}yH.Polling=Uf});var jf=N((fH)=>{Object.defineProperty(fH,"__esModule",{value:!0});fH.WebSocket=void 0;var N7$=DX(),O7$=W4(),wf=(0,O7$.default)("engine:ws");class Hf extends N7$.Transport{constructor($){super($);this.writable=!1,this.perMessageDeflate=null}get name(){return"websocket"}get handlesUpgrades(){return!0}send($){this.writable=!1;for(let X=0;X<$.length;X++){let Q=$[X],J=X+1===$.length,v=(Y)=>{let z=typeof Y!=="string",W=this.perMessageDeflate&&Buffer.byteLength(Y)>this.perMessageDeflate.threshold;if(wf('writing "%s"',Y),this.socket.send(Y,z,W),J)this.emit("drain"),this.writable=!0,this.emit("ready")};if(Q.options&&typeof Q.options.wsPreEncoded==="string")v(Q.options.wsPreEncoded);else this.parser.encodePacket(Q,this.supportsBinary,v)}}doClose($){wf("closing"),$&&$(),this.socket.end()}}fH.WebSocket=Hf});var Vf=N((bA)=>{Object.defineProperty(bA,"__esModule",{value:!0});var L7$=Kf(),A7$=jf();bA.default={polling:L7$.Polling,websocket:A7$.WebSocket}});var Ff=N((kH)=>{Object.defineProperty(kH,"__esModule",{value:!0});kH.uServer=void 0;var Z7$=W4(),AX=xA(),q7$=Vf(),w9=(0,Z7$.default)("engine:uws");class Bf extends AX.BaseServer{init(){}cleanup(){}prepare($,X){$.method=$.getMethod().toUpperCase(),$.url=$.getUrl();let Q=new URLSearchParams($.getQuery());$._query=Object.fromEntries(Q.entries()),$.headers={},$.forEach((J,v)=>{$.headers[J]=v}),$.connection={remoteAddress:Buffer.from(X.getRemoteAddressAsText()).toString()},X.onAborted(()=>{w9("response has been aborted")})}createTransport($,X){return new q7$.default[$](X)}attach($,X={}){let Q=this._computePath(X);$.any(Q,this.handleRequest.bind(this)).ws(Q,{compression:X.compression,idleTimeout:X.idleTimeout,maxBackpressure:X.maxBackpressure,maxPayloadLength:this.opts.maxHttpBufferSize,upgrade:this.handleUpgrade.bind(this),open:(J)=>{let v=J.getUserData().transport;v.socket=J,v.writable=!0,v.emit("ready")},message:(J,v,Y)=>{J.getUserData().transport.onData(Y?v:Buffer.from(v).toString())},close:(J,v,Y)=>{J.getUserData().transport.onClose(v,Y)}})}_applyMiddlewares($,X,Q){if(this.middlewares.length===0)return Q();$.res=new Df(X),super._applyMiddlewares($,$.res,(J)=>{$.res.writeHead(),Q(J)})}handleRequest($,X){w9('handling "%s" http request "%s"',X.getMethod(),X.getUrl()),this.prepare(X,$),X.res=$;let Q=(J,v)=>{if(J!==void 0){this.emit("connection_error",{req:X,code:J,message:AX.Server.errorMessages[J],context:v}),this.abortRequest(X.res,J,v);return}if(X._query.sid)w9("setting new request for existing client"),this.clients[X._query.sid].transport.onRequest(X);else{let Y=(z,W)=>this.abortRequest($,z,W);this.handshake(X._query.transport,X,Y)}};this._applyMiddlewares(X,$,(J)=>{if(J)Q(AX.Server.errors.BAD_REQUEST,{name:"MIDDLEWARE_FAILURE"});else this.verify(X,!1,Q)})}handleUpgrade($,X,Q){w9("on upgrade"),this.prepare(X,$),X.res=$;let J=async(v,Y)=>{if(v!==void 0){this.emit("connection_error",{req:X,code:v,message:AX.Server.errorMessages[v],context:Y}),this.abortRequest($,v,Y);return}let z=X._query.sid,W;if(z){let G=this.clients[z];if(!G)return w9("upgrade attempt for closed client"),$.close();else if(G.upgrading)return w9("transport has already been trying to upgrade"),$.close();else if(G.upgraded)return w9("transport had already been upgraded"),$.close();else w9("upgrading existing transport"),W=this.createTransport(X._query.transport,X),G._maybeUpgrade(W)}else if(W=await this.handshake(X._query.transport,X,(G,U)=>this.abortRequest($,G,U)),!W)return;X.res.writeStatus("101 Switching Protocols"),$.upgrade({transport:W},X.getHeader("sec-websocket-key"),X.getHeader("sec-websocket-protocol"),X.getHeader("sec-websocket-extensions"),Q)};this._applyMiddlewares(X,$,(v)=>{if(v)J(AX.Server.errors.BAD_REQUEST,{name:"MIDDLEWARE_FAILURE"});else this.verify(X,!0,J)})}abortRequest($,X,Q){let J=X===AX.Server.errors.FORBIDDEN?"403 Forbidden":"400 Bad Request",v=Q&&Q.message?Q.message:AX.Server.errorMessages[X];$.writeStatus(J),$.writeHeader("Content-Type","application/json"),$.end(JSON.stringify({code:X,message:v}))}}kH.uServer=Bf;class Df{constructor($){this.res=$,this.statusWritten=!1,this.headers=[],this.isAborted=!1}set statusCode($){if(!$)return;this.writeStatus($===200?"200 OK":"204 No Content")}writeHead($){this.statusCode=$}setHeader($,X){if(Array.isArray(X))X.forEach((Q)=>{this.writeHeader($,Q)});else this.writeHeader($,X)}removeHeader(){}getHeader(){}writeStatus($){if(this.isAborted)return;return this.res.writeStatus($),this.statusWritten=!0,this.writeBufferedHeaders(),this}writeHeader($,X){if(this.isAborted)return;if($==="Content-Length")return;if(this.statusWritten)this.res.writeHeader($,X);else this.headers.push([$,X])}writeBufferedHeaders(){this.headers.forEach(([$,X])=>{this.res.writeHeader($,X)})}end($){if(this.isAborted)return;this.res.cork(()=>{if(!this.statusWritten)this.writeBufferedHeaders();this.res.end($)})}onData($){if(this.isAborted)return;this.res.onData($)}onAborted($){if(this.isAborted)return;this.res.onAborted(()=>{this.isAborted=!0,$()})}cork($){if(this.isAborted)return;this.res.cork($)}}});var Af=N((y4)=>{Object.defineProperty(y4,"__esModule",{value:!0});y4.protocol=y4.Transport=y4.Socket=y4.uServer=y4.parser=y4.transports=y4.Server=void 0;y4.listen=R7$;y4.attach=Lf;var I7$=require("http"),Nf=xA();Object.defineProperty(y4,"Server",{enumerable:!0,get:function(){return Nf.Server}});var M7$=zA();y4.transports=M7$.default;var Of=v7();y4.parser=Of;var x7$=Ff();Object.defineProperty(y4,"uServer",{enumerable:!0,get:function(){return x7$.uServer}});var b7$=WA();Object.defineProperty(y4,"Socket",{enumerable:!0,get:function(){return b7$.Socket}});var i7$=DX();Object.defineProperty(y4,"Transport",{enumerable:!0,get:function(){return i7$.Transport}});y4.protocol=Of.protocol;function R7$($,X,Q){if(typeof X==="function")Q=X,X={};let J=(0,I7$.createServer)(function(Y,z){z.writeHead(501),z.end("Not Implemented")}),v=Lf(J,X);return v.httpServer=J,J.listen($,Q),v}function Lf($,X){let Q=new Nf.Server(X);return Q.attach($,X),Q}});var qf=N((Zf)=>{Zf.Emitter=x6;function x6($){if($)return E7$($)}function E7$($){for(var X in x6.prototype)$[X]=x6.prototype[X];return $}x6.prototype.on=x6.prototype.addEventListener=function($,X){return this._callbacks=this._callbacks||{},(this._callbacks["$"+$]=this._callbacks["$"+$]||[]).push(X),this};x6.prototype.once=function($,X){function Q(){this.off($,Q),X.apply(this,arguments)}return Q.fn=X,this.on($,Q),this};x6.prototype.off=x6.prototype.removeListener=x6.prototype.removeAllListeners=x6.prototype.removeEventListener=function($,X){if(this._callbacks=this._callbacks||{},arguments.length==0)return this._callbacks={},this;var Q=this._callbacks["$"+$];if(!Q)return this;if(arguments.length==1)return delete this._callbacks["$"+$],this;var J;for(var v=0;v{Object.defineProperty(gH,"__esModule",{value:!0});gH.isBinary=Mf;gH.hasBinary=nH;var P7$=typeof ArrayBuffer==="function",T7$=($)=>{return typeof ArrayBuffer.isView==="function"?ArrayBuffer.isView($):$.buffer instanceof ArrayBuffer},If=Object.prototype.toString,S7$=typeof Blob==="function"||typeof Blob<"u"&&If.call(Blob)==="[object BlobConstructor]",C7$=typeof File==="function"||typeof File<"u"&&If.call(File)==="[object FileConstructor]";function Mf($){return P7$&&($ instanceof ArrayBuffer||T7$($))||S7$&&$ instanceof Blob||C7$&&$ instanceof File}function nH($,X){if(!$||typeof $!=="object")return!1;if(Array.isArray($)){for(let Q=0,J=$.length;Q{Object.defineProperty(cH,"__esModule",{value:!0});cH.deconstructPacket=_7$;cH.reconstructPacket=y7$;var u7$=iA();function _7$($){let X=[],Q=$.data,J=$;return J.data=RA(Q,X),J.attachments=X.length,{packet:J,buffers:X}}function RA($,X){if(!$)return $;if((0,u7$.isBinary)($)){let Q={_placeholder:!0,num:X.length};return X.push($),Q}else if(Array.isArray($)){let Q=Array($.length);for(let J=0;J<$.length;J++)Q[J]=RA($[J],X);return Q}else if(typeof $==="object"&&!($ instanceof Date)){let Q={};for(let J in $)if(Object.prototype.hasOwnProperty.call($,J))Q[J]=RA($[J],X);return Q}return $}function y7$($,X){return $.data=EA($.data,X),delete $.attachments,$}function EA($,X){if(!$)return $;if($&&$._placeholder===!0)if(typeof $.num==="number"&&$.num>=0&&$.num{Object.defineProperty(Q0,"__esModule",{value:!0});Q0.Decoder=Q0.Encoder=Q0.PacketType=Q0.protocol=void 0;Q0.isPacketValid=h7$;var f7$=qf(),bf=xf(),Rf=iA(),k7$=W4(),PA=(0,k7$.default)("socket.io-parser"),Ef=["connect","connect_error","disconnect","disconnecting","newListener","removeListener"];Q0.protocol=5;var c$;(function($){$[$.CONNECT=0]="CONNECT",$[$.DISCONNECT=1]="DISCONNECT",$[$.EVENT=2]="EVENT",$[$.ACK=3]="ACK",$[$.CONNECT_ERROR=4]="CONNECT_ERROR",$[$.BINARY_EVENT=5]="BINARY_EVENT",$[$.BINARY_ACK=6]="BINARY_ACK"})(c$||(Q0.PacketType=c$={}));class Pf{constructor($){this.replacer=$}encode($){if(PA("encoding packet %j",$),$.type===c$.EVENT||$.type===c$.ACK){if((0,Rf.hasBinary)($))return this.encodeAsBinary({type:$.type===c$.EVENT?c$.BINARY_EVENT:c$.BINARY_ACK,nsp:$.nsp,data:$.data,id:$.id})}return[this.encodeAsString($)]}encodeAsString($){let X=""+$.type;if($.type===c$.BINARY_EVENT||$.type===c$.BINARY_ACK)X+=$.attachments+"-";if($.nsp&&$.nsp!=="/")X+=$.nsp+",";if($.id!=null)X+=$.id;if($.data!=null)X+=JSON.stringify($.data,this.replacer);return PA("encoded %j as %s",$,X),X}encodeAsBinary($){let X=(0,bf.deconstructPacket)($),Q=this.encodeAsString(X.packet),J=X.buffers;return J.unshift(Q),J}}Q0.Encoder=Pf;class TA extends f7$.Emitter{constructor($){super();this.reviver=$}add($){let X;if(typeof $==="string"){if(this.reconstructor)throw Error("got plaintext data when reconstructing a packet");X=this.decodeString($);let Q=X.type===c$.BINARY_EVENT;if(Q||X.type===c$.BINARY_ACK){if(X.type=Q?c$.EVENT:c$.ACK,this.reconstructor=new Tf(X),X.attachments===0)super.emitReserved("decoded",X)}else super.emitReserved("decoded",X)}else if((0,Rf.isBinary)($)||$.base64){if(!this.reconstructor)throw Error("got binary data when not reconstructing a packet");else if(X=this.reconstructor.takeBinaryData($),X)this.reconstructor=null,super.emitReserved("decoded",X)}else throw Error("Unknown type: "+$)}decodeString($){let X=0,Q={type:Number($.charAt(0))};if(c$[Q.type]===void 0)throw Error("unknown packet type "+Q.type);if(Q.type===c$.BINARY_EVENT||Q.type===c$.BINARY_ACK){let v=X+1;while($.charAt(++X)!=="-"&&X!=$.length);let Y=$.substring(v,X);if(Y!=Number(Y)||$.charAt(X)!=="-")throw Error("Illegal attachments");Q.attachments=Number(Y)}if($.charAt(X+1)==="/"){let v=X+1;while(++X){if($.charAt(X)===",")break;if(X===$.length)break}Q.nsp=$.substring(v,X)}else Q.nsp="/";let J=$.charAt(X+1);if(J!==""&&Number(J)==J){let v=X+1;while(++X){let Y=$.charAt(X);if(Y==null||Number(Y)!=Y){--X;break}if(X===$.length)break}Q.id=Number($.substring(v,X+1))}if($.charAt(++X)){let v=this.tryParse($.substr(X));if(TA.isPayloadValid(Q.type,v))Q.data=v;else throw Error("invalid payload")}return PA("decoded %s as %j",$,Q),Q}tryParse($){try{return JSON.parse($,this.reviver)}catch(X){return!1}}static isPayloadValid($,X){switch($){case c$.CONNECT:return mH(X);case c$.DISCONNECT:return X===void 0;case c$.CONNECT_ERROR:return typeof X==="string"||mH(X);case c$.EVENT:case c$.BINARY_EVENT:return Array.isArray(X)&&(typeof X[0]==="number"||typeof X[0]==="string"&&Ef.indexOf(X[0])===-1);case c$.ACK:case c$.BINARY_ACK:return Array.isArray(X)}}destroy(){if(this.reconstructor)this.reconstructor.finishedReconstruction(),this.reconstructor=null}}Q0.Decoder=TA;class Tf{constructor($){this.packet=$,this.buffers=[],this.reconPack=$}takeBinaryData($){if(this.buffers.push($),this.buffers.length===this.reconPack.attachments){let X=(0,bf.reconstructPacket)(this.reconPack,this.buffers);return this.finishedReconstruction(),X}return null}finishedReconstruction(){this.reconPack=null,this.buffers=[]}}function n7$($){return typeof $==="string"}var g7$=Number.isInteger||function($){return typeof $==="number"&&isFinite($)&&Math.floor($)===$};function c7$($){return $===void 0||g7$($)}function mH($){return Object.prototype.toString.call($)==="[object Object]"}function m7$($,X){switch($){case c$.CONNECT:return X===void 0||mH(X);case c$.DISCONNECT:return X===void 0;case c$.EVENT:return Array.isArray(X)&&(typeof X[0]==="number"||typeof X[0]==="string"&&Ef.indexOf(X[0])===-1);case c$.ACK:return Array.isArray(X);case c$.CONNECT_ERROR:return typeof X==="string"||mH(X);default:return!1}}function h7$($){return n7$($.nsp)&&c7$($.id)&&m7$($.type,$.data)}});var Cf=N((gv)=>{var l7$=gv&&gv.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(gv,"__esModule",{value:!0});gv.Client=void 0;var hH=V7(),p7$=l7$(W4()),v0=(0,p7$.default)("socket.io:client");class Sf{constructor($,X){this.sockets=new Map,this.nsps=new Map,this.server=$,this.conn=X,this.encoder=$.encoder,this.decoder=new $._parser.Decoder,this.id=X.id,this.setup()}get request(){return this.conn.request}setup(){this.onclose=this.onclose.bind(this),this.ondata=this.ondata.bind(this),this.onerror=this.onerror.bind(this),this.ondecoded=this.ondecoded.bind(this),this.decoder.on("decoded",this.ondecoded),this.conn.on("data",this.ondata),this.conn.on("error",this.onerror),this.conn.on("close",this.onclose),this.connectTimeout=setTimeout(()=>{if(this.nsps.size===0)v0("no namespace joined yet, close the client"),this.close();else v0("the client has already joined a namespace, nothing to do")},this.server._connectTimeout)}connect($,X={}){if(this.server._nsps.has($))return v0("connecting to namespace %s",$),this.doConnect($,X);this.server._checkNamespace($,X,(Q)=>{if(Q)this.doConnect($,X);else v0("creation of namespace %s was denied",$),this._packet({type:hH.PacketType.CONNECT_ERROR,nsp:$,data:{message:"Invalid namespace"}})})}doConnect($,X){let Q=this.server.of($);Q._add(this,X,(J)=>{if(this.sockets.set(J.id,J),this.nsps.set(Q.name,J),this.connectTimeout)clearTimeout(this.connectTimeout),this.connectTimeout=void 0})}_disconnect(){for(let $ of this.sockets.values())$.disconnect();this.sockets.clear(),this.close()}_remove($){if(this.sockets.has($.id)){let X=this.sockets.get($.id).nsp.name;this.sockets.delete($.id),this.nsps.delete(X)}else v0("ignoring remove for %s",$.id)}close(){if(this.conn.readyState==="open")v0("forcing transport close"),this.conn.close(),this.onclose("forced server close")}_packet($,X={}){if(this.conn.readyState!=="open"){v0("ignoring packet write %j",$);return}let Q=X.preEncoded?$:this.encoder.encode($);this.writeToEngine(Q,X)}writeToEngine($,X){if(X.volatile&&!this.conn.transport.writable){v0("volatile packet is discarded since the transport is not currently writable");return}let Q=Array.isArray($)?$:[$];for(let J of Q)this.conn.write(J,X)}ondata($){try{this.decoder.add($)}catch(X){v0("invalid packet format"),this.onerror(X)}}ondecoded($){let{namespace:X,authPayload:Q}=this._parseNamespace($),J=this.nsps.get(X);if(!J&&$.type===hH.PacketType.CONNECT)this.connect(X,Q);else if(J&&$.type!==hH.PacketType.CONNECT&&$.type!==hH.PacketType.CONNECT_ERROR)process.nextTick(function(){J._onpacket($)});else v0("invalid state (packet type: %s)",$.type),this.close()}_parseNamespace($){if(this.conn.protocol!==3)return{namespace:$.nsp,authPayload:$.data};let X=new URL($.nsp,"https://socket.io");return{namespace:X.pathname,authPayload:Object.fromEntries(X.searchParams.entries())}}onerror($){for(let X of this.sockets.values())X._onerror($);this.conn.close()}onclose($,X){v0("client close with reason %s",$),this.destroy();for(let Q of this.sockets.values())Q._onclose($,X);this.sockets.clear(),this.decoder.destroy()}destroy(){if(this.conn.removeListener("data",this.ondata),this.conn.removeListener("error",this.onerror),this.conn.removeListener("close",this.onclose),this.decoder.removeListener("decoded",this.ondecoded),this.connectTimeout)clearTimeout(this.connectTimeout),this.connectTimeout=void 0}}gv.Client=Sf});var pH=N((lH)=>{Object.defineProperty(lH,"__esModule",{value:!0});lH.StrictEventEmitter=void 0;var d7$=require("events");class uf extends d7$.EventEmitter{on($,X){return super.on($,X)}once($,X){return super.once($,X)}emit($,...X){return super.emit($,...X)}emitReserved($,...X){return super.emit($,...X)}emitUntyped($,...X){return super.emit($,...X)}listeners($){return super.listeners($)}}lH.StrictEventEmitter=uf});var SA=N((dH)=>{Object.defineProperty(dH,"__esModule",{value:!0});dH.RESERVED_EVENTS=void 0;dH.RESERVED_EVENTS=new Set(["connect","connect_error","disconnect","disconnecting","newListener","removeListener"])});var uA=N((cv)=>{Object.defineProperty(cv,"__esModule",{value:!0});cv.RemoteSocket=cv.BroadcastOperator=void 0;var o7$=SA(),a7$=V7();class N8{constructor($,X=new Set,Q=new Set,J={}){this.adapter=$,this.rooms=X,this.exceptRooms=Q,this.flags=J}to($){let X=new Set(this.rooms);if(Array.isArray($))$.forEach((Q)=>X.add(Q));else X.add($);return new N8(this.adapter,X,this.exceptRooms,this.flags)}in($){return this.to($)}except($){let X=new Set(this.exceptRooms);if(Array.isArray($))$.forEach((Q)=>X.add(Q));else X.add($);return new N8(this.adapter,this.rooms,X,this.flags)}compress($){let X=Object.assign({},this.flags,{compress:$});return new N8(this.adapter,this.rooms,this.exceptRooms,X)}get volatile(){let $=Object.assign({},this.flags,{volatile:!0});return new N8(this.adapter,this.rooms,this.exceptRooms,$)}get local(){let $=Object.assign({},this.flags,{local:!0});return new N8(this.adapter,this.rooms,this.exceptRooms,$)}timeout($){let X=Object.assign({},this.flags,{timeout:$});return new N8(this.adapter,this.rooms,this.exceptRooms,X)}emit($,...X){if(o7$.RESERVED_EVENTS.has($))throw Error(`"${String($)}" is a reserved event name`);let Q=[$,...X],J={type:a7$.PacketType.EVENT,data:Q};if(typeof Q[Q.length-1]!=="function")return this.adapter.broadcast(J,{rooms:this.rooms,except:this.exceptRooms,flags:this.flags}),!0;let Y=Q.pop(),z=!1,W=[],G=setTimeout(()=>{z=!0,Y.apply(this,[Error("operation has timed out"),this.flags.expectSingleResponse?null:W])},this.flags.timeout),U=-1,w=0,H=0,j=()=>{if(!z&&U===w&&W.length===H)clearTimeout(G),Y.apply(this,[null,this.flags.expectSingleResponse?W[0]:W])};return this.adapter.broadcastWithAck(J,{rooms:this.rooms,except:this.exceptRooms,flags:this.flags},(V)=>{H+=V,w++,j()},(V)=>{W.push(V),j()}),this.adapter.serverCount().then((V)=>{U=V,j()}),!0}emitWithAck($,...X){return new Promise((Q,J)=>{X.push((v,Y)=>{if(v)return v.responses=Y,J(v);else return Q(Y)}),this.emit($,...X)})}allSockets(){if(!this.adapter)throw Error("No adapter for this namespace, are you trying to get the list of clients of a dynamic namespace?");return this.adapter.sockets(this.rooms)}fetchSockets(){return this.adapter.fetchSockets({rooms:this.rooms,except:this.exceptRooms,flags:this.flags}).then(($)=>{return $.map((X)=>{if(X.server)return X;else return new CA(this.adapter,X)})})}socketsJoin($){this.adapter.addSockets({rooms:this.rooms,except:this.exceptRooms,flags:this.flags},Array.isArray($)?$:[$])}socketsLeave($){this.adapter.delSockets({rooms:this.rooms,except:this.exceptRooms,flags:this.flags},Array.isArray($)?$:[$])}disconnectSockets($=!1){this.adapter.disconnectSockets({rooms:this.rooms,except:this.exceptRooms,flags:this.flags},$)}}cv.BroadcastOperator=N8;class CA{constructor($,X){this.id=X.id,this.handshake=X.handshake,this.rooms=new Set(X.rooms),this.data=X.data,this.operator=new N8($,new Set([this.id]),new Set,{expectSingleResponse:!0})}timeout($){return this.operator.timeout($)}emit($,...X){return this.operator.emit($,...X)}join($){return this.operator.socketsJoin($)}leave($){return this.operator.socketsLeave($)}disconnect($=!1){return this.operator.disconnectSockets($),this}}cv.RemoteSocket=CA});var _A=N((mv)=>{var ff=mv&&mv.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(mv,"__esModule",{value:!0});mv.Socket=void 0;var x1=V7(),r7$=ff(W4()),t7$=pH(),_f=ff(dL()),s7$=uA(),e7$=SA(),W6=(0,r7$.default)("socket.io:socket"),$5$=new Set(["transport error","transport close","forced close","ping timeout","server shutting down","forced server close"]);function yf(){}class kf extends t7$.StrictEventEmitter{constructor($,X,Q,J){super();if(this.nsp=$,this.client=X,this.recovered=!1,this.data={},this.connected=!1,this.acks=new Map,this.fns=[],this.flags={},this.server=$.server,this.adapter=$.adapter,J)this.id=J.sid,this.pid=J.pid,J.rooms.forEach((v)=>this.join(v)),this.data=J.data,J.missedPackets.forEach((v)=>{this.packet({type:x1.PacketType.EVENT,data:v})}),this.recovered=!0;else{if(X.conn.protocol===3)this.id=$.name!=="/"?$.name+"#"+X.id:X.id;else this.id=_f.default.generateId();if(this.server._opts.connectionStateRecovery)this.pid=_f.default.generateId()}this.handshake=this.buildHandshake(Q),this.on("error",yf)}buildHandshake($){var X,Q,J,v;return{headers:((X=this.request)===null||X===void 0?void 0:X.headers)||{},time:new Date+"",address:this.conn.remoteAddress,xdomain:!!((Q=this.request)===null||Q===void 0?void 0:Q.headers.origin),secure:!this.request||!!this.request.connection.encrypted,issued:+new Date,url:(J=this.request)===null||J===void 0?void 0:J.url,query:((v=this.request)===null||v===void 0?void 0:v._query)||{},auth:$}}emit($,...X){if(e7$.RESERVED_EVENTS.has($))throw Error(`"${String($)}" is a reserved event name`);let Q=[$,...X],J={type:x1.PacketType.EVENT,data:Q};if(typeof Q[Q.length-1]==="function"){let Y=this.nsp._ids++;W6("emitting packet with ack id %d",Y),this.registerAckCallback(Y,Q.pop()),J.id=Y}let v=Object.assign({},this.flags);if(this.flags={},this.nsp.server.opts.connectionStateRecovery)this.adapter.broadcast(J,{rooms:new Set([this.id]),except:new Set,flags:v});else this.notifyOutgoingListeners(J),this.packet(J,v);return!0}emitWithAck($,...X){let Q=this.flags.timeout!==void 0;return new Promise((J,v)=>{X.push((Y,z)=>{if(Q)return Y?v(Y):J(z);else return J(Y)}),this.emit($,...X)})}registerAckCallback($,X){let Q=this.flags.timeout;if(Q===void 0){this.acks.set($,X);return}let J=setTimeout(()=>{W6("event with ack id %d has timed out after %d ms",$,Q),this.acks.delete($),X.call(this,Error("operation has timed out"))},Q);this.acks.set($,(...v)=>{clearTimeout(J),X.apply(this,[null,...v])})}to($){return this.newBroadcastOperator().to($)}in($){return this.newBroadcastOperator().in($)}except($){return this.newBroadcastOperator().except($)}send(...$){return this.emit("message",...$),this}write(...$){return this.emit("message",...$),this}packet($,X={}){$.nsp=this.nsp.name,X.compress=X.compress!==!1,this.client._packet($,X)}join($){return W6("join room %s",$),this.adapter.addAll(this.id,new Set(Array.isArray($)?$:[$]))}leave($){return W6("leave room %s",$),this.adapter.del(this.id,$)}leaveAll(){this.adapter.delAll(this.id)}_onconnect(){if(W6("socket connected - writing packet"),this.connected=!0,this.join(this.id),this.conn.protocol===3)this.packet({type:x1.PacketType.CONNECT});else this.packet({type:x1.PacketType.CONNECT,data:{sid:this.id,pid:this.pid}})}_onpacket($){switch(W6("got packet %j",$),$.type){case x1.PacketType.EVENT:this.onevent($);break;case x1.PacketType.BINARY_EVENT:this.onevent($);break;case x1.PacketType.ACK:this.onack($);break;case x1.PacketType.BINARY_ACK:this.onack($);break;case x1.PacketType.DISCONNECT:this.ondisconnect();break}}onevent($){let X=$.data||[];if(W6("emitting event %j",X),$.id!=null)W6("attaching ack callback to event"),X.push(this.ack($.id));if(this._anyListeners&&this._anyListeners.length){let Q=this._anyListeners.slice();for(let J of Q)J.apply(this,X)}this.dispatch(X)}ack($){let X=this,Q=!1;return function(){if(Q)return;let J=Array.prototype.slice.call(arguments);W6("sending ack %j",J),X.packet({id:$,type:x1.PacketType.ACK,data:J}),Q=!0}}onack($){let X=this.acks.get($.id);if(typeof X=="function")W6("calling ack %s with %j",$.id,$.data),X.apply(this,$.data),this.acks.delete($.id);else W6("bad ack %s",$.id)}ondisconnect(){W6("got disconnect packet"),this._onclose("client namespace disconnect")}_onerror($){this.emitReserved("error",$)}_onclose($,X){if(!this.connected)return this;if(W6("closing socket - reason %s",$),this.emitReserved("disconnecting",$,X),this.server._opts.connectionStateRecovery&&$5$.has($))W6("connection state recovery is enabled for sid %s",this.id),this.adapter.persistSession({sid:this.id,pid:this.pid,rooms:[...this.rooms],data:this.data});this._cleanup(),this.client._remove(this),this.connected=!1,this.emitReserved("disconnect",$,X);return}_cleanup(){this.leaveAll(),this.nsp._remove(this),this.join=yf}_error($){this.packet({type:x1.PacketType.CONNECT_ERROR,data:$})}disconnect($=!1){if(!this.connected)return this;if($)this.client._disconnect();else this.packet({type:x1.PacketType.DISCONNECT}),this._onclose("server namespace disconnect");return this}compress($){return this.flags.compress=$,this}get volatile(){return this.flags.volatile=!0,this}get broadcast(){return this.newBroadcastOperator()}get local(){return this.newBroadcastOperator().local}timeout($){return this.flags.timeout=$,this}dispatch($){W6("dispatching an event %j",$),this.run($,(X)=>{process.nextTick(()=>{if(X)return this._onerror(X);if(this.connected)super.emitUntyped.apply(this,$);else W6("ignore packet received after disconnection")})})}use($){return this.fns.push($),this}run($,X){if(!this.fns.length)return X();let Q=this.fns.slice(0);function J(v){Q[v]($,(Y)=>{if(Y)return X(Y);if(!Q[v+1])return X();J(v+1)})}J(0)}get disconnected(){return!this.connected}get request(){return this.client.request}get conn(){return this.client.conn}get rooms(){return this.adapter.socketRooms(this.id)||new Set}onAny($){return this._anyListeners=this._anyListeners||[],this._anyListeners.push($),this}prependAny($){return this._anyListeners=this._anyListeners||[],this._anyListeners.unshift($),this}offAny($){if(!this._anyListeners)return this;if($){let X=this._anyListeners;for(let Q=0;Q{var X5$=O8&&O8.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(O8,"__esModule",{value:!0});O8.Namespace=O8.RESERVED_EVENTS=void 0;var nf=_A(),Q5$=pH(),v5$=X5$(W4()),H1=uA(),hv=(0,v5$.default)("socket.io:namespace");O8.RESERVED_EVENTS=new Set(["connect","connection","new_namespace"]);class gf extends Q5$.StrictEventEmitter{constructor($,X){super();this.sockets=new Map,this._preConnectSockets=new Map,this._fns=[],this._ids=0,this.server=$,this.name=X,this._initAdapter()}_initAdapter(){this.adapter=new(this.server.adapter())(this),Promise.resolve(this.adapter.init()).catch(($)=>{hv("error while initializing adapter: %s",$)})}use($){return this._fns.push($),this}run($,X){if(!this._fns.length)return X();let Q=this._fns.slice(0);function J(v){Q[v]($,(Y)=>{if(Y)return X(Y);if(!Q[v+1])return X();J(v+1)})}J(0)}to($){return new H1.BroadcastOperator(this.adapter).to($)}in($){return new H1.BroadcastOperator(this.adapter).in($)}except($){return new H1.BroadcastOperator(this.adapter).except($)}async _add($,X,Q){var J;hv("adding socket to nsp %s",this.name);let v=await this._createSocket($,X);if(this._preConnectSockets.set(v.id,v),((J=this.server.opts.connectionStateRecovery)===null||J===void 0?void 0:J.skipMiddlewares)&&v.recovered&&$.conn.readyState==="open")return this._doConnect(v,Q);this.run(v,(Y)=>{process.nextTick(()=>{if($.conn.readyState!=="open"){hv("next called after client was closed - ignoring socket"),v._cleanup();return}if(Y)if(hv("middleware error, sending CONNECT_ERROR packet to the client"),v._cleanup(),$.conn.protocol===3)return v._error(Y.data||Y.message);else return v._error({message:Y.message,data:Y.data});this._doConnect(v,Q)})})}async _createSocket($,X){let{pid:Q,offset:J}=X;if(this.server.opts.connectionStateRecovery&&typeof Q==="string"&&typeof J==="string"){let v;try{v=await this.adapter.restoreSession(Q,J)}catch(Y){hv("error while restoring session: %s",Y)}if(v)return hv("connection state recovered for sid %s",v.sid),new nf.Socket(this,$,X,v)}return new nf.Socket(this,$,X)}_doConnect($,X){if(this._preConnectSockets.delete($.id),this.sockets.set($.id,$),$._onconnect(),X)X($);this.emitReserved("connect",$),this.emitReserved("connection",$)}_remove($){this.sockets.delete($.id)||this._preConnectSockets.delete($.id)}emit($,...X){return new H1.BroadcastOperator(this.adapter).emit($,...X)}send(...$){return this.emit("message",...$),this}write(...$){return this.emit("message",...$),this}serverSideEmit($,...X){if(O8.RESERVED_EVENTS.has($))throw Error(`"${String($)}" is a reserved event name`);return X.unshift($),this.adapter.serverSideEmit(X),!0}serverSideEmitWithAck($,...X){return new Promise((Q,J)=>{X.push((v,Y)=>{if(v)return v.responses=Y,J(v);else return Q(Y)}),this.serverSideEmit($,...X)})}_onServerSideEmit($){super.emitUntyped.apply(this,$)}allSockets(){return new H1.BroadcastOperator(this.adapter).allSockets()}compress($){return new H1.BroadcastOperator(this.adapter).compress($)}get volatile(){return new H1.BroadcastOperator(this.adapter).volatile}get local(){return new H1.BroadcastOperator(this.adapter).local}timeout($){return new H1.BroadcastOperator(this.adapter).timeout($)}fetchSockets(){return new H1.BroadcastOperator(this.adapter).fetchSockets()}socketsJoin($){return new H1.BroadcastOperator(this.adapter).socketsJoin($)}socketsLeave($){return new H1.BroadcastOperator(this.adapter).socketsLeave($)}disconnectSockets($=!1){return new H1.BroadcastOperator(this.adapter).disconnectSockets($)}}O8.Namespace=gf});var pf=N((B7)=>{Object.defineProperty(B7,"__esModule",{value:!0});B7.encode=fA;B7.decode=J5$;B7.yeast=Y5$;var hf="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""),oH=64,lf={},cf=0,H9=0,mf;function fA($){let X="";do X=hf[$%oH]+X,$=Math.floor($/oH);while($>0);return X}function J5$($){let X=0;for(H9=0;H9<$.length;H9++)X=X*oH+lf[$.charAt(H9)];return X}function Y5$(){let $=fA(+new Date);if($!==mf)return cf=0,mf=$;return $+"."+fA(cf++)}for(;H9{var kA;Object.defineProperty(lv,"__esModule",{value:!0});lv.SessionAwareAdapter=lv.Adapter=void 0;var z5$=require("events"),W5$=pf(),aH=qA(),G5$=typeof((kA=aH===null||aH===void 0?void 0:aH.Sender)===null||kA===void 0?void 0:kA.frame)==="function";class nA extends z5$.EventEmitter{constructor($){super();this.nsp=$,this.rooms=new Map,this.sids=new Map,this.encoder=$.server.encoder}init(){}close(){}serverCount(){return Promise.resolve(1)}addAll($,X){if(!this.sids.has($))this.sids.set($,new Set);for(let Q of X){if(this.sids.get($).add(Q),!this.rooms.has(Q))this.rooms.set(Q,new Set),this.emit("create-room",Q);if(!this.rooms.get(Q).has($))this.rooms.get(Q).add($),this.emit("join-room",Q,$)}}del($,X){if(this.sids.has($))this.sids.get($).delete(X);this._del(X,$)}_del($,X){let Q=this.rooms.get($);if(Q!=null){if(Q.delete(X))this.emit("leave-room",$,X);if(Q.size===0&&this.rooms.delete($))this.emit("delete-room",$)}}delAll($){if(!this.sids.has($))return;for(let X of this.sids.get($))this._del(X,$);this.sids.delete($)}broadcast($,X){let Q=X.flags||{},J={preEncoded:!0,volatile:Q.volatile,compress:Q.compress};$.nsp=this.nsp.name;let v=this._encode($,J);this.apply(X,(Y)=>{if(typeof Y.notifyOutgoingListeners==="function")Y.notifyOutgoingListeners($);Y.client.writeToEngine(v,J)})}broadcastWithAck($,X,Q,J){let v=X.flags||{},Y={preEncoded:!0,volatile:v.volatile,compress:v.compress};$.nsp=this.nsp.name,$.id=this.nsp._ids++;let z=this._encode($,Y),W=0;this.apply(X,(G)=>{if(W++,G.acks.set($.id,J),typeof G.notifyOutgoingListeners==="function")G.notifyOutgoingListeners($);G.client.writeToEngine(z,Y)}),Q(W)}_encode($,X){let Q=this.encoder.encode($);if(G5$&&Q.length===1&&typeof Q[0]==="string"){let J=Buffer.from("4"+Q[0]);X.wsPreEncodedFrame=aH.Sender.frame(J,{readOnly:!1,mask:!1,rsv1:!1,opcode:1,fin:!0})}return Q}sockets($){let X=new Set;return this.apply({rooms:$},(Q)=>{X.add(Q.id)}),Promise.resolve(X)}socketRooms($){return this.sids.get($)}fetchSockets($){let X=[];return this.apply($,(Q)=>{X.push(Q)}),Promise.resolve(X)}addSockets($,X){this.apply($,(Q)=>{Q.join(X)})}delSockets($,X){this.apply($,(Q)=>{X.forEach((J)=>Q.leave(J))})}disconnectSockets($,X){this.apply($,(Q)=>{Q.disconnect(X)})}apply($,X){let Q=$.rooms,J=this.computeExceptSids($.except);if(Q.size){let v=new Set;for(let Y of Q){if(!this.rooms.has(Y))continue;for(let z of this.rooms.get(Y)){if(v.has(z)||J.has(z))continue;let W=this.nsp.sockets.get(z);if(W)X(W),v.add(z)}}}else for(let[v]of this.sids){if(J.has(v))continue;let Y=this.nsp.sockets.get(v);if(Y)X(Y)}}computeExceptSids($){let X=new Set;if($&&$.size>0){for(let Q of $)if(this.rooms.has(Q))this.rooms.get(Q).forEach((J)=>X.add(J))}return X}serverSideEmit($){console.warn("this adapter does not support the serverSideEmit() functionality")}persistSession($){}restoreSession($,X){return null}}lv.Adapter=nA;class df extends nA{constructor($){super($);this.nsp=$,this.sessions=new Map,this.packets=[],this.maxDisconnectionDuration=$.server.opts.connectionStateRecovery.maxDisconnectionDuration,setInterval(()=>{let Q=Date.now()-this.maxDisconnectionDuration;this.sessions.forEach((J,v)=>{if(J.disconnectedAt=0;J--)if(this.packets[J].emittedAtz.id===X);if(v===-1)return null;let Y=[];for(let z=v+1;zX.rooms.has(v)),J=$.every((v)=>!X.except.has(v));return Q&&J}});var af=N((n0)=>{var K5$=n0&&n0.__rest||function($,X){var Q={};for(var J in $)if(Object.prototype.hasOwnProperty.call($,J)&&X.indexOf(J)<0)Q[J]=$[J];if($!=null&&typeof Object.getOwnPropertySymbols==="function"){for(var v=0,J=Object.getOwnPropertySymbols($);v{Y4("[%s] waiting for %d client acknowledgements",this.uid,J),this.publishResponse($.uid,{type:z$.BROADCAST_CLIENT_COUNT,data:{requestId:$.data.requestId,clientCount:J}})},(J)=>{Y4("[%s] received acknowledgement with value %j",this.uid,J),this.publishResponse($.uid,{type:z$.BROADCAST_ACK,data:{requestId:$.data.requestId,packet:J}})});else{let J=$.data.packet,v=pv($.data.opts);this.addOffsetIfNecessary(J,v,X),super.broadcast(J,v)}break}case z$.SOCKETS_JOIN:super.addSockets(pv($.data.opts),$.data.rooms);break;case z$.SOCKETS_LEAVE:super.delSockets(pv($.data.opts),$.data.rooms);break;case z$.DISCONNECT_SOCKETS:super.disconnectSockets(pv($.data.opts),$.data.close);break;case z$.FETCH_SOCKETS:{Y4("[%s] calling fetchSockets with opts %j",this.uid,$.data.opts),super.fetchSockets(pv($.data.opts)).then((Q)=>{this.publishResponse($.uid,{type:z$.FETCH_SOCKETS_RESPONSE,data:{requestId:$.data.requestId,sockets:Q.map((J)=>{let v=J.handshake,{sessionStore:Y}=v,z=K5$(v,["sessionStore"]);return{id:J.id,handshake:z,rooms:[...J.rooms],data:J.data}})}})});break}case z$.SERVER_SIDE_EMIT:{let Q=$.data.packet;if($.data.requestId===void 0){this.nsp._onServerSideEmit(Q);return}let v=!1,Y=(z)=>{if(v)return;v=!0,Y4("[%s] calling acknowledgement with %j",this.uid,z),this.publishResponse($.uid,{type:z$.SERVER_SIDE_EMIT_RESPONSE,data:{requestId:$.data.requestId,packet:z}})};this.nsp._onServerSideEmit([...Q,Y]);break}case z$.BROADCAST_CLIENT_COUNT:case z$.BROADCAST_ACK:case z$.FETCH_SOCKETS_RESPONSE:case z$.SERVER_SIDE_EMIT_RESPONSE:this.onResponse($);break;default:Y4("[%s] unknown message type: %s",this.uid,$.type)}}onResponse($){var X,Q;let J=$.data.requestId;switch(Y4("[%s] received response %s to request %s",this.uid,$.type,J),$.type){case z$.BROADCAST_CLIENT_COUNT:{(X=this.ackRequests.get(J))===null||X===void 0||X.clientCountCallback($.data.clientCount);break}case z$.BROADCAST_ACK:{(Q=this.ackRequests.get(J))===null||Q===void 0||Q.ack($.data.packet);break}case z$.FETCH_SOCKETS_RESPONSE:{let v=this.requests.get(J);if(!v)return;if(v.current++,$.data.sockets.forEach((Y)=>v.responses.push(Y)),v.current===v.expected)clearTimeout(v.timeout),v.resolve(v.responses),this.requests.delete(J);break}case z$.SERVER_SIDE_EMIT_RESPONSE:{let v=this.requests.get(J);if(!v)return;if(v.current++,v.responses.push($.data.packet),v.current===v.expected)clearTimeout(v.timeout),v.resolve(null,v.responses),this.requests.delete(J);break}default:Y4("[%s] unknown response type: %s",this.uid,$.type)}}async broadcast($,X){var Q;if(!((Q=X.flags)===null||Q===void 0?void 0:Q.local))try{let v=await this.publishAndReturnOffset({type:z$.BROADCAST,data:{packet:$,opts:ZX(X)}});this.addOffsetIfNecessary($,X,v)}catch(v){return Y4("[%s] error while broadcasting message: %s",this.uid,v.message)}super.broadcast($,X)}addOffsetIfNecessary($,X,Q){var J;if(!this.nsp.server.opts.connectionStateRecovery)return;let v=$.type===2,Y=$.id===void 0,z=((J=X.flags)===null||J===void 0?void 0:J.volatile)===void 0;if(v&&Y&&z)$.data.push(Q)}broadcastWithAck($,X,Q,J){var v;if(!((v=X===null||X===void 0?void 0:X.flags)===null||v===void 0?void 0:v.local)){let z=dv();this.ackRequests.set(z,{clientCountCallback:Q,ack:J}),this.publish({type:z$.BROADCAST,data:{packet:$,requestId:z,opts:ZX(X)}}),setTimeout(()=>{this.ackRequests.delete(z)},X.flags.timeout)}super.broadcastWithAck($,X,Q,J)}async addSockets($,X){var Q;if(!((Q=$.flags)===null||Q===void 0?void 0:Q.local))try{await this.publishAndReturnOffset({type:z$.SOCKETS_JOIN,data:{opts:ZX($),rooms:X}})}catch(v){Y4("[%s] error while publishing message: %s",this.uid,v.message)}super.addSockets($,X)}async delSockets($,X){var Q;if(!((Q=$.flags)===null||Q===void 0?void 0:Q.local))try{await this.publishAndReturnOffset({type:z$.SOCKETS_LEAVE,data:{opts:ZX($),rooms:X}})}catch(v){Y4("[%s] error while publishing message: %s",this.uid,v.message)}super.delSockets($,X)}async disconnectSockets($,X){var Q;if(!((Q=$.flags)===null||Q===void 0?void 0:Q.local))try{await this.publishAndReturnOffset({type:z$.DISCONNECT_SOCKETS,data:{opts:ZX($),close:X}})}catch(v){Y4("[%s] error while publishing message: %s",this.uid,v.message)}super.disconnectSockets($,X)}async fetchSockets($){var X;let[Q,J]=await Promise.all([super.fetchSockets($),this.serverCount()]),v=J-1;if(((X=$.flags)===null||X===void 0?void 0:X.local)||v<=0)return Q;let Y=dv();return new Promise((z,W)=>{let G=setTimeout(()=>{let w=this.requests.get(Y);if(w)W(Error(`timeout reached: only ${w.current} responses received out of ${w.expected}`)),this.requests.delete(Y)},$.flags.timeout||rH),U={type:z$.FETCH_SOCKETS,resolve:z,timeout:G,current:0,expected:v,responses:Q};this.requests.set(Y,U),this.publish({type:z$.FETCH_SOCKETS,data:{opts:ZX($),requestId:Y}})})}async serverSideEmit($){if(typeof $[$.length-1]!=="function")return this.publish({type:z$.SERVER_SIDE_EMIT,data:{packet:$}});let Q=$.pop(),J=await this.serverCount()-1;if(Y4('[%s] waiting for %d responses to "serverSideEmit" request',this.uid,J),J<=0)return Q(null,[]);let v=dv(),Y=setTimeout(()=>{let W=this.requests.get(v);if(W)Q(Error(`timeout reached: only ${W.current} responses received out of ${W.expected}`),W.responses),this.requests.delete(v)},rH),z={type:z$.SERVER_SIDE_EMIT,resolve:Q,timeout:Y,current:0,expected:J,responses:[]};this.requests.set(v,z),this.publish({type:z$.SERVER_SIDE_EMIT,data:{requestId:v,packet:$}})}publish($){Y4("[%s] sending message %s",this.uid,$.type),this.publishAndReturnOffset($).catch((X)=>{Y4("[%s] error while publishing message: %s",this.uid,X)})}publishAndReturnOffset($){return $.uid=this.uid,$.nsp=this.nsp.name,this.doPublish($)}publishResponse($,X){X.uid=this.uid,X.nsp=this.nsp.name,Y4("[%s] sending response %s to %s",this.uid,X.type,$),this.doPublishResponse($,X).catch((Q)=>{Y4("[%s] error while publishing response: %s",this.uid,Q)})}}n0.ClusterAdapter=cA;class of extends cA{constructor($,X){super($);this.nodesMap=new Map,this.customRequests=new Map,this._opts=Object.assign({heartbeatInterval:5000,heartbeatTimeout:1e4},X),this.cleanupTimer=setInterval(()=>{let Q=Date.now();this.nodesMap.forEach((J,v)=>{if(Q-J>this._opts.heartbeatTimeout)Y4("[%s] node %s seems down",this.uid,v),this.removeNode(v)})},1000)}init(){this.publish({type:z$.INITIAL_HEARTBEAT})}scheduleHeartbeat(){if(this.heartbeatTimer)this.heartbeatTimer.refresh();else this.heartbeatTimer=setTimeout(()=>{this.publish({type:z$.HEARTBEAT})},this._opts.heartbeatInterval)}close(){if(this.publish({type:z$.ADAPTER_CLOSE}),clearTimeout(this.heartbeatTimer),this.cleanupTimer)clearInterval(this.cleanupTimer)}onMessage($,X){if($.uid===this.uid)return Y4("[%s] ignore message from self",this.uid);if($.uid&&$.uid!==V5$)this.nodesMap.set($.uid,Date.now());switch($.type){case z$.INITIAL_HEARTBEAT:this.publish({type:z$.HEARTBEAT});break;case z$.HEARTBEAT:break;case z$.ADAPTER_CLOSE:this.removeNode($.uid);break;default:super.onMessage($,X)}}serverCount(){return Promise.resolve(1+this.nodesMap.size)}publish($){return this.scheduleHeartbeat(),super.publish($)}async serverSideEmit($){if(typeof $[$.length-1]!=="function")return this.publish({type:z$.SERVER_SIDE_EMIT,data:{packet:$}});let Q=$.pop(),J=this.nodesMap.size;if(Y4('[%s] waiting for %d responses to "serverSideEmit" request',this.uid,J),J<=0)return Q(null,[]);let v=dv(),Y=setTimeout(()=>{let W=this.customRequests.get(v);if(W)Q(Error(`timeout reached: missing ${W.missingUids.size} responses`),W.responses),this.customRequests.delete(v)},rH),z={type:z$.SERVER_SIDE_EMIT,resolve:Q,timeout:Y,missingUids:new Set([...this.nodesMap.keys()]),responses:[]};this.customRequests.set(v,z),this.publish({type:z$.SERVER_SIDE_EMIT,data:{requestId:v,packet:$}})}async fetchSockets($){var X;let[Q,J]=await Promise.all([super.fetchSockets({rooms:$.rooms,except:$.except,flags:{local:!0}}),this.serverCount()]),v=J-1;if(((X=$.flags)===null||X===void 0?void 0:X.local)||v<=0)return Q;let Y=dv();return new Promise((z,W)=>{let G=setTimeout(()=>{let w=this.customRequests.get(Y);if(w)W(Error(`timeout reached: missing ${w.missingUids.size} responses`)),this.customRequests.delete(Y)},$.flags.timeout||rH),U={type:z$.FETCH_SOCKETS,resolve:z,timeout:G,missingUids:new Set([...this.nodesMap.keys()]),responses:Q};this.customRequests.set(Y,U),this.publish({type:z$.FETCH_SOCKETS,data:{opts:ZX($),requestId:Y}})})}onResponse($){let X=$.data.requestId;switch(Y4("[%s] received response %s to request %s",this.uid,$.type,X),$.type){case z$.FETCH_SOCKETS_RESPONSE:{let Q=this.customRequests.get(X);if(!Q)return;if($.data.sockets.forEach((J)=>Q.responses.push(J)),Q.missingUids.delete($.uid),Q.missingUids.size===0)clearTimeout(Q.timeout),Q.resolve(Q.responses),this.customRequests.delete(X);break}case z$.SERVER_SIDE_EMIT_RESPONSE:{let Q=this.customRequests.get(X);if(!Q)return;if(Q.responses.push($.data.packet),Q.missingUids.delete($.uid),Q.missingUids.size===0)clearTimeout(Q.timeout),Q.resolve(null,Q.responses),this.customRequests.delete(X);break}default:super.onResponse($)}}removeNode($){this.customRequests.forEach((X,Q)=>{if(X.missingUids.delete($),X.missingUids.size===0){if(clearTimeout(X.timeout),X.type===z$.FETCH_SOCKETS)X.resolve(X.responses);else if(X.type===z$.SERVER_SIDE_EMIT)X.resolve(null,X.responses);this.customRequests.delete(Q)}}),this.nodesMap.delete($)}}n0.ClusterAdapterWithHeartbeat=of});var tH=N((b1)=>{Object.defineProperty(b1,"__esModule",{value:!0});b1.MessageType=b1.ClusterAdapterWithHeartbeat=b1.ClusterAdapter=b1.SessionAwareAdapter=b1.Adapter=void 0;var rf=gA();Object.defineProperty(b1,"Adapter",{enumerable:!0,get:function(){return rf.Adapter}});Object.defineProperty(b1,"SessionAwareAdapter",{enumerable:!0,get:function(){return rf.SessionAwareAdapter}});var mA=af();Object.defineProperty(b1,"ClusterAdapter",{enumerable:!0,get:function(){return mA.ClusterAdapter}});Object.defineProperty(b1,"ClusterAdapterWithHeartbeat",{enumerable:!0,get:function(){return mA.ClusterAdapterWithHeartbeat}});Object.defineProperty(b1,"MessageType",{enumerable:!0,get:function(){return mA.MessageType}})});var $k=N((ov)=>{var B5$=ov&&ov.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(ov,"__esModule",{value:!0});ov.ParentNamespace=void 0;var tf=yA(),D5$=tH(),F5$=B5$(W4()),sf=(0,F5$.default)("socket.io:parent-namespace");class sH extends tf.Namespace{constructor($){super($,"/_"+sH.count++);this.children=new Set}_initAdapter(){this.adapter=new ef(this)}emit($,...X){return this.children.forEach((Q)=>{Q.emit($,...X)}),!0}createChild($){sf("creating child namespace %s",$);let X=new tf.Namespace(this.server,$);if(this._fns.forEach((Q)=>X.use(Q)),this.listeners("connect").forEach((Q)=>X.on("connect",Q)),this.listeners("connection").forEach((Q)=>X.on("connection",Q)),this.children.add(X),this.server._opts.cleanupEmptyChildNamespaces){let Q=X._remove;X._remove=(J)=>{if(Q.call(X,J),X.sockets.size===0)sf("closing child namespace %s",$),X.adapter.close(),this.server._nsps.delete(X.name),this.children.delete(X)}}return this.server._nsps.set($,X),this.server.sockets.emitReserved("new_namespace",X),X}fetchSockets(){throw Error("fetchSockets() is not supported on parent namespaces")}}ov.ParentNamespace=sH;sH.count=0;class ef extends D5$.Adapter{broadcast($,X){this.nsp.children.forEach((Q)=>{Q.adapter.broadcast($,X)})}}});var zk=N((IX)=>{var N5$=IX&&IX.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(IX,"__esModule",{value:!0});IX.patchAdapter=L5$;IX.restoreAdapter=A5$;IX.serveFile=q5$;var qX=tH(),Xk=require("fs"),O5$=N5$(W4()),eH=(0,O5$.default)("socket.io:adapter-uws"),hA="\x1F",{addAll:vk,del:Jk,broadcast:Yk}=qX.Adapter.prototype;function L5$($){qX.Adapter.prototype.addAll=function(X,Q){let J=!this.sids.has(X);vk.call(this,X,Q);let v=this.nsp.sockets.get(X)||this.nsp._preConnectSockets.get(X);if(!v)return;if(v.conn.transport.name==="websocket"){Qk(this.nsp.name,v,J,Q);return}if(J)v.conn.on("upgrade",()=>{let Y=this.sids.get(X);if(Y)Qk(this.nsp.name,v,J,Y)})},qX.Adapter.prototype.del=function(X,Q){Jk.call(this,X,Q);let J=this.nsp.sockets.get(X)||this.nsp._preConnectSockets.get(X);if(J&&J.conn.transport.name==="websocket"){let v=J.conn.id,Y=J.conn.transport.socket,z=`${this.nsp.name}${hA}${Q}`;eH("unsubscribe connection %s from topic %s",v,z),Y.unsubscribe(z)}},qX.Adapter.prototype.broadcast=function(X,Q){if(!(Q.rooms.size<=1&&Q.except.size===0)){Yk.call(this,X,Q);return}let v=Q.flags||{},Y={preEncoded:!0,volatile:v.volatile,compress:v.compress};X.nsp=this.nsp.name;let z=this.encoder.encode(X),W=Q.rooms.size===0?this.nsp.name:`${this.nsp.name}${hA}${Q.rooms.keys().next().value}`;eH("fast publish to %s",W),z.forEach((G)=>{let U=typeof G!=="string";$.publish(W,U?G:"4"+G,U)}),this.apply(Q,(G)=>{if(G.conn.transport.name!=="websocket")G.client.writeToEngine(z,Y)})}}function Qk($,X,Q,J){let v=X.conn.id,Y=X.conn.transport.socket;if(Q)eH("subscribe connection %s to topic %s",v,$),Y.subscribe($);J.forEach((z)=>{let W=`${$}${hA}${z}`;eH("subscribe connection %s to topic %s",v,W),Y.subscribe(W)})}function A5$(){qX.Adapter.prototype.addAll=vk,qX.Adapter.prototype.del=Jk,qX.Adapter.prototype.broadcast=Yk}var Z5$=($)=>{let{buffer:X,byteOffset:Q,byteLength:J}=$;return X.slice(Q,Q+J)};function q5$($,X){let{size:Q}=(0,Xk.statSync)(X),J=(0,Xk.createReadStream)(X),v=()=>!J.destroyed&&J.destroy(),Y=(W)=>{throw v(),W},z=(W)=>{let G=Z5$(W);$.cork(()=>{let U=$.getWriteOffset(),[w,H]=$.tryEnd(G,Q);if(!H&&!w)J.pause(),$.onWritable((j)=>{let[V,B]=$.tryEnd(G.slice(j-U),Q);if(!B&&V)J.resume();return V})})};$.onAborted(v),J.on("data",z).on("error",Y).on("end",v)}});var Wk=N((PP$,I5$)=>{I5$.exports={name:"socket.io",version:"4.8.3",description:"node.js realtime framework server",keywords:["realtime","framework","websocket","tcp","events","socket","io"],files:["dist/","client-dist/","wrapper.mjs","!**/*.tsbuildinfo"],directories:{doc:"docs/",example:"example/",lib:"lib/",test:"test/"},type:"commonjs",main:"./dist/index.js",exports:{".":{types:"./dist/index.d.ts",import:"./wrapper.mjs",require:"./dist/index.js"},"./package.json":"./package.json"},types:"./dist/index.d.ts",license:"MIT",homepage:"https://github.com/socketio/socket.io/tree/main/packages/socket.io#readme",repository:{type:"git",url:"git+https://github.com/socketio/socket.io.git"},bugs:{url:"https://github.com/socketio/socket.io/issues"},scripts:{compile:"rimraf ./dist && tsc",test:"npm run format:check && npm run compile && npm run test:types && npm run test:unit","test:types":"tsd","test:unit":"nyc mocha --import=tsx --reporter spec --slow 200 --bail --timeout 10000 test/index.ts","format:check":'prettier --check "lib/**/*.ts" "test/**/*.ts"',"format:fix":'prettier --write "lib/**/*.ts" "test/**/*.ts"',prepack:"npm run compile"},dependencies:{accepts:"~1.3.4",base64id:"~2.0.0",cors:"~2.8.5",debug:"~4.4.1","engine.io":"~6.6.0","socket.io-adapter":"~2.5.2","socket.io-parser":"~4.2.4"},contributors:[{name:"Guillermo Rauch",email:"rauchg@gmail.com"},{name:"Arnout Kazemier",email:"info@3rd-eden.com"},{name:"Vladimir Dronnikov",email:"dronnikov@gmail.com"},{name:"Einar Otto Stangvik",email:"einaros@gmail.com"}],engines:{node:">=10.2.0"},tsd:{directory:"test"}}});var Bk=N((G6,D7)=>{var __dirname="C:\\Users\\lawrence\\workspace\\smm_github\\node_modules\\.pnpm\\socket.io@4.8.3\\node_modules\\socket.io\\dist",M5$=G6&&G6.__createBinding||(Object.create?function($,X,Q,J){if(J===void 0)J=Q;var v=Object.getOwnPropertyDescriptor(X,Q);if(!v||("get"in v?!X.__esModule:v.writable||v.configurable))v={enumerable:!0,get:function(){return X[Q]}};Object.defineProperty($,J,v)}:function($,X,Q,J){if(J===void 0)J=Q;$[J]=X[Q]}),x5$=G6&&G6.__setModuleDefault||(Object.create?function($,X){Object.defineProperty($,"default",{enumerable:!0,value:X})}:function($,X){$.default=X}),b5$=G6&&G6.__importStar||function($){if($&&$.__esModule)return $;var X={};if($!=null){for(var Q in $)if(Q!=="default"&&Object.prototype.hasOwnProperty.call($,Q))M5$(X,$,Q)}return x5$(X,$),X},dA=G6&&G6.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(G6,"__esModule",{value:!0});G6.Namespace=G6.Socket=G6.Server=void 0;var i5$=dA(require("http")),R5$=require("fs"),lA=require("zlib"),E5$=KH(),$j=require("stream"),Gk=require("path"),Uk=Af(),P5$=Cf(),Kk=require("events"),oA=yA();Object.defineProperty(G6,"Namespace",{enumerable:!0,get:function(){return oA.Namespace}});var T5$=$k(),wk=tH(),S5$=b5$(V7()),C5$=dA(W4()),Vk=_A();Object.defineProperty(G6,"Socket",{enumerable:!0,get:function(){return Vk.Socket}});var u5$=pH(),pA=zk(),_5$=dA(MA()),b6=(0,C5$.default)("socket.io:server"),Hk=Wk().version,jk=/\.map/;class av extends u5$.StrictEventEmitter{constructor($,X={}){super();if(this._nsps=new Map,this.parentNsps=new Map,this.parentNamespacesFromRegExp=new Map,typeof $==="object"&&$ instanceof Object&&!$.listen)X=$,$=void 0;if(this.path(X.path||"/socket.io"),this.connectTimeout(X.connectTimeout||45000),this.serveClient(X.serveClient!==!1),this._parser=X.parser||S5$,this.encoder=new this._parser.Encoder,this.opts=X,X.connectionStateRecovery)X.connectionStateRecovery=Object.assign({maxDisconnectionDuration:120000,skipMiddlewares:!0},X.connectionStateRecovery),this.adapter(X.adapter||wk.SessionAwareAdapter);else this.adapter(X.adapter||wk.Adapter);if(X.cleanupEmptyChildNamespaces=!!X.cleanupEmptyChildNamespaces,this.sockets=this.of("/"),$||typeof $=="number")this.attach($);if(this.opts.cors)this._corsMiddleware=(0,_5$.default)(this.opts.cors)}get _opts(){return this.opts}serveClient($){if(!arguments.length)return this._serveClient;return this._serveClient=$,this}_checkNamespace($,X,Q){if(this.parentNsps.size===0)return Q(!1);let J=this.parentNsps.keys(),v=()=>{let Y=J.next();if(Y.done)return Q(!1);Y.value($,X,(z,W)=>{if(z||!W)return v();if(this._nsps.has($))return b6("dynamic namespace %s already exists",$),Q(this._nsps.get($));let G=this.parentNsps.get(Y.value).createChild($);b6("dynamic namespace %s was created",$),Q(G)})};v()}path($){if(!arguments.length)return this._path;this._path=$.replace(/\/$/,"");let X=this._path.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&");return this.clientPathRegex=new RegExp("^"+X+"/socket\\.io(\\.msgpack|\\.esm)?(\\.min)?\\.js(\\.map)?(?:\\?|$)"),this}connectTimeout($){if($===void 0)return this._connectTimeout;return this._connectTimeout=$,this}adapter($){if(!arguments.length)return this._adapter;this._adapter=$;for(let X of this._nsps.values())X._initAdapter();return this}listen($,X={}){return this.attach($,X)}attach($,X={}){if(typeof $=="function")throw Error("You are trying to attach socket.io to an express request handler function. Please pass a http.Server instance.");if(Number($)==$)$=Number($);if(typeof $=="number"){b6("creating http server and binding to %d",$);let Q=$;$=i5$.default.createServer((J,v)=>{v.writeHead(404),v.end()}),$.listen(Q)}return Object.assign(X,this.opts),X.path=X.path||this._path,this.initEngine($,X),this}attachApp($,X={}){Object.assign(X,this.opts),X.path=X.path||this._path,b6("creating uWebSockets.js-based engine with opts %j",X);let Q=new Uk.uServer(X);if(Q.attach($,X),this.bind(Q),this._serveClient)$.get(`${this._path}/*`,(J,v)=>{if(!this.clientPathRegex.test(v.getUrl())){v.setYield(!0);return}let Y=v.getUrl().replace(this._path,"").replace(/\?.*$/,"").replace(/^\//,""),z=jk.test(Y),W=z?"map":"source",G='"'+Hk+'"',U="W/"+G,w=v.getHeader("if-none-match");if(w){if(G===w||U===w){b6("serve client %s 304",W),J.writeStatus("304 Not Modified"),J.end();return}}b6("serve client %s",W),J.writeHeader("cache-control","public, max-age=0"),J.writeHeader("content-type","application/"+(z?"json":"javascript")+"; charset=utf-8"),J.writeHeader("etag",G);let H=Gk.join(__dirname,"../client-dist/",Y);(0,pA.serveFile)(J,H)});(0,pA.patchAdapter)($)}initEngine($,X){if(b6("creating engine.io instance with opts %j",X),this.eio=(0,Uk.attach)($,X),this._serveClient)this.attachServe($);this.httpServer=$,this.bind(this.eio)}attachServe($){b6("attaching client serving req handler");let X=$.listeners("request").slice(0);$.removeAllListeners("request"),$.on("request",(Q,J)=>{if(this.clientPathRegex.test(Q.url))if(this._corsMiddleware)this._corsMiddleware(Q,J,()=>{this.serve(Q,J)});else this.serve(Q,J);else for(let v=0;v{if(z)Q.end()};switch(v){case"br":Q.writeHead(200,{"content-encoding":"br"}),(0,$j.pipeline)(J,(0,lA.createBrotliCompress)(),Q,Y);break;case"gzip":Q.writeHead(200,{"content-encoding":"gzip"}),(0,$j.pipeline)(J,(0,lA.createGzip)(),Q,Y);break;case"deflate":Q.writeHead(200,{"content-encoding":"deflate"}),(0,$j.pipeline)(J,(0,lA.createDeflate)(),Q,Y);break;default:Q.writeHead(200),(0,$j.pipeline)(J,Q,Y)}}bind($){return this.engine=$,this.engine.on("connection",this.onconnection.bind(this)),this}onconnection($){b6("incoming connection with id %s",$.id);let X=new P5$.Client(this,$);if($.protocol===3)X.connect("/");return this}of($,X){if(typeof $==="function"||$ instanceof RegExp){let J=new T5$.ParentNamespace(this);if(b6("initializing parent namespace %s",J.name),typeof $==="function")this.parentNsps.set($,J);else this.parentNsps.set((v,Y,z)=>z(null,$.test(v)),J),this.parentNamespacesFromRegExp.set($,J);if(X)J.on("connect",X);return J}if(String($)[0]!=="/")$="/"+$;let Q=this._nsps.get($);if(!Q){for(let[J,v]of this.parentNamespacesFromRegExp)if(J.test($))return b6("attaching namespace %s to parent namespace %s",$,J),v.createChild($);if(b6("initializing namespace %s",$),Q=new oA.Namespace(this,$),this._nsps.set($,Q),$!=="/")this.sockets.emitReserved("new_namespace",Q)}if(X)Q.on("connect",X);return Q}async close($){if(await Promise.allSettled([...this._nsps.values()].map(async(X)=>{X.sockets.forEach((Q)=>{Q._onclose("server shutting down")}),await X.adapter.close()})),this.engine.close(),(0,pA.restoreAdapter)(),this.httpServer)return new Promise((X)=>{this.httpServer.close((Q)=>{if($&&$(Q),Q)b6("server was not running");X()})});else $&&$()}use($){return this.sockets.use($),this}to($){return this.sockets.to($)}in($){return this.sockets.in($)}except($){return this.sockets.except($)}send(...$){return this.sockets.emit("message",...$),this}write(...$){return this.sockets.emit("message",...$),this}serverSideEmit($,...X){return this.sockets.serverSideEmit($,...X)}serverSideEmitWithAck($,...X){return this.sockets.serverSideEmitWithAck($,...X)}allSockets(){return this.sockets.allSockets()}compress($){return this.sockets.compress($)}get volatile(){return this.sockets.volatile}get local(){return this.sockets.local}timeout($){return this.sockets.timeout($)}fetchSockets(){return this.sockets.fetchSockets()}socketsJoin($){return this.sockets.socketsJoin($)}socketsLeave($){return this.sockets.socketsLeave($)}disconnectSockets($=!1){return this.sockets.disconnectSockets($)}}G6.Server=av;var y5$=Object.keys(Kk.EventEmitter.prototype).filter(function($){return typeof Kk.EventEmitter.prototype[$]==="function"});y5$.forEach(function($){av.prototype[$]=function(){return this.sockets[$].apply(this.sockets,arguments)}});D7.exports=($,X)=>new av($,X);D7.exports.Server=av;D7.exports.Namespace=oA.Namespace;D7.exports.Socket=Vk.Socket});var R7=N((E$)=>{Object.defineProperty(E$,"__esModule",{value:!0});E$.regexpCode=E$.getEsmExportName=E$.getProperty=E$.safeStringify=E$.stringify=E$.strConcat=E$.addCodeArg=E$.str=E$._=E$.nil=E$._Code=E$.Name=E$.IDENTIFIER=E$._CodeOrName=void 0;class Ej{}E$._CodeOrName=Ej;E$.IDENTIFIER=/^[a-z$_][a-z$_0-9]*$/i;class JJ extends Ej{constructor($){super();if(!E$.IDENTIFIER.test($))throw Error("CodeGen: name must be a valid identifier");this.str=$}toString(){return this.str}emptyStr(){return!1}get names(){return{[this.str]:1}}}E$.Name=JJ;class z0 extends Ej{constructor($){super();this._items=typeof $==="string"?[$]:$}toString(){return this.str}emptyStr(){if(this._items.length>1)return!1;let $=this._items[0];return $===""||$==='""'}get str(){var $;return($=this._str)!==null&&$!==void 0?$:this._str=this._items.reduce((X,Q)=>`${X}${Q}`,"")}get names(){var $;return($=this._names)!==null&&$!==void 0?$:this._names=this._items.reduce((X,Q)=>{if(Q instanceof JJ)X[Q.str]=(X[Q.str]||0)+1;return X},{})}}E$._Code=z0;E$.nil=new z0("");function An($,...X){let Q=[$[0]],J=0;while(J{Object.defineProperty(p6,"__esModule",{value:!0});p6.ValueScope=p6.ValueScopeName=p6.Scope=p6.varKinds=p6.UsedValueState=void 0;var l6=R7();class qn extends Error{constructor($){super(`CodeGen: "code" for ${$} not defined`);this.value=$.value}}var Pj;(function($){$[$.Started=0]="Started",$[$.Completed=1]="Completed"})(Pj||(p6.UsedValueState=Pj={}));p6.varKinds={const:new l6.Name("const"),let:new l6.Name("let"),var:new l6.Name("var")};class qZ{constructor({prefixes:$,parent:X}={}){this._names={},this._prefixes=$,this._parent=X}toName($){return $ instanceof l6.Name?$:this.name($)}name($){return new l6.Name(this._newName($))}_newName($){let X=this._names[$]||this._nameGroup($);return`${$}${X.index++}`}_nameGroup($){var X,Q;if(((Q=(X=this._parent)===null||X===void 0?void 0:X._prefixes)===null||Q===void 0?void 0:Q.has($))||this._prefixes&&!this._prefixes.has($))throw Error(`CodeGen: prefix "${$}" is not allowed in this scope`);return this._names[$]={prefix:$,index:0}}}p6.Scope=qZ;class IZ extends l6.Name{constructor($,X){super(X);this.prefix=$}setValue($,{property:X,itemIndex:Q}){this.value=$,this.scopePath=l6._`.${new l6.Name(X)}[${Q}]`}}p6.ValueScopeName=IZ;var kG$=l6._`\n`;class In extends qZ{constructor($){super($);this._values={},this._scope=$.scope,this.opts={...$,_n:$.lines?kG$:l6.nil}}get(){return this._scope}name($){return new IZ($,this._newName($))}value($,X){var Q;if(X.ref===void 0)throw Error("CodeGen: ref must be passed in value");let J=this.toName($),{prefix:v}=J,Y=(Q=X.key)!==null&&Q!==void 0?Q:X.ref,z=this._values[v];if(z){let U=z.get(Y);if(U)return U}else z=this._values[v]=new Map;z.set(Y,J);let W=this._scope[v]||(this._scope[v]=[]),G=W.length;return W[G]=X.ref,J.setValue(X,{property:v,itemIndex:G}),J}getValue($,X){let Q=this._values[$];if(!Q)return;return Q.get(X)}scopeRefs($,X=this._values){return this._reduceValues(X,(Q)=>{if(Q.scopePath===void 0)throw Error(`CodeGen: name "${Q}" has no value`);return l6._`${$}${Q.scopePath}`})}scopeCode($=this._values,X,Q){return this._reduceValues($,(J)=>{if(J.value===void 0)throw Error(`CodeGen: name "${J}" has no value`);return J.value.code},X,Q)}_reduceValues($,X,Q={},J){let v=l6.nil;for(let Y in $){let z=$[Y];if(!z)continue;let W=Q[Y]=Q[Y]||new Map;z.forEach((G)=>{if(W.has(G))return;W.set(G,Pj.Started);let U=X(G);if(U){let w=this.opts.es5?p6.varKinds.var:p6.varKinds.const;v=l6._`${v}${w} ${G} = ${U};${this.opts._n}`}else if(U=J===null||J===void 0?void 0:J(G))v=l6._`${v}${U}${this.opts._n}`;else throw new qn(G);W.set(G,Pj.Completed)})}return v}}p6.ValueScope=In});var D$=N((L$)=>{Object.defineProperty(L$,"__esModule",{value:!0});L$.or=L$.and=L$.not=L$.CodeGen=L$.operators=L$.varKinds=L$.ValueScopeName=L$.ValueScope=L$.Scope=L$.Name=L$.regexpCode=L$.stringify=L$.getProperty=L$.nil=L$.strConcat=L$.str=L$._=void 0;var M$=R7(),W0=MZ(),N9=R7();Object.defineProperty(L$,"_",{enumerable:!0,get:function(){return N9._}});Object.defineProperty(L$,"str",{enumerable:!0,get:function(){return N9.str}});Object.defineProperty(L$,"strConcat",{enumerable:!0,get:function(){return N9.strConcat}});Object.defineProperty(L$,"nil",{enumerable:!0,get:function(){return N9.nil}});Object.defineProperty(L$,"getProperty",{enumerable:!0,get:function(){return N9.getProperty}});Object.defineProperty(L$,"stringify",{enumerable:!0,get:function(){return N9.stringify}});Object.defineProperty(L$,"regexpCode",{enumerable:!0,get:function(){return N9.regexpCode}});Object.defineProperty(L$,"Name",{enumerable:!0,get:function(){return N9.Name}});var yj=MZ();Object.defineProperty(L$,"Scope",{enumerable:!0,get:function(){return yj.Scope}});Object.defineProperty(L$,"ValueScope",{enumerable:!0,get:function(){return yj.ValueScope}});Object.defineProperty(L$,"ValueScopeName",{enumerable:!0,get:function(){return yj.ValueScopeName}});Object.defineProperty(L$,"varKinds",{enumerable:!0,get:function(){return yj.varKinds}});L$.operators={GT:new M$._Code(">"),GTE:new M$._Code(">="),LT:new M$._Code("<"),LTE:new M$._Code("<="),EQ:new M$._Code("==="),NEQ:new M$._Code("!=="),NOT:new M$._Code("!"),OR:new M$._Code("||"),AND:new M$._Code("&&"),ADD:new M$._Code("+")};class O9{optimizeNodes(){return this}optimizeNames($,X){return this}}class Mn extends O9{constructor($,X,Q){super();this.varKind=$,this.name=X,this.rhs=Q}render({es5:$,_n:X}){let Q=$?W0.varKinds.var:this.varKind,J=this.rhs===void 0?"":` = ${this.rhs}`;return`${Q} ${this.name}${J};`+X}optimizeNames($,X){if(!$[this.name.str])return;if(this.rhs)this.rhs=zJ(this.rhs,$,X);return this}get names(){return this.rhs instanceof M$._CodeOrName?this.rhs.names:{}}}class iZ extends O9{constructor($,X,Q){super();this.lhs=$,this.rhs=X,this.sideEffects=Q}render({_n:$}){return`${this.lhs} = ${this.rhs};`+$}optimizeNames($,X){if(this.lhs instanceof M$.Name&&!$[this.lhs.str]&&!this.sideEffects)return;return this.rhs=zJ(this.rhs,$,X),this}get names(){let $=this.lhs instanceof M$.Name?{}:{...this.lhs.names};return _j($,this.rhs)}}class xn extends iZ{constructor($,X,Q,J){super($,Q,J);this.op=X}render({_n:$}){return`${this.lhs} ${this.op}= ${this.rhs};`+$}}class bn extends O9{constructor($){super();this.label=$,this.names={}}render({_n:$}){return`${this.label}:`+$}}class Rn extends O9{constructor($){super();this.label=$,this.names={}}render({_n:$}){return`break${this.label?` ${this.label}`:""};`+$}}class En extends O9{constructor($){super();this.error=$}render({_n:$}){return`throw ${this.error};`+$}get names(){return this.error.names}}class Pn extends O9{constructor($){super();this.code=$}render({_n:$}){return`${this.code};`+$}optimizeNodes(){return`${this.code}`?this:void 0}optimizeNames($,X){return this.code=zJ(this.code,$,X),this}get names(){return this.code instanceof M$._CodeOrName?this.code.names:{}}}class fj extends O9{constructor($=[]){super();this.nodes=$}render($){return this.nodes.reduce((X,Q)=>X+Q.render($),"")}optimizeNodes(){let{nodes:$}=this,X=$.length;while(X--){let Q=$[X].optimizeNodes();if(Array.isArray(Q))$.splice(X,1,...Q);else if(Q)$[X]=Q;else $.splice(X,1)}return $.length>0?this:void 0}optimizeNames($,X){let{nodes:Q}=this,J=Q.length;while(J--){let v=Q[J];if(v.optimizeNames($,X))continue;nG$($,v.names),Q.splice(J,1)}return Q.length>0?this:void 0}get names(){return this.nodes.reduce(($,X)=>xX($,X.names),{})}}class L9 extends fj{render($){return"{"+$._n+super.render($)+"}"+$._n}}class Tn extends fj{}class E7 extends L9{}E7.kind="else";class A8 extends L9{constructor($,X){super(X);this.condition=$}render($){let X=`if(${this.condition})`+super.render($);if(this.else)X+="else "+this.else.render($);return X}optimizeNodes(){super.optimizeNodes();let $=this.condition;if($===!0)return this.nodes;let X=this.else;if(X){let Q=X.optimizeNodes();X=this.else=Array.isArray(Q)?new E7(Q):Q}if(X){if($===!1)return X instanceof A8?X:X.nodes;if(this.nodes.length)return this;return new A8(yn($),X instanceof A8?[X]:X.nodes)}if($===!1||!this.nodes.length)return;return this}optimizeNames($,X){var Q;if(this.else=(Q=this.else)===null||Q===void 0?void 0:Q.optimizeNames($,X),!(super.optimizeNames($,X)||this.else))return;return this.condition=zJ(this.condition,$,X),this}get names(){let $=super.names;if(_j($,this.condition),this.else)xX($,this.else.names);return $}}A8.kind="if";class YJ extends L9{}YJ.kind="for";class Sn extends YJ{constructor($){super();this.iteration=$}render($){return`for(${this.iteration})`+super.render($)}optimizeNames($,X){if(!super.optimizeNames($,X))return;return this.iteration=zJ(this.iteration,$,X),this}get names(){return xX(super.names,this.iteration.names)}}class Cn extends YJ{constructor($,X,Q,J){super();this.varKind=$,this.name=X,this.from=Q,this.to=J}render($){let X=$.es5?W0.varKinds.var:this.varKind,{name:Q,from:J,to:v}=this;return`for(${X} ${Q}=${J}; ${Q}<${v}; ${Q}++)`+super.render($)}get names(){let $=_j(super.names,this.from);return _j($,this.to)}}class xZ extends YJ{constructor($,X,Q,J){super();this.loop=$,this.varKind=X,this.name=Q,this.iterable=J}render($){return`for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})`+super.render($)}optimizeNames($,X){if(!super.optimizeNames($,X))return;return this.iterable=zJ(this.iterable,$,X),this}get names(){return xX(super.names,this.iterable.names)}}class Tj extends L9{constructor($,X,Q){super();this.name=$,this.args=X,this.async=Q}render($){return`${this.async?"async ":""}function ${this.name}(${this.args})`+super.render($)}}Tj.kind="func";class Sj extends fj{render($){return"return "+super.render($)}}Sj.kind="return";class un extends L9{render($){let X="try"+super.render($);if(this.catch)X+=this.catch.render($);if(this.finally)X+=this.finally.render($);return X}optimizeNodes(){var $,X;return super.optimizeNodes(),($=this.catch)===null||$===void 0||$.optimizeNodes(),(X=this.finally)===null||X===void 0||X.optimizeNodes(),this}optimizeNames($,X){var Q,J;return super.optimizeNames($,X),(Q=this.catch)===null||Q===void 0||Q.optimizeNames($,X),(J=this.finally)===null||J===void 0||J.optimizeNames($,X),this}get names(){let $=super.names;if(this.catch)xX($,this.catch.names);if(this.finally)xX($,this.finally.names);return $}}class Cj extends L9{constructor($){super();this.error=$}render($){return`catch(${this.error})`+super.render($)}}Cj.kind="catch";class uj extends L9{render($){return"finally"+super.render($)}}uj.kind="finally";class _n{constructor($,X={}){this._values={},this._blockStarts=[],this._constants={},this.opts={...X,_n:X.lines?` +`:""},this._extScope=$,this._scope=new W0.Scope({parent:$}),this._nodes=[new Tn]}toString(){return this._root.render(this.opts)}name($){return this._scope.name($)}scopeName($){return this._extScope.name($)}scopeValue($,X){let Q=this._extScope.value($,X);return(this._values[Q.prefix]||(this._values[Q.prefix]=new Set)).add(Q),Q}getScopeValue($,X){return this._extScope.getValue($,X)}scopeRefs($){return this._extScope.scopeRefs($,this._values)}scopeCode(){return this._extScope.scopeCode(this._values)}_def($,X,Q,J){let v=this._scope.toName(X);if(Q!==void 0&&J)this._constants[v.str]=Q;return this._leafNode(new Mn($,v,Q)),v}const($,X,Q){return this._def(W0.varKinds.const,$,X,Q)}let($,X,Q){return this._def(W0.varKinds.let,$,X,Q)}var($,X,Q){return this._def(W0.varKinds.var,$,X,Q)}assign($,X,Q){return this._leafNode(new iZ($,X,Q))}add($,X){return this._leafNode(new xn($,L$.operators.ADD,X))}code($){if(typeof $=="function")$();else if($!==M$.nil)this._leafNode(new Pn($));return this}object(...$){let X=["{"];for(let[Q,J]of $){if(X.length>1)X.push(",");if(X.push(Q),Q!==J||this.opts.es5)X.push(":"),(0,M$.addCodeArg)(X,J)}return X.push("}"),new M$._Code(X)}if($,X,Q){if(this._blockNode(new A8($)),X&&Q)this.code(X).else().code(Q).endIf();else if(X)this.code(X).endIf();else if(Q)throw Error('CodeGen: "else" body without "then" body');return this}elseIf($){return this._elseNode(new A8($))}else(){return this._elseNode(new E7)}endIf(){return this._endBlockNode(A8,E7)}_for($,X){if(this._blockNode($),X)this.code(X).endFor();return this}for($,X){return this._for(new Sn($),X)}forRange($,X,Q,J,v=this.opts.es5?W0.varKinds.var:W0.varKinds.let){let Y=this._scope.toName($);return this._for(new Cn(v,Y,X,Q),()=>J(Y))}forOf($,X,Q,J=W0.varKinds.const){let v=this._scope.toName($);if(this.opts.es5){let Y=X instanceof M$.Name?X:this.var("_arr",X);return this.forRange("_i",0,M$._`${Y}.length`,(z)=>{this.var(v,M$._`${Y}[${z}]`),Q(v)})}return this._for(new xZ("of",J,v,X),()=>Q(v))}forIn($,X,Q,J=this.opts.es5?W0.varKinds.var:W0.varKinds.const){if(this.opts.ownProperties)return this.forOf($,M$._`Object.keys(${X})`,Q);let v=this._scope.toName($);return this._for(new xZ("in",J,v,X),()=>Q(v))}endFor(){return this._endBlockNode(YJ)}label($){return this._leafNode(new bn($))}break($){return this._leafNode(new Rn($))}return($){let X=new Sj;if(this._blockNode(X),this.code($),X.nodes.length!==1)throw Error('CodeGen: "return" should have one node');return this._endBlockNode(Sj)}try($,X,Q){if(!X&&!Q)throw Error('CodeGen: "try" without "catch" and "finally"');let J=new un;if(this._blockNode(J),this.code($),X){let v=this.name("e");this._currNode=J.catch=new Cj(v),X(v)}if(Q)this._currNode=J.finally=new uj,this.code(Q);return this._endBlockNode(Cj,uj)}throw($){return this._leafNode(new En($))}block($,X){if(this._blockStarts.push(this._nodes.length),$)this.code($).endBlock(X);return this}endBlock($){let X=this._blockStarts.pop();if(X===void 0)throw Error("CodeGen: not in self-balancing block");let Q=this._nodes.length-X;if(Q<0||$!==void 0&&Q!==$)throw Error(`CodeGen: wrong number of nodes: ${Q} vs ${$} expected`);return this._nodes.length=X,this}func($,X=M$.nil,Q,J){if(this._blockNode(new Tj($,X,Q)),J)this.code(J).endFunc();return this}endFunc(){return this._endBlockNode(Tj)}optimize($=1){while($-- >0)this._root.optimizeNodes(),this._root.optimizeNames(this._root.names,this._constants)}_leafNode($){return this._currNode.nodes.push($),this}_blockNode($){this._currNode.nodes.push($),this._nodes.push($)}_endBlockNode($,X){let Q=this._currNode;if(Q instanceof $||X&&Q instanceof X)return this._nodes.pop(),this;throw Error(`CodeGen: not in block "${X?`${$.kind}/${X.kind}`:$.kind}"`)}_elseNode($){let X=this._currNode;if(!(X instanceof A8))throw Error('CodeGen: "else" without "if"');return this._currNode=X.else=$,this}get _root(){return this._nodes[0]}get _currNode(){let $=this._nodes;return $[$.length-1]}set _currNode($){let X=this._nodes;X[X.length-1]=$}}L$.CodeGen=_n;function xX($,X){for(let Q in X)$[Q]=($[Q]||0)+(X[Q]||0);return $}function _j($,X){return X instanceof M$._CodeOrName?xX($,X.names):$}function zJ($,X,Q){if($ instanceof M$.Name)return J($);if(!v($))return $;return new M$._Code($._items.reduce((Y,z)=>{if(z instanceof M$.Name)z=J(z);if(z instanceof M$._Code)Y.push(...z._items);else Y.push(z);return Y},[]));function J(Y){let z=Q[Y.str];if(z===void 0||X[Y.str]!==1)return Y;return delete X[Y.str],z}function v(Y){return Y instanceof M$._Code&&Y._items.some((z)=>z instanceof M$.Name&&X[z.str]===1&&Q[z.str]!==void 0)}}function nG$($,X){for(let Q in X)$[Q]=($[Q]||0)-(X[Q]||0)}function yn($){return typeof $=="boolean"||typeof $=="number"||$===null?!$:M$._`!${bZ($)}`}L$.not=yn;var gG$=fn(L$.operators.AND);function cG$(...$){return $.reduce(gG$)}L$.and=cG$;var mG$=fn(L$.operators.OR);function hG$(...$){return $.reduce(mG$)}L$.or=hG$;function fn($){return(X,Q)=>X===M$.nil?Q:Q===M$.nil?X:M$._`${bZ(X)} ${$} ${bZ(Q)}`}function bZ($){return $ instanceof M$.Name?$:M$._`(${$})`}});var b$=N((A$)=>{Object.defineProperty(A$,"__esModule",{value:!0});A$.checkStrictMode=A$.getErrorPath=A$.Type=A$.useFunc=A$.setEvaluated=A$.evaluatedPropsToName=A$.mergeEvaluated=A$.eachItem=A$.unescapeJsonPointer=A$.escapeJsonPointer=A$.escapeFragment=A$.unescapeFragment=A$.schemaRefOrVal=A$.schemaHasRulesButRef=A$.schemaHasRules=A$.checkUnknownRules=A$.alwaysValidSchema=A$.toHash=void 0;var p$=D$(),lG$=R7();function pG$($){let X={};for(let Q of $)X[Q]=!0;return X}A$.toHash=pG$;function dG$($,X){if(typeof X=="boolean")return X;if(Object.keys(X).length===0)return!0;return gn($,X),!cn(X,$.self.RULES.all)}A$.alwaysValidSchema=dG$;function gn($,X=$.schema){let{opts:Q,self:J}=$;if(!Q.strictSchema)return;if(typeof X==="boolean")return;let v=J.RULES.keywords;for(let Y in X)if(!v[Y])ln($,`unknown keyword: "${Y}"`)}A$.checkUnknownRules=gn;function cn($,X){if(typeof $=="boolean")return!$;for(let Q in $)if(X[Q])return!0;return!1}A$.schemaHasRules=cn;function oG$($,X){if(typeof $=="boolean")return!$;for(let Q in $)if(Q!=="$ref"&&X.all[Q])return!0;return!1}A$.schemaHasRulesButRef=oG$;function aG$({topSchemaRef:$,schemaPath:X},Q,J,v){if(!v){if(typeof Q=="number"||typeof Q=="boolean")return Q;if(typeof Q=="string")return p$._`${Q}`}return p$._`${$}${X}${(0,p$.getProperty)(J)}`}A$.schemaRefOrVal=aG$;function rG$($){return mn(decodeURIComponent($))}A$.unescapeFragment=rG$;function tG$($){return encodeURIComponent(EZ($))}A$.escapeFragment=tG$;function EZ($){if(typeof $=="number")return`${$}`;return $.replace(/~/g,"~0").replace(/\//g,"~1")}A$.escapeJsonPointer=EZ;function mn($){return $.replace(/~1/g,"/").replace(/~0/g,"~")}A$.unescapeJsonPointer=mn;function sG$($,X){if(Array.isArray($))for(let Q of $)X(Q);else X($)}A$.eachItem=sG$;function kn({mergeNames:$,mergeToName:X,mergeValues:Q,resultToName:J}){return(v,Y,z,W)=>{let G=z===void 0?Y:z instanceof p$.Name?(Y instanceof p$.Name?$(v,Y,z):X(v,Y,z),z):Y instanceof p$.Name?(X(v,z,Y),Y):Q(Y,z);return W===p$.Name&&!(G instanceof p$.Name)?J(v,G):G}}A$.mergeEvaluated={props:kn({mergeNames:($,X,Q)=>$.if(p$._`${Q} !== true && ${X} !== undefined`,()=>{$.if(p$._`${X} === true`,()=>$.assign(Q,!0),()=>$.assign(Q,p$._`${Q} || {}`).code(p$._`Object.assign(${Q}, ${X})`))}),mergeToName:($,X,Q)=>$.if(p$._`${Q} !== true`,()=>{if(X===!0)$.assign(Q,!0);else $.assign(Q,p$._`${Q} || {}`),PZ($,Q,X)}),mergeValues:($,X)=>$===!0?!0:{...$,...X},resultToName:hn}),items:kn({mergeNames:($,X,Q)=>$.if(p$._`${Q} !== true && ${X} !== undefined`,()=>$.assign(Q,p$._`${X} === true ? true : ${Q} > ${X} ? ${Q} : ${X}`)),mergeToName:($,X,Q)=>$.if(p$._`${Q} !== true`,()=>$.assign(Q,X===!0?!0:p$._`${Q} > ${X} ? ${Q} : ${X}`)),mergeValues:($,X)=>$===!0?!0:Math.max($,X),resultToName:($,X)=>$.var("items",X)})};function hn($,X){if(X===!0)return $.var("props",!0);let Q=$.var("props",p$._`{}`);if(X!==void 0)PZ($,Q,X);return Q}A$.evaluatedPropsToName=hn;function PZ($,X,Q){Object.keys(Q).forEach((J)=>$.assign(p$._`${X}${(0,p$.getProperty)(J)}`,!0))}A$.setEvaluated=PZ;var nn={};function eG$($,X){return $.scopeValue("func",{ref:X,code:nn[X.code]||(nn[X.code]=new lG$._Code(X.code))})}A$.useFunc=eG$;var RZ;(function($){$[$.Num=0]="Num",$[$.Str=1]="Str"})(RZ||(A$.Type=RZ={}));function $U$($,X,Q){if($ instanceof p$.Name){let J=X===RZ.Num;return Q?J?p$._`"[" + ${$} + "]"`:p$._`"['" + ${$} + "']"`:J?p$._`"/" + ${$}`:p$._`"/" + ${$}.replace(/~/g, "~0").replace(/\\//g, "~1")`}return Q?(0,p$.getProperty)($).toString():"/"+EZ($)}A$.getErrorPath=$U$;function ln($,X,Q=$.opts.strictSchema){if(!Q)return;if(X=`strict mode: ${X}`,Q===!0)throw Error(X);$.self.logger.warn(X)}A$.checkStrictMode=ln});var Z8=N((TZ)=>{Object.defineProperty(TZ,"__esModule",{value:!0});var U6=D$(),XU$={data:new U6.Name("data"),valCxt:new U6.Name("valCxt"),instancePath:new U6.Name("instancePath"),parentData:new U6.Name("parentData"),parentDataProperty:new U6.Name("parentDataProperty"),rootData:new U6.Name("rootData"),dynamicAnchors:new U6.Name("dynamicAnchors"),vErrors:new U6.Name("vErrors"),errors:new U6.Name("errors"),this:new U6.Name("this"),self:new U6.Name("self"),scope:new U6.Name("scope"),json:new U6.Name("json"),jsonPos:new U6.Name("jsonPos"),jsonLen:new U6.Name("jsonLen"),jsonPart:new U6.Name("jsonPart")};TZ.default=XU$});var P7=N((K6)=>{Object.defineProperty(K6,"__esModule",{value:!0});K6.extendErrors=K6.resetErrorsCount=K6.reportExtraError=K6.reportError=K6.keyword$DataError=K6.keywordError=void 0;var i$=D$(),kj=b$(),R6=Z8();K6.keywordError={message:({keyword:$})=>i$.str`must pass "${$}" keyword validation`};K6.keyword$DataError={message:({keyword:$,schemaType:X})=>X?i$.str`"${$}" keyword must be ${X} ($data)`:i$.str`"${$}" keyword is invalid ($data)`};function QU$($,X=K6.keywordError,Q,J){let{it:v}=$,{gen:Y,compositeRule:z,allErrors:W}=v,G=on($,X,Q);if(J!==null&&J!==void 0?J:z||W)pn(Y,G);else dn(v,i$._`[${G}]`)}K6.reportError=QU$;function vU$($,X=K6.keywordError,Q){let{it:J}=$,{gen:v,compositeRule:Y,allErrors:z}=J,W=on($,X,Q);if(pn(v,W),!(Y||z))dn(J,R6.default.vErrors)}K6.reportExtraError=vU$;function JU$($,X){$.assign(R6.default.errors,X),$.if(i$._`${R6.default.vErrors} !== null`,()=>$.if(X,()=>$.assign(i$._`${R6.default.vErrors}.length`,X),()=>$.assign(R6.default.vErrors,null)))}K6.resetErrorsCount=JU$;function YU$({gen:$,keyword:X,schemaValue:Q,data:J,errsCount:v,it:Y}){if(v===void 0)throw Error("ajv implementation error");let z=$.name("err");$.forRange("i",v,R6.default.errors,(W)=>{if($.const(z,i$._`${R6.default.vErrors}[${W}]`),$.if(i$._`${z}.instancePath === undefined`,()=>$.assign(i$._`${z}.instancePath`,(0,i$.strConcat)(R6.default.instancePath,Y.errorPath))),$.assign(i$._`${z}.schemaPath`,i$.str`${Y.errSchemaPath}/${X}`),Y.opts.verbose)$.assign(i$._`${z}.schema`,Q),$.assign(i$._`${z}.data`,J)})}K6.extendErrors=YU$;function pn($,X){let Q=$.const("err",X);$.if(i$._`${R6.default.vErrors} === null`,()=>$.assign(R6.default.vErrors,i$._`[${Q}]`),i$._`${R6.default.vErrors}.push(${Q})`),$.code(i$._`${R6.default.errors}++`)}function dn($,X){let{gen:Q,validateName:J,schemaEnv:v}=$;if(v.$async)Q.throw(i$._`new ${$.ValidationError}(${X})`);else Q.assign(i$._`${J}.errors`,X),Q.return(!1)}var bX={keyword:new i$.Name("keyword"),schemaPath:new i$.Name("schemaPath"),params:new i$.Name("params"),propertyName:new i$.Name("propertyName"),message:new i$.Name("message"),schema:new i$.Name("schema"),parentSchema:new i$.Name("parentSchema")};function on($,X,Q){let{createErrors:J}=$.it;if(J===!1)return i$._`{}`;return zU$($,X,Q)}function zU$($,X,Q={}){let{gen:J,it:v}=$,Y=[WU$(v,Q),GU$($,Q)];return UU$($,X,Y),J.object(...Y)}function WU$({errorPath:$},{instancePath:X}){let Q=X?i$.str`${$}${(0,kj.getErrorPath)(X,kj.Type.Str)}`:$;return[R6.default.instancePath,(0,i$.strConcat)(R6.default.instancePath,Q)]}function GU$({keyword:$,it:{errSchemaPath:X}},{schemaPath:Q,parentSchema:J}){let v=J?X:i$.str`${X}/${$}`;if(Q)v=i$.str`${v}${(0,kj.getErrorPath)(Q,kj.Type.Str)}`;return[bX.schemaPath,v]}function UU$($,{params:X,message:Q},J){let{keyword:v,data:Y,schemaValue:z,it:W}=$,{opts:G,propertyName:U,topSchemaRef:w,schemaPath:H}=W;if(J.push([bX.keyword,v],[bX.params,typeof X=="function"?X($):X||i$._`{}`]),G.messages)J.push([bX.message,typeof Q=="function"?Q($):Q]);if(G.verbose)J.push([bX.schema,z],[bX.parentSchema,i$._`${w}${H}`],[R6.default.data,Y]);if(U)J.push([bX.propertyName,U])}});var rn=N((WJ)=>{Object.defineProperty(WJ,"__esModule",{value:!0});WJ.boolOrEmptySchema=WJ.topBoolOrEmptySchema=void 0;var KU$=P7(),wU$=D$(),HU$=Z8(),jU$={message:"boolean schema is false"};function VU$($){let{gen:X,schema:Q,validateName:J}=$;if(Q===!1)an($,!1);else if(typeof Q=="object"&&Q.$async===!0)X.return(HU$.default.data);else X.assign(wU$._`${J}.errors`,null),X.return(!0)}WJ.topBoolOrEmptySchema=VU$;function BU$($,X){let{gen:Q,schema:J}=$;if(J===!1)Q.var(X,!1),an($);else Q.var(X,!0)}WJ.boolOrEmptySchema=BU$;function an($,X){let{gen:Q,data:J}=$,v={gen:Q,keyword:"false schema",data:J,schema:!1,schemaCode:!1,schemaValue:!1,params:{},it:$};(0,KU$.reportError)(v,jU$,void 0,X)}});var SZ=N((GJ)=>{Object.defineProperty(GJ,"__esModule",{value:!0});GJ.getRules=GJ.isJSONType=void 0;var DU$=["string","number","integer","boolean","null","object","array"],FU$=new Set(DU$);function NU$($){return typeof $=="string"&&FU$.has($)}GJ.isJSONType=NU$;function OU$(){let $={number:{type:"number",rules:[]},string:{type:"string",rules:[]},array:{type:"array",rules:[]},object:{type:"object",rules:[]}};return{types:{...$,integer:!0,boolean:!0,null:!0},rules:[{rules:[]},$.number,$.string,$.array,$.object],post:{rules:[]},all:{},keywords:{}}}GJ.getRules=OU$});var CZ=N((A9)=>{Object.defineProperty(A9,"__esModule",{value:!0});A9.shouldUseRule=A9.shouldUseGroup=A9.schemaHasRulesForType=void 0;function LU$({schema:$,self:X},Q){let J=X.RULES.types[Q];return J&&J!==!0&&tn($,J)}A9.schemaHasRulesForType=LU$;function tn($,X){return X.rules.some((Q)=>sn($,Q))}A9.shouldUseGroup=tn;function sn($,X){var Q;return $[X.keyword]!==void 0||((Q=X.definition.implements)===null||Q===void 0?void 0:Q.some((J)=>$[J]!==void 0))}A9.shouldUseRule=sn});var T7=N((w6)=>{Object.defineProperty(w6,"__esModule",{value:!0});w6.reportTypeError=w6.checkDataTypes=w6.checkDataType=w6.coerceAndCheckDataType=w6.getJSONTypes=w6.getSchemaTypes=w6.DataType=void 0;var AU$=SZ(),ZU$=CZ(),qU$=P7(),j$=D$(),en=b$(),UJ;(function($){$[$.Correct=0]="Correct",$[$.Wrong=1]="Wrong"})(UJ||(w6.DataType=UJ={}));function IU$($){let X=$g($.type);if(X.includes("null")){if($.nullable===!1)throw Error("type: null contradicts nullable: false")}else{if(!X.length&&$.nullable!==void 0)throw Error('"nullable" cannot be used without "type"');if($.nullable===!0)X.push("null")}return X}w6.getSchemaTypes=IU$;function $g($){let X=Array.isArray($)?$:$?[$]:[];if(X.every(AU$.isJSONType))return X;throw Error("type must be JSONType or JSONType[]: "+X.join(","))}w6.getJSONTypes=$g;function MU$($,X){let{gen:Q,data:J,opts:v}=$,Y=xU$(X,v.coerceTypes),z=X.length>0&&!(Y.length===0&&X.length===1&&(0,ZU$.schemaHasRulesForType)($,X[0]));if(z){let W=_Z(X,J,v.strictNumbers,UJ.Wrong);Q.if(W,()=>{if(Y.length)bU$($,X,Y);else yZ($)})}return z}w6.coerceAndCheckDataType=MU$;var Xg=new Set(["string","number","integer","boolean","null"]);function xU$($,X){return X?$.filter((Q)=>Xg.has(Q)||X==="array"&&Q==="array"):[]}function bU$($,X,Q){let{gen:J,data:v,opts:Y}=$,z=J.let("dataType",j$._`typeof ${v}`),W=J.let("coerced",j$._`undefined`);if(Y.coerceTypes==="array")J.if(j$._`${z} == 'object' && Array.isArray(${v}) && ${v}.length == 1`,()=>J.assign(v,j$._`${v}[0]`).assign(z,j$._`typeof ${v}`).if(_Z(X,v,Y.strictNumbers),()=>J.assign(W,v)));J.if(j$._`${W} !== undefined`);for(let U of Q)if(Xg.has(U)||U==="array"&&Y.coerceTypes==="array")G(U);J.else(),yZ($),J.endIf(),J.if(j$._`${W} !== undefined`,()=>{J.assign(v,W),iU$($,W)});function G(U){switch(U){case"string":J.elseIf(j$._`${z} == "number" || ${z} == "boolean"`).assign(W,j$._`"" + ${v}`).elseIf(j$._`${v} === null`).assign(W,j$._`""`);return;case"number":J.elseIf(j$._`${z} == "boolean" || ${v} === null + || (${z} == "string" && ${v} && ${v} == +${v})`).assign(W,j$._`+${v}`);return;case"integer":J.elseIf(j$._`${z} === "boolean" || ${v} === null + || (${z} === "string" && ${v} && ${v} == +${v} && !(${v} % 1))`).assign(W,j$._`+${v}`);return;case"boolean":J.elseIf(j$._`${v} === "false" || ${v} === 0 || ${v} === null`).assign(W,!1).elseIf(j$._`${v} === "true" || ${v} === 1`).assign(W,!0);return;case"null":J.elseIf(j$._`${v} === "" || ${v} === 0 || ${v} === false`),J.assign(W,null);return;case"array":J.elseIf(j$._`${z} === "string" || ${z} === "number" + || ${z} === "boolean" || ${v} === null`).assign(W,j$._`[${v}]`)}}}function iU$({gen:$,parentData:X,parentDataProperty:Q},J){$.if(j$._`${X} !== undefined`,()=>$.assign(j$._`${X}[${Q}]`,J))}function uZ($,X,Q,J=UJ.Correct){let v=J===UJ.Correct?j$.operators.EQ:j$.operators.NEQ,Y;switch($){case"null":return j$._`${X} ${v} null`;case"array":Y=j$._`Array.isArray(${X})`;break;case"object":Y=j$._`${X} && typeof ${X} == "object" && !Array.isArray(${X})`;break;case"integer":Y=z(j$._`!(${X} % 1) && !isNaN(${X})`);break;case"number":Y=z();break;default:return j$._`typeof ${X} ${v} ${$}`}return J===UJ.Correct?Y:(0,j$.not)(Y);function z(W=j$.nil){return(0,j$.and)(j$._`typeof ${X} == "number"`,W,Q?j$._`isFinite(${X})`:j$.nil)}}w6.checkDataType=uZ;function _Z($,X,Q,J){if($.length===1)return uZ($[0],X,Q,J);let v,Y=(0,en.toHash)($);if(Y.array&&Y.object){let z=j$._`typeof ${X} != "object"`;v=Y.null?z:j$._`!${X} || ${z}`,delete Y.null,delete Y.array,delete Y.object}else v=j$.nil;if(Y.number)delete Y.integer;for(let z in Y)v=(0,j$.and)(v,uZ(z,X,Q,J));return v}w6.checkDataTypes=_Z;var RU$={message:({schema:$})=>`must be ${$}`,params:({schema:$,schemaValue:X})=>typeof $=="string"?j$._`{type: ${$}}`:j$._`{type: ${X}}`};function yZ($){let X=EU$($);(0,qU$.reportError)(X,RU$)}w6.reportTypeError=yZ;function EU$($){let{gen:X,data:Q,schema:J}=$,v=(0,en.schemaRefOrVal)($,J,"type");return{gen:X,keyword:"type",data:Q,schema:J.type,schemaCode:v,schemaValue:v,parentSchema:J,params:{},it:$}}});var vg=N((nj)=>{Object.defineProperty(nj,"__esModule",{value:!0});nj.assignDefaults=void 0;var KJ=D$(),PU$=b$();function TU$($,X){let{properties:Q,items:J}=$.schema;if(X==="object"&&Q)for(let v in Q)Qg($,v,Q[v].default);else if(X==="array"&&Array.isArray(J))J.forEach((v,Y)=>Qg($,Y,v.default))}nj.assignDefaults=TU$;function Qg($,X,Q){let{gen:J,compositeRule:v,data:Y,opts:z}=$;if(Q===void 0)return;let W=KJ._`${Y}${(0,KJ.getProperty)(X)}`;if(v){(0,PU$.checkStrictMode)($,`default is ignored for: ${W}`);return}let G=KJ._`${W} === undefined`;if(z.useDefaults==="empty")G=KJ._`${G} || ${W} === null || ${W} === ""`;J.if(G,KJ._`${W} = ${(0,KJ.stringify)(Q)}`)}});var P1=N((m$)=>{Object.defineProperty(m$,"__esModule",{value:!0});m$.validateUnion=m$.validateArray=m$.usePattern=m$.callValidateCode=m$.schemaProperties=m$.allSchemaProperties=m$.noPropertyInData=m$.propertyInData=m$.isOwnProperty=m$.hasPropFunc=m$.reportMissingProp=m$.checkMissingProp=m$.checkReportMissingProp=void 0;var s$=D$(),fZ=b$(),Z9=Z8(),SU$=b$();function CU$($,X){let{gen:Q,data:J,it:v}=$;Q.if(nZ(Q,J,X,v.opts.ownProperties),()=>{$.setParams({missingProperty:s$._`${X}`},!0),$.error()})}m$.checkReportMissingProp=CU$;function uU$({gen:$,data:X,it:{opts:Q}},J,v){return(0,s$.or)(...J.map((Y)=>(0,s$.and)(nZ($,X,Y,Q.ownProperties),s$._`${v} = ${Y}`)))}m$.checkMissingProp=uU$;function _U$($,X){$.setParams({missingProperty:X},!0),$.error()}m$.reportMissingProp=_U$;function Jg($){return $.scopeValue("func",{ref:Object.prototype.hasOwnProperty,code:s$._`Object.prototype.hasOwnProperty`})}m$.hasPropFunc=Jg;function kZ($,X,Q){return s$._`${Jg($)}.call(${X}, ${Q})`}m$.isOwnProperty=kZ;function yU$($,X,Q,J){let v=s$._`${X}${(0,s$.getProperty)(Q)} !== undefined`;return J?s$._`${v} && ${kZ($,X,Q)}`:v}m$.propertyInData=yU$;function nZ($,X,Q,J){let v=s$._`${X}${(0,s$.getProperty)(Q)} === undefined`;return J?(0,s$.or)(v,(0,s$.not)(kZ($,X,Q))):v}m$.noPropertyInData=nZ;function Yg($){return $?Object.keys($).filter((X)=>X!=="__proto__"):[]}m$.allSchemaProperties=Yg;function fU$($,X){return Yg(X).filter((Q)=>!(0,fZ.alwaysValidSchema)($,X[Q]))}m$.schemaProperties=fU$;function kU$({schemaCode:$,data:X,it:{gen:Q,topSchemaRef:J,schemaPath:v,errorPath:Y},it:z},W,G,U){let w=U?s$._`${$}, ${X}, ${J}${v}`:X,H=[[Z9.default.instancePath,(0,s$.strConcat)(Z9.default.instancePath,Y)],[Z9.default.parentData,z.parentData],[Z9.default.parentDataProperty,z.parentDataProperty],[Z9.default.rootData,Z9.default.rootData]];if(z.opts.dynamicRef)H.push([Z9.default.dynamicAnchors,Z9.default.dynamicAnchors]);let j=s$._`${w}, ${Q.object(...H)}`;return G!==s$.nil?s$._`${W}.call(${G}, ${j})`:s$._`${W}(${j})`}m$.callValidateCode=kU$;var nU$=s$._`new RegExp`;function gU$({gen:$,it:{opts:X}},Q){let J=X.unicodeRegExp?"u":"",{regExp:v}=X.code,Y=v(Q,J);return $.scopeValue("pattern",{key:Y.toString(),ref:Y,code:s$._`${v.code==="new RegExp"?nU$:(0,SU$.useFunc)($,v)}(${Q}, ${J})`})}m$.usePattern=gU$;function cU$($){let{gen:X,data:Q,keyword:J,it:v}=$,Y=X.name("valid");if(v.allErrors){let W=X.let("valid",!0);return z(()=>X.assign(W,!1)),W}return X.var(Y,!0),z(()=>X.break()),Y;function z(W){let G=X.const("len",s$._`${Q}.length`);X.forRange("i",0,G,(U)=>{$.subschema({keyword:J,dataProp:U,dataPropType:fZ.Type.Num},Y),X.if((0,s$.not)(Y),W)})}}m$.validateArray=cU$;function mU$($){let{gen:X,schema:Q,keyword:J,it:v}=$;if(!Array.isArray(Q))throw Error("ajv implementation error");if(Q.some((G)=>(0,fZ.alwaysValidSchema)(v,G))&&!v.opts.unevaluated)return;let z=X.let("valid",!1),W=X.name("_valid");X.block(()=>Q.forEach((G,U)=>{let w=$.subschema({keyword:J,schemaProp:U,compositeRule:!0},W);if(X.assign(z,s$._`${z} || ${W}`),!$.mergeValidEvaluated(w,W))X.if((0,s$.not)(z))})),$.result(z,()=>$.reset(),()=>$.error(!0))}m$.validateUnion=mU$});var Gg=N((g0)=>{Object.defineProperty(g0,"__esModule",{value:!0});g0.validateKeywordUsage=g0.validSchemaType=g0.funcKeywordCode=g0.macroKeywordCode=void 0;var E6=D$(),iX=Z8(),hU$=P1(),lU$=P7();function pU$($,X){let{gen:Q,keyword:J,schema:v,parentSchema:Y,it:z}=$,W=X.macro.call(z.self,v,Y,z),G=Wg(Q,J,W);if(z.opts.validateSchema!==!1)z.self.validateSchema(W,!0);let U=Q.name("valid");$.subschema({schema:W,schemaPath:E6.nil,errSchemaPath:`${z.errSchemaPath}/${J}`,topSchemaRef:G,compositeRule:!0},U),$.pass(U,()=>$.error(!0))}g0.macroKeywordCode=pU$;function dU$($,X){var Q;let{gen:J,keyword:v,schema:Y,parentSchema:z,$data:W,it:G}=$;aU$(G,X);let U=!W&&X.compile?X.compile.call(G.self,Y,z,G):X.validate,w=Wg(J,v,U),H=J.let("valid");$.block$data(H,j),$.ok((Q=X.valid)!==null&&Q!==void 0?Q:H);function j(){if(X.errors===!1){if(D(),X.modifying)zg($);F(()=>$.error())}else{let L=X.async?V():B();if(X.modifying)zg($);F(()=>oU$($,L))}}function V(){let L=J.let("ruleErrs",null);return J.try(()=>D(E6._`await `),(A)=>J.assign(H,!1).if(E6._`${A} instanceof ${G.ValidationError}`,()=>J.assign(L,E6._`${A}.errors`),()=>J.throw(A))),L}function B(){let L=E6._`${w}.errors`;return J.assign(L,null),D(E6.nil),L}function D(L=X.async?E6._`await `:E6.nil){let A=G.opts.passContext?iX.default.this:iX.default.self,O=!(("compile"in X)&&!W||X.schema===!1);J.assign(H,E6._`${L}${(0,hU$.callValidateCode)($,w,A,O)}`,X.modifying)}function F(L){var A;J.if((0,E6.not)((A=X.valid)!==null&&A!==void 0?A:H),L)}}g0.funcKeywordCode=dU$;function zg($){let{gen:X,data:Q,it:J}=$;X.if(J.parentData,()=>X.assign(Q,E6._`${J.parentData}[${J.parentDataProperty}]`))}function oU$($,X){let{gen:Q}=$;Q.if(E6._`Array.isArray(${X})`,()=>{Q.assign(iX.default.vErrors,E6._`${iX.default.vErrors} === null ? ${X} : ${iX.default.vErrors}.concat(${X})`).assign(iX.default.errors,E6._`${iX.default.vErrors}.length`),(0,lU$.extendErrors)($)},()=>$.error())}function aU$({schemaEnv:$},X){if(X.async&&!$.$async)throw Error("async keyword in sync schema")}function Wg($,X,Q){if(Q===void 0)throw Error(`keyword "${X}" failed to compile`);return $.scopeValue("keyword",typeof Q=="function"?{ref:Q}:{ref:Q,code:(0,E6.stringify)(Q)})}function rU$($,X,Q=!1){return!X.length||X.some((J)=>J==="array"?Array.isArray($):J==="object"?$&&typeof $=="object"&&!Array.isArray($):typeof $==J||Q&&typeof $>"u")}g0.validSchemaType=rU$;function tU$({schema:$,opts:X,self:Q,errSchemaPath:J},v,Y){if(Array.isArray(v.keyword)?!v.keyword.includes(Y):v.keyword!==Y)throw Error("ajv implementation error");let z=v.dependencies;if(z===null||z===void 0?void 0:z.some((W)=>!Object.prototype.hasOwnProperty.call($,W)))throw Error(`parent schema must have dependencies of ${Y}: ${z.join(",")}`);if(v.validateSchema){if(!v.validateSchema($[Y])){let G=`keyword "${Y}" value is invalid at path "${J}": `+Q.errorsText(v.validateSchema.errors);if(X.validateSchema==="log")Q.logger.error(G);else throw Error(G)}}}g0.validateKeywordUsage=tU$});var Kg=N((q9)=>{Object.defineProperty(q9,"__esModule",{value:!0});q9.extendSubschemaMode=q9.extendSubschemaData=q9.getSubschema=void 0;var c0=D$(),Ug=b$();function sU$($,{keyword:X,schemaProp:Q,schema:J,schemaPath:v,errSchemaPath:Y,topSchemaRef:z}){if(X!==void 0&&J!==void 0)throw Error('both "keyword" and "schema" passed, only one allowed');if(X!==void 0){let W=$.schema[X];return Q===void 0?{schema:W,schemaPath:c0._`${$.schemaPath}${(0,c0.getProperty)(X)}`,errSchemaPath:`${$.errSchemaPath}/${X}`}:{schema:W[Q],schemaPath:c0._`${$.schemaPath}${(0,c0.getProperty)(X)}${(0,c0.getProperty)(Q)}`,errSchemaPath:`${$.errSchemaPath}/${X}/${(0,Ug.escapeFragment)(Q)}`}}if(J!==void 0){if(v===void 0||Y===void 0||z===void 0)throw Error('"schemaPath", "errSchemaPath" and "topSchemaRef" are required with "schema"');return{schema:J,schemaPath:v,topSchemaRef:z,errSchemaPath:Y}}throw Error('either "keyword" or "schema" must be passed')}q9.getSubschema=sU$;function eU$($,X,{dataProp:Q,dataPropType:J,data:v,dataTypes:Y,propertyName:z}){if(v!==void 0&&Q!==void 0)throw Error('both "data" and "dataProp" passed, only one allowed');let{gen:W}=X;if(Q!==void 0){let{errorPath:U,dataPathArr:w,opts:H}=X,j=W.let("data",c0._`${X.data}${(0,c0.getProperty)(Q)}`,!0);G(j),$.errorPath=c0.str`${U}${(0,Ug.getErrorPath)(Q,J,H.jsPropertySyntax)}`,$.parentDataProperty=c0._`${Q}`,$.dataPathArr=[...w,$.parentDataProperty]}if(v!==void 0){let U=v instanceof c0.Name?v:W.let("data",v,!0);if(G(U),z!==void 0)$.propertyName=z}if(Y)$.dataTypes=Y;function G(U){$.data=U,$.dataLevel=X.dataLevel+1,$.dataTypes=[],X.definedProperties=new Set,$.parentData=X.data,$.dataNames=[...X.dataNames,U]}}q9.extendSubschemaData=eU$;function $K$($,{jtdDiscriminator:X,jtdMetadata:Q,compositeRule:J,createErrors:v,allErrors:Y}){if(J!==void 0)$.compositeRule=J;if(v!==void 0)$.createErrors=v;if(Y!==void 0)$.allErrors=Y;$.jtdDiscriminator=X,$.jtdMetadata=Q}q9.extendSubschemaMode=$K$});var gZ=N((qu$,wg)=>{wg.exports=function $(X,Q){if(X===Q)return!0;if(X&&Q&&typeof X=="object"&&typeof Q=="object"){if(X.constructor!==Q.constructor)return!1;var J,v,Y;if(Array.isArray(X)){if(J=X.length,J!=Q.length)return!1;for(v=J;v--!==0;)if(!$(X[v],Q[v]))return!1;return!0}if(X.constructor===RegExp)return X.source===Q.source&&X.flags===Q.flags;if(X.valueOf!==Object.prototype.valueOf)return X.valueOf()===Q.valueOf();if(X.toString!==Object.prototype.toString)return X.toString()===Q.toString();if(Y=Object.keys(X),J=Y.length,J!==Object.keys(Q).length)return!1;for(v=J;v--!==0;)if(!Object.prototype.hasOwnProperty.call(Q,Y[v]))return!1;for(v=J;v--!==0;){var z=Y[v];if(!$(X[z],Q[z]))return!1}return!0}return X!==X&&Q!==Q}});var jg=N((Iu$,Hg)=>{var I9=Hg.exports=function($,X,Q){if(typeof X=="function")Q=X,X={};Q=X.cb||Q;var J=typeof Q=="function"?Q:Q.pre||function(){},v=Q.post||function(){};gj(X,J,v,$,"",$)};I9.keywords={additionalItems:!0,items:!0,contains:!0,additionalProperties:!0,propertyNames:!0,not:!0,if:!0,then:!0,else:!0};I9.arrayKeywords={items:!0,allOf:!0,anyOf:!0,oneOf:!0};I9.propsKeywords={$defs:!0,definitions:!0,properties:!0,patternProperties:!0,dependencies:!0};I9.skipKeywords={default:!0,enum:!0,const:!0,required:!0,maximum:!0,minimum:!0,exclusiveMaximum:!0,exclusiveMinimum:!0,multipleOf:!0,maxLength:!0,minLength:!0,pattern:!0,format:!0,maxItems:!0,minItems:!0,uniqueItems:!0,maxProperties:!0,minProperties:!0};function gj($,X,Q,J,v,Y,z,W,G,U){if(J&&typeof J=="object"&&!Array.isArray(J)){X(J,v,Y,z,W,G,U);for(var w in J){var H=J[w];if(Array.isArray(H)){if(w in I9.arrayKeywords)for(var j=0;j{Object.defineProperty(d6,"__esModule",{value:!0});d6.getSchemaRefs=d6.resolveUrl=d6.normalizeId=d6._getFullPath=d6.getFullPath=d6.inlineRef=void 0;var QK$=b$(),vK$=gZ(),JK$=jg(),YK$=new Set(["type","format","pattern","maxLength","minLength","maxProperties","minProperties","maxItems","minItems","maximum","minimum","uniqueItems","multipleOf","required","enum","const"]);function zK$($,X=!0){if(typeof $=="boolean")return!0;if(X===!0)return!cZ($);if(!X)return!1;return Vg($)<=X}d6.inlineRef=zK$;var WK$=new Set(["$ref","$recursiveRef","$recursiveAnchor","$dynamicRef","$dynamicAnchor"]);function cZ($){for(let X in $){if(WK$.has(X))return!0;let Q=$[X];if(Array.isArray(Q)&&Q.some(cZ))return!0;if(typeof Q=="object"&&cZ(Q))return!0}return!1}function Vg($){let X=0;for(let Q in $){if(Q==="$ref")return 1/0;if(X++,YK$.has(Q))continue;if(typeof $[Q]=="object")(0,QK$.eachItem)($[Q],(J)=>X+=Vg(J));if(X===1/0)return 1/0}return X}function Bg($,X="",Q){if(Q!==!1)X=wJ(X);let J=$.parse(X);return Dg($,J)}d6.getFullPath=Bg;function Dg($,X){return $.serialize(X).split("#")[0]+"#"}d6._getFullPath=Dg;var GK$=/#\/?$/;function wJ($){return $?$.replace(GK$,""):""}d6.normalizeId=wJ;function UK$($,X,Q){return Q=wJ(Q),$.resolve(X,Q)}d6.resolveUrl=UK$;var KK$=/^[a-z_][-a-z0-9._]*$/i;function wK$($,X){if(typeof $=="boolean")return{};let{schemaId:Q,uriResolver:J}=this.opts,v=wJ($[Q]||X),Y={"":v},z=Bg(J,v,!1),W={},G=new Set;return JK$($,{allKeys:!0},(H,j,V,B)=>{if(B===void 0)return;let D=z+j,F=Y[B];if(typeof H[Q]=="string")F=L.call(this,H[Q]);A.call(this,H.$anchor),A.call(this,H.$dynamicAnchor),Y[j]=F;function L(O){let i=this.opts.uriResolver.resolve;if(O=wJ(F?i(F,O):O),G.has(O))throw w(O);G.add(O);let M=this.refs[O];if(typeof M=="string")M=this.refs[M];if(typeof M=="object")U(H,M.schema,O);else if(O!==wJ(D))if(O[0]==="#")U(H,W[O],O),W[O]=H;else this.refs[O]=D;return O}function A(O){if(typeof O=="string"){if(!KK$.test(O))throw Error(`invalid anchor "${O}"`);L.call(this,`#${O}`)}}}),W;function U(H,j,V){if(j!==void 0&&!vK$(H,j))throw w(V)}function w(H){return Error(`reference "${H}" resolves to more than one schema`)}}d6.getSchemaRefs=wK$});var _7=N((M9)=>{Object.defineProperty(M9,"__esModule",{value:!0});M9.getData=M9.KeywordCxt=M9.validateFunctionCode=void 0;var Ag=rn(),Fg=T7(),hZ=CZ(),cj=T7(),HK$=vg(),u7=Gg(),mZ=Kg(),a=D$(),W$=Z8(),jK$=S7(),q8=b$(),C7=P7();function VK$($){if(Ig($)){if(Mg($),qg($)){FK$($);return}}Zg($,()=>(0,Ag.topBoolOrEmptySchema)($))}M9.validateFunctionCode=VK$;function Zg({gen:$,validateName:X,schema:Q,schemaEnv:J,opts:v},Y){if(v.code.es5)$.func(X,a._`${W$.default.data}, ${W$.default.valCxt}`,J.$async,()=>{$.code(a._`"use strict"; ${Ng(Q,v)}`),DK$($,v),$.code(Y)});else $.func(X,a._`${W$.default.data}, ${BK$(v)}`,J.$async,()=>$.code(Ng(Q,v)).code(Y))}function BK$($){return a._`{${W$.default.instancePath}="", ${W$.default.parentData}, ${W$.default.parentDataProperty}, ${W$.default.rootData}=${W$.default.data}${$.dynamicRef?a._`, ${W$.default.dynamicAnchors}={}`:a.nil}}={}`}function DK$($,X){$.if(W$.default.valCxt,()=>{if($.var(W$.default.instancePath,a._`${W$.default.valCxt}.${W$.default.instancePath}`),$.var(W$.default.parentData,a._`${W$.default.valCxt}.${W$.default.parentData}`),$.var(W$.default.parentDataProperty,a._`${W$.default.valCxt}.${W$.default.parentDataProperty}`),$.var(W$.default.rootData,a._`${W$.default.valCxt}.${W$.default.rootData}`),X.dynamicRef)$.var(W$.default.dynamicAnchors,a._`${W$.default.valCxt}.${W$.default.dynamicAnchors}`)},()=>{if($.var(W$.default.instancePath,a._`""`),$.var(W$.default.parentData,a._`undefined`),$.var(W$.default.parentDataProperty,a._`undefined`),$.var(W$.default.rootData,W$.default.data),X.dynamicRef)$.var(W$.default.dynamicAnchors,a._`{}`)})}function FK$($){let{schema:X,opts:Q,gen:J}=$;Zg($,()=>{if(Q.$comment&&X.$comment)bg($);if(ZK$($),J.let(W$.default.vErrors,null),J.let(W$.default.errors,0),Q.unevaluated)NK$($);xg($),MK$($)});return}function NK$($){let{gen:X,validateName:Q}=$;$.evaluated=X.const("evaluated",a._`${Q}.evaluated`),X.if(a._`${$.evaluated}.dynamicProps`,()=>X.assign(a._`${$.evaluated}.props`,a._`undefined`)),X.if(a._`${$.evaluated}.dynamicItems`,()=>X.assign(a._`${$.evaluated}.items`,a._`undefined`))}function Ng($,X){let Q=typeof $=="object"&&$[X.schemaId];return Q&&(X.code.source||X.code.process)?a._`/*# sourceURL=${Q} */`:a.nil}function OK$($,X){if(Ig($)){if(Mg($),qg($)){LK$($,X);return}}(0,Ag.boolOrEmptySchema)($,X)}function qg({schema:$,self:X}){if(typeof $=="boolean")return!$;for(let Q in $)if(X.RULES.all[Q])return!0;return!1}function Ig($){return typeof $.schema!="boolean"}function LK$($,X){let{schema:Q,gen:J,opts:v}=$;if(v.$comment&&Q.$comment)bg($);qK$($),IK$($);let Y=J.const("_errs",W$.default.errors);xg($,Y),J.var(X,a._`${Y} === ${W$.default.errors}`)}function Mg($){(0,q8.checkUnknownRules)($),AK$($)}function xg($,X){if($.opts.jtd)return Og($,[],!1,X);let Q=(0,Fg.getSchemaTypes)($.schema),J=(0,Fg.coerceAndCheckDataType)($,Q);Og($,Q,!J,X)}function AK$($){let{schema:X,errSchemaPath:Q,opts:J,self:v}=$;if(X.$ref&&J.ignoreKeywordsWithRef&&(0,q8.schemaHasRulesButRef)(X,v.RULES))v.logger.warn(`$ref: keywords ignored in schema at path "${Q}"`)}function ZK$($){let{schema:X,opts:Q}=$;if(X.default!==void 0&&Q.useDefaults&&Q.strictSchema)(0,q8.checkStrictMode)($,"default is ignored in the schema root")}function qK$($){let X=$.schema[$.opts.schemaId];if(X)$.baseId=(0,jK$.resolveUrl)($.opts.uriResolver,$.baseId,X)}function IK$($){if($.schema.$async&&!$.schemaEnv.$async)throw Error("async schema in sync schema")}function bg({gen:$,schemaEnv:X,schema:Q,errSchemaPath:J,opts:v}){let Y=Q.$comment;if(v.$comment===!0)$.code(a._`${W$.default.self}.logger.log(${Y})`);else if(typeof v.$comment=="function"){let z=a.str`${J}/$comment`,W=$.scopeValue("root",{ref:X.root});$.code(a._`${W$.default.self}.opts.$comment(${Y}, ${z}, ${W}.schema)`)}}function MK$($){let{gen:X,schemaEnv:Q,validateName:J,ValidationError:v,opts:Y}=$;if(Q.$async)X.if(a._`${W$.default.errors} === 0`,()=>X.return(W$.default.data),()=>X.throw(a._`new ${v}(${W$.default.vErrors})`));else{if(X.assign(a._`${J}.errors`,W$.default.vErrors),Y.unevaluated)xK$($);X.return(a._`${W$.default.errors} === 0`)}}function xK$({gen:$,evaluated:X,props:Q,items:J}){if(Q instanceof a.Name)$.assign(a._`${X}.props`,Q);if(J instanceof a.Name)$.assign(a._`${X}.items`,J)}function Og($,X,Q,J){let{gen:v,schema:Y,data:z,allErrors:W,opts:G,self:U}=$,{RULES:w}=U;if(Y.$ref&&(G.ignoreKeywordsWithRef||!(0,q8.schemaHasRulesButRef)(Y,w))){v.block(()=>Rg($,"$ref",w.all.$ref.definition));return}if(!G.jtd)bK$($,X);v.block(()=>{for(let j of w.rules)H(j);H(w.post)});function H(j){if(!(0,hZ.shouldUseGroup)(Y,j))return;if(j.type){if(v.if((0,cj.checkDataType)(j.type,z,G.strictNumbers)),Lg($,j),X.length===1&&X[0]===j.type&&Q)v.else(),(0,cj.reportTypeError)($);v.endIf()}else Lg($,j);if(!W)v.if(a._`${W$.default.errors} === ${J||0}`)}}function Lg($,X){let{gen:Q,schema:J,opts:{useDefaults:v}}=$;if(v)(0,HK$.assignDefaults)($,X.type);Q.block(()=>{for(let Y of X.rules)if((0,hZ.shouldUseRule)(J,Y))Rg($,Y.keyword,Y.definition,X.type)})}function bK$($,X){if($.schemaEnv.meta||!$.opts.strictTypes)return;if(iK$($,X),!$.opts.allowUnionTypes)RK$($,X);EK$($,$.dataTypes)}function iK$($,X){if(!X.length)return;if(!$.dataTypes.length){$.dataTypes=X;return}X.forEach((Q)=>{if(!ig($.dataTypes,Q))lZ($,`type "${Q}" not allowed by context "${$.dataTypes.join(",")}"`)}),TK$($,X)}function RK$($,X){if(X.length>1&&!(X.length===2&&X.includes("null")))lZ($,"use allowUnionTypes to allow union type keyword")}function EK$($,X){let Q=$.self.RULES.all;for(let J in Q){let v=Q[J];if(typeof v=="object"&&(0,hZ.shouldUseRule)($.schema,v)){let{type:Y}=v.definition;if(Y.length&&!Y.some((z)=>PK$(X,z)))lZ($,`missing type "${Y.join(",")}" for keyword "${J}"`)}}}function PK$($,X){return $.includes(X)||X==="number"&&$.includes("integer")}function ig($,X){return $.includes(X)||X==="integer"&&$.includes("number")}function TK$($,X){let Q=[];for(let J of $.dataTypes)if(ig(X,J))Q.push(J);else if(X.includes("integer")&&J==="number")Q.push("integer");$.dataTypes=Q}function lZ($,X){let Q=$.schemaEnv.baseId+$.errSchemaPath;X+=` at "${Q}" (strictTypes)`,(0,q8.checkStrictMode)($,X,$.opts.strictTypes)}class pZ{constructor($,X,Q){if((0,u7.validateKeywordUsage)($,X,Q),this.gen=$.gen,this.allErrors=$.allErrors,this.keyword=Q,this.data=$.data,this.schema=$.schema[Q],this.$data=X.$data&&$.opts.$data&&this.schema&&this.schema.$data,this.schemaValue=(0,q8.schemaRefOrVal)($,this.schema,Q,this.$data),this.schemaType=X.schemaType,this.parentSchema=$.schema,this.params={},this.it=$,this.def=X,this.$data)this.schemaCode=$.gen.const("vSchema",Eg(this.$data,$));else if(this.schemaCode=this.schemaValue,!(0,u7.validSchemaType)(this.schema,X.schemaType,X.allowUndefined))throw Error(`${Q} value must be ${JSON.stringify(X.schemaType)}`);if("code"in X?X.trackErrors:X.errors!==!1)this.errsCount=$.gen.const("_errs",W$.default.errors)}result($,X,Q){this.failResult((0,a.not)($),X,Q)}failResult($,X,Q){if(this.gen.if($),Q)Q();else this.error();if(X){if(this.gen.else(),X(),this.allErrors)this.gen.endIf()}else if(this.allErrors)this.gen.endIf();else this.gen.else()}pass($,X){this.failResult((0,a.not)($),void 0,X)}fail($){if($===void 0){if(this.error(),!this.allErrors)this.gen.if(!1);return}if(this.gen.if($),this.error(),this.allErrors)this.gen.endIf();else this.gen.else()}fail$data($){if(!this.$data)return this.fail($);let{schemaCode:X}=this;this.fail(a._`${X} !== undefined && (${(0,a.or)(this.invalid$data(),$)})`)}error($,X,Q){if(X){this.setParams(X),this._error($,Q),this.setParams({});return}this._error($,Q)}_error($,X){($?C7.reportExtraError:C7.reportError)(this,this.def.error,X)}$dataError(){(0,C7.reportError)(this,this.def.$dataError||C7.keyword$DataError)}reset(){if(this.errsCount===void 0)throw Error('add "trackErrors" to keyword definition');(0,C7.resetErrorsCount)(this.gen,this.errsCount)}ok($){if(!this.allErrors)this.gen.if($)}setParams($,X){if(X)Object.assign(this.params,$);else this.params=$}block$data($,X,Q=a.nil){this.gen.block(()=>{this.check$data($,Q),X()})}check$data($=a.nil,X=a.nil){if(!this.$data)return;let{gen:Q,schemaCode:J,schemaType:v,def:Y}=this;if(Q.if((0,a.or)(a._`${J} === undefined`,X)),$!==a.nil)Q.assign($,!0);if(v.length||Y.validateSchema){if(Q.elseIf(this.invalid$data()),this.$dataError(),$!==a.nil)Q.assign($,!1)}Q.else()}invalid$data(){let{gen:$,schemaCode:X,schemaType:Q,def:J,it:v}=this;return(0,a.or)(Y(),z());function Y(){if(Q.length){if(!(X instanceof a.Name))throw Error("ajv implementation error");let W=Array.isArray(Q)?Q:[Q];return a._`${(0,cj.checkDataTypes)(W,X,v.opts.strictNumbers,cj.DataType.Wrong)}`}return a.nil}function z(){if(J.validateSchema){let W=$.scopeValue("validate$data",{ref:J.validateSchema});return a._`!${W}(${X})`}return a.nil}}subschema($,X){let Q=(0,mZ.getSubschema)(this.it,$);(0,mZ.extendSubschemaData)(Q,this.it,$),(0,mZ.extendSubschemaMode)(Q,$);let J={...this.it,...Q,items:void 0,props:void 0};return OK$(J,X),J}mergeEvaluated($,X){let{it:Q,gen:J}=this;if(!Q.opts.unevaluated)return;if(Q.props!==!0&&$.props!==void 0)Q.props=q8.mergeEvaluated.props(J,$.props,Q.props,X);if(Q.items!==!0&&$.items!==void 0)Q.items=q8.mergeEvaluated.items(J,$.items,Q.items,X)}mergeValidEvaluated($,X){let{it:Q,gen:J}=this;if(Q.opts.unevaluated&&(Q.props!==!0||Q.items!==!0))return J.if(X,()=>this.mergeEvaluated($,a.Name)),!0}}M9.KeywordCxt=pZ;function Rg($,X,Q,J){let v=new pZ($,Q,X);if("code"in Q)Q.code(v,J);else if(v.$data&&Q.validate)(0,u7.funcKeywordCode)(v,Q);else if("macro"in Q)(0,u7.macroKeywordCode)(v,Q);else if(Q.compile||Q.validate)(0,u7.funcKeywordCode)(v,Q)}var SK$=/^\/(?:[^~]|~0|~1)*$/,CK$=/^([0-9]+)(#|\/(?:[^~]|~0|~1)*)?$/;function Eg($,{dataLevel:X,dataNames:Q,dataPathArr:J}){let v,Y;if($==="")return W$.default.rootData;if($[0]==="/"){if(!SK$.test($))throw Error(`Invalid JSON-pointer: ${$}`);v=$,Y=W$.default.rootData}else{let U=CK$.exec($);if(!U)throw Error(`Invalid JSON-pointer: ${$}`);let w=+U[1];if(v=U[2],v==="#"){if(w>=X)throw Error(G("property/index",w));return J[X-w]}if(w>X)throw Error(G("data",w));if(Y=Q[X-w],!v)return Y}let z=Y,W=v.split("/");for(let U of W)if(U)Y=a._`${Y}${(0,a.getProperty)((0,q8.unescapeJsonPointer)(U))}`,z=a._`${z} && ${Y}`;return z;function G(U,w){return`Cannot access ${U} ${w} levels up, current level is ${X}`}}M9.getData=Eg});var mj=N((dZ)=>{Object.defineProperty(dZ,"__esModule",{value:!0});class Pg extends Error{constructor($){super("validation failed");this.errors=$,this.ajv=this.validation=!0}}dZ.default=Pg});var y7=N((aZ)=>{Object.defineProperty(aZ,"__esModule",{value:!0});var oZ=S7();class Tg extends Error{constructor($,X,Q,J){super(J||`can't resolve reference ${Q} from id ${X}`);this.missingRef=(0,oZ.resolveUrl)($,X,Q),this.missingSchema=(0,oZ.normalizeId)((0,oZ.getFullPath)($,this.missingRef))}}aZ.default=Tg});var lj=N((T1)=>{Object.defineProperty(T1,"__esModule",{value:!0});T1.resolveSchema=T1.getCompilingSchema=T1.resolveRef=T1.compileSchema=T1.SchemaEnv=void 0;var G0=D$(),uK$=mj(),RX=Z8(),U0=S7(),Sg=b$(),_K$=_7();class f7{constructor($){var X;this.refs={},this.dynamicAnchors={};let Q;if(typeof $.schema=="object")Q=$.schema;this.schema=$.schema,this.schemaId=$.schemaId,this.root=$.root||this,this.baseId=(X=$.baseId)!==null&&X!==void 0?X:(0,U0.normalizeId)(Q===null||Q===void 0?void 0:Q[$.schemaId||"$id"]),this.schemaPath=$.schemaPath,this.localRefs=$.localRefs,this.meta=$.meta,this.$async=Q===null||Q===void 0?void 0:Q.$async,this.refs={}}}T1.SchemaEnv=f7;function tZ($){let X=Cg.call(this,$);if(X)return X;let Q=(0,U0.getFullPath)(this.opts.uriResolver,$.root.baseId),{es5:J,lines:v}=this.opts.code,{ownProperties:Y}=this.opts,z=new G0.CodeGen(this.scope,{es5:J,lines:v,ownProperties:Y}),W;if($.$async)W=z.scopeValue("Error",{ref:uK$.default,code:G0._`require("ajv/dist/runtime/validation_error").default`});let G=z.scopeName("validate");$.validateName=G;let U={gen:z,allErrors:this.opts.allErrors,data:RX.default.data,parentData:RX.default.parentData,parentDataProperty:RX.default.parentDataProperty,dataNames:[RX.default.data],dataPathArr:[G0.nil],dataLevel:0,dataTypes:[],definedProperties:new Set,topSchemaRef:z.scopeValue("schema",this.opts.code.source===!0?{ref:$.schema,code:(0,G0.stringify)($.schema)}:{ref:$.schema}),validateName:G,ValidationError:W,schema:$.schema,schemaEnv:$,rootId:Q,baseId:$.baseId||Q,schemaPath:G0.nil,errSchemaPath:$.schemaPath||(this.opts.jtd?"":"#"),errorPath:G0._`""`,opts:this.opts,self:this},w;try{this._compilations.add($),(0,_K$.validateFunctionCode)(U),z.optimize(this.opts.code.optimize);let H=z.toString();if(w=`${z.scopeRefs(RX.default.scope)}return ${H}`,this.opts.code.process)w=this.opts.code.process(w,$);let V=Function(`${RX.default.self}`,`${RX.default.scope}`,w)(this,this.scope.get());if(this.scope.value(G,{ref:V}),V.errors=null,V.schema=$.schema,V.schemaEnv=$,$.$async)V.$async=!0;if(this.opts.code.source===!0)V.source={validateName:G,validateCode:H,scopeValues:z._values};if(this.opts.unevaluated){let{props:B,items:D}=U;if(V.evaluated={props:B instanceof G0.Name?void 0:B,items:D instanceof G0.Name?void 0:D,dynamicProps:B instanceof G0.Name,dynamicItems:D instanceof G0.Name},V.source)V.source.evaluated=(0,G0.stringify)(V.evaluated)}return $.validate=V,$}catch(H){if(delete $.validate,delete $.validateName,w)this.logger.error("Error compiling schema, function code:",w);throw H}finally{this._compilations.delete($)}}T1.compileSchema=tZ;function yK$($,X,Q){var J;Q=(0,U0.resolveUrl)(this.opts.uriResolver,X,Q);let v=$.refs[Q];if(v)return v;let Y=nK$.call(this,$,Q);if(Y===void 0){let z=(J=$.localRefs)===null||J===void 0?void 0:J[Q],{schemaId:W}=this.opts;if(z)Y=new f7({schema:z,schemaId:W,root:$,baseId:X})}if(Y===void 0)return;return $.refs[Q]=fK$.call(this,Y)}T1.resolveRef=yK$;function fK$($){if((0,U0.inlineRef)($.schema,this.opts.inlineRefs))return $.schema;return $.validate?$:tZ.call(this,$)}function Cg($){for(let X of this._compilations)if(kK$(X,$))return X}T1.getCompilingSchema=Cg;function kK$($,X){return $.schema===X.schema&&$.root===X.root&&$.baseId===X.baseId}function nK$($,X){let Q;while(typeof(Q=this.refs[X])=="string")X=Q;return Q||this.schemas[X]||hj.call(this,$,X)}function hj($,X){let Q=this.opts.uriResolver.parse(X),J=(0,U0._getFullPath)(this.opts.uriResolver,Q),v=(0,U0.getFullPath)(this.opts.uriResolver,$.baseId,void 0);if(Object.keys($.schema).length>0&&J===v)return rZ.call(this,Q,$);let Y=(0,U0.normalizeId)(J),z=this.refs[Y]||this.schemas[Y];if(typeof z=="string"){let W=hj.call(this,$,z);if(typeof(W===null||W===void 0?void 0:W.schema)!=="object")return;return rZ.call(this,Q,W)}if(typeof(z===null||z===void 0?void 0:z.schema)!=="object")return;if(!z.validate)tZ.call(this,z);if(Y===(0,U0.normalizeId)(X)){let{schema:W}=z,{schemaId:G}=this.opts,U=W[G];if(U)v=(0,U0.resolveUrl)(this.opts.uriResolver,v,U);return new f7({schema:W,schemaId:G,root:$,baseId:v})}return rZ.call(this,Q,z)}T1.resolveSchema=hj;var gK$=new Set(["properties","patternProperties","enum","dependencies","definitions"]);function rZ($,{baseId:X,schema:Q,root:J}){var v;if(((v=$.fragment)===null||v===void 0?void 0:v[0])!=="/")return;for(let W of $.fragment.slice(1).split("/")){if(typeof Q==="boolean")return;let G=Q[(0,Sg.unescapeFragment)(W)];if(G===void 0)return;Q=G;let U=typeof Q==="object"&&Q[this.opts.schemaId];if(!gK$.has(W)&&U)X=(0,U0.resolveUrl)(this.opts.uriResolver,X,U)}let Y;if(typeof Q!="boolean"&&Q.$ref&&!(0,Sg.schemaHasRulesButRef)(Q,this.RULES)){let W=(0,U0.resolveUrl)(this.opts.uriResolver,X,Q.$ref);Y=hj.call(this,J,W)}let{schemaId:z}=this.opts;if(Y=Y||new f7({schema:Q,schemaId:z,root:J,baseId:X}),Y.schema!==Y.root.schema)return Y;return}});var ug=N((Eu$,cK$)=>{cK$.exports={$id:"https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#",description:"Meta-schema for $data reference (JSON AnySchema extension proposal)",type:"object",required:["$data"],properties:{$data:{type:"string",anyOf:[{format:"relative-json-pointer"},{format:"json-pointer"}]}},additionalProperties:!1}});var eZ=N((Pu$,kg)=>{var mK$=RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu),yg=RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);function sZ($){let X="",Q=0,J=0;for(J=0;J<$.length;J++){if(Q=$[J].charCodeAt(0),Q===48)continue;if(!(Q>=48&&Q<=57||Q>=65&&Q<=70||Q>=97&&Q<=102))return"";X+=$[J];break}for(J+=1;J<$.length;J++){if(Q=$[J].charCodeAt(0),!(Q>=48&&Q<=57||Q>=65&&Q<=70||Q>=97&&Q<=102))return"";X+=$[J]}return X}var hK$=RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u);function _g($){return $.length=0,!0}function lK$($,X,Q){if($.length){let J=sZ($);if(J!=="")X.push(J);else return Q.error=!0,!1;$.length=0}return!0}function pK$($){let X=0,Q={error:!1,address:"",zone:""},J=[],v=[],Y=!1,z=!1,W=lK$;for(let G=0;G<$.length;G++){let U=$[G];if(U==="["||U==="]")continue;if(U===":"){if(Y===!0)z=!0;if(!W(v,J,Q))break;if(++X>7){Q.error=!0;break}if(G>0&&$[G-1]===":")Y=!0;J.push(":");continue}else if(U==="%"){if(!W(v,J,Q))break;W=_g}else{v.push(U);continue}}if(v.length)if(W===_g)Q.zone=v.join("");else if(z)J.push(v.join(""));else J.push(sZ(v));return Q.address=J.join(""),Q}function fg($){if(dK$($,":")<2)return{host:$,isIPV6:!1};let X=pK$($);if(!X.error){let{address:Q,address:J}=X;if(X.zone)Q+="%"+X.zone,J+="%25"+X.zone;return{host:Q,isIPV6:!0,escapedHost:J}}else return{host:$,isIPV6:!1}}function dK$($,X){let Q=0;for(let J=0;J<$.length;J++)if($[J]===X)Q++;return Q}function oK$($){let X=$,Q=[],J=-1,v=0;while(v=X.length){if(v===1)if(X===".")break;else if(X==="/"){Q.push("/");break}else{Q.push(X);break}else if(v===2){if(X[0]==="."){if(X[1]===".")break;else if(X[1]==="/"){X=X.slice(2);continue}}else if(X[0]==="/"){if(X[1]==="."||X[1]==="/"){Q.push("/");break}}}else if(v===3){if(X==="/.."){if(Q.length!==0)Q.pop();Q.push("/");break}}if(X[0]==="."){if(X[1]==="."){if(X[2]==="/"){X=X.slice(3);continue}}else if(X[1]==="/"){X=X.slice(2);continue}}else if(X[0]==="/"){if(X[1]==="."){if(X[2]==="/"){X=X.slice(2);continue}else if(X[2]==="."){if(X[3]==="/"){if(X=X.slice(3),Q.length!==0)Q.pop();continue}}}}if((J=X.indexOf("/",1))===-1){Q.push(X);break}else Q.push(X.slice(0,J)),X=X.slice(J)}return Q.join("")}function aK$($,X){let Q=X!==!0?escape:unescape;if($.scheme!==void 0)$.scheme=Q($.scheme);if($.userinfo!==void 0)$.userinfo=Q($.userinfo);if($.host!==void 0)$.host=Q($.host);if($.path!==void 0)$.path=Q($.path);if($.query!==void 0)$.query=Q($.query);if($.fragment!==void 0)$.fragment=Q($.fragment);return $}function rK$($){let X=[];if($.userinfo!==void 0)X.push($.userinfo),X.push("@");if($.host!==void 0){let Q=unescape($.host);if(!yg(Q)){let J=fg(Q);if(J.isIPV6===!0)Q=`[${J.escapedHost}]`;else Q=$.host}X.push(Q)}if(typeof $.port==="number"||typeof $.port==="string")X.push(":"),X.push(String($.port));return X.length?X.join(""):void 0}kg.exports={nonSimpleDomain:hK$,recomposeAuthority:rK$,normalizeComponentEncoding:aK$,removeDotSegments:oK$,isIPv4:yg,isUUID:mK$,normalizeIPv6:fg,stringArrayToHexStripped:sZ}});var hg=N((Tu$,mg)=>{var{isUUID:tK$}=eZ(),sK$=/([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu,eK$=["http","https","ws","wss","urn","urn:uuid"];function $w$($){return eK$.indexOf($)!==-1}function $q($){if($.secure===!0)return!0;else if($.secure===!1)return!1;else if($.scheme)return $.scheme.length===3&&($.scheme[0]==="w"||$.scheme[0]==="W")&&($.scheme[1]==="s"||$.scheme[1]==="S")&&($.scheme[2]==="s"||$.scheme[2]==="S");else return!1}function ng($){if(!$.host)$.error=$.error||"HTTP URIs must have a host.";return $}function gg($){let X=String($.scheme).toLowerCase()==="https";if($.port===(X?443:80)||$.port==="")$.port=void 0;if(!$.path)$.path="/";return $}function Xw$($){return $.secure=$q($),$.resourceName=($.path||"/")+($.query?"?"+$.query:""),$.path=void 0,$.query=void 0,$}function Qw$($){if($.port===($q($)?443:80)||$.port==="")$.port=void 0;if(typeof $.secure==="boolean")$.scheme=$.secure?"wss":"ws",$.secure=void 0;if($.resourceName){let[X,Q]=$.resourceName.split("?");$.path=X&&X!=="/"?X:void 0,$.query=Q,$.resourceName=void 0}return $.fragment=void 0,$}function vw$($,X){if(!$.path)return $.error="URN can not be parsed",$;let Q=$.path.match(sK$);if(Q){let J=X.scheme||$.scheme||"urn";$.nid=Q[1].toLowerCase(),$.nss=Q[2];let v=`${J}:${X.nid||$.nid}`,Y=Xq(v);if($.path=void 0,Y)$=Y.parse($,X)}else $.error=$.error||"URN can not be parsed.";return $}function Jw$($,X){if($.nid===void 0)throw Error("URN without nid cannot be serialized");let Q=X.scheme||$.scheme||"urn",J=$.nid.toLowerCase(),v=`${Q}:${X.nid||J}`,Y=Xq(v);if(Y)$=Y.serialize($,X);let z=$,W=$.nss;return z.path=`${J||X.nid}:${W}`,X.skipEscape=!0,z}function Yw$($,X){let Q=$;if(Q.uuid=Q.nss,Q.nss=void 0,!X.tolerant&&(!Q.uuid||!tK$(Q.uuid)))Q.error=Q.error||"UUID is not valid.";return Q}function zw$($){let X=$;return X.nss=($.uuid||"").toLowerCase(),X}var cg={scheme:"http",domainHost:!0,parse:ng,serialize:gg},Ww$={scheme:"https",domainHost:cg.domainHost,parse:ng,serialize:gg},pj={scheme:"ws",domainHost:!0,parse:Xw$,serialize:Qw$},Gw$={scheme:"wss",domainHost:pj.domainHost,parse:pj.parse,serialize:pj.serialize},Uw$={scheme:"urn",parse:vw$,serialize:Jw$,skipNormalize:!0},Kw$={scheme:"urn:uuid",parse:Yw$,serialize:zw$,skipNormalize:!0},dj={http:cg,https:Ww$,ws:pj,wss:Gw$,urn:Uw$,"urn:uuid":Kw$};Object.setPrototypeOf(dj,null);function Xq($){return $&&(dj[$]||dj[$.toLowerCase()])||void 0}mg.exports={wsIsSecure:$q,SCHEMES:dj,isValidSchemeName:$w$,getSchemeHandler:Xq}});var dg=N((Su$,aj)=>{var{normalizeIPv6:ww$,removeDotSegments:k7,recomposeAuthority:Hw$,normalizeComponentEncoding:oj,isIPv4:jw$,nonSimpleDomain:Vw$}=eZ(),{SCHEMES:Bw$,getSchemeHandler:lg}=hg();function Dw$($,X){if(typeof $==="string")$=m0(I8($,X),X);else if(typeof $==="object")$=I8(m0($,X),X);return $}function Fw$($,X,Q){let J=Q?Object.assign({scheme:"null"},Q):{scheme:"null"},v=pg(I8($,J),I8(X,J),J,!0);return J.skipEscape=!0,m0(v,J)}function pg($,X,Q,J){let v={};if(!J)$=I8(m0($,Q),Q),X=I8(m0(X,Q),Q);if(Q=Q||{},!Q.tolerant&&X.scheme)v.scheme=X.scheme,v.userinfo=X.userinfo,v.host=X.host,v.port=X.port,v.path=k7(X.path||""),v.query=X.query;else{if(X.userinfo!==void 0||X.host!==void 0||X.port!==void 0)v.userinfo=X.userinfo,v.host=X.host,v.port=X.port,v.path=k7(X.path||""),v.query=X.query;else{if(!X.path)if(v.path=$.path,X.query!==void 0)v.query=X.query;else v.query=$.query;else{if(X.path[0]==="/")v.path=k7(X.path);else{if(($.userinfo!==void 0||$.host!==void 0||$.port!==void 0)&&!$.path)v.path="/"+X.path;else if(!$.path)v.path=X.path;else v.path=$.path.slice(0,$.path.lastIndexOf("/")+1)+X.path;v.path=k7(v.path)}v.query=X.query}v.userinfo=$.userinfo,v.host=$.host,v.port=$.port}v.scheme=$.scheme}return v.fragment=X.fragment,v}function Nw$($,X,Q){if(typeof $==="string")$=unescape($),$=m0(oj(I8($,Q),!0),{...Q,skipEscape:!0});else if(typeof $==="object")$=m0(oj($,!0),{...Q,skipEscape:!0});if(typeof X==="string")X=unescape(X),X=m0(oj(I8(X,Q),!0),{...Q,skipEscape:!0});else if(typeof X==="object")X=m0(oj(X,!0),{...Q,skipEscape:!0});return $.toLowerCase()===X.toLowerCase()}function m0($,X){let Q={host:$.host,scheme:$.scheme,userinfo:$.userinfo,port:$.port,path:$.path,query:$.query,nid:$.nid,nss:$.nss,uuid:$.uuid,fragment:$.fragment,reference:$.reference,resourceName:$.resourceName,secure:$.secure,error:""},J=Object.assign({},X),v=[],Y=lg(J.scheme||Q.scheme);if(Y&&Y.serialize)Y.serialize(Q,J);if(Q.path!==void 0)if(!J.skipEscape){if(Q.path=escape(Q.path),Q.scheme!==void 0)Q.path=Q.path.split("%3A").join(":")}else Q.path=unescape(Q.path);if(J.reference!=="suffix"&&Q.scheme)v.push(Q.scheme,":");let z=Hw$(Q);if(z!==void 0){if(J.reference!=="suffix")v.push("//");if(v.push(z),Q.path&&Q.path[0]!=="/")v.push("/")}if(Q.path!==void 0){let W=Q.path;if(!J.absolutePath&&(!Y||!Y.absolutePath))W=k7(W);if(z===void 0&&W[0]==="/"&&W[1]==="/")W="/%2F"+W.slice(2);v.push(W)}if(Q.query!==void 0)v.push("?",Q.query);if(Q.fragment!==void 0)v.push("#",Q.fragment);return v.join("")}var Ow$=/^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;function I8($,X){let Q=Object.assign({},X),J={scheme:void 0,userinfo:void 0,host:"",port:void 0,path:"",query:void 0,fragment:void 0},v=!1;if(Q.reference==="suffix")if(Q.scheme)$=Q.scheme+":"+$;else $="//"+$;let Y=$.match(Ow$);if(Y){if(J.scheme=Y[1],J.userinfo=Y[3],J.host=Y[4],J.port=parseInt(Y[5],10),J.path=Y[6]||"",J.query=Y[7],J.fragment=Y[8],isNaN(J.port))J.port=Y[5];if(J.host)if(jw$(J.host)===!1){let G=ww$(J.host);J.host=G.host.toLowerCase(),v=G.isIPV6}else v=!0;if(J.scheme===void 0&&J.userinfo===void 0&&J.host===void 0&&J.port===void 0&&J.query===void 0&&!J.path)J.reference="same-document";else if(J.scheme===void 0)J.reference="relative";else if(J.fragment===void 0)J.reference="absolute";else J.reference="uri";if(Q.reference&&Q.reference!=="suffix"&&Q.reference!==J.reference)J.error=J.error||"URI is not a "+Q.reference+" reference.";let z=lg(Q.scheme||J.scheme);if(!Q.unicodeSupport&&(!z||!z.unicodeSupport)){if(J.host&&(Q.domainHost||z&&z.domainHost)&&v===!1&&Vw$(J.host))try{J.host=URL.domainToASCII(J.host.toLowerCase())}catch(W){J.error=J.error||"Host's domain name can not be converted to ASCII: "+W}}if(!z||z&&!z.skipNormalize){if($.indexOf("%")!==-1){if(J.scheme!==void 0)J.scheme=unescape(J.scheme);if(J.host!==void 0)J.host=unescape(J.host)}if(J.path)J.path=escape(unescape(J.path));if(J.fragment)J.fragment=encodeURI(decodeURIComponent(J.fragment))}if(z&&z.parse)z.parse(J,Q)}else J.error=J.error||"URI can not be parsed.";return J}var Qq={SCHEMES:Bw$,normalize:Dw$,resolve:Fw$,resolveComponent:pg,equal:Nw$,serialize:m0,parse:I8};aj.exports=Qq;aj.exports.default=Qq;aj.exports.fastUri=Qq});var ag=N((vq)=>{Object.defineProperty(vq,"__esModule",{value:!0});var og=dg();og.code='require("ajv/dist/runtime/uri").default';vq.default=og});var vc=N((s4)=>{Object.defineProperty(s4,"__esModule",{value:!0});s4.CodeGen=s4.Name=s4.nil=s4.stringify=s4.str=s4._=s4.KeywordCxt=void 0;var Lw$=_7();Object.defineProperty(s4,"KeywordCxt",{enumerable:!0,get:function(){return Lw$.KeywordCxt}});var HJ=D$();Object.defineProperty(s4,"_",{enumerable:!0,get:function(){return HJ._}});Object.defineProperty(s4,"str",{enumerable:!0,get:function(){return HJ.str}});Object.defineProperty(s4,"stringify",{enumerable:!0,get:function(){return HJ.stringify}});Object.defineProperty(s4,"nil",{enumerable:!0,get:function(){return HJ.nil}});Object.defineProperty(s4,"Name",{enumerable:!0,get:function(){return HJ.Name}});Object.defineProperty(s4,"CodeGen",{enumerable:!0,get:function(){return HJ.CodeGen}});var Aw$=mj(),$c=y7(),Zw$=SZ(),n7=lj(),qw$=D$(),g7=S7(),rj=T7(),Yq=b$(),rg=ug(),Iw$=ag(),Xc=($,X)=>new RegExp($,X);Xc.code="new RegExp";var Mw$=["removeAdditional","useDefaults","coerceTypes"],xw$=new Set(["validate","serialize","parse","wrapper","root","schema","keyword","pattern","formats","validate$data","func","obj","Error"]),bw$={errorDataPath:"",format:"`validateFormats: false` can be used instead.",nullable:'"nullable" keyword is supported by default.',jsonPointers:"Deprecated jsPropertySyntax can be used instead.",extendRefs:"Deprecated ignoreKeywordsWithRef can be used instead.",missingRefs:"Pass empty schema with $id that should be ignored to ajv.addSchema.",processCode:"Use option `code: {process: (code, schemaEnv: object) => string}`",sourceCode:"Use option `code: {source: true}`",strictDefaults:"It is default now, see option `strict`.",strictKeywords:"It is default now, see option `strict`.",uniqueItems:'"uniqueItems" keyword is always validated.',unknownFormats:"Disable strict mode or pass `true` to `ajv.addFormat` (or `formats` option).",cache:"Map is used as cache, schema object as key.",serialize:"Map is used as cache, schema object as key.",ajvErrors:"It is default now."},iw$={ignoreKeywordsWithRef:"",jsPropertySyntax:"",unicode:'"minLength"/"maxLength" account for unicode characters by default.'},tg=200;function Rw$($){var X,Q,J,v,Y,z,W,G,U,w,H,j,V,B,D,F,L,A,O,i,M,R,E,v$,q$;let U$=$.strict,f4=(X=$.code)===null||X===void 0?void 0:X.optimize,a6=f4===!0||f4===void 0?1:f4||0,H6=(J=(Q=$.code)===null||Q===void 0?void 0:Q.regExp)!==null&&J!==void 0?J:Xc,d0=(v=$.uriResolver)!==null&&v!==void 0?v:Iw$.default;return{strictSchema:(z=(Y=$.strictSchema)!==null&&Y!==void 0?Y:U$)!==null&&z!==void 0?z:!0,strictNumbers:(G=(W=$.strictNumbers)!==null&&W!==void 0?W:U$)!==null&&G!==void 0?G:!0,strictTypes:(w=(U=$.strictTypes)!==null&&U!==void 0?U:U$)!==null&&w!==void 0?w:"log",strictTuples:(j=(H=$.strictTuples)!==null&&H!==void 0?H:U$)!==null&&j!==void 0?j:"log",strictRequired:(B=(V=$.strictRequired)!==null&&V!==void 0?V:U$)!==null&&B!==void 0?B:!1,code:$.code?{...$.code,optimize:a6,regExp:H6}:{optimize:a6,regExp:H6},loopRequired:(D=$.loopRequired)!==null&&D!==void 0?D:tg,loopEnum:(F=$.loopEnum)!==null&&F!==void 0?F:tg,meta:(L=$.meta)!==null&&L!==void 0?L:!0,messages:(A=$.messages)!==null&&A!==void 0?A:!0,inlineRefs:(O=$.inlineRefs)!==null&&O!==void 0?O:!0,schemaId:(i=$.schemaId)!==null&&i!==void 0?i:"$id",addUsedSchema:(M=$.addUsedSchema)!==null&&M!==void 0?M:!0,validateSchema:(R=$.validateSchema)!==null&&R!==void 0?R:!0,validateFormats:(E=$.validateFormats)!==null&&E!==void 0?E:!0,unicodeRegExp:(v$=$.unicodeRegExp)!==null&&v$!==void 0?v$:!0,int32range:(q$=$.int32range)!==null&&q$!==void 0?q$:!0,uriResolver:d0}}class tj{constructor($={}){this.schemas={},this.refs={},this.formats={},this._compilations=new Set,this._loading={},this._cache=new Map,$=this.opts={...$,...Rw$($)};let{es5:X,lines:Q}=this.opts.code;this.scope=new qw$.ValueScope({scope:{},prefixes:xw$,es5:X,lines:Q}),this.logger=uw$($.logger);let J=$.validateFormats;if($.validateFormats=!1,this.RULES=(0,Zw$.getRules)(),sg.call(this,bw$,$,"NOT SUPPORTED"),sg.call(this,iw$,$,"DEPRECATED","warn"),this._metaOpts=Sw$.call(this),$.formats)Pw$.call(this);if(this._addVocabularies(),this._addDefaultMetaSchema(),$.keywords)Tw$.call(this,$.keywords);if(typeof $.meta=="object")this.addMetaSchema($.meta);Ew$.call(this),$.validateFormats=J}_addVocabularies(){this.addKeyword("$async")}_addDefaultMetaSchema(){let{$data:$,meta:X,schemaId:Q}=this.opts,J=rg;if(Q==="id")J={...rg},J.id=J.$id,delete J.$id;if(X&&$)this.addMetaSchema(J,J[Q],!1)}defaultMeta(){let{meta:$,schemaId:X}=this.opts;return this.opts.defaultMeta=typeof $=="object"?$[X]||$:void 0}validate($,X){let Q;if(typeof $=="string"){if(Q=this.getSchema($),!Q)throw Error(`no schema with key or ref "${$}"`)}else Q=this.compile($);let J=Q(X);if(!("$async"in Q))this.errors=Q.errors;return J}compile($,X){let Q=this._addSchema($,X);return Q.validate||this._compileSchemaEnv(Q)}compileAsync($,X){if(typeof this.opts.loadSchema!="function")throw Error("options.loadSchema should be a function");let{loadSchema:Q}=this.opts;return J.call(this,$,X);async function J(U,w){await v.call(this,U.$schema);let H=this._addSchema(U,w);return H.validate||Y.call(this,H)}async function v(U){if(U&&!this.getSchema(U))await J.call(this,{$ref:U},!0)}async function Y(U){try{return this._compileSchemaEnv(U)}catch(w){if(!(w instanceof $c.default))throw w;return z.call(this,w),await W.call(this,w.missingSchema),Y.call(this,U)}}function z({missingSchema:U,missingRef:w}){if(this.refs[U])throw Error(`AnySchema ${U} is loaded but ${w} cannot be resolved`)}async function W(U){let w=await G.call(this,U);if(!this.refs[U])await v.call(this,w.$schema);if(!this.refs[U])this.addSchema(w,U,X)}async function G(U){let w=this._loading[U];if(w)return w;try{return await(this._loading[U]=Q(U))}finally{delete this._loading[U]}}}addSchema($,X,Q,J=this.opts.validateSchema){if(Array.isArray($)){for(let Y of $)this.addSchema(Y,void 0,Q,J);return this}let v;if(typeof $==="object"){let{schemaId:Y}=this.opts;if(v=$[Y],v!==void 0&&typeof v!="string")throw Error(`schema ${Y} must be string`)}return X=(0,g7.normalizeId)(X||v),this._checkUnique(X),this.schemas[X]=this._addSchema($,Q,X,J,!0),this}addMetaSchema($,X,Q=this.opts.validateSchema){return this.addSchema($,X,!0,Q),this}validateSchema($,X){if(typeof $=="boolean")return!0;let Q;if(Q=$.$schema,Q!==void 0&&typeof Q!="string")throw Error("$schema must be a string");if(Q=Q||this.opts.defaultMeta||this.defaultMeta(),!Q)return this.logger.warn("meta-schema not available"),this.errors=null,!0;let J=this.validate(Q,$);if(!J&&X){let v="schema is invalid: "+this.errorsText();if(this.opts.validateSchema==="log")this.logger.error(v);else throw Error(v)}return J}getSchema($){let X;while(typeof(X=eg.call(this,$))=="string")$=X;if(X===void 0){let{schemaId:Q}=this.opts,J=new n7.SchemaEnv({schema:{},schemaId:Q});if(X=n7.resolveSchema.call(this,J,$),!X)return;this.refs[$]=X}return X.validate||this._compileSchemaEnv(X)}removeSchema($){if($ instanceof RegExp)return this._removeAllSchemas(this.schemas,$),this._removeAllSchemas(this.refs,$),this;switch(typeof $){case"undefined":return this._removeAllSchemas(this.schemas),this._removeAllSchemas(this.refs),this._cache.clear(),this;case"string":{let X=eg.call(this,$);if(typeof X=="object")this._cache.delete(X.schema);return delete this.schemas[$],delete this.refs[$],this}case"object":{let X=$;this._cache.delete(X);let Q=$[this.opts.schemaId];if(Q)Q=(0,g7.normalizeId)(Q),delete this.schemas[Q],delete this.refs[Q];return this}default:throw Error("ajv.removeSchema: invalid parameter")}}addVocabulary($){for(let X of $)this.addKeyword(X);return this}addKeyword($,X){let Q;if(typeof $=="string"){if(Q=$,typeof X=="object")this.logger.warn("these parameters are deprecated, see docs for addKeyword"),X.keyword=Q}else if(typeof $=="object"&&X===void 0){if(X=$,Q=X.keyword,Array.isArray(Q)&&!Q.length)throw Error("addKeywords: keyword must be string or non-empty array")}else throw Error("invalid addKeywords parameters");if(yw$.call(this,Q,X),!X)return(0,Yq.eachItem)(Q,(v)=>Jq.call(this,v)),this;kw$.call(this,X);let J={...X,type:(0,rj.getJSONTypes)(X.type),schemaType:(0,rj.getJSONTypes)(X.schemaType)};return(0,Yq.eachItem)(Q,J.type.length===0?(v)=>Jq.call(this,v,J):(v)=>J.type.forEach((Y)=>Jq.call(this,v,J,Y))),this}getKeyword($){let X=this.RULES.all[$];return typeof X=="object"?X.definition:!!X}removeKeyword($){let{RULES:X}=this;delete X.keywords[$],delete X.all[$];for(let Q of X.rules){let J=Q.rules.findIndex((v)=>v.keyword===$);if(J>=0)Q.rules.splice(J,1)}return this}addFormat($,X){if(typeof X=="string")X=new RegExp(X);return this.formats[$]=X,this}errorsText($=this.errors,{separator:X=", ",dataVar:Q="data"}={}){if(!$||$.length===0)return"No errors";return $.map((J)=>`${Q}${J.instancePath} ${J.message}`).reduce((J,v)=>J+X+v)}$dataMetaSchema($,X){let Q=this.RULES.all;$=JSON.parse(JSON.stringify($));for(let J of X){let v=J.split("/").slice(1),Y=$;for(let z of v)Y=Y[z];for(let z in Q){let W=Q[z];if(typeof W!="object")continue;let{$data:G}=W.definition,U=Y[z];if(G&&U)Y[z]=Qc(U)}}return $}_removeAllSchemas($,X){for(let Q in $){let J=$[Q];if(!X||X.test(Q)){if(typeof J=="string")delete $[Q];else if(J&&!J.meta)this._cache.delete(J.schema),delete $[Q]}}}_addSchema($,X,Q,J=this.opts.validateSchema,v=this.opts.addUsedSchema){let Y,{schemaId:z}=this.opts;if(typeof $=="object")Y=$[z];else if(this.opts.jtd)throw Error("schema must be object");else if(typeof $!="boolean")throw Error("schema must be object or boolean");let W=this._cache.get($);if(W!==void 0)return W;Q=(0,g7.normalizeId)(Y||Q);let G=g7.getSchemaRefs.call(this,$,Q);if(W=new n7.SchemaEnv({schema:$,schemaId:z,meta:X,baseId:Q,localRefs:G}),this._cache.set(W.schema,W),v&&!Q.startsWith("#")){if(Q)this._checkUnique(Q);this.refs[Q]=W}if(J)this.validateSchema($,!0);return W}_checkUnique($){if(this.schemas[$]||this.refs[$])throw Error(`schema with key or id "${$}" already exists`)}_compileSchemaEnv($){if($.meta)this._compileMetaSchema($);else n7.compileSchema.call(this,$);if(!$.validate)throw Error("ajv implementation error");return $.validate}_compileMetaSchema($){let X=this.opts;this.opts=this._metaOpts;try{n7.compileSchema.call(this,$)}finally{this.opts=X}}}tj.ValidationError=Aw$.default;tj.MissingRefError=$c.default;s4.default=tj;function sg($,X,Q,J="error"){for(let v in $){let Y=v;if(Y in X)this.logger[J](`${Q}: option ${v}. ${$[Y]}`)}}function eg($){return $=(0,g7.normalizeId)($),this.schemas[$]||this.refs[$]}function Ew$(){let $=this.opts.schemas;if(!$)return;if(Array.isArray($))this.addSchema($);else for(let X in $)this.addSchema($[X],X)}function Pw$(){for(let $ in this.opts.formats){let X=this.opts.formats[$];if(X)this.addFormat($,X)}}function Tw$($){if(Array.isArray($)){this.addVocabulary($);return}this.logger.warn("keywords option as map is deprecated, pass array");for(let X in $){let Q=$[X];if(!Q.keyword)Q.keyword=X;this.addKeyword(Q)}}function Sw$(){let $={...this.opts};for(let X of Mw$)delete $[X];return $}var Cw$={log(){},warn(){},error(){}};function uw$($){if($===!1)return Cw$;if($===void 0)return console;if($.log&&$.warn&&$.error)return $;throw Error("logger must implement log, warn and error methods")}var _w$=/^[a-z_$][a-z0-9_$:-]*$/i;function yw$($,X){let{RULES:Q}=this;if((0,Yq.eachItem)($,(J)=>{if(Q.keywords[J])throw Error(`Keyword ${J} is already defined`);if(!_w$.test(J))throw Error(`Keyword ${J} has invalid name`)}),!X)return;if(X.$data&&!(("code"in X)||("validate"in X)))throw Error('$data keyword must have "code" or "validate" function')}function Jq($,X,Q){var J;let v=X===null||X===void 0?void 0:X.post;if(Q&&v)throw Error('keyword with "post" flag cannot have "type"');let{RULES:Y}=this,z=v?Y.post:Y.rules.find(({type:G})=>G===Q);if(!z)z={type:Q,rules:[]},Y.rules.push(z);if(Y.keywords[$]=!0,!X)return;let W={keyword:$,definition:{...X,type:(0,rj.getJSONTypes)(X.type),schemaType:(0,rj.getJSONTypes)(X.schemaType)}};if(X.before)fw$.call(this,z,W,X.before);else z.rules.push(W);Y.all[$]=W,(J=X.implements)===null||J===void 0||J.forEach((G)=>this.addKeyword(G))}function fw$($,X,Q){let J=$.rules.findIndex((v)=>v.keyword===Q);if(J>=0)$.rules.splice(J,0,X);else $.rules.push(X),this.logger.warn(`rule ${Q} is not defined`)}function kw$($){let{metaSchema:X}=$;if(X===void 0)return;if($.$data&&this.opts.$data)X=Qc(X);$.validateSchema=this.compile(X,!0)}var nw$={$ref:"https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#"};function Qc($){return{anyOf:[$,nw$]}}});var Jc=N((zq)=>{Object.defineProperty(zq,"__esModule",{value:!0});var gw$={keyword:"id",code(){throw Error('NOT SUPPORTED: keyword "id", use "$id" for schema ID')}};zq.default=gw$});var Gc=N((EX)=>{Object.defineProperty(EX,"__esModule",{value:!0});EX.callRef=EX.getValidate=void 0;var cw$=y7(),Yc=P1(),o6=D$(),jJ=Z8(),zc=lj(),sj=b$(),mw$={keyword:"$ref",schemaType:"string",code($){let{gen:X,schema:Q,it:J}=$,{baseId:v,schemaEnv:Y,validateName:z,opts:W,self:G}=J,{root:U}=Y;if((Q==="#"||Q==="#/")&&v===U.baseId)return H();let w=zc.resolveRef.call(G,U,v,Q);if(w===void 0)throw new cw$.default(J.opts.uriResolver,v,Q);if(w instanceof zc.SchemaEnv)return j(w);return V(w);function H(){if(Y===U)return ej($,z,Y,Y.$async);let B=X.scopeValue("root",{ref:U});return ej($,o6._`${B}.validate`,U,U.$async)}function j(B){let D=Wc($,B);ej($,D,B,B.$async)}function V(B){let D=X.scopeValue("schema",W.code.source===!0?{ref:B,code:(0,o6.stringify)(B)}:{ref:B}),F=X.name("valid"),L=$.subschema({schema:B,dataTypes:[],schemaPath:o6.nil,topSchemaRef:D,errSchemaPath:Q},F);$.mergeEvaluated(L),$.ok(F)}}};function Wc($,X){let{gen:Q}=$;return X.validate?Q.scopeValue("validate",{ref:X.validate}):o6._`${Q.scopeValue("wrapper",{ref:X})}.validate`}EX.getValidate=Wc;function ej($,X,Q,J){let{gen:v,it:Y}=$,{allErrors:z,schemaEnv:W,opts:G}=Y,U=G.passContext?jJ.default.this:o6.nil;if(J)w();else H();function w(){if(!W.$async)throw Error("async schema referenced by sync schema");let B=v.let("valid");v.try(()=>{if(v.code(o6._`await ${(0,Yc.callValidateCode)($,X,U)}`),V(X),!z)v.assign(B,!0)},(D)=>{if(v.if(o6._`!(${D} instanceof ${Y.ValidationError})`,()=>v.throw(D)),j(D),!z)v.assign(B,!1)}),$.ok(B)}function H(){$.result((0,Yc.callValidateCode)($,X,U),()=>V(X),()=>j(X))}function j(B){let D=o6._`${B}.errors`;v.assign(jJ.default.vErrors,o6._`${jJ.default.vErrors} === null ? ${D} : ${jJ.default.vErrors}.concat(${D})`),v.assign(jJ.default.errors,o6._`${jJ.default.vErrors}.length`)}function V(B){var D;if(!Y.opts.unevaluated)return;let F=(D=Q===null||Q===void 0?void 0:Q.validate)===null||D===void 0?void 0:D.evaluated;if(Y.props!==!0)if(F&&!F.dynamicProps){if(F.props!==void 0)Y.props=sj.mergeEvaluated.props(v,F.props,Y.props)}else{let L=v.var("props",o6._`${B}.evaluated.props`);Y.props=sj.mergeEvaluated.props(v,L,Y.props,o6.Name)}if(Y.items!==!0)if(F&&!F.dynamicItems){if(F.items!==void 0)Y.items=sj.mergeEvaluated.items(v,F.items,Y.items)}else{let L=v.var("items",o6._`${B}.evaluated.items`);Y.items=sj.mergeEvaluated.items(v,L,Y.items,o6.Name)}}}EX.callRef=ej;EX.default=mw$});var Uc=N((Wq)=>{Object.defineProperty(Wq,"__esModule",{value:!0});var hw$=Jc(),lw$=Gc(),pw$=["$schema","$id","$defs","$vocabulary",{keyword:"$comment"},"definitions",hw$.default,lw$.default];Wq.default=pw$});var Kc=N((Gq)=>{Object.defineProperty(Gq,"__esModule",{value:!0});var $V=D$(),x9=$V.operators,XV={maximum:{okStr:"<=",ok:x9.LTE,fail:x9.GT},minimum:{okStr:">=",ok:x9.GTE,fail:x9.LT},exclusiveMaximum:{okStr:"<",ok:x9.LT,fail:x9.GTE},exclusiveMinimum:{okStr:">",ok:x9.GT,fail:x9.LTE}},dw$={message:({keyword:$,schemaCode:X})=>$V.str`must be ${XV[$].okStr} ${X}`,params:({keyword:$,schemaCode:X})=>$V._`{comparison: ${XV[$].okStr}, limit: ${X}}`},ow$={keyword:Object.keys(XV),type:"number",schemaType:"number",$data:!0,error:dw$,code($){let{keyword:X,data:Q,schemaCode:J}=$;$.fail$data($V._`${Q} ${XV[X].fail} ${J} || isNaN(${Q})`)}};Gq.default=ow$});var wc=N((Uq)=>{Object.defineProperty(Uq,"__esModule",{value:!0});var c7=D$(),aw$={message:({schemaCode:$})=>c7.str`must be multiple of ${$}`,params:({schemaCode:$})=>c7._`{multipleOf: ${$}}`},rw$={keyword:"multipleOf",type:"number",schemaType:"number",$data:!0,error:aw$,code($){let{gen:X,data:Q,schemaCode:J,it:v}=$,Y=v.opts.multipleOfPrecision,z=X.let("res"),W=Y?c7._`Math.abs(Math.round(${z}) - ${z}) > 1e-${Y}`:c7._`${z} !== parseInt(${z})`;$.fail$data(c7._`(${J} === 0 || (${z} = ${Q}/${J}, ${W}))`)}};Uq.default=rw$});var jc=N((Kq)=>{Object.defineProperty(Kq,"__esModule",{value:!0});function Hc($){let X=$.length,Q=0,J=0,v;while(J=55296&&v<=56319&&J{Object.defineProperty(wq,"__esModule",{value:!0});var PX=D$(),tw$=b$(),sw$=jc(),ew$={message({keyword:$,schemaCode:X}){let Q=$==="maxLength"?"more":"fewer";return PX.str`must NOT have ${Q} than ${X} characters`},params:({schemaCode:$})=>PX._`{limit: ${$}}`},$H$={keyword:["maxLength","minLength"],type:"string",schemaType:"number",$data:!0,error:ew$,code($){let{keyword:X,data:Q,schemaCode:J,it:v}=$,Y=X==="maxLength"?PX.operators.GT:PX.operators.LT,z=v.opts.unicode===!1?PX._`${Q}.length`:PX._`${(0,tw$.useFunc)($.gen,sw$.default)}(${Q})`;$.fail$data(PX._`${z} ${Y} ${J}`)}};wq.default=$H$});var Bc=N((Hq)=>{Object.defineProperty(Hq,"__esModule",{value:!0});var XH$=P1(),QH$=b$(),VJ=D$(),vH$={message:({schemaCode:$})=>VJ.str`must match pattern "${$}"`,params:({schemaCode:$})=>VJ._`{pattern: ${$}}`},JH$={keyword:"pattern",type:"string",schemaType:"string",$data:!0,error:vH$,code($){let{gen:X,data:Q,$data:J,schema:v,schemaCode:Y,it:z}=$,W=z.opts.unicodeRegExp?"u":"";if(J){let{regExp:G}=z.opts.code,U=G.code==="new RegExp"?VJ._`new RegExp`:(0,QH$.useFunc)(X,G),w=X.let("valid");X.try(()=>X.assign(w,VJ._`${U}(${Y}, ${W}).test(${Q})`),()=>X.assign(w,!1)),$.fail$data(VJ._`!${w}`)}else{let G=(0,XH$.usePattern)($,v);$.fail$data(VJ._`!${G}.test(${Q})`)}}};Hq.default=JH$});var Dc=N((jq)=>{Object.defineProperty(jq,"__esModule",{value:!0});var m7=D$(),YH$={message({keyword:$,schemaCode:X}){let Q=$==="maxProperties"?"more":"fewer";return m7.str`must NOT have ${Q} than ${X} properties`},params:({schemaCode:$})=>m7._`{limit: ${$}}`},zH$={keyword:["maxProperties","minProperties"],type:"object",schemaType:"number",$data:!0,error:YH$,code($){let{keyword:X,data:Q,schemaCode:J}=$,v=X==="maxProperties"?m7.operators.GT:m7.operators.LT;$.fail$data(m7._`Object.keys(${Q}).length ${v} ${J}`)}};jq.default=zH$});var Fc=N((Vq)=>{Object.defineProperty(Vq,"__esModule",{value:!0});var h7=P1(),l7=D$(),WH$=b$(),GH$={message:({params:{missingProperty:$}})=>l7.str`must have required property '${$}'`,params:({params:{missingProperty:$}})=>l7._`{missingProperty: ${$}}`},UH$={keyword:"required",type:"object",schemaType:"array",$data:!0,error:GH$,code($){let{gen:X,schema:Q,schemaCode:J,data:v,$data:Y,it:z}=$,{opts:W}=z;if(!Y&&Q.length===0)return;let G=Q.length>=W.loopRequired;if(z.allErrors)U();else w();if(W.strictRequired){let V=$.parentSchema.properties,{definedProperties:B}=$.it;for(let D of Q)if((V===null||V===void 0?void 0:V[D])===void 0&&!B.has(D)){let F=z.schemaEnv.baseId+z.errSchemaPath,L=`required property "${D}" is not defined at "${F}" (strictRequired)`;(0,WH$.checkStrictMode)(z,L,z.opts.strictRequired)}}function U(){if(G||Y)$.block$data(l7.nil,H);else for(let V of Q)(0,h7.checkReportMissingProp)($,V)}function w(){let V=X.let("missing");if(G||Y){let B=X.let("valid",!0);$.block$data(B,()=>j(V,B)),$.ok(B)}else X.if((0,h7.checkMissingProp)($,Q,V)),(0,h7.reportMissingProp)($,V),X.else()}function H(){X.forOf("prop",J,(V)=>{$.setParams({missingProperty:V}),X.if((0,h7.noPropertyInData)(X,v,V,W.ownProperties),()=>$.error())})}function j(V,B){$.setParams({missingProperty:V}),X.forOf(V,J,()=>{X.assign(B,(0,h7.propertyInData)(X,v,V,W.ownProperties)),X.if((0,l7.not)(B),()=>{$.error(),X.break()})},l7.nil)}}};Vq.default=UH$});var Nc=N((Bq)=>{Object.defineProperty(Bq,"__esModule",{value:!0});var p7=D$(),KH$={message({keyword:$,schemaCode:X}){let Q=$==="maxItems"?"more":"fewer";return p7.str`must NOT have ${Q} than ${X} items`},params:({schemaCode:$})=>p7._`{limit: ${$}}`},wH$={keyword:["maxItems","minItems"],type:"array",schemaType:"number",$data:!0,error:KH$,code($){let{keyword:X,data:Q,schemaCode:J}=$,v=X==="maxItems"?p7.operators.GT:p7.operators.LT;$.fail$data(p7._`${Q}.length ${v} ${J}`)}};Bq.default=wH$});var QV=N((Dq)=>{Object.defineProperty(Dq,"__esModule",{value:!0});var Oc=gZ();Oc.code='require("ajv/dist/runtime/equal").default';Dq.default=Oc});var Lc=N((Nq)=>{Object.defineProperty(Nq,"__esModule",{value:!0});var Fq=T7(),e4=D$(),HH$=b$(),jH$=QV(),VH$={message:({params:{i:$,j:X}})=>e4.str`must NOT have duplicate items (items ## ${X} and ${$} are identical)`,params:({params:{i:$,j:X}})=>e4._`{i: ${$}, j: ${X}}`},BH$={keyword:"uniqueItems",type:"array",schemaType:"boolean",$data:!0,error:VH$,code($){let{gen:X,data:Q,$data:J,schema:v,parentSchema:Y,schemaCode:z,it:W}=$;if(!J&&!v)return;let G=X.let("valid"),U=Y.items?(0,Fq.getSchemaTypes)(Y.items):[];$.block$data(G,w,e4._`${z} === false`),$.ok(G);function w(){let B=X.let("i",e4._`${Q}.length`),D=X.let("j");$.setParams({i:B,j:D}),X.assign(G,!0),X.if(e4._`${B} > 1`,()=>(H()?j:V)(B,D))}function H(){return U.length>0&&!U.some((B)=>B==="object"||B==="array")}function j(B,D){let F=X.name("item"),L=(0,Fq.checkDataTypes)(U,F,W.opts.strictNumbers,Fq.DataType.Wrong),A=X.const("indices",e4._`{}`);X.for(e4._`;${B}--;`,()=>{if(X.let(F,e4._`${Q}[${B}]`),X.if(L,e4._`continue`),U.length>1)X.if(e4._`typeof ${F} == "string"`,e4._`${F} += "_"`);X.if(e4._`typeof ${A}[${F}] == "number"`,()=>{X.assign(D,e4._`${A}[${F}]`),$.error(),X.assign(G,!1).break()}).code(e4._`${A}[${F}] = ${B}`)})}function V(B,D){let F=(0,HH$.useFunc)(X,jH$.default),L=X.name("outer");X.label(L).for(e4._`;${B}--;`,()=>X.for(e4._`${D} = ${B}; ${D}--;`,()=>X.if(e4._`${F}(${Q}[${B}], ${Q}[${D}])`,()=>{$.error(),X.assign(G,!1).break(L)})))}}};Nq.default=BH$});var Ac=N((Lq)=>{Object.defineProperty(Lq,"__esModule",{value:!0});var Oq=D$(),DH$=b$(),FH$=QV(),NH$={message:"must be equal to constant",params:({schemaCode:$})=>Oq._`{allowedValue: ${$}}`},OH$={keyword:"const",$data:!0,error:NH$,code($){let{gen:X,data:Q,$data:J,schemaCode:v,schema:Y}=$;if(J||Y&&typeof Y=="object")$.fail$data(Oq._`!${(0,DH$.useFunc)(X,FH$.default)}(${Q}, ${v})`);else $.fail(Oq._`${Y} !== ${Q}`)}};Lq.default=OH$});var Zc=N((Aq)=>{Object.defineProperty(Aq,"__esModule",{value:!0});var d7=D$(),LH$=b$(),AH$=QV(),ZH$={message:"must be equal to one of the allowed values",params:({schemaCode:$})=>d7._`{allowedValues: ${$}}`},qH$={keyword:"enum",schemaType:"array",$data:!0,error:ZH$,code($){let{gen:X,data:Q,$data:J,schema:v,schemaCode:Y,it:z}=$;if(!J&&v.length===0)throw Error("enum must have non-empty array");let W=v.length>=z.opts.loopEnum,G,U=()=>G!==null&&G!==void 0?G:G=(0,LH$.useFunc)(X,AH$.default),w;if(W||J)w=X.let("valid"),$.block$data(w,H);else{if(!Array.isArray(v))throw Error("ajv implementation error");let V=X.const("vSchema",Y);w=(0,d7.or)(...v.map((B,D)=>j(V,D)))}$.pass(w);function H(){X.assign(w,!1),X.forOf("v",Y,(V)=>X.if(d7._`${U()}(${Q}, ${V})`,()=>X.assign(w,!0).break()))}function j(V,B){let D=v[B];return typeof D==="object"&&D!==null?d7._`${U()}(${Q}, ${V}[${B}])`:d7._`${Q} === ${D}`}}};Aq.default=qH$});var qc=N((Zq)=>{Object.defineProperty(Zq,"__esModule",{value:!0});var IH$=Kc(),MH$=wc(),xH$=Vc(),bH$=Bc(),iH$=Dc(),RH$=Fc(),EH$=Nc(),PH$=Lc(),TH$=Ac(),SH$=Zc(),CH$=[IH$.default,MH$.default,xH$.default,bH$.default,iH$.default,RH$.default,EH$.default,PH$.default,{keyword:"type",schemaType:["string","array"]},{keyword:"nullable",schemaType:"boolean"},TH$.default,SH$.default];Zq.default=CH$});var Iq=N((o7)=>{Object.defineProperty(o7,"__esModule",{value:!0});o7.validateAdditionalItems=void 0;var TX=D$(),qq=b$(),uH$={message:({params:{len:$}})=>TX.str`must NOT have more than ${$} items`,params:({params:{len:$}})=>TX._`{limit: ${$}}`},_H$={keyword:"additionalItems",type:"array",schemaType:["boolean","object"],before:"uniqueItems",error:uH$,code($){let{parentSchema:X,it:Q}=$,{items:J}=X;if(!Array.isArray(J)){(0,qq.checkStrictMode)(Q,'"additionalItems" is ignored when "items" is not an array of schemas');return}Ic($,J)}};function Ic($,X){let{gen:Q,schema:J,data:v,keyword:Y,it:z}=$;z.items=!0;let W=Q.const("len",TX._`${v}.length`);if(J===!1)$.setParams({len:X.length}),$.pass(TX._`${W} <= ${X.length}`);else if(typeof J=="object"&&!(0,qq.alwaysValidSchema)(z,J)){let U=Q.var("valid",TX._`${W} <= ${X.length}`);Q.if((0,TX.not)(U),()=>G(U)),$.ok(U)}function G(U){Q.forRange("i",X.length,W,(w)=>{if($.subschema({keyword:Y,dataProp:w,dataPropType:qq.Type.Num},U),!z.allErrors)Q.if((0,TX.not)(U),()=>Q.break())})}}o7.validateAdditionalItems=Ic;o7.default=_H$});var Mq=N((a7)=>{Object.defineProperty(a7,"__esModule",{value:!0});a7.validateTuple=void 0;var Mc=D$(),vV=b$(),yH$=P1(),fH$={keyword:"items",type:"array",schemaType:["object","array","boolean"],before:"uniqueItems",code($){let{schema:X,it:Q}=$;if(Array.isArray(X))return xc($,"additionalItems",X);if(Q.items=!0,(0,vV.alwaysValidSchema)(Q,X))return;$.ok((0,yH$.validateArray)($))}};function xc($,X,Q=$.schema){let{gen:J,parentSchema:v,data:Y,keyword:z,it:W}=$;if(w(v),W.opts.unevaluated&&Q.length&&W.items!==!0)W.items=vV.mergeEvaluated.items(J,Q.length,W.items);let G=J.name("valid"),U=J.const("len",Mc._`${Y}.length`);Q.forEach((H,j)=>{if((0,vV.alwaysValidSchema)(W,H))return;J.if(Mc._`${U} > ${j}`,()=>$.subschema({keyword:z,schemaProp:j,dataProp:j},G)),$.ok(G)});function w(H){let{opts:j,errSchemaPath:V}=W,B=Q.length,D=B===H.minItems&&(B===H.maxItems||H[X]===!1);if(j.strictTuples&&!D){let F=`"${z}" is ${B}-tuple, but minItems or maxItems/${X} are not specified or different at path "${V}"`;(0,vV.checkStrictMode)(W,F,j.strictTuples)}}}a7.validateTuple=xc;a7.default=fH$});var bc=N((xq)=>{Object.defineProperty(xq,"__esModule",{value:!0});var kH$=Mq(),nH$={keyword:"prefixItems",type:"array",schemaType:["array"],before:"uniqueItems",code:($)=>(0,kH$.validateTuple)($,"items")};xq.default=nH$});var Rc=N((bq)=>{Object.defineProperty(bq,"__esModule",{value:!0});var ic=D$(),gH$=b$(),cH$=P1(),mH$=Iq(),hH$={message:({params:{len:$}})=>ic.str`must NOT have more than ${$} items`,params:({params:{len:$}})=>ic._`{limit: ${$}}`},lH$={keyword:"items",type:"array",schemaType:["object","boolean"],before:"uniqueItems",error:hH$,code($){let{schema:X,parentSchema:Q,it:J}=$,{prefixItems:v}=Q;if(J.items=!0,(0,gH$.alwaysValidSchema)(J,X))return;if(v)(0,mH$.validateAdditionalItems)($,v);else $.ok((0,cH$.validateArray)($))}};bq.default=lH$});var Ec=N((iq)=>{Object.defineProperty(iq,"__esModule",{value:!0});var S1=D$(),JV=b$(),pH$={message:({params:{min:$,max:X}})=>X===void 0?S1.str`must contain at least ${$} valid item(s)`:S1.str`must contain at least ${$} and no more than ${X} valid item(s)`,params:({params:{min:$,max:X}})=>X===void 0?S1._`{minContains: ${$}}`:S1._`{minContains: ${$}, maxContains: ${X}}`},dH$={keyword:"contains",type:"array",schemaType:["object","boolean"],before:"uniqueItems",trackErrors:!0,error:pH$,code($){let{gen:X,schema:Q,parentSchema:J,data:v,it:Y}=$,z,W,{minContains:G,maxContains:U}=J;if(Y.opts.next)z=G===void 0?1:G,W=U;else z=1;let w=X.const("len",S1._`${v}.length`);if($.setParams({min:z,max:W}),W===void 0&&z===0){(0,JV.checkStrictMode)(Y,'"minContains" == 0 without "maxContains": "contains" keyword ignored');return}if(W!==void 0&&z>W){(0,JV.checkStrictMode)(Y,'"minContains" > "maxContains" is always invalid'),$.fail();return}if((0,JV.alwaysValidSchema)(Y,Q)){let D=S1._`${w} >= ${z}`;if(W!==void 0)D=S1._`${D} && ${w} <= ${W}`;$.pass(D);return}Y.items=!0;let H=X.name("valid");if(W===void 0&&z===1)V(H,()=>X.if(H,()=>X.break()));else if(z===0){if(X.let(H,!0),W!==void 0)X.if(S1._`${v}.length > 0`,j)}else X.let(H,!1),j();$.result(H,()=>$.reset());function j(){let D=X.name("_valid"),F=X.let("count",0);V(D,()=>X.if(D,()=>B(F)))}function V(D,F){X.forRange("i",0,w,(L)=>{$.subschema({keyword:"contains",dataProp:L,dataPropType:JV.Type.Num,compositeRule:!0},D),F()})}function B(D){if(X.code(S1._`${D}++`),W===void 0)X.if(S1._`${D} >= ${z}`,()=>X.assign(H,!0).break());else if(X.if(S1._`${D} > ${W}`,()=>X.assign(H,!1).break()),z===1)X.assign(H,!0);else X.if(S1._`${D} >= ${z}`,()=>X.assign(H,!0))}}};iq.default=dH$});var Sc=N((h0)=>{Object.defineProperty(h0,"__esModule",{value:!0});h0.validateSchemaDeps=h0.validatePropertyDeps=h0.error=void 0;var Rq=D$(),oH$=b$(),r7=P1();h0.error={message:({params:{property:$,depsCount:X,deps:Q}})=>{let J=X===1?"property":"properties";return Rq.str`must have ${J} ${Q} when property ${$} is present`},params:({params:{property:$,depsCount:X,deps:Q,missingProperty:J}})=>Rq._`{property: ${$}, + missingProperty: ${J}, + depsCount: ${X}, + deps: ${Q}}`};var aH$={keyword:"dependencies",type:"object",schemaType:"object",error:h0.error,code($){let[X,Q]=rH$($);Pc($,X),Tc($,Q)}};function rH$({schema:$}){let X={},Q={};for(let J in $){if(J==="__proto__")continue;let v=Array.isArray($[J])?X:Q;v[J]=$[J]}return[X,Q]}function Pc($,X=$.schema){let{gen:Q,data:J,it:v}=$;if(Object.keys(X).length===0)return;let Y=Q.let("missing");for(let z in X){let W=X[z];if(W.length===0)continue;let G=(0,r7.propertyInData)(Q,J,z,v.opts.ownProperties);if($.setParams({property:z,depsCount:W.length,deps:W.join(", ")}),v.allErrors)Q.if(G,()=>{for(let U of W)(0,r7.checkReportMissingProp)($,U)});else Q.if(Rq._`${G} && (${(0,r7.checkMissingProp)($,W,Y)})`),(0,r7.reportMissingProp)($,Y),Q.else()}}h0.validatePropertyDeps=Pc;function Tc($,X=$.schema){let{gen:Q,data:J,keyword:v,it:Y}=$,z=Q.name("valid");for(let W in X){if((0,oH$.alwaysValidSchema)(Y,X[W]))continue;Q.if((0,r7.propertyInData)(Q,J,W,Y.opts.ownProperties),()=>{let G=$.subschema({keyword:v,schemaProp:W},z);$.mergeValidEvaluated(G,z)},()=>Q.var(z,!0)),$.ok(z)}}h0.validateSchemaDeps=Tc;h0.default=aH$});var uc=N((Eq)=>{Object.defineProperty(Eq,"__esModule",{value:!0});var Cc=D$(),tH$=b$(),sH$={message:"property name must be valid",params:({params:$})=>Cc._`{propertyName: ${$.propertyName}}`},eH$={keyword:"propertyNames",type:"object",schemaType:["object","boolean"],error:sH$,code($){let{gen:X,schema:Q,data:J,it:v}=$;if((0,tH$.alwaysValidSchema)(v,Q))return;let Y=X.name("valid");X.forIn("key",J,(z)=>{$.setParams({propertyName:z}),$.subschema({keyword:"propertyNames",data:z,dataTypes:["string"],propertyName:z,compositeRule:!0},Y),X.if((0,Cc.not)(Y),()=>{if($.error(!0),!v.allErrors)X.break()})}),$.ok(Y)}};Eq.default=eH$});var Tq=N((Pq)=>{Object.defineProperty(Pq,"__esModule",{value:!0});var YV=P1(),K0=D$(),$j$=Z8(),zV=b$(),Xj$={message:"must NOT have additional properties",params:({params:$})=>K0._`{additionalProperty: ${$.additionalProperty}}`},Qj$={keyword:"additionalProperties",type:["object"],schemaType:["boolean","object"],allowUndefined:!0,trackErrors:!0,error:Xj$,code($){let{gen:X,schema:Q,parentSchema:J,data:v,errsCount:Y,it:z}=$;if(!Y)throw Error("ajv implementation error");let{allErrors:W,opts:G}=z;if(z.props=!0,G.removeAdditional!=="all"&&(0,zV.alwaysValidSchema)(z,Q))return;let U=(0,YV.allSchemaProperties)(J.properties),w=(0,YV.allSchemaProperties)(J.patternProperties);H(),$.ok(K0._`${Y} === ${$j$.default.errors}`);function H(){X.forIn("key",v,(F)=>{if(!U.length&&!w.length)B(F);else X.if(j(F),()=>B(F))})}function j(F){let L;if(U.length>8){let A=(0,zV.schemaRefOrVal)(z,J.properties,"properties");L=(0,YV.isOwnProperty)(X,A,F)}else if(U.length)L=(0,K0.or)(...U.map((A)=>K0._`${F} === ${A}`));else L=K0.nil;if(w.length)L=(0,K0.or)(L,...w.map((A)=>K0._`${(0,YV.usePattern)($,A)}.test(${F})`));return(0,K0.not)(L)}function V(F){X.code(K0._`delete ${v}[${F}]`)}function B(F){if(G.removeAdditional==="all"||G.removeAdditional&&Q===!1){V(F);return}if(Q===!1){if($.setParams({additionalProperty:F}),$.error(),!W)X.break();return}if(typeof Q=="object"&&!(0,zV.alwaysValidSchema)(z,Q)){let L=X.name("valid");if(G.removeAdditional==="failing")D(F,L,!1),X.if((0,K0.not)(L),()=>{$.reset(),V(F)});else if(D(F,L),!W)X.if((0,K0.not)(L),()=>X.break())}}function D(F,L,A){let O={keyword:"additionalProperties",dataProp:F,dataPropType:zV.Type.Str};if(A===!1)Object.assign(O,{compositeRule:!0,createErrors:!1,allErrors:!1});$.subschema(O,L)}}};Pq.default=Qj$});var fc=N((Cq)=>{Object.defineProperty(Cq,"__esModule",{value:!0});var vj$=_7(),_c=P1(),Sq=b$(),yc=Tq(),Jj$={keyword:"properties",type:"object",schemaType:"object",code($){let{gen:X,schema:Q,parentSchema:J,data:v,it:Y}=$;if(Y.opts.removeAdditional==="all"&&J.additionalProperties===void 0)yc.default.code(new vj$.KeywordCxt(Y,yc.default,"additionalProperties"));let z=(0,_c.allSchemaProperties)(Q);for(let H of z)Y.definedProperties.add(H);if(Y.opts.unevaluated&&z.length&&Y.props!==!0)Y.props=Sq.mergeEvaluated.props(X,(0,Sq.toHash)(z),Y.props);let W=z.filter((H)=>!(0,Sq.alwaysValidSchema)(Y,Q[H]));if(W.length===0)return;let G=X.name("valid");for(let H of W){if(U(H))w(H);else{if(X.if((0,_c.propertyInData)(X,v,H,Y.opts.ownProperties)),w(H),!Y.allErrors)X.else().var(G,!0);X.endIf()}$.it.definedProperties.add(H),$.ok(G)}function U(H){return Y.opts.useDefaults&&!Y.compositeRule&&Q[H].default!==void 0}function w(H){$.subschema({keyword:"properties",schemaProp:H,dataProp:H},G)}}};Cq.default=Jj$});var cc=N((uq)=>{Object.defineProperty(uq,"__esModule",{value:!0});var kc=P1(),WV=D$(),nc=b$(),gc=b$(),Yj$={keyword:"patternProperties",type:"object",schemaType:"object",code($){let{gen:X,schema:Q,data:J,parentSchema:v,it:Y}=$,{opts:z}=Y,W=(0,kc.allSchemaProperties)(Q),G=W.filter((D)=>(0,nc.alwaysValidSchema)(Y,Q[D]));if(W.length===0||G.length===W.length&&(!Y.opts.unevaluated||Y.props===!0))return;let U=z.strictSchema&&!z.allowMatchingProperties&&v.properties,w=X.name("valid");if(Y.props!==!0&&!(Y.props instanceof WV.Name))Y.props=(0,gc.evaluatedPropsToName)(X,Y.props);let{props:H}=Y;j();function j(){for(let D of W){if(U)V(D);if(Y.allErrors)B(D);else X.var(w,!0),B(D),X.if(w)}}function V(D){for(let F in U)if(new RegExp(D).test(F))(0,nc.checkStrictMode)(Y,`property ${F} matches pattern ${D} (use allowMatchingProperties)`)}function B(D){X.forIn("key",J,(F)=>{X.if(WV._`${(0,kc.usePattern)($,D)}.test(${F})`,()=>{let L=G.includes(D);if(!L)$.subschema({keyword:"patternProperties",schemaProp:D,dataProp:F,dataPropType:gc.Type.Str},w);if(Y.opts.unevaluated&&H!==!0)X.assign(WV._`${H}[${F}]`,!0);else if(!L&&!Y.allErrors)X.if((0,WV.not)(w),()=>X.break())})})}}};uq.default=Yj$});var mc=N((_q)=>{Object.defineProperty(_q,"__esModule",{value:!0});var zj$=b$(),Wj$={keyword:"not",schemaType:["object","boolean"],trackErrors:!0,code($){let{gen:X,schema:Q,it:J}=$;if((0,zj$.alwaysValidSchema)(J,Q)){$.fail();return}let v=X.name("valid");$.subschema({keyword:"not",compositeRule:!0,createErrors:!1,allErrors:!1},v),$.failResult(v,()=>$.reset(),()=>$.error())},error:{message:"must NOT be valid"}};_q.default=Wj$});var hc=N((yq)=>{Object.defineProperty(yq,"__esModule",{value:!0});var Gj$=P1(),Uj$={keyword:"anyOf",schemaType:"array",trackErrors:!0,code:Gj$.validateUnion,error:{message:"must match a schema in anyOf"}};yq.default=Uj$});var lc=N((fq)=>{Object.defineProperty(fq,"__esModule",{value:!0});var GV=D$(),Kj$=b$(),wj$={message:"must match exactly one schema in oneOf",params:({params:$})=>GV._`{passingSchemas: ${$.passing}}`},Hj$={keyword:"oneOf",schemaType:"array",trackErrors:!0,error:wj$,code($){let{gen:X,schema:Q,parentSchema:J,it:v}=$;if(!Array.isArray(Q))throw Error("ajv implementation error");if(v.opts.discriminator&&J.discriminator)return;let Y=Q,z=X.let("valid",!1),W=X.let("passing",null),G=X.name("_valid");$.setParams({passing:W}),X.block(U),$.result(z,()=>$.reset(),()=>$.error(!0));function U(){Y.forEach((w,H)=>{let j;if((0,Kj$.alwaysValidSchema)(v,w))X.var(G,!0);else j=$.subschema({keyword:"oneOf",schemaProp:H,compositeRule:!0},G);if(H>0)X.if(GV._`${G} && ${z}`).assign(z,!1).assign(W,GV._`[${W}, ${H}]`).else();X.if(G,()=>{if(X.assign(z,!0),X.assign(W,H),j)$.mergeEvaluated(j,GV.Name)})})}}};fq.default=Hj$});var pc=N((kq)=>{Object.defineProperty(kq,"__esModule",{value:!0});var jj$=b$(),Vj$={keyword:"allOf",schemaType:"array",code($){let{gen:X,schema:Q,it:J}=$;if(!Array.isArray(Q))throw Error("ajv implementation error");let v=X.name("valid");Q.forEach((Y,z)=>{if((0,jj$.alwaysValidSchema)(J,Y))return;let W=$.subschema({keyword:"allOf",schemaProp:z},v);$.ok(v),$.mergeEvaluated(W)})}};kq.default=Vj$});var ac=N((nq)=>{Object.defineProperty(nq,"__esModule",{value:!0});var UV=D$(),oc=b$(),Bj$={message:({params:$})=>UV.str`must match "${$.ifClause}" schema`,params:({params:$})=>UV._`{failingKeyword: ${$.ifClause}}`},Dj$={keyword:"if",schemaType:["object","boolean"],trackErrors:!0,error:Bj$,code($){let{gen:X,parentSchema:Q,it:J}=$;if(Q.then===void 0&&Q.else===void 0)(0,oc.checkStrictMode)(J,'"if" without "then" and "else" is ignored');let v=dc(J,"then"),Y=dc(J,"else");if(!v&&!Y)return;let z=X.let("valid",!0),W=X.name("_valid");if(G(),$.reset(),v&&Y){let w=X.let("ifClause");$.setParams({ifClause:w}),X.if(W,U("then",w),U("else",w))}else if(v)X.if(W,U("then"));else X.if((0,UV.not)(W),U("else"));$.pass(z,()=>$.error(!0));function G(){let w=$.subschema({keyword:"if",compositeRule:!0,createErrors:!1,allErrors:!1},W);$.mergeEvaluated(w)}function U(w,H){return()=>{let j=$.subschema({keyword:w},W);if(X.assign(z,W),$.mergeValidEvaluated(j,z),H)X.assign(H,UV._`${w}`);else $.setParams({ifClause:w})}}}};function dc($,X){let Q=$.schema[X];return Q!==void 0&&!(0,oc.alwaysValidSchema)($,Q)}nq.default=Dj$});var rc=N((gq)=>{Object.defineProperty(gq,"__esModule",{value:!0});var Fj$=b$(),Nj$={keyword:["then","else"],schemaType:["object","boolean"],code({keyword:$,parentSchema:X,it:Q}){if(X.if===void 0)(0,Fj$.checkStrictMode)(Q,`"${$}" without "if" is ignored`)}};gq.default=Nj$});var tc=N((cq)=>{Object.defineProperty(cq,"__esModule",{value:!0});var Oj$=Iq(),Lj$=bc(),Aj$=Mq(),Zj$=Rc(),qj$=Ec(),Ij$=Sc(),Mj$=uc(),xj$=Tq(),bj$=fc(),ij$=cc(),Rj$=mc(),Ej$=hc(),Pj$=lc(),Tj$=pc(),Sj$=ac(),Cj$=rc();function uj$($=!1){let X=[Rj$.default,Ej$.default,Pj$.default,Tj$.default,Sj$.default,Cj$.default,Mj$.default,xj$.default,Ij$.default,bj$.default,ij$.default];if($)X.push(Lj$.default,Zj$.default);else X.push(Oj$.default,Aj$.default);return X.push(qj$.default),X}cq.default=uj$});var sc=N((mq)=>{Object.defineProperty(mq,"__esModule",{value:!0});var B4=D$(),_j$={message:({schemaCode:$})=>B4.str`must match format "${$}"`,params:({schemaCode:$})=>B4._`{format: ${$}}`},yj$={keyword:"format",type:["number","string"],schemaType:"string",$data:!0,error:_j$,code($,X){let{gen:Q,data:J,$data:v,schema:Y,schemaCode:z,it:W}=$,{opts:G,errSchemaPath:U,schemaEnv:w,self:H}=W;if(!G.validateFormats)return;if(v)j();else V();function j(){let B=Q.scopeValue("formats",{ref:H.formats,code:G.code.formats}),D=Q.const("fDef",B4._`${B}[${z}]`),F=Q.let("fType"),L=Q.let("format");Q.if(B4._`typeof ${D} == "object" && !(${D} instanceof RegExp)`,()=>Q.assign(F,B4._`${D}.type || "string"`).assign(L,B4._`${D}.validate`),()=>Q.assign(F,B4._`"string"`).assign(L,D)),$.fail$data((0,B4.or)(A(),O()));function A(){if(G.strictSchema===!1)return B4.nil;return B4._`${z} && !${L}`}function O(){let i=w.$async?B4._`(${D}.async ? await ${L}(${J}) : ${L}(${J}))`:B4._`${L}(${J})`,M=B4._`(typeof ${L} == "function" ? ${i} : ${L}.test(${J}))`;return B4._`${L} && ${L} !== true && ${F} === ${X} && !${M}`}}function V(){let B=H.formats[Y];if(!B){A();return}if(B===!0)return;let[D,F,L]=O(B);if(D===X)$.pass(i());function A(){if(G.strictSchema===!1){H.logger.warn(M());return}throw Error(M());function M(){return`unknown format "${Y}" ignored in schema at path "${U}"`}}function O(M){let R=M instanceof RegExp?(0,B4.regexpCode)(M):G.code.formats?B4._`${G.code.formats}${(0,B4.getProperty)(Y)}`:void 0,E=Q.scopeValue("formats",{key:Y,ref:M,code:R});if(typeof M=="object"&&!(M instanceof RegExp))return[M.type||"string",M.validate,B4._`${E}.validate`];return["string",M,E]}function i(){if(typeof B=="object"&&!(B instanceof RegExp)&&B.async){if(!w.$async)throw Error("async format in sync schema");return B4._`await ${L}(${J})`}return typeof F=="function"?B4._`${L}(${J})`:B4._`${L}.test(${J})`}}}};mq.default=yj$});var ec=N((hq)=>{Object.defineProperty(hq,"__esModule",{value:!0});var fj$=sc(),kj$=[fj$.default];hq.default=kj$});var $m=N((BJ)=>{Object.defineProperty(BJ,"__esModule",{value:!0});BJ.contentVocabulary=BJ.metadataVocabulary=void 0;BJ.metadataVocabulary=["title","description","default","deprecated","readOnly","writeOnly","examples"];BJ.contentVocabulary=["contentMediaType","contentEncoding","contentSchema"]});var Qm=N((lq)=>{Object.defineProperty(lq,"__esModule",{value:!0});var nj$=Uc(),gj$=qc(),cj$=tc(),mj$=ec(),Xm=$m(),hj$=[nj$.default,gj$.default,(0,cj$.default)(),mj$.default,Xm.metadataVocabulary,Xm.contentVocabulary];lq.default=hj$});var Jm=N((KV)=>{Object.defineProperty(KV,"__esModule",{value:!0});KV.DiscrError=void 0;var vm;(function($){$.Tag="tag",$.Mapping="mapping"})(vm||(KV.DiscrError=vm={}))});var zm=N((dq)=>{Object.defineProperty(dq,"__esModule",{value:!0});var DJ=D$(),pq=Jm(),Ym=lj(),lj$=y7(),pj$=b$(),dj$={message:({params:{discrError:$,tagName:X}})=>$===pq.DiscrError.Tag?`tag "${X}" must be string`:`value of tag "${X}" must be in oneOf`,params:({params:{discrError:$,tag:X,tagName:Q}})=>DJ._`{error: ${$}, tag: ${Q}, tagValue: ${X}}`},oj$={keyword:"discriminator",type:"object",schemaType:"object",error:dj$,code($){let{gen:X,data:Q,schema:J,parentSchema:v,it:Y}=$,{oneOf:z}=v;if(!Y.opts.discriminator)throw Error("discriminator: requires discriminator option");let W=J.propertyName;if(typeof W!="string")throw Error("discriminator: requires propertyName");if(J.mapping)throw Error("discriminator: mapping is not supported");if(!z)throw Error("discriminator: requires oneOf keyword");let G=X.let("valid",!1),U=X.const("tag",DJ._`${Q}${(0,DJ.getProperty)(W)}`);X.if(DJ._`typeof ${U} == "string"`,()=>w(),()=>$.error(!1,{discrError:pq.DiscrError.Tag,tag:U,tagName:W})),$.ok(G);function w(){let V=j();X.if(!1);for(let B in V)X.elseIf(DJ._`${U} === ${B}`),X.assign(G,H(V[B]));X.else(),$.error(!1,{discrError:pq.DiscrError.Mapping,tag:U,tagName:W}),X.endIf()}function H(V){let B=X.name("valid"),D=$.subschema({keyword:"oneOf",schemaProp:V},B);return $.mergeEvaluated(D,DJ.Name),B}function j(){var V;let B={},D=L(v),F=!0;for(let i=0;i{aj$.exports={$schema:"http://json-schema.org/draft-07/schema#",$id:"http://json-schema.org/draft-07/schema#",title:"Core schema meta-schema",definitions:{schemaArray:{type:"array",minItems:1,items:{$ref:"#"}},nonNegativeInteger:{type:"integer",minimum:0},nonNegativeIntegerDefault0:{allOf:[{$ref:"#/definitions/nonNegativeInteger"},{default:0}]},simpleTypes:{enum:["array","boolean","integer","null","number","object","string"]},stringArray:{type:"array",items:{type:"string"},uniqueItems:!0,default:[]}},type:["object","boolean"],properties:{$id:{type:"string",format:"uri-reference"},$schema:{type:"string",format:"uri"},$ref:{type:"string",format:"uri-reference"},$comment:{type:"string"},title:{type:"string"},description:{type:"string"},default:!0,readOnly:{type:"boolean",default:!1},examples:{type:"array",items:!0},multipleOf:{type:"number",exclusiveMinimum:0},maximum:{type:"number"},exclusiveMaximum:{type:"number"},minimum:{type:"number"},exclusiveMinimum:{type:"number"},maxLength:{$ref:"#/definitions/nonNegativeInteger"},minLength:{$ref:"#/definitions/nonNegativeIntegerDefault0"},pattern:{type:"string",format:"regex"},additionalItems:{$ref:"#"},items:{anyOf:[{$ref:"#"},{$ref:"#/definitions/schemaArray"}],default:!0},maxItems:{$ref:"#/definitions/nonNegativeInteger"},minItems:{$ref:"#/definitions/nonNegativeIntegerDefault0"},uniqueItems:{type:"boolean",default:!1},contains:{$ref:"#"},maxProperties:{$ref:"#/definitions/nonNegativeInteger"},minProperties:{$ref:"#/definitions/nonNegativeIntegerDefault0"},required:{$ref:"#/definitions/stringArray"},additionalProperties:{$ref:"#"},definitions:{type:"object",additionalProperties:{$ref:"#"},default:{}},properties:{type:"object",additionalProperties:{$ref:"#"},default:{}},patternProperties:{type:"object",additionalProperties:{$ref:"#"},propertyNames:{format:"regex"},default:{}},dependencies:{type:"object",additionalProperties:{anyOf:[{$ref:"#"},{$ref:"#/definitions/stringArray"}]}},propertyNames:{$ref:"#"},const:!0,enum:{type:"array",items:!0,minItems:1,uniqueItems:!0},type:{anyOf:[{$ref:"#/definitions/simpleTypes"},{type:"array",items:{$ref:"#/definitions/simpleTypes"},minItems:1,uniqueItems:!0}]},format:{type:"string"},contentMediaType:{type:"string"},contentEncoding:{type:"string"},if:{$ref:"#"},then:{$ref:"#"},else:{$ref:"#"},allOf:{$ref:"#/definitions/schemaArray"},anyOf:{$ref:"#/definitions/schemaArray"},oneOf:{$ref:"#/definitions/schemaArray"},not:{$ref:"#"}},default:!0}});var aq=N((e$,oq)=>{Object.defineProperty(e$,"__esModule",{value:!0});e$.MissingRefError=e$.ValidationError=e$.CodeGen=e$.Name=e$.nil=e$.stringify=e$.str=e$._=e$.KeywordCxt=e$.Ajv=void 0;var rj$=vc(),tj$=Qm(),sj$=zm(),Gm=Wm(),ej$=["/properties"],wV="http://json-schema.org/draft-07/schema";class t7 extends rj$.default{_addVocabularies(){if(super._addVocabularies(),tj$.default.forEach(($)=>this.addVocabulary($)),this.opts.discriminator)this.addKeyword(sj$.default)}_addDefaultMetaSchema(){if(super._addDefaultMetaSchema(),!this.opts.meta)return;let $=this.opts.$data?this.$dataMetaSchema(Gm,ej$):Gm;this.addMetaSchema($,wV,!1),this.refs["http://json-schema.org/schema"]=wV}defaultMeta(){return this.opts.defaultMeta=super.defaultMeta()||(this.getSchema(wV)?wV:void 0)}}e$.Ajv=t7;oq.exports=e$=t7;oq.exports.Ajv=t7;Object.defineProperty(e$,"__esModule",{value:!0});e$.default=t7;var $V$=_7();Object.defineProperty(e$,"KeywordCxt",{enumerable:!0,get:function(){return $V$.KeywordCxt}});var FJ=D$();Object.defineProperty(e$,"_",{enumerable:!0,get:function(){return FJ._}});Object.defineProperty(e$,"str",{enumerable:!0,get:function(){return FJ.str}});Object.defineProperty(e$,"stringify",{enumerable:!0,get:function(){return FJ.stringify}});Object.defineProperty(e$,"nil",{enumerable:!0,get:function(){return FJ.nil}});Object.defineProperty(e$,"Name",{enumerable:!0,get:function(){return FJ.Name}});Object.defineProperty(e$,"CodeGen",{enumerable:!0,get:function(){return FJ.CodeGen}});var XV$=mj();Object.defineProperty(e$,"ValidationError",{enumerable:!0,get:function(){return XV$.default}});var QV$=y7();Object.defineProperty(e$,"MissingRefError",{enumerable:!0,get:function(){return QV$.default}})});var Dm=N((p0)=>{Object.defineProperty(p0,"__esModule",{value:!0});p0.formatNames=p0.fastFormats=p0.fullFormats=void 0;function l0($,X){return{validate:$,compare:X}}p0.fullFormats={date:l0(Hm,eq),time:l0(tq(!0),$I),"date-time":l0(Um(!0),Vm),"iso-time":l0(tq(),jm),"iso-date-time":l0(Um(),Bm),duration:/^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$/,uri:GV$,"uri-reference":/^(?:[a-z][a-z0-9+\-.]*:)?(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'"()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'"()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'"()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i,"uri-template":/^(?:(?:[^\x00-\x20"'<>%\\^`{|}]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?::[1-9][0-9]{0,3}|\*)?)*\})*$/i,url:/^(?:https?|ftp):\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)(?:\.(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)*(?:\.(?:[a-z\u{00a1}-\u{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu,email:/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,hostname:/^(?=.{1,253}\.?$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[-0-9a-z]{0,61}[0-9a-z])?)*\.?$/i,ipv4:/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/,ipv6:/^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i,regex:BV$,uuid:/^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i,"json-pointer":/^(?:\/(?:[^~/]|~0|~1)*)*$/,"json-pointer-uri-fragment":/^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i,"relative-json-pointer":/^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/,byte:UV$,int32:{type:"number",validate:HV$},int64:{type:"number",validate:jV$},float:{type:"number",validate:wm},double:{type:"number",validate:wm},password:!0,binary:!0};p0.fastFormats={...p0.fullFormats,date:l0(/^\d\d\d\d-[0-1]\d-[0-3]\d$/,eq),time:l0(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i,$I),"date-time":l0(/^\d\d\d\d-[0-1]\d-[0-3]\dt(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)$/i,Vm),"iso-time":l0(/^(?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i,jm),"iso-date-time":l0(/^\d\d\d\d-[0-1]\d-[0-3]\d[t\s](?:[0-2]\d:[0-5]\d:[0-5]\d|23:59:60)(?:\.\d+)?(?:z|[+-]\d\d(?::?\d\d)?)?$/i,Bm),uri:/^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/)?[^\s]*$/i,"uri-reference":/^(?:(?:[a-z][a-z0-9+\-.]*:)?\/?\/)?(?:[^\\\s#][^\s#]*)?(?:#[^\\\s]*)?$/i,email:/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i};p0.formatNames=Object.keys(p0.fullFormats);function vV$($){return $%4===0&&($%100!==0||$%400===0)}var JV$=/^(\d\d\d\d)-(\d\d)-(\d\d)$/,YV$=[0,31,28,31,30,31,30,31,31,30,31,30,31];function Hm($){let X=JV$.exec($);if(!X)return!1;let Q=+X[1],J=+X[2],v=+X[3];return J>=1&&J<=12&&v>=1&&v<=(J===2&&vV$(Q)?29:YV$[J])}function eq($,X){if(!($&&X))return;if($>X)return 1;if($23||w>59||$&&!W)return!1;if(v<=23&&Y<=59&&z<60)return!0;let H=Y-w*G,j=v-U*G-(H<0?1:0);return(j===23||j===-1)&&(H===59||H===-1)&&z<61}}function $I($,X){if(!($&&X))return;let Q=new Date("2020-01-01T"+$).valueOf(),J=new Date("2020-01-01T"+X).valueOf();if(!(Q&&J))return;return Q-J}function jm($,X){if(!($&&X))return;let Q=rq.exec($),J=rq.exec(X);if(!(Q&&J))return;if($=Q[1]+Q[2]+Q[3],X=J[1]+J[2]+J[3],$>X)return 1;if($=KV$}function jV$($){return Number.isInteger($)}function wm(){return!0}var VV$=/[^\\]\\Z/;function BV$($){if(VV$.test($))return!1;try{return new RegExp($),!0}catch(X){return!1}}});var Fm=N((NJ)=>{Object.defineProperty(NJ,"__esModule",{value:!0});NJ.formatLimitDefinition=void 0;var DV$=aq(),w0=D$(),b9=w0.operators,HV={formatMaximum:{okStr:"<=",ok:b9.LTE,fail:b9.GT},formatMinimum:{okStr:">=",ok:b9.GTE,fail:b9.LT},formatExclusiveMaximum:{okStr:"<",ok:b9.LT,fail:b9.GTE},formatExclusiveMinimum:{okStr:">",ok:b9.GT,fail:b9.LTE}},FV$={message:({keyword:$,schemaCode:X})=>w0.str`should be ${HV[$].okStr} ${X}`,params:({keyword:$,schemaCode:X})=>w0._`{comparison: ${HV[$].okStr}, limit: ${X}}`};NJ.formatLimitDefinition={keyword:Object.keys(HV),type:"string",schemaType:"string",$data:!0,error:FV$,code($){let{gen:X,data:Q,schemaCode:J,keyword:v,it:Y}=$,{opts:z,self:W}=Y;if(!z.validateFormats)return;let G=new DV$.KeywordCxt(Y,W.RULES.all.format.definition,"format");if(G.$data)U();else w();function U(){let j=X.scopeValue("formats",{ref:W.formats,code:z.code.formats}),V=X.const("fmt",w0._`${j}[${G.schemaCode}]`);$.fail$data((0,w0.or)(w0._`typeof ${V} != "object"`,w0._`${V} instanceof RegExp`,w0._`typeof ${V}.compare != "function"`,H(V)))}function w(){let j=G.schema,V=W.formats[j];if(!V||V===!0)return;if(typeof V!="object"||V instanceof RegExp||typeof V.compare!="function")throw Error(`"${v}": format "${j}" does not define "compare" function`);let B=X.scopeValue("formats",{key:j,ref:V,code:z.code.formats?w0._`${z.code.formats}${(0,w0.getProperty)(j)}`:void 0});$.fail$data(H(B))}function H(j){return w0._`${j}.compare(${Q}, ${J}) ${HV[v].fail} 0`}},dependencies:["format"]};var NV$=($)=>{return $.addKeyword(NJ.formatLimitDefinition),$};NJ.default=NV$});var Am=N((s7,Lm)=>{Object.defineProperty(s7,"__esModule",{value:!0});var OJ=Dm(),OV$=Fm(),XI=D$(),Nm=new XI.Name("fullFormats"),LV$=new XI.Name("fastFormats"),QI=($,X={keywords:!0})=>{if(Array.isArray(X))return Om($,X,OJ.fullFormats,Nm),$;let[Q,J]=X.mode==="fast"?[OJ.fastFormats,LV$]:[OJ.fullFormats,Nm],v=X.formats||OJ.formatNames;if(Om($,v,Q,J),X.keywords)(0,OV$.default)($);return $};QI.get=($,X="full")=>{let J=(X==="fast"?OJ.fastFormats:OJ.fullFormats)[$];if(!J)throw Error(`Unknown format "${$}"`);return J};function Om($,X,Q,J){var v,Y;(v=(Y=$.opts.code).formats)!==null&&v!==void 0||(Y.formats=XI._`require("ajv-formats/dist/formats").${J}`);for(let z of X)$.addFormat(z,Q[z])}Lm.exports=s7=QI;Object.defineProperty(s7,"__esModule",{value:!0});s7.default=QI});var gV$={};u1(gV$,{validateUpstreamBaseURL:()=>qL,validatePathIsInAllowlist:()=>b4,stopMcpServerWithUserConfig:()=>tz,startMcpServerWithUserConfig:()=>rz,resolveWebUiBindAddress:()=>hI,resolveReverseProxyBindAddress:()=>iJ,resolveReverseProxyAdvertisedHost:()=>RJ,resolveMcpBindAddress:()=>lI,resolveMcpAdvertisedHost:()=>pI,resolveFolderExistence:()=>Wv,rejectUnauthorized:()=>EV,registerCoreRoutes:()=>_u,parseStartOptionsFromBody:()=>bv,parseBearerToken:()=>RV,migrateAIConfig:()=>_S,isRequestAuthorized:()=>_I,isError:()=>Oz,isAuthTokenValid:()=>z5,handleWriteFilePost:()=>_z,handleUpdatePlanPost:()=>X7,handleRenameFolderPost:()=>gz,handleRenameFilesPost:()=>cz,handleReadImagePost:()=>lz,handleReadFilePost:()=>uz,handleProxyRequest:()=>yw,handleMcpStopPut:()=>QH,handleMcpStopPost:()=>GH,handleMcpStatusGet:()=>vH,handleMcpStartPut:()=>XH,handleMcpStartPost:()=>WH,handleMcpGetServerStatusGet:()=>zH,handleListFilesPost:()=>Tz,handleListFilesInMediaFolderPost:()=>nz,handleListFilesGet:()=>Pz,handleIsFolderAvailablePost:()=>Cz,handleHelloPost:()=>Sz,handleGetPlansPost:()=>ez,handleGetEpisodesPost:()=>kz,handleDownloadImageGet:()=>mz,handleDownloadImageAsFilePost:()=>hz,handleDiscoverGet:()=>pz,handleDeleteFolderPost:()=>fz,handleDeleteFilePost:()=>yz,handleCreatePlanPost:()=>$7,handleCoreRoutesRequest:()=>nL,handleChatPost:()=>Uw,getMcpServerStatusWithUserConfig:()=>sz,findAvailableReverseProxyPort:()=>bL,filterResponseHeaders:()=>ML,filterRequestHeaders:()=>IL,executeScrape:()=>Kz,executeGetJob:()=>Hz,enforceCoreRoutesAuth:()=>W5,doWriteFile:()=>cw,doUpdatePlan:()=>Mz,doRenameFolder:()=>WX,doRenameFiles:()=>Az,doReadImage:()=>bz,doReadFile:()=>hw,doMcpStop:()=>oz,doMcpStart:()=>dz,doMcpGetStatus:()=>az,doListFilesInMediaFolder:()=>Lz,doListFiles:()=>s1,doIsFolderAvailable:()=>gK,doHello:()=>j5,doGetPlans:()=>Zz,doGetPlanById:()=>Iz,doGetEpisodes:()=>U8,doFetchDiscoveredMediaDatabases:()=>Su,doFetchDiscoverConfig:()=>Ez,doDownloadImageAsFile:()=>xz,doDownloadImage:()=>Nz,doDeleteFolder:()=>ow,doDeleteFile:()=>pw,doCreatePlan:()=>qz,doChat:()=>tO,defaultChatFs:()=>J4,createSuccessResponse:()=>t,createStreamingNodeHttpFetch:()=>Vu,createSocketIOManager:()=>Ak,createReverseProxyRequestHandler:()=>xL,createReverseProxyManager:()=>wu,createProxiedFetch:()=>Tw,createOpenAICompatible:()=>CS,createNodeRenameFileExistenceProbe:()=>nX,createNodeHttpFetch:()=>ju,createMcpStreamableHttpHandler:()=>wI,createErrorResponse:()=>n,createCoreRoutesRequestHandler:()=>kL,createChatTools:()=>Ww,coreRouteHandlers:()=>fL,cleanupStalePlans:()=>Iu,cleanPreparingPlans:()=>sK,checkFolderPathAvailable:()=>NT,checkFileIsReadable:()=>SL,buildUpstreamUrl:()=>ZL,buildScrapeTool:()=>dK,buildGetJobTool:()=>oK,applyMcpLifecycleFromConfig:()=>_L,PORT_RANGE_START:()=>uw,PORT_RANGE_END:()=>_w,MCP_TOOL_NAMES:()=>Xh,FALLBACK_DISCOVER_CONFIG:()=>Pu,ExistedFileError:()=>Mv,EMPTY_DISCOVER_CONFIG:()=>$H,DISCOVER_TIMEOUT_MS:()=>Rz,DEFAULT_MCP_PORT:()=>YH,DEFAULT_MCP_HOST:()=>JH,DEFAULT_DISCOVER_CONFIG_URL:()=>uL,DEFAULT_ALLOWED_UPSTREAM_HOSTS:()=>Cw});module.exports=Dh(gV$);var uI=require("node:crypto");function CI($,X){let Q=$.headers.host??`127.0.0.1:${X}`;return new URL($.url??"/",`http://${Q}`)}function f($,X,Q){let J=JSON.stringify(Q);$.writeHead(X,{"Content-Type":"application/json; charset=utf-8","Content-Length":Buffer.byteLength(J,"utf8"),"Cache-Control":"no-store"}),$.end(J)}async function R$($){let X=[];for await(let J of $)X.push(Buffer.isBuffer(J)?J:Buffer.from(J));let Q=Buffer.concat(X).toString("utf8").trim();if(!Q)return{};return JSON.parse(Q)}function bJ($){if($===void 0||$===null)return;return $==="true"}function RV($){let X=Array.isArray($)?$[0]:$;if(!X)return null;let J=/^Bearer\s+(.+)$/i.exec(X.trim())?.[1]?.trim();return J?J:null}function z5($,X){let Q=RV($);if(!Q)return!1;let J=Buffer.from(Q,"utf8"),v=Buffer.from(X,"utf8");if(J.length!==v.length)return!1;return uI.timingSafeEqual(J,v)}function EV($){f($,401,{error:"Unauthorized: invalid or missing token"})}function W5($,X,Q){let J=Q.auth;if(!J?.enabled)return!1;if($.method==="OPTIONS")return!1;if(z5($.headers.authorization,J.token))return!1;return EV(X),!0}function _I($,X){if(!X?.enabled)return!0;return z5($,X.token)}function b4($,X){return X.some((Q)=>$.startsWith(Q))}var mI=require("node:fs/promises");function yI($,X=1){let Q=[],J=Math.floor(X),v=(Y,z)=>{for(let W=0;W:"/\\|?*\u0000-\u001F]|[. ]$/g}function kI(){return/^(con|prn|aux|nul|com\d|lpt\d)$/i}var Sd=100,Cd=/^\.+(\\|\/)|^\.+$/,K5=/[. ]+$/,ud=/[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/gu,_d=/[\p{Control}\p{Format}\p{Zl}\p{Zp}\uFFF0-\uFFFF]/u,nI=($)=>$==="‍",yd=/([<>:"/\\|?*\u0000-\u001F]){2,}/g,fd=/[<>:"/\\|?*\u0000-\u001F]/,kd=/[\t\n\r\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]+/g,gI;function nd(){return gI??=new Intl.Segmenter(void 0,{granularity:"grapheme"}),gI}function gd($,X){if($.length<=X)return $;let Q=$.lastIndexOf(".");if(Q===-1)return cI($,X);let J=$.slice(0,Q),v=$.slice(Q),Y=Math.max(0,X-v.length);return cI(J,Y).replace(/ +$/,"")+v}function w5($,X={}){if(typeof $!=="string")throw TypeError("Expected a string");let Q=X.replacement??"!",J=fd.test(Q),v=[...Q].some((z)=>_d.test(z)&&!nI(z));if(J||v)throw Error("Replacement string cannot contain reserved filename characters");if($=$.normalize("NFC"),$=$.replaceAll(kd," "),Q.length>0)$=$.replaceAll(yd,"$1");if($=$.replace(K5,""),$=$.replace(Cd,Q),$=$.replace(SV(),Q),$=$.replaceAll(ud,(z)=>nI(z)?z:Q),$=$.replace(K5,""),$.length===0){if($=Q.replace(K5,""),$.length===0&&Q.length>0)$="!"}let Y=typeof X.maxLength==="number"?X.maxLength:Sd;if($=gd($,Y),$=$.replace(K5,""),kI().test($))$+=Q;return $}function cI($,X){if($.length<=X)return $;let Q=0,J="";for(let{segment:v}of nd().segment($)){let Y=Q+v.length;if(Y>X)break;J+=v,Q=Y}return J}var V0="\\",I4="/";function CV($){return $.trim()!==""}function H5($){let X=$.split(":\\").filter(CV);return X=G5(X.map((Q)=>Q.split("\\").filter(CV))),X=G5(X.map((Q)=>Q.split("/").filter(CV))),X}class Z{static serverPlatform=null;root;sub;unc;constructor($,X){if($.trim()==="")throw Error("InvalidArgumentError: root path cannot be empty");if(X!==void 0){if(H5(X).length===0)if(X.length===0)throw Error("InvalidArgumentError: sub path cannot be empty");else throw Error("InvalidArgumentError: invalid sub path")}if(X?.trim()==="")throw Error("InvalidArgumentError: sub path cannot be empty");if(this.unc=$.startsWith("\\\\"),!($.startsWith("/")||/^[A-Za-z]:/.test($)||$.startsWith("\\\\")))throw Error(`InvalidArgumentError: root=${$}. root path must start with "/" for POSIX format, "C:" for Windows format, or "\\\\" for Windows UNC format`);if(this.root=H5($),this.sub=X===void 0?[]:H5(X),this.root.length===0)throw Error("InvalidArgumentError: invalid root path")}_uncPath(){let $=this.root[0],X=this.root.slice(1).join(V0),Q=this.sub.length===0?"":V0+this.sub.join(V0);return`\\\\${$}\\${X}${Q}`}abs($="posix"){if($==="win")if(this.unc)return this._uncPath();else{if(this.root[0]?.length!==1)return this._uncPath();let X=this.root.slice(1).join(V0),Q=this.sub.length===0?"":V0+this.sub.join(V0);return`${this.root[0]}:${V0}${X}${Q}`}else{let X=this.sub.length===0?"":I4+this.sub.join(I4);return`${I4}${this.root.join(I4)}${X}`}}rel($="posix"){if($==="win")return this.sub.join(V0);else return this.sub.join(I4)}name(){return U5(this.sub)||U5(this.root)||""}dir(){return"/"+this.root.join(I4)}cd($){return new Z(this.dir(),$)}platformAbsPath(){return Z.isWindows()?this.abs("win"):this.abs("posix")}platformRelPath(){return Z.isWindows()?this.rel("win"):this.rel("posix")}join($){let X=H5($);return new Z(I4+this.root.join(I4),[...this.sub,...X].join(I4))}filename($){if(this.sub.length===0)throw Error("InvalidArgumentError: sub path cannot be empty");else{let X=w5($);return new Z(I4+this.root.join(I4),[...this.sub.slice(0,-1),X].join(I4))}}parent(){if(this.sub.length===0)throw Error("reaching parent folder is not allowed");else{let $=this.sub.slice(0,-1);if($.length===0)return new Z(I4+this.root.join(I4));else return new Z(I4+this.root.join(I4),$.join(I4))}}static fromAbsolutePath($,X){return new Z(X,$.replace(X,""))}static posix($){return new Z($).abs("posix")}static win($){return new Z($).abs("win")}static slash($){return TV($)}static backslash($){return $.replace(I4,V0)}static setServerPlatform($){Z.serverPlatform=$}static resetServerPlatformForTests(){Z.serverPlatform=null}static getServerPlatform(){return Z.serverPlatform}static isWindows(){if(Z.serverPlatform!==null)return Z.serverPlatform==="win32";let $=typeof globalThis<"u"?globalThis.process:void 0;if($?.platform)return $.platform==="win32";let X=typeof globalThis<"u"?globalThis.window:void 0;if(X){let Q=X.electron;if(Q?.process?.platform)return Q.process.platform==="win32"}return!1}static pathSeparator(){return Z.isWindows()?V0:I4}static toPlatformPath($){return Z.isWindows()?Z.win($):Z.posix($)}toString(){return this.abs()}}function md($,X){return Promise.race([mI.stat($),new Promise((Q,J)=>setTimeout(()=>J(Error(`stat timeout for path: ${$}`)),X))])}function nX($=1000){return{async isFile(X){try{return(await md(Z.toPlatformPath(X),$)).isFile()}catch{return!1}}}}function hI(){return process.env.WEBUI_ADDRESS?.trim()||"127.0.0.1"}function iJ(){return process.env.REVERSE_PROXY_ADDRESS?.trim()||"127.0.0.1"}function lI($){let X=process.env.MCP_ADDRESS?.trim();if(X)return X;return $?.trim()||"127.0.0.1"}function pI($){return RJ($)}function RJ($){if($==="0.0.0.0"||$==="::")return"127.0.0.1";return $}function j5($){return{uptime:process.uptime(),...$,platform:$.platform??process.platform}}var AM="vercel.ai.error",hd=Symbol.for(AM),dI,oI,m=class $ extends(oI=Error,dI=hd,oI){constructor({name:X,message:Q,cause:J}){super(Q);this[dI]=!0,this.name=X,this.cause=J}static isInstance(X){return $.hasMarker(X,AM)}static hasMarker(X,Q){let J=Symbol.for(Q);return X!=null&&typeof X==="object"&&J in X&&typeof X[J]==="boolean"&&X[J]===!0}},ZM="AI_APICallError",qM=`vercel.ai.error.${ZM}`,ld=Symbol.for(qM),aI,rI,h$=class extends(rI=m,aI=ld,rI){constructor({message:$,url:X,requestBodyValues:Q,statusCode:J,responseHeaders:v,responseBody:Y,cause:z,isRetryable:W=J!=null&&(J===408||J===409||J===429||J>=500),data:G}){super({name:ZM,message:$,cause:z});this[aI]=!0,this.url=X,this.requestBodyValues=Q,this.statusCode=J,this.responseHeaders=v,this.responseBody=Y,this.isRetryable=W,this.data=G}static isInstance($){return m.hasMarker($,qM)}},IM="AI_EmptyResponseBodyError",MM=`vercel.ai.error.${IM}`,pd=Symbol.for(MM),tI,sI,xM=class extends(sI=m,tI=pd,sI){constructor({message:$="Empty response body"}={}){super({name:IM,message:$});this[tI]=!0}static isInstance($){return m.hasMarker($,MM)}};function _1($){if($==null)return"unknown error";if(typeof $==="string")return $;if($ instanceof Error)return $.message;return JSON.stringify($)}var bM="AI_InvalidArgumentError",iM=`vercel.ai.error.${bM}`,dd=Symbol.for(iM),eI,$M,V5=class extends($M=m,eI=dd,$M){constructor({message:$,cause:X,argument:Q}){super({name:bM,message:$,cause:X});this[eI]=!0,this.argument=Q}static isInstance($){return m.hasMarker($,iM)}},RM="AI_InvalidPromptError",EM=`vercel.ai.error.${RM}`,od=Symbol.for(EM),XM,QM,a0=class extends(QM=m,XM=od,QM){constructor({prompt:$,message:X,cause:Q}){super({name:RM,message:`Invalid prompt: ${X}`,cause:Q});this[XM]=!0,this.prompt=$}static isInstance($){return m.hasMarker($,EM)}},PM="AI_InvalidResponseDataError",TM=`vercel.ai.error.${PM}`,ad=Symbol.for(TM),vM,JM,uV=class extends(JM=m,vM=ad,JM){constructor({data:$,message:X=`Invalid response data: ${JSON.stringify($)}.`}){super({name:PM,message:X});this[vM]=!0,this.data=$}static isInstance($){return m.hasMarker($,TM)}},SM="AI_JSONParseError",CM=`vercel.ai.error.${SM}`,rd=Symbol.for(CM),YM,zM,EJ=class extends(zM=m,YM=rd,zM){constructor({text:$,cause:X}){super({name:SM,message:`JSON parsing failed: Text: ${$}. +Error message: ${_1(X)}`,cause:X});this[YM]=!0,this.text=$}static isInstance($){return m.hasMarker($,CM)}},uM="AI_LoadAPIKeyError",_M=`vercel.ai.error.${uM}`,td=Symbol.for(_M),WM,GM,VB$=class extends(GM=m,WM=td,GM){constructor({message:$}){super({name:uM,message:$});this[WM]=!0}static isInstance($){return m.hasMarker($,_M)}},yM="AI_LoadSettingError",fM=`vercel.ai.error.${yM}`,sd=Symbol.for(fM),UM,KM,BB$=class extends(KM=m,UM=sd,KM){constructor({message:$}){super({name:yM,message:$});this[UM]=!0}static isInstance($){return m.hasMarker($,fM)}},kM="AI_NoContentGeneratedError",nM=`vercel.ai.error.${kM}`,ed=Symbol.for(nM),wM,HM,DB$=class extends(HM=m,wM=ed,HM){constructor({message:$="No content generated."}={}){super({name:kM,message:$});this[wM]=!0}static isInstance($){return m.hasMarker($,nM)}},gM="AI_NoSuchModelError",cM=`vercel.ai.error.${gM}`,$o=Symbol.for(cM),jM,VM,FB$=class extends(VM=m,jM=$o,VM){constructor({errorName:$=gM,modelId:X,modelType:Q,message:J=`No such ${Q}: ${X}`}){super({name:$,message:J});this[jM]=!0,this.modelId=X,this.modelType=Q}static isInstance($){return m.hasMarker($,cM)}},mM="AI_TooManyEmbeddingValuesForCallError",hM=`vercel.ai.error.${mM}`,Xo=Symbol.for(hM),BM,DM,lM=class extends(DM=m,BM=Xo,DM){constructor($){super({name:mM,message:`Too many values for a single embedding call. The ${$.provider} model "${$.modelId}" can only embed up to ${$.maxEmbeddingsPerCall} values per call, but ${$.values.length} values were provided.`});this[BM]=!0,this.provider=$.provider,this.modelId=$.modelId,this.maxEmbeddingsPerCall=$.maxEmbeddingsPerCall,this.values=$.values}static isInstance($){return m.hasMarker($,hM)}},pM="AI_TypeValidationError",dM=`vercel.ai.error.${pM}`,Qo=Symbol.for(dM),FM,NM,y1=class $ extends(NM=m,FM=Qo,NM){constructor({value:X,cause:Q,context:J}){let v="Type validation failed";if(J==null?void 0:J.field)v+=` for ${J.field}`;if((J==null?void 0:J.entityName)||(J==null?void 0:J.entityId)){v+=" (";let Y=[];if(J.entityName)Y.push(J.entityName);if(J.entityId)Y.push(`id: "${J.entityId}"`);v+=Y.join(", "),v+=")"}super({name:pM,message:`${v}: Value: ${JSON.stringify(X)}. +Error message: ${_1(Q)}`,cause:Q});this[FM]=!0,this.value=X,this.context=J}static isInstance(X){return m.hasMarker(X,dM)}static wrap({value:X,cause:Q,context:J}){var v,Y,z;if($.isInstance(Q)&&Q.value===X&&((v=Q.context)==null?void 0:v.field)===(J==null?void 0:J.field)&&((Y=Q.context)==null?void 0:Y.entityName)===(J==null?void 0:J.entityName)&&((z=Q.context)==null?void 0:z.entityId)===(J==null?void 0:J.entityId))return Q;return new $({value:X,cause:Q,context:J})}},oM="AI_UnsupportedFunctionalityError",aM=`vercel.ai.error.${oM}`,vo=Symbol.for(aM),OM,LM,D1=class extends(LM=m,OM=vo,LM){constructor({functionality:$,message:X=`'${$}' functionality not supported.`}){super({name:oM,message:X});this[OM]=!0,this.functionality=$}static isInstance($){return m.hasMarker($,aM)}};var K={};u1(K,{xor:()=>Sx,xid:()=>Jx,void:()=>Rx,uuidv7:()=>r2,uuidv6:()=>a2,uuidv4:()=>o2,uuid:()=>d2,util:()=>C,url:()=>t2,uppercase:()=>JQ,unknown:()=>f$,union:()=>C$,undefined:()=>bx,ulid:()=>vx,uint64:()=>Mx,uint32:()=>Zx,tuple:()=>j3,trim:()=>KQ,treeifyError:()=>pV,transform:()=>dG,toUpperCase:()=>HQ,toLowerCase:()=>wQ,toJSONSchema:()=>s0,templateLiteral:()=>hx,symbol:()=>xx,superRefine:()=>k3,success:()=>gx,stringbool:()=>tx,stringFormat:()=>Bx,string:()=>x,strictObject:()=>Tx,startsWith:()=>zQ,slugify:()=>jQ,size:()=>n9,setErrorMap:()=>Ar,set:()=>yx,safeParseAsync:()=>OY,safeParse:()=>lF,safeEncodeAsync:()=>sF,safeEncode:()=>rF,safeDecodeAsync:()=>eF,safeDecode:()=>tF,registry:()=>kW,regexes:()=>t6,regex:()=>QQ,refine:()=>f3,record:()=>l$,readonly:()=>T3,property:()=>wG,promise:()=>lx,prettifyError:()=>dV,preprocess:()=>EY,prefault:()=>M3,positive:()=>WG,pipe:()=>ZY,partialRecord:()=>Cx,parseAsync:()=>hF,parse:()=>mF,overwrite:()=>f1,optional:()=>a$,object:()=>h,number:()=>P$,nullish:()=>nx,nullable:()=>AY,null:()=>AQ,normalize:()=>UQ,nonpositive:()=>UG,nonoptional:()=>x3,nonnegative:()=>KG,never:()=>pG,negative:()=>GG,nativeEnum:()=>fx,nanoid:()=>$x,nan:()=>cx,multipleOf:()=>C8,minSize:()=>N0,minLength:()=>t0,mime:()=>GQ,meta:()=>ax,maxSize:()=>u8,maxLength:()=>g9,map:()=>_x,mac:()=>Wx,lte:()=>s6,lt:()=>D0,lowercase:()=>vQ,looseRecord:()=>ux,looseObject:()=>l4,locales:()=>eX,literal:()=>d,length:()=>c9,lazy:()=>u3,ksuid:()=>Yx,keyof:()=>Px,jwt:()=>Vx,json:()=>sx,iso:()=>k8,ipv6:()=>Gx,ipv4:()=>zx,intersection:()=>ZQ,int64:()=>Ix,int32:()=>Ax,int:()=>bG,instanceof:()=>rx,includes:()=>YQ,httpUrl:()=>s2,hostname:()=>Dx,hex:()=>Fx,hash:()=>Nx,guid:()=>p2,gte:()=>D6,gt:()=>F0,globalRegistry:()=>h4,getErrorMap:()=>Zr,function:()=>px,fromJSONSchema:()=>$b,formatError:()=>nJ,float64:()=>Lx,float32:()=>Ox,flattenError:()=>kJ,file:()=>kx,exactOptional:()=>L3,enum:()=>p4,endsWith:()=>WQ,encodeAsync:()=>oF,encode:()=>pF,emoji:()=>e2,email:()=>l2,e164:()=>jx,discriminatedUnion:()=>bY,describe:()=>ox,decodeAsync:()=>aF,decode:()=>dF,date:()=>Ex,custom:()=>tG,cuid2:()=>Qx,cuid:()=>Xx,core:()=>k1,config:()=>U4,coerce:()=>g3,codec:()=>mx,clone:()=>m4,cidrv6:()=>Kx,cidrv4:()=>Ux,check:()=>dx,catch:()=>R3,boolean:()=>K4,bigint:()=>qx,base64url:()=>Hx,base64:()=>wx,array:()=>N$,any:()=>ix,_function:()=>px,_default:()=>q3,_ZodString:()=>iG,ZodXor:()=>U3,ZodXID:()=>uG,ZodVoid:()=>W3,ZodUnknown:()=>Y3,ZodUnion:()=>xY,ZodUndefined:()=>Q3,ZodUUID:()=>O0,ZodURL:()=>qY,ZodULID:()=>CG,ZodType:()=>w$,ZodTuple:()=>H3,ZodTransform:()=>N3,ZodTemplateLiteral:()=>S3,ZodSymbol:()=>X3,ZodSuccess:()=>b3,ZodStringFormat:()=>y$,ZodString:()=>DQ,ZodSet:()=>B3,ZodRecord:()=>iY,ZodRealError:()=>C6,ZodReadonly:()=>P3,ZodPromise:()=>_3,ZodPrefault:()=>I3,ZodPipe:()=>aG,ZodOptional:()=>qQ,ZodObject:()=>MY,ZodNumberFormat:()=>h9,ZodNumber:()=>NQ,ZodNullable:()=>A3,ZodNull:()=>v3,ZodNonOptional:()=>oG,ZodNever:()=>z3,ZodNanoID:()=>PG,ZodNaN:()=>E3,ZodMap:()=>V3,ZodMAC:()=>$3,ZodLiteral:()=>D3,ZodLazy:()=>C3,ZodKSUID:()=>_G,ZodJWT:()=>hG,ZodIssueCode:()=>Lr,ZodIntersection:()=>w3,ZodISOTime:()=>MG,ZodISODuration:()=>xG,ZodISODateTime:()=>qG,ZodISODate:()=>IG,ZodIPv6:()=>fG,ZodIPv4:()=>yG,ZodGUID:()=>LY,ZodFunction:()=>y3,ZodFirstPartyTypeKind:()=>n3,ZodFile:()=>F3,ZodExactOptional:()=>O3,ZodError:()=>Nr,ZodEnum:()=>BQ,ZodEmoji:()=>EG,ZodEmail:()=>RG,ZodE164:()=>mG,ZodDiscriminatedUnion:()=>K3,ZodDefault:()=>Z3,ZodDate:()=>IY,ZodCustomStringFormat:()=>FQ,ZodCustom:()=>RY,ZodCodec:()=>rG,ZodCatch:()=>i3,ZodCUID2:()=>SG,ZodCUID:()=>TG,ZodCIDRv6:()=>nG,ZodCIDRv4:()=>kG,ZodBoolean:()=>OQ,ZodBigIntFormat:()=>lG,ZodBigInt:()=>LQ,ZodBase64URL:()=>cG,ZodBase64:()=>gG,ZodArray:()=>G3,ZodAny:()=>J3,TimePrecision:()=>hD,NEVER:()=>_V,$output:()=>nD,$input:()=>gD,$brand:()=>yV});var k1={};u1(k1,{version:()=>nB,util:()=>C,treeifyError:()=>pV,toJSONSchema:()=>s0,toDotPath:()=>W2,safeParseAsync:()=>T8,safeParse:()=>P8,safeEncodeAsync:()=>V2,safeEncode:()=>H2,safeDecodeAsync:()=>B2,safeDecode:()=>j2,registry:()=>kW,regexes:()=>t6,process:()=>T$,prettifyError:()=>dV,parseAsync:()=>f9,parse:()=>y9,meta:()=>FG,locales:()=>eX,isValidJWT:()=>C2,isValidBase64URL:()=>S2,isValidBase64:()=>pB,initializeContext:()=>_8,globalRegistry:()=>h4,globalConfig:()=>PJ,formatError:()=>nJ,flattenError:()=>kJ,finalize:()=>f8,extractDefs:()=>y8,encodeAsync:()=>K2,encode:()=>G2,describe:()=>DG,decodeAsync:()=>w2,decode:()=>U2,createToJSONSchemaMethod:()=>$F,createStandardJSONSchemaMethod:()=>VQ,config:()=>U4,clone:()=>m4,_xor:()=>oa,_xid:()=>GY,_void:()=>JG,_uuidv7:()=>QY,_uuidv6:()=>XY,_uuidv4:()=>$Y,_uuid:()=>eJ,_url:()=>XQ,_uppercase:()=>JQ,_unknown:()=>QG,_union:()=>da,_undefined:()=>eW,_ulid:()=>WY,_uint64:()=>tW,_uint32:()=>dW,_tuple:()=>ta,_trim:()=>KQ,_transform:()=>Jr,_toUpperCase:()=>HQ,_toLowerCase:()=>wQ,_templateLiteral:()=>jr,_symbol:()=>sW,_superRefine:()=>BG,_success:()=>Ur,_stringbool:()=>NG,_stringFormat:()=>m9,_string:()=>nW,_startsWith:()=>zQ,_slugify:()=>jQ,_size:()=>n9,_set:()=>$r,_safeParseAsync:()=>dX,_safeParse:()=>pX,_safeEncodeAsync:()=>I5,_safeEncode:()=>Z5,_safeDecodeAsync:()=>M5,_safeDecode:()=>q5,_regex:()=>QQ,_refine:()=>VG,_record:()=>sa,_readonly:()=>Hr,_property:()=>wG,_promise:()=>Br,_positive:()=>WG,_pipe:()=>wr,_parseAsync:()=>lX,_parse:()=>hX,_overwrite:()=>f1,_optional:()=>Yr,_number:()=>cW,_nullable:()=>zr,_null:()=>$G,_normalize:()=>UQ,_nonpositive:()=>UG,_nonoptional:()=>Gr,_nonnegative:()=>KG,_never:()=>vG,_negative:()=>GG,_nativeEnum:()=>Qr,_nanoid:()=>JY,_nan:()=>zG,_multipleOf:()=>C8,_minSize:()=>N0,_minLength:()=>t0,_min:()=>D6,_mime:()=>GQ,_maxSize:()=>u8,_maxLength:()=>g9,_max:()=>s6,_map:()=>ea,_mac:()=>gW,_lte:()=>s6,_lt:()=>D0,_lowercase:()=>vQ,_literal:()=>vr,_length:()=>c9,_lazy:()=>Vr,_ksuid:()=>UY,_jwt:()=>FY,_isoTime:()=>dD,_isoDuration:()=>oD,_isoDateTime:()=>lD,_isoDate:()=>pD,_ipv6:()=>wY,_ipv4:()=>KY,_intersection:()=>ra,_int64:()=>rW,_int32:()=>pW,_int:()=>mW,_includes:()=>YQ,_guid:()=>$Q,_gte:()=>D6,_gt:()=>F0,_float64:()=>lW,_float32:()=>hW,_file:()=>HG,_enum:()=>Xr,_endsWith:()=>WQ,_encodeAsync:()=>L5,_encode:()=>N5,_emoji:()=>vY,_email:()=>sJ,_e164:()=>DY,_discriminatedUnion:()=>aa,_default:()=>Wr,_decodeAsync:()=>A5,_decode:()=>O5,_date:()=>YG,_custom:()=>jG,_cuid2:()=>zY,_cuid:()=>YY,_coercedString:()=>mD,_coercedNumber:()=>aD,_coercedDate:()=>sD,_coercedBoolean:()=>rD,_coercedBigint:()=>tD,_cidrv6:()=>jY,_cidrv4:()=>HY,_check:()=>c2,_catch:()=>Kr,_boolean:()=>oW,_bigint:()=>aW,_base64url:()=>BY,_base64:()=>VY,_array:()=>eD,_any:()=>XG,TimePrecision:()=>hD,NEVER:()=>_V,JSONSchemaGenerator:()=>fF,JSONSchema:()=>m2,Doc:()=>R5,$output:()=>nD,$input:()=>gD,$constructor:()=>q,$brand:()=>yV,$ZodXor:()=>VW,$ZodXID:()=>h5,$ZodVoid:()=>KW,$ZodUnknown:()=>GW,$ZodUnion:()=>rX,$ZodUndefined:()=>YW,$ZodUUID:()=>_5,$ZodURL:()=>f5,$ZodULID:()=>m5,$ZodType:()=>J$,$ZodTuple:()=>pJ,$ZodTransform:()=>qW,$ZodTemplateLiteral:()=>CW,$ZodSymbol:()=>JW,$ZodSuccess:()=>RW,$ZodStringFormat:()=>S$,$ZodString:()=>S8,$ZodSet:()=>OW,$ZodRegistry:()=>cD,$ZodRecord:()=>FW,$ZodRealError:()=>S6,$ZodReadonly:()=>SW,$ZodPromise:()=>_W,$ZodPrefault:()=>bW,$ZodPipe:()=>TW,$ZodOptional:()=>dJ,$ZodObjectJIT:()=>dB,$ZodObject:()=>jW,$ZodNumberFormat:()=>QW,$ZodNumber:()=>hJ,$ZodNullable:()=>MW,$ZodNull:()=>zW,$ZodNonOptional:()=>iW,$ZodNever:()=>UW,$ZodNanoID:()=>n5,$ZodNaN:()=>PW,$ZodMap:()=>NW,$ZodMAC:()=>o5,$ZodLiteral:()=>AW,$ZodLazy:()=>yW,$ZodKSUID:()=>l5,$ZodJWT:()=>$W,$ZodIntersection:()=>DW,$ZodISOTime:()=>hB,$ZodISODuration:()=>lB,$ZodISODateTime:()=>cB,$ZodISODate:()=>mB,$ZodIPv6:()=>d5,$ZodIPv4:()=>p5,$ZodGUID:()=>u5,$ZodFunction:()=>uW,$ZodFile:()=>ZW,$ZodExactOptional:()=>IW,$ZodError:()=>fJ,$ZodEnum:()=>LW,$ZodEncodeError:()=>u9,$ZodEmoji:()=>k5,$ZodEmail:()=>y5,$ZodE164:()=>e5,$ZodDiscriminatedUnion:()=>BW,$ZodDefault:()=>xW,$ZodDate:()=>wW,$ZodCustomStringFormat:()=>XW,$ZodCustom:()=>fW,$ZodCodec:()=>tX,$ZodCheckUpperCase:()=>SB,$ZodCheckStringFormat:()=>oX,$ZodCheckStartsWith:()=>uB,$ZodCheckSizeEquals:()=>bB,$ZodCheckRegex:()=>PB,$ZodCheckProperty:()=>yB,$ZodCheckOverwrite:()=>kB,$ZodCheckNumberFormat:()=>qB,$ZodCheckMultipleOf:()=>ZB,$ZodCheckMinSize:()=>xB,$ZodCheckMinLength:()=>RB,$ZodCheckMimeType:()=>fB,$ZodCheckMaxSize:()=>MB,$ZodCheckMaxLength:()=>iB,$ZodCheckLowerCase:()=>TB,$ZodCheckLessThan:()=>b5,$ZodCheckLengthEquals:()=>EB,$ZodCheckIncludes:()=>CB,$ZodCheckGreaterThan:()=>i5,$ZodCheckEndsWith:()=>_B,$ZodCheckBigIntFormat:()=>IB,$ZodCheck:()=>_$,$ZodCatch:()=>EW,$ZodCUID2:()=>c5,$ZodCUID:()=>g5,$ZodCIDRv6:()=>r5,$ZodCIDRv4:()=>a5,$ZodBoolean:()=>aX,$ZodBigIntFormat:()=>vW,$ZodBigInt:()=>lJ,$ZodBase64URL:()=>s5,$ZodBase64:()=>t5,$ZodAsyncError:()=>B0,$ZodArray:()=>HW,$ZodAny:()=>WW});var _V=Object.freeze({status:"aborted"});function q($,X,Q){function J(W,G){if(!W._zod)Object.defineProperty(W,"_zod",{value:{def:G,constr:z,traits:new Set},enumerable:!1});if(W._zod.traits.has($))return;W._zod.traits.add($),X(W,G);let U=z.prototype,w=Object.keys(U);for(let H=0;H{if(Q?.Parent&&W instanceof Q.Parent)return!0;return W?._zod?.traits?.has($)}}),Object.defineProperty(z,"name",{value:$}),z}var yV=Symbol("zod_brand");class B0 extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}class u9 extends Error{constructor($){super(`Encountered unidirectional transform during encode: ${$}`);this.name="ZodEncodeError"}}var PJ={};function U4($){if($)Object.assign(PJ,$);return PJ}var C={};u1(C,{unwrapMessage:()=>TJ,uint8ArrayToHex:()=>Zo,uint8ArrayToBase64url:()=>Lo,uint8ArrayToBase64:()=>J2,stringifyPrimitive:()=>P,slugify:()=>nV,shallowClone:()=>F5,safeExtend:()=>$2,required:()=>Q2,randomString:()=>jo,propertyKeyTypes:()=>uJ,promiseAllObject:()=>Ho,primitiveTypes:()=>cV,prefixIssues:()=>r6,pick:()=>tM,partial:()=>X2,parsedType:()=>S,optionalKeys:()=>mV,omit:()=>sM,objectClone:()=>Uo,numKeys:()=>Vo,nullish:()=>b8,normalizeParams:()=>u,mergeDefs:()=>r0,merge:()=>Fo,jsonStringifyReplacer:()=>gX,joinValues:()=>I,issue:()=>mX,isPlainObject:()=>R8,isObject:()=>_9,hexToUint8Array:()=>Ao,getSizableOrigin:()=>_J,getParsedType:()=>Bo,getLengthableOrigin:()=>yJ,getEnumValues:()=>SJ,getElementAtPath:()=>wo,floatSafeRemainder:()=>kV,finalizeIssue:()=>T6,extend:()=>eM,escapeRegex:()=>F1,esc:()=>B5,defineLazy:()=>K$,createTransparentProxy:()=>Do,cloneDef:()=>Ko,clone:()=>m4,cleanRegex:()=>CJ,cleanEnum:()=>No,captureStackTrace:()=>D5,cached:()=>cX,base64urlToUint8Array:()=>Oo,base64ToUint8Array:()=>v2,assignProp:()=>i8,assertNotEqual:()=>Yo,assertNever:()=>Wo,assertIs:()=>zo,assertEqual:()=>Jo,assert:()=>Go,allowsEval:()=>gV,aborted:()=>E8,NUMBER_FORMAT_RANGES:()=>hV,Class:()=>Y2,BIGINT_FORMAT_RANGES:()=>lV});function Jo($){return $}function Yo($){return $}function zo($){}function Wo($){throw Error("Unexpected value in exhaustive check")}function Go($){}function SJ($){let X=Object.values($).filter((J)=>typeof J==="number");return Object.entries($).filter(([J,v])=>X.indexOf(+J)===-1).map(([J,v])=>v)}function I($,X="|"){return $.map((Q)=>P(Q)).join(X)}function gX($,X){if(typeof X==="bigint")return X.toString();return X}function cX($){return{get value(){{let Q=$();return Object.defineProperty(this,"value",{value:Q}),Q}throw Error("cached value already set")}}}function b8($){return $===null||$===void 0}function CJ($){let X=$.startsWith("^")?1:0,Q=$.endsWith("$")?$.length-1:$.length;return $.slice(X,Q)}function kV($,X){let Q=($.toString().split(".")[1]||"").length,J=X.toString(),v=(J.split(".")[1]||"").length;if(v===0&&/\d?e-\d?/.test(J)){let G=J.match(/\d?e-(\d?)/);if(G?.[1])v=Number.parseInt(G[1])}let Y=Q>v?Q:v,z=Number.parseInt($.toFixed(Y).replace(".","")),W=Number.parseInt(X.toFixed(Y).replace(".",""));return z%W/10**Y}var rM=Symbol("evaluating");function K$($,X,Q){let J=void 0;Object.defineProperty($,X,{get(){if(J===rM)return;if(J===void 0)J=rM,J=Q();return J},set(v){Object.defineProperty($,X,{value:v})},configurable:!0})}function Uo($){return Object.create(Object.getPrototypeOf($),Object.getOwnPropertyDescriptors($))}function i8($,X,Q){Object.defineProperty($,X,{value:Q,writable:!0,enumerable:!0,configurable:!0})}function r0(...$){let X={};for(let Q of $){let J=Object.getOwnPropertyDescriptors(Q);Object.assign(X,J)}return Object.defineProperties({},X)}function Ko($){return r0($._zod.def)}function wo($,X){if(!X)return $;return X.reduce((Q,J)=>Q?.[J],$)}function Ho($){let X=Object.keys($),Q=X.map((J)=>$[J]);return Promise.all(Q).then((J)=>{let v={};for(let Y=0;Y{};function _9($){return typeof $==="object"&&$!==null&&!Array.isArray($)}var gV=cX(()=>{if(typeof navigator<"u"&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{return new Function(""),!0}catch($){return!1}});function R8($){if(_9($)===!1)return!1;let X=$.constructor;if(X===void 0)return!0;if(typeof X!=="function")return!0;let Q=X.prototype;if(_9(Q)===!1)return!1;if(Object.prototype.hasOwnProperty.call(Q,"isPrototypeOf")===!1)return!1;return!0}function F5($){if(R8($))return{...$};if(Array.isArray($))return[...$];return $}function Vo($){let X=0;for(let Q in $)if(Object.prototype.hasOwnProperty.call($,Q))X++;return X}var Bo=($)=>{let X=typeof $;switch(X){case"undefined":return"undefined";case"string":return"string";case"number":return Number.isNaN($)?"nan":"number";case"boolean":return"boolean";case"function":return"function";case"bigint":return"bigint";case"symbol":return"symbol";case"object":if(Array.isArray($))return"array";if($===null)return"null";if($.then&&typeof $.then==="function"&&$.catch&&typeof $.catch==="function")return"promise";if(typeof Map<"u"&&$ instanceof Map)return"map";if(typeof Set<"u"&&$ instanceof Set)return"set";if(typeof Date<"u"&&$ instanceof Date)return"date";if(typeof File<"u"&&$ instanceof File)return"file";return"object";default:throw Error(`Unknown data type: ${X}`)}},uJ=new Set(["string","number","symbol"]),cV=new Set(["string","number","bigint","boolean","symbol","undefined"]);function F1($){return $.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function m4($,X,Q){let J=new $._zod.constr(X??$._zod.def);if(!X||Q?.parent)J._zod.parent=$;return J}function u($){let X=$;if(!X)return{};if(typeof X==="string")return{error:()=>X};if(X?.message!==void 0){if(X?.error!==void 0)throw Error("Cannot specify both `message` and `error` params");X.error=X.message}if(delete X.message,typeof X.error==="string")return{...X,error:()=>X.error};return X}function Do($){let X;return new Proxy({},{get(Q,J,v){return X??(X=$()),Reflect.get(X,J,v)},set(Q,J,v,Y){return X??(X=$()),Reflect.set(X,J,v,Y)},has(Q,J){return X??(X=$()),Reflect.has(X,J)},deleteProperty(Q,J){return X??(X=$()),Reflect.deleteProperty(X,J)},ownKeys(Q){return X??(X=$()),Reflect.ownKeys(X)},getOwnPropertyDescriptor(Q,J){return X??(X=$()),Reflect.getOwnPropertyDescriptor(X,J)},defineProperty(Q,J,v){return X??(X=$()),Reflect.defineProperty(X,J,v)}})}function P($){if(typeof $==="bigint")return $.toString()+"n";if(typeof $==="string")return`"${$}"`;return`${$}`}function mV($){return Object.keys($).filter((X)=>{return $[X]._zod.optin==="optional"&&$[X]._zod.optout==="optional"})}var hV={safeint:[Number.MIN_SAFE_INTEGER,Number.MAX_SAFE_INTEGER],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-340282346638528860000000000000000000000,340282346638528860000000000000000000000],float64:[-Number.MAX_VALUE,Number.MAX_VALUE]},lV={int64:[BigInt("-9223372036854775808"),BigInt("9223372036854775807")],uint64:[BigInt(0),BigInt("18446744073709551615")]};function tM($,X){let Q=$._zod.def,J=Q.checks;if(J&&J.length>0)throw Error(".pick() cannot be used on object schemas containing refinements");let Y=r0($._zod.def,{get shape(){let z={};for(let W in X){if(!(W in Q.shape))throw Error(`Unrecognized key: "${W}"`);if(!X[W])continue;z[W]=Q.shape[W]}return i8(this,"shape",z),z},checks:[]});return m4($,Y)}function sM($,X){let Q=$._zod.def,J=Q.checks;if(J&&J.length>0)throw Error(".omit() cannot be used on object schemas containing refinements");let Y=r0($._zod.def,{get shape(){let z={...$._zod.def.shape};for(let W in X){if(!(W in Q.shape))throw Error(`Unrecognized key: "${W}"`);if(!X[W])continue;delete z[W]}return i8(this,"shape",z),z},checks:[]});return m4($,Y)}function eM($,X){if(!R8(X))throw Error("Invalid input to extend: expected a plain object");let Q=$._zod.def.checks;if(Q&&Q.length>0){let Y=$._zod.def.shape;for(let z in X)if(Object.getOwnPropertyDescriptor(Y,z)!==void 0)throw Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}let v=r0($._zod.def,{get shape(){let Y={...$._zod.def.shape,...X};return i8(this,"shape",Y),Y}});return m4($,v)}function $2($,X){if(!R8(X))throw Error("Invalid input to safeExtend: expected a plain object");let Q=r0($._zod.def,{get shape(){let J={...$._zod.def.shape,...X};return i8(this,"shape",J),J}});return m4($,Q)}function Fo($,X){let Q=r0($._zod.def,{get shape(){let J={...$._zod.def.shape,...X._zod.def.shape};return i8(this,"shape",J),J},get catchall(){return X._zod.def.catchall},checks:[]});return m4($,Q)}function X2($,X,Q){let v=X._zod.def.checks;if(v&&v.length>0)throw Error(".partial() cannot be used on object schemas containing refinements");let z=r0(X._zod.def,{get shape(){let W=X._zod.def.shape,G={...W};if(Q)for(let U in Q){if(!(U in W))throw Error(`Unrecognized key: "${U}"`);if(!Q[U])continue;G[U]=$?new $({type:"optional",innerType:W[U]}):W[U]}else for(let U in W)G[U]=$?new $({type:"optional",innerType:W[U]}):W[U];return i8(this,"shape",G),G},checks:[]});return m4(X,z)}function Q2($,X,Q){let J=r0(X._zod.def,{get shape(){let v=X._zod.def.shape,Y={...v};if(Q)for(let z in Q){if(!(z in Y))throw Error(`Unrecognized key: "${z}"`);if(!Q[z])continue;Y[z]=new $({type:"nonoptional",innerType:v[z]})}else for(let z in v)Y[z]=new $({type:"nonoptional",innerType:v[z]});return i8(this,"shape",Y),Y}});return m4(X,J)}function E8($,X=0){if($.aborted===!0)return!0;for(let Q=X;Q<$.issues.length;Q++)if($.issues[Q]?.continue!==!0)return!0;return!1}function r6($,X){return X.map((Q)=>{var J;return(J=Q).path??(J.path=[]),Q.path.unshift($),Q})}function TJ($){return typeof $==="string"?$:$?.message}function T6($,X,Q){let J={...$,path:$.path??[]};if(!$.message){let v=TJ($.inst?._zod.def?.error?.($))??TJ(X?.error?.($))??TJ(Q.customError?.($))??TJ(Q.localeError?.($))??"Invalid input";J.message=v}if(delete J.inst,delete J.continue,!X?.reportInput)delete J.input;return J}function _J($){if($ instanceof Set)return"set";if($ instanceof Map)return"map";if($ instanceof File)return"file";return"unknown"}function yJ($){if(Array.isArray($))return"array";if(typeof $==="string")return"string";return"unknown"}function S($){let X=typeof $;switch(X){case"number":return Number.isNaN($)?"nan":"number";case"object":{if($===null)return"null";if(Array.isArray($))return"array";let Q=$;if(Q&&Object.getPrototypeOf(Q)!==Object.prototype&&"constructor"in Q&&Q.constructor)return Q.constructor.name}}return X}function mX(...$){let[X,Q,J]=$;if(typeof X==="string")return{message:X,code:"custom",input:Q,inst:J};return{...X}}function No($){return Object.entries($).filter(([X,Q])=>{return Number.isNaN(Number.parseInt(X,10))}).map((X)=>X[1])}function v2($){let X=atob($),Q=new Uint8Array(X.length);for(let J=0;JX.toString(16).padStart(2,"0")).join("")}class Y2{constructor(...$){}}var z2=($,X)=>{$.name="$ZodError",Object.defineProperty($,"_zod",{value:$._zod,enumerable:!1}),Object.defineProperty($,"issues",{value:X,enumerable:!1}),$.message=JSON.stringify(X,gX,2),Object.defineProperty($,"toString",{value:()=>$.message,enumerable:!1})},fJ=q("$ZodError",z2),S6=q("$ZodError",z2,{Parent:Error});function kJ($,X=(Q)=>Q.message){let Q={},J=[];for(let v of $.issues)if(v.path.length>0)Q[v.path[0]]=Q[v.path[0]]||[],Q[v.path[0]].push(X(v));else J.push(X(v));return{formErrors:J,fieldErrors:Q}}function nJ($,X=(Q)=>Q.message){let Q={_errors:[]},J=(v)=>{for(let Y of v.issues)if(Y.code==="invalid_union"&&Y.errors.length)Y.errors.map((z)=>J({issues:z}));else if(Y.code==="invalid_key")J({issues:Y.issues});else if(Y.code==="invalid_element")J({issues:Y.issues});else if(Y.path.length===0)Q._errors.push(X(Y));else{let z=Q,W=0;while(WQ.message){let Q={errors:[]},J=(v,Y=[])=>{var z,W;for(let G of v.issues)if(G.code==="invalid_union"&&G.errors.length)G.errors.map((U)=>J({issues:U},G.path));else if(G.code==="invalid_key")J({issues:G.issues},G.path);else if(G.code==="invalid_element")J({issues:G.issues},G.path);else{let U=[...Y,...G.path];if(U.length===0){Q.errors.push(X(G));continue}let w=Q,H=0;while(Htypeof J==="object"?J.key:J);for(let J of Q)if(typeof J==="number")X.push(`[${J}]`);else if(typeof J==="symbol")X.push(`[${JSON.stringify(String(J))}]`);else if(/[^\w$]/.test(J))X.push(`[${JSON.stringify(J)}]`);else{if(X.length)X.push(".");X.push(J)}return X.join("")}function dV($){let X=[],Q=[...$.issues].sort((J,v)=>(J.path??[]).length-(v.path??[]).length);for(let J of Q)if(X.push(`✖ ${J.message}`),J.path?.length)X.push(` → at ${W2(J.path)}`);return X.join(` +`)}var hX=($)=>(X,Q,J,v)=>{let Y=J?Object.assign(J,{async:!1}):{async:!1},z=X._zod.run({value:Q,issues:[]},Y);if(z instanceof Promise)throw new B0;if(z.issues.length){let W=new(v?.Err??$)(z.issues.map((G)=>T6(G,Y,U4())));throw D5(W,v?.callee),W}return z.value},y9=hX(S6),lX=($)=>async(X,Q,J,v)=>{let Y=J?Object.assign(J,{async:!0}):{async:!0},z=X._zod.run({value:Q,issues:[]},Y);if(z instanceof Promise)z=await z;if(z.issues.length){let W=new(v?.Err??$)(z.issues.map((G)=>T6(G,Y,U4())));throw D5(W,v?.callee),W}return z.value},f9=lX(S6),pX=($)=>(X,Q,J)=>{let v=J?{...J,async:!1}:{async:!1},Y=X._zod.run({value:Q,issues:[]},v);if(Y instanceof Promise)throw new B0;return Y.issues.length?{success:!1,error:new($??fJ)(Y.issues.map((z)=>T6(z,v,U4())))}:{success:!0,data:Y.value}},P8=pX(S6),dX=($)=>async(X,Q,J)=>{let v=J?Object.assign(J,{async:!0}):{async:!0},Y=X._zod.run({value:Q,issues:[]},v);if(Y instanceof Promise)Y=await Y;return Y.issues.length?{success:!1,error:new $(Y.issues.map((z)=>T6(z,v,U4())))}:{success:!0,data:Y.value}},T8=dX(S6),N5=($)=>(X,Q,J)=>{let v=J?Object.assign(J,{direction:"backward"}):{direction:"backward"};return hX($)(X,Q,v)},G2=N5(S6),O5=($)=>(X,Q,J)=>{return hX($)(X,Q,J)},U2=O5(S6),L5=($)=>async(X,Q,J)=>{let v=J?Object.assign(J,{direction:"backward"}):{direction:"backward"};return lX($)(X,Q,v)},K2=L5(S6),A5=($)=>async(X,Q,J)=>{return lX($)(X,Q,J)},w2=A5(S6),Z5=($)=>(X,Q,J)=>{let v=J?Object.assign(J,{direction:"backward"}):{direction:"backward"};return pX($)(X,Q,v)},H2=Z5(S6),q5=($)=>(X,Q,J)=>{return pX($)(X,Q,J)},j2=q5(S6),I5=($)=>async(X,Q,J)=>{let v=J?Object.assign(J,{direction:"backward"}):{direction:"backward"};return dX($)(X,Q,v)},V2=I5(S6),M5=($)=>async(X,Q,J)=>{return dX($)(X,Q,J)},B2=M5(S6);var t6={};u1(t6,{xid:()=>tV,uuid7:()=>bo,uuid6:()=>xo,uuid4:()=>Mo,uuid:()=>k9,uppercase:()=>AB,unicodeEmail:()=>D2,undefined:()=>OB,ulid:()=>rV,time:()=>HB,string:()=>VB,sha512_hex:()=>ao,sha512_base64url:()=>to,sha512_base64:()=>ro,sha384_hex:()=>lo,sha384_base64url:()=>oo,sha384_base64:()=>po,sha256_hex:()=>co,sha256_base64url:()=>ho,sha256_base64:()=>mo,sha1_hex:()=>ko,sha1_base64url:()=>go,sha1_base64:()=>no,rfc5322Email:()=>Ro,number:()=>gJ,null:()=>NB,nanoid:()=>eV,md5_hex:()=>_o,md5_base64url:()=>fo,md5_base64:()=>yo,mac:()=>zB,lowercase:()=>LB,ksuid:()=>sV,ipv6:()=>YB,ipv4:()=>JB,integer:()=>DB,idnEmail:()=>Eo,html5Email:()=>io,hostname:()=>So,hex:()=>uo,guid:()=>XB,extendedDuration:()=>Io,emoji:()=>vB,email:()=>QB,e164:()=>KB,duration:()=>$B,domain:()=>Co,datetime:()=>jB,date:()=>wB,cuid2:()=>aV,cuid:()=>oV,cidrv6:()=>GB,cidrv4:()=>WB,browserEmail:()=>Po,boolean:()=>FB,bigint:()=>BB,base64url:()=>x5,base64:()=>UB});var oV=/^[cC][^\s-]{8,}$/,aV=/^[0-9a-z]+$/,rV=/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/,tV=/^[0-9a-vA-V]{20}$/,sV=/^[A-Za-z0-9]{27}$/,eV=/^[a-zA-Z0-9_-]{21}$/,$B=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,Io=/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/,XB=/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/,k9=($)=>{if(!$)return/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${$}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`)},Mo=k9(4),xo=k9(6),bo=k9(7),QB=/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/,io=/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,Ro=/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,D2=/^[^\s@"]{1,64}@[^\s@]{1,255}$/u,Eo=D2,Po=/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,To="^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$";function vB(){return new RegExp(To,"u")}var JB=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,YB=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/,zB=($)=>{let X=F1($??":");return new RegExp(`^(?:[0-9A-F]{2}${X}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${X}){5}[0-9a-f]{2}$`)},WB=/^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/,GB=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,UB=/^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/,x5=/^[A-Za-z0-9_-]*$/,So=/^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/,Co=/^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/,KB=/^\+[1-9]\d{6,14}$/,F2="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",wB=new RegExp(`^${F2}$`);function N2($){return typeof $.precision==="number"?$.precision===-1?"(?:[01]\\d|2[0-3]):[0-5]\\d":$.precision===0?"(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d":`(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d\\.\\d{${$.precision}}`:"(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?"}function HB($){return new RegExp(`^${N2($)}$`)}function jB($){let X=N2({precision:$.precision}),Q=["Z"];if($.local)Q.push("");if($.offset)Q.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)");let J=`${X}(?:${Q.join("|")})`;return new RegExp(`^${F2}T(?:${J})$`)}var VB=($)=>{let X=$?`[\\s\\S]{${$?.minimum??0},${$?.maximum??""}}`:"[\\s\\S]*";return new RegExp(`^${X}$`)},BB=/^-?\d+n?$/,DB=/^-?\d+$/,gJ=/^-?\d+(?:\.\d+)?$/,FB=/^(?:true|false)$/i,NB=/^null$/i;var OB=/^undefined$/i;var LB=/^[^A-Z]*$/,AB=/^[^a-z]*$/,uo=/^[0-9a-fA-F]*$/;function cJ($,X){return new RegExp(`^[A-Za-z0-9+/]{${$}}${X}$`)}function mJ($){return new RegExp(`^[A-Za-z0-9_-]{${$}}$`)}var _o=/^[0-9a-fA-F]{32}$/,yo=cJ(22,"=="),fo=mJ(22),ko=/^[0-9a-fA-F]{40}$/,no=cJ(27,"="),go=mJ(27),co=/^[0-9a-fA-F]{64}$/,mo=cJ(43,"="),ho=mJ(43),lo=/^[0-9a-fA-F]{96}$/,po=cJ(64,""),oo=mJ(64),ao=/^[0-9a-fA-F]{128}$/,ro=cJ(86,"=="),to=mJ(86);var _$=q("$ZodCheck",($,X)=>{var Q;$._zod??($._zod={}),$._zod.def=X,(Q=$._zod).onattach??(Q.onattach=[])}),L2={number:"number",bigint:"bigint",object:"date"},b5=q("$ZodCheckLessThan",($,X)=>{_$.init($,X);let Q=L2[typeof X.value];$._zod.onattach.push((J)=>{let v=J._zod.bag,Y=(X.inclusive?v.maximum:v.exclusiveMaximum)??Number.POSITIVE_INFINITY;if(X.value{if(X.inclusive?J.value<=X.value:J.value{_$.init($,X);let Q=L2[typeof X.value];$._zod.onattach.push((J)=>{let v=J._zod.bag,Y=(X.inclusive?v.minimum:v.exclusiveMinimum)??Number.NEGATIVE_INFINITY;if(X.value>Y)if(X.inclusive)v.minimum=X.value;else v.exclusiveMinimum=X.value}),$._zod.check=(J)=>{if(X.inclusive?J.value>=X.value:J.value>X.value)return;J.issues.push({origin:Q,code:"too_small",minimum:typeof X.value==="object"?X.value.getTime():X.value,input:J.value,inclusive:X.inclusive,inst:$,continue:!X.abort})}}),ZB=q("$ZodCheckMultipleOf",($,X)=>{_$.init($,X),$._zod.onattach.push((Q)=>{var J;(J=Q._zod.bag).multipleOf??(J.multipleOf=X.value)}),$._zod.check=(Q)=>{if(typeof Q.value!==typeof X.value)throw Error("Cannot mix number and bigint in multiple_of check.");if(typeof Q.value==="bigint"?Q.value%X.value===BigInt(0):kV(Q.value,X.value)===0)return;Q.issues.push({origin:typeof Q.value,code:"not_multiple_of",divisor:X.value,input:Q.value,inst:$,continue:!X.abort})}}),qB=q("$ZodCheckNumberFormat",($,X)=>{_$.init($,X),X.format=X.format||"float64";let Q=X.format?.includes("int"),J=Q?"int":"number",[v,Y]=hV[X.format];$._zod.onattach.push((z)=>{let W=z._zod.bag;if(W.format=X.format,W.minimum=v,W.maximum=Y,Q)W.pattern=DB}),$._zod.check=(z)=>{let W=z.value;if(Q){if(!Number.isInteger(W)){z.issues.push({expected:J,format:X.format,code:"invalid_type",continue:!1,input:W,inst:$});return}if(!Number.isSafeInteger(W)){if(W>0)z.issues.push({input:W,code:"too_big",maximum:Number.MAX_SAFE_INTEGER,note:"Integers must be within the safe integer range.",inst:$,origin:J,inclusive:!0,continue:!X.abort});else z.issues.push({input:W,code:"too_small",minimum:Number.MIN_SAFE_INTEGER,note:"Integers must be within the safe integer range.",inst:$,origin:J,inclusive:!0,continue:!X.abort});return}}if(WY)z.issues.push({origin:"number",input:W,code:"too_big",maximum:Y,inclusive:!0,inst:$,continue:!X.abort})}}),IB=q("$ZodCheckBigIntFormat",($,X)=>{_$.init($,X);let[Q,J]=lV[X.format];$._zod.onattach.push((v)=>{let Y=v._zod.bag;Y.format=X.format,Y.minimum=Q,Y.maximum=J}),$._zod.check=(v)=>{let Y=v.value;if(YJ)v.issues.push({origin:"bigint",input:Y,code:"too_big",maximum:J,inclusive:!0,inst:$,continue:!X.abort})}}),MB=q("$ZodCheckMaxSize",($,X)=>{var Q;_$.init($,X),(Q=$._zod.def).when??(Q.when=(J)=>{let v=J.value;return!b8(v)&&v.size!==void 0}),$._zod.onattach.push((J)=>{let v=J._zod.bag.maximum??Number.POSITIVE_INFINITY;if(X.maximum{let v=J.value;if(v.size<=X.maximum)return;J.issues.push({origin:_J(v),code:"too_big",maximum:X.maximum,inclusive:!0,input:v,inst:$,continue:!X.abort})}}),xB=q("$ZodCheckMinSize",($,X)=>{var Q;_$.init($,X),(Q=$._zod.def).when??(Q.when=(J)=>{let v=J.value;return!b8(v)&&v.size!==void 0}),$._zod.onattach.push((J)=>{let v=J._zod.bag.minimum??Number.NEGATIVE_INFINITY;if(X.minimum>v)J._zod.bag.minimum=X.minimum}),$._zod.check=(J)=>{let v=J.value;if(v.size>=X.minimum)return;J.issues.push({origin:_J(v),code:"too_small",minimum:X.minimum,inclusive:!0,input:v,inst:$,continue:!X.abort})}}),bB=q("$ZodCheckSizeEquals",($,X)=>{var Q;_$.init($,X),(Q=$._zod.def).when??(Q.when=(J)=>{let v=J.value;return!b8(v)&&v.size!==void 0}),$._zod.onattach.push((J)=>{let v=J._zod.bag;v.minimum=X.size,v.maximum=X.size,v.size=X.size}),$._zod.check=(J)=>{let v=J.value,Y=v.size;if(Y===X.size)return;let z=Y>X.size;J.issues.push({origin:_J(v),...z?{code:"too_big",maximum:X.size}:{code:"too_small",minimum:X.size},inclusive:!0,exact:!0,input:J.value,inst:$,continue:!X.abort})}}),iB=q("$ZodCheckMaxLength",($,X)=>{var Q;_$.init($,X),(Q=$._zod.def).when??(Q.when=(J)=>{let v=J.value;return!b8(v)&&v.length!==void 0}),$._zod.onattach.push((J)=>{let v=J._zod.bag.maximum??Number.POSITIVE_INFINITY;if(X.maximum{let v=J.value;if(v.length<=X.maximum)return;let z=yJ(v);J.issues.push({origin:z,code:"too_big",maximum:X.maximum,inclusive:!0,input:v,inst:$,continue:!X.abort})}}),RB=q("$ZodCheckMinLength",($,X)=>{var Q;_$.init($,X),(Q=$._zod.def).when??(Q.when=(J)=>{let v=J.value;return!b8(v)&&v.length!==void 0}),$._zod.onattach.push((J)=>{let v=J._zod.bag.minimum??Number.NEGATIVE_INFINITY;if(X.minimum>v)J._zod.bag.minimum=X.minimum}),$._zod.check=(J)=>{let v=J.value;if(v.length>=X.minimum)return;let z=yJ(v);J.issues.push({origin:z,code:"too_small",minimum:X.minimum,inclusive:!0,input:v,inst:$,continue:!X.abort})}}),EB=q("$ZodCheckLengthEquals",($,X)=>{var Q;_$.init($,X),(Q=$._zod.def).when??(Q.when=(J)=>{let v=J.value;return!b8(v)&&v.length!==void 0}),$._zod.onattach.push((J)=>{let v=J._zod.bag;v.minimum=X.length,v.maximum=X.length,v.length=X.length}),$._zod.check=(J)=>{let v=J.value,Y=v.length;if(Y===X.length)return;let z=yJ(v),W=Y>X.length;J.issues.push({origin:z,...W?{code:"too_big",maximum:X.length}:{code:"too_small",minimum:X.length},inclusive:!0,exact:!0,input:J.value,inst:$,continue:!X.abort})}}),oX=q("$ZodCheckStringFormat",($,X)=>{var Q,J;if(_$.init($,X),$._zod.onattach.push((v)=>{let Y=v._zod.bag;if(Y.format=X.format,X.pattern)Y.patterns??(Y.patterns=new Set),Y.patterns.add(X.pattern)}),X.pattern)(Q=$._zod).check??(Q.check=(v)=>{if(X.pattern.lastIndex=0,X.pattern.test(v.value))return;v.issues.push({origin:"string",code:"invalid_format",format:X.format,input:v.value,...X.pattern?{pattern:X.pattern.toString()}:{},inst:$,continue:!X.abort})});else(J=$._zod).check??(J.check=()=>{})}),PB=q("$ZodCheckRegex",($,X)=>{oX.init($,X),$._zod.check=(Q)=>{if(X.pattern.lastIndex=0,X.pattern.test(Q.value))return;Q.issues.push({origin:"string",code:"invalid_format",format:"regex",input:Q.value,pattern:X.pattern.toString(),inst:$,continue:!X.abort})}}),TB=q("$ZodCheckLowerCase",($,X)=>{X.pattern??(X.pattern=LB),oX.init($,X)}),SB=q("$ZodCheckUpperCase",($,X)=>{X.pattern??(X.pattern=AB),oX.init($,X)}),CB=q("$ZodCheckIncludes",($,X)=>{_$.init($,X);let Q=F1(X.includes),J=new RegExp(typeof X.position==="number"?`^.{${X.position}}${Q}`:Q);X.pattern=J,$._zod.onattach.push((v)=>{let Y=v._zod.bag;Y.patterns??(Y.patterns=new Set),Y.patterns.add(J)}),$._zod.check=(v)=>{if(v.value.includes(X.includes,X.position))return;v.issues.push({origin:"string",code:"invalid_format",format:"includes",includes:X.includes,input:v.value,inst:$,continue:!X.abort})}}),uB=q("$ZodCheckStartsWith",($,X)=>{_$.init($,X);let Q=new RegExp(`^${F1(X.prefix)}.*`);X.pattern??(X.pattern=Q),$._zod.onattach.push((J)=>{let v=J._zod.bag;v.patterns??(v.patterns=new Set),v.patterns.add(Q)}),$._zod.check=(J)=>{if(J.value.startsWith(X.prefix))return;J.issues.push({origin:"string",code:"invalid_format",format:"starts_with",prefix:X.prefix,input:J.value,inst:$,continue:!X.abort})}}),_B=q("$ZodCheckEndsWith",($,X)=>{_$.init($,X);let Q=new RegExp(`.*${F1(X.suffix)}$`);X.pattern??(X.pattern=Q),$._zod.onattach.push((J)=>{let v=J._zod.bag;v.patterns??(v.patterns=new Set),v.patterns.add(Q)}),$._zod.check=(J)=>{if(J.value.endsWith(X.suffix))return;J.issues.push({origin:"string",code:"invalid_format",format:"ends_with",suffix:X.suffix,input:J.value,inst:$,continue:!X.abort})}});function O2($,X,Q){if($.issues.length)X.issues.push(...r6(Q,$.issues))}var yB=q("$ZodCheckProperty",($,X)=>{_$.init($,X),$._zod.check=(Q)=>{let J=X.schema._zod.run({value:Q.value[X.property],issues:[]},{});if(J instanceof Promise)return J.then((v)=>O2(v,Q,X.property));O2(J,Q,X.property);return}}),fB=q("$ZodCheckMimeType",($,X)=>{_$.init($,X);let Q=new Set(X.mime);$._zod.onattach.push((J)=>{J._zod.bag.mime=X.mime}),$._zod.check=(J)=>{if(Q.has(J.value.type))return;J.issues.push({code:"invalid_value",values:X.mime,input:J.value.type,inst:$,continue:!X.abort})}}),kB=q("$ZodCheckOverwrite",($,X)=>{_$.init($,X),$._zod.check=(Q)=>{Q.value=X.tx(Q.value)}});class R5{constructor($=[]){if(this.content=[],this.indent=0,this)this.args=$}indented($){this.indent+=1,$(this),this.indent-=1}write($){if(typeof $==="function"){$(this,{execution:"sync"}),$(this,{execution:"async"});return}let Q=$.split(` +`).filter((Y)=>Y),J=Math.min(...Q.map((Y)=>Y.length-Y.trimStart().length)),v=Q.map((Y)=>Y.slice(J)).map((Y)=>" ".repeat(this.indent*2)+Y);for(let Y of v)this.content.push(Y)}compile(){let $=Function,X=this?.args,J=[...(this?.content??[""]).map((v)=>` ${v}`)];return new $(...X,J.join(` +`))}}var nB={major:4,minor:3,patch:6};var J$=q("$ZodType",($,X)=>{var Q;$??($={}),$._zod.def=X,$._zod.bag=$._zod.bag||{},$._zod.version=nB;let J=[...$._zod.def.checks??[]];if($._zod.traits.has("$ZodCheck"))J.unshift($);for(let v of J)for(let Y of v._zod.onattach)Y($);if(J.length===0)(Q=$._zod).deferred??(Q.deferred=[]),$._zod.deferred?.push(()=>{$._zod.run=$._zod.parse});else{let v=(z,W,G)=>{let U=E8(z),w;for(let H of W){if(H._zod.def.when){if(!H._zod.def.when(z))continue}else if(U)continue;let j=z.issues.length,V=H._zod.check(z);if(V instanceof Promise&&G?.async===!1)throw new B0;if(w||V instanceof Promise)w=(w??Promise.resolve()).then(async()=>{if(await V,z.issues.length===j)return;if(!U)U=E8(z,j)});else{if(z.issues.length===j)continue;if(!U)U=E8(z,j)}}if(w)return w.then(()=>{return z});return z},Y=(z,W,G)=>{if(E8(z))return z.aborted=!0,z;let U=v(W,J,G);if(U instanceof Promise){if(G.async===!1)throw new B0;return U.then((w)=>$._zod.parse(w,G))}return $._zod.parse(U,G)};$._zod.run=(z,W)=>{if(W.skipChecks)return $._zod.parse(z,W);if(W.direction==="backward"){let U=$._zod.parse({value:z.value,issues:[]},{...W,skipChecks:!0});if(U instanceof Promise)return U.then((w)=>{return Y(w,z,W)});return Y(U,z,W)}let G=$._zod.parse(z,W);if(G instanceof Promise){if(W.async===!1)throw new B0;return G.then((U)=>v(U,J,W))}return v(G,J,W)}}K$($,"~standard",()=>({validate:(v)=>{try{let Y=P8($,v);return Y.success?{value:Y.data}:{issues:Y.error?.issues}}catch(Y){return T8($,v).then((z)=>z.success?{value:z.data}:{issues:z.error?.issues})}},vendor:"zod",version:1}))}),S8=q("$ZodString",($,X)=>{J$.init($,X),$._zod.pattern=[...$?._zod.bag?.patterns??[]].pop()??VB($._zod.bag),$._zod.parse=(Q,J)=>{if(X.coerce)try{Q.value=String(Q.value)}catch(v){}if(typeof Q.value==="string")return Q;return Q.issues.push({expected:"string",code:"invalid_type",input:Q.value,inst:$}),Q}}),S$=q("$ZodStringFormat",($,X)=>{oX.init($,X),S8.init($,X)}),u5=q("$ZodGUID",($,X)=>{X.pattern??(X.pattern=XB),S$.init($,X)}),_5=q("$ZodUUID",($,X)=>{if(X.version){let J={v1:1,v2:2,v3:3,v4:4,v5:5,v6:6,v7:7,v8:8}[X.version];if(J===void 0)throw Error(`Invalid UUID version: "${X.version}"`);X.pattern??(X.pattern=k9(J))}else X.pattern??(X.pattern=k9());S$.init($,X)}),y5=q("$ZodEmail",($,X)=>{X.pattern??(X.pattern=QB),S$.init($,X)}),f5=q("$ZodURL",($,X)=>{S$.init($,X),$._zod.check=(Q)=>{try{let J=Q.value.trim(),v=new URL(J);if(X.hostname){if(X.hostname.lastIndex=0,!X.hostname.test(v.hostname))Q.issues.push({code:"invalid_format",format:"url",note:"Invalid hostname",pattern:X.hostname.source,input:Q.value,inst:$,continue:!X.abort})}if(X.protocol){if(X.protocol.lastIndex=0,!X.protocol.test(v.protocol.endsWith(":")?v.protocol.slice(0,-1):v.protocol))Q.issues.push({code:"invalid_format",format:"url",note:"Invalid protocol",pattern:X.protocol.source,input:Q.value,inst:$,continue:!X.abort})}if(X.normalize)Q.value=v.href;else Q.value=J;return}catch(J){Q.issues.push({code:"invalid_format",format:"url",input:Q.value,inst:$,continue:!X.abort})}}}),k5=q("$ZodEmoji",($,X)=>{X.pattern??(X.pattern=vB()),S$.init($,X)}),n5=q("$ZodNanoID",($,X)=>{X.pattern??(X.pattern=eV),S$.init($,X)}),g5=q("$ZodCUID",($,X)=>{X.pattern??(X.pattern=oV),S$.init($,X)}),c5=q("$ZodCUID2",($,X)=>{X.pattern??(X.pattern=aV),S$.init($,X)}),m5=q("$ZodULID",($,X)=>{X.pattern??(X.pattern=rV),S$.init($,X)}),h5=q("$ZodXID",($,X)=>{X.pattern??(X.pattern=tV),S$.init($,X)}),l5=q("$ZodKSUID",($,X)=>{X.pattern??(X.pattern=sV),S$.init($,X)}),cB=q("$ZodISODateTime",($,X)=>{X.pattern??(X.pattern=jB(X)),S$.init($,X)}),mB=q("$ZodISODate",($,X)=>{X.pattern??(X.pattern=wB),S$.init($,X)}),hB=q("$ZodISOTime",($,X)=>{X.pattern??(X.pattern=HB(X)),S$.init($,X)}),lB=q("$ZodISODuration",($,X)=>{X.pattern??(X.pattern=$B),S$.init($,X)}),p5=q("$ZodIPv4",($,X)=>{X.pattern??(X.pattern=JB),S$.init($,X),$._zod.bag.format="ipv4"}),d5=q("$ZodIPv6",($,X)=>{X.pattern??(X.pattern=YB),S$.init($,X),$._zod.bag.format="ipv6",$._zod.check=(Q)=>{try{new URL(`http://[${Q.value}]`)}catch{Q.issues.push({code:"invalid_format",format:"ipv6",input:Q.value,inst:$,continue:!X.abort})}}}),o5=q("$ZodMAC",($,X)=>{X.pattern??(X.pattern=zB(X.delimiter)),S$.init($,X),$._zod.bag.format="mac"}),a5=q("$ZodCIDRv4",($,X)=>{X.pattern??(X.pattern=WB),S$.init($,X)}),r5=q("$ZodCIDRv6",($,X)=>{X.pattern??(X.pattern=GB),S$.init($,X),$._zod.check=(Q)=>{let J=Q.value.split("/");try{if(J.length!==2)throw Error();let[v,Y]=J;if(!Y)throw Error();let z=Number(Y);if(`${z}`!==Y)throw Error();if(z<0||z>128)throw Error();new URL(`http://[${v}]`)}catch{Q.issues.push({code:"invalid_format",format:"cidrv6",input:Q.value,inst:$,continue:!X.abort})}}});function pB($){if($==="")return!0;if($.length%4!==0)return!1;try{return atob($),!0}catch{return!1}}var t5=q("$ZodBase64",($,X)=>{X.pattern??(X.pattern=UB),S$.init($,X),$._zod.bag.contentEncoding="base64",$._zod.check=(Q)=>{if(pB(Q.value))return;Q.issues.push({code:"invalid_format",format:"base64",input:Q.value,inst:$,continue:!X.abort})}});function S2($){if(!x5.test($))return!1;let X=$.replace(/[-_]/g,(J)=>J==="-"?"+":"/"),Q=X.padEnd(Math.ceil(X.length/4)*4,"=");return pB(Q)}var s5=q("$ZodBase64URL",($,X)=>{X.pattern??(X.pattern=x5),S$.init($,X),$._zod.bag.contentEncoding="base64url",$._zod.check=(Q)=>{if(S2(Q.value))return;Q.issues.push({code:"invalid_format",format:"base64url",input:Q.value,inst:$,continue:!X.abort})}}),e5=q("$ZodE164",($,X)=>{X.pattern??(X.pattern=KB),S$.init($,X)});function C2($,X=null){try{let Q=$.split(".");if(Q.length!==3)return!1;let[J]=Q;if(!J)return!1;let v=JSON.parse(atob(J));if("typ"in v&&v?.typ!=="JWT")return!1;if(!v.alg)return!1;if(X&&(!("alg"in v)||v.alg!==X))return!1;return!0}catch{return!1}}var $W=q("$ZodJWT",($,X)=>{S$.init($,X),$._zod.check=(Q)=>{if(C2(Q.value,X.alg))return;Q.issues.push({code:"invalid_format",format:"jwt",input:Q.value,inst:$,continue:!X.abort})}}),XW=q("$ZodCustomStringFormat",($,X)=>{S$.init($,X),$._zod.check=(Q)=>{if(X.fn(Q.value))return;Q.issues.push({code:"invalid_format",format:X.format,input:Q.value,inst:$,continue:!X.abort})}}),hJ=q("$ZodNumber",($,X)=>{J$.init($,X),$._zod.pattern=$._zod.bag.pattern??gJ,$._zod.parse=(Q,J)=>{if(X.coerce)try{Q.value=Number(Q.value)}catch(z){}let v=Q.value;if(typeof v==="number"&&!Number.isNaN(v)&&Number.isFinite(v))return Q;let Y=typeof v==="number"?Number.isNaN(v)?"NaN":!Number.isFinite(v)?"Infinity":void 0:void 0;return Q.issues.push({expected:"number",code:"invalid_type",input:v,inst:$,...Y?{received:Y}:{}}),Q}}),QW=q("$ZodNumberFormat",($,X)=>{qB.init($,X),hJ.init($,X)}),aX=q("$ZodBoolean",($,X)=>{J$.init($,X),$._zod.pattern=FB,$._zod.parse=(Q,J)=>{if(X.coerce)try{Q.value=Boolean(Q.value)}catch(Y){}let v=Q.value;if(typeof v==="boolean")return Q;return Q.issues.push({expected:"boolean",code:"invalid_type",input:v,inst:$}),Q}}),lJ=q("$ZodBigInt",($,X)=>{J$.init($,X),$._zod.pattern=BB,$._zod.parse=(Q,J)=>{if(X.coerce)try{Q.value=BigInt(Q.value)}catch(v){}if(typeof Q.value==="bigint")return Q;return Q.issues.push({expected:"bigint",code:"invalid_type",input:Q.value,inst:$}),Q}}),vW=q("$ZodBigIntFormat",($,X)=>{IB.init($,X),lJ.init($,X)}),JW=q("$ZodSymbol",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(typeof v==="symbol")return Q;return Q.issues.push({expected:"symbol",code:"invalid_type",input:v,inst:$}),Q}}),YW=q("$ZodUndefined",($,X)=>{J$.init($,X),$._zod.pattern=OB,$._zod.values=new Set([void 0]),$._zod.optin="optional",$._zod.optout="optional",$._zod.parse=(Q,J)=>{let v=Q.value;if(typeof v>"u")return Q;return Q.issues.push({expected:"undefined",code:"invalid_type",input:v,inst:$}),Q}}),zW=q("$ZodNull",($,X)=>{J$.init($,X),$._zod.pattern=NB,$._zod.values=new Set([null]),$._zod.parse=(Q,J)=>{let v=Q.value;if(v===null)return Q;return Q.issues.push({expected:"null",code:"invalid_type",input:v,inst:$}),Q}}),WW=q("$ZodAny",($,X)=>{J$.init($,X),$._zod.parse=(Q)=>Q}),GW=q("$ZodUnknown",($,X)=>{J$.init($,X),$._zod.parse=(Q)=>Q}),UW=q("$ZodNever",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{return Q.issues.push({expected:"never",code:"invalid_type",input:Q.value,inst:$}),Q}}),KW=q("$ZodVoid",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(typeof v>"u")return Q;return Q.issues.push({expected:"void",code:"invalid_type",input:v,inst:$}),Q}}),wW=q("$ZodDate",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{if(X.coerce)try{Q.value=new Date(Q.value)}catch(W){}let v=Q.value,Y=v instanceof Date;if(Y&&!Number.isNaN(v.getTime()))return Q;return Q.issues.push({expected:"date",code:"invalid_type",input:v,...Y?{received:"Invalid Date"}:{},inst:$}),Q}});function Z2($,X,Q){if($.issues.length)X.issues.push(...r6(Q,$.issues));X.value[Q]=$.value}var HW=q("$ZodArray",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(!Array.isArray(v))return Q.issues.push({expected:"array",code:"invalid_type",input:v,inst:$}),Q;Q.value=Array(v.length);let Y=[];for(let z=0;zZ2(U,Q,z)));else Z2(G,Q,z)}if(Y.length)return Promise.all(Y).then(()=>Q);return Q}});function C5($,X,Q,J,v){if($.issues.length){if(v&&!(Q in J))return;X.issues.push(...r6(Q,$.issues))}if($.value===void 0){if(Q in J)X.value[Q]=void 0}else X.value[Q]=$.value}function u2($){let X=Object.keys($.shape);for(let J of X)if(!$.shape?.[J]?._zod?.traits?.has("$ZodType"))throw Error(`Invalid element at key "${J}": expected a Zod schema`);let Q=mV($.shape);return{...$,keys:X,keySet:new Set(X),numKeys:X.length,optionalKeys:new Set(Q)}}function _2($,X,Q,J,v,Y){let z=[],W=v.keySet,G=v.catchall._zod,U=G.def.type,w=G.optout==="optional";for(let H in X){if(W.has(H))continue;if(U==="never"){z.push(H);continue}let j=G.run({value:X[H],issues:[]},J);if(j instanceof Promise)$.push(j.then((V)=>C5(V,Q,H,X,w)));else C5(j,Q,H,X,w)}if(z.length)Q.issues.push({code:"unrecognized_keys",keys:z,input:X,inst:Y});if(!$.length)return Q;return Promise.all($).then(()=>{return Q})}var jW=q("$ZodObject",($,X)=>{if(J$.init($,X),!Object.getOwnPropertyDescriptor(X,"shape")?.get){let W=X.shape;Object.defineProperty(X,"shape",{get:()=>{let G={...W};return Object.defineProperty(X,"shape",{value:G}),G}})}let J=cX(()=>u2(X));K$($._zod,"propValues",()=>{let W=X.shape,G={};for(let U in W){let w=W[U]._zod;if(w.values){G[U]??(G[U]=new Set);for(let H of w.values)G[U].add(H)}}return G});let v=_9,Y=X.catchall,z;$._zod.parse=(W,G)=>{z??(z=J.value);let U=W.value;if(!v(U))return W.issues.push({expected:"object",code:"invalid_type",input:U,inst:$}),W;W.value={};let w=[],H=z.shape;for(let j of z.keys){let V=H[j],B=V._zod.optout==="optional",D=V._zod.run({value:U[j],issues:[]},G);if(D instanceof Promise)w.push(D.then((F)=>C5(F,W,j,U,B)));else C5(D,W,j,U,B)}if(!Y)return w.length?Promise.all(w).then(()=>W):W;return _2(w,U,W,G,J.value,$)}}),dB=q("$ZodObjectJIT",($,X)=>{jW.init($,X);let Q=$._zod.parse,J=cX(()=>u2(X)),v=(j)=>{let V=new R5(["shape","payload","ctx"]),B=J.value,D=(O)=>{let i=B5(O);return`shape[${i}]._zod.run({ value: input[${i}], issues: [] }, ctx)`};V.write("const input = payload.value;");let F=Object.create(null),L=0;for(let O of B.keys)F[O]=`key_${L++}`;V.write("const newResult = {};");for(let O of B.keys){let i=F[O],M=B5(O),E=j[O]?._zod?.optout==="optional";if(V.write(`const ${i} = ${D(O)};`),E)V.write(` + if (${i}.issues.length) { + if (${M} in input) { + payload.issues = payload.issues.concat(${i}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${M}, ...iss.path] : [${M}] + }))); } } - } - logWarn2(logger, { - clientId, - availableRooms: Array.from(io2.sockets.adapter.rooms.keys()) - }, "[DEBUG] no sockets found in specified room, falling back to first available socket"); - } - const result = getFirstAvailableSocket(); - if (!result) { - throw new Error("No active Socket.IO connections available"); - } - return result; - } - async function acknowledge(message, timeoutMs) { - const { socket, clientId } = findSocketByClientId(message.clientId); - const startTime = performance.now(); - logInfo2(logger, { - clientId, - socketId: socket.id, - event: message.event, - timeoutMs: timeoutMs ?? "none", - data: message.data - }, "[DEBUG] socket.io sending message to specific socket with acknowledgement"); - try { - let response; - if (timeoutMs !== undefined && timeoutMs > 0) { - response = await socket.timeout(timeoutMs).emitWithAck(message.event, message.data); - } else { - response = await socket.emitWithAck(message.event, message.data); - } - const duration3 = performance.now() - startTime; - logDebug(logger, { - clientId, - socketId: socket.id, - event: message.event, - hasResponse: response !== undefined && response !== null, - response, - durationMs: Math.round(duration3) - }, "socket.io acknowledgement received successfully"); - return response; - } catch (error48) { - const duration3 = performance.now() - startTime; - const errorMessage = error48 instanceof Error ? error48.message : String(error48); - logError2(logger, { - clientId, - socketId: socket.id, - event: message.event, - error: errorMessage, - errorType: typeof error48, - errorDetails: error48, - durationMs: Math.round(duration3) - }, "[DEBUG] socket.io acknowledgement timeout or error"); - throw new Error(`Socket.IO request timed out or failed: ${errorMessage}`); - } - } - function getFirstActiveConnection() { - const sockets = Array.from(io2.sockets.sockets.values()); - if (sockets.length === 0) { - return null; - } - const firstSocket = sockets[0]; - if (!firstSocket) { - return null; - } - const clientRoom = Array.from(firstSocket.rooms).find((room) => room !== firstSocket.id); - return clientRoom ?? null; - } - function isClientConnected(clientId) { - const socketsInRoom = io2.sockets.adapter.rooms.get(clientId); - return socketsInRoom !== undefined && socketsInRoom.size > 0; - } - function getConnectedClientIds() { - const clientIds = []; - const sockets = Array.from(io2.sockets.sockets.values()); - for (const socket of sockets) { - const clientRoom = Array.from(socket.rooms).find((room) => room !== socket.id); - if (clientRoom) { - clientIds.push(clientRoom); - } - } - return clientIds; - } - return { - broadcast, - acknowledge, - getFirstAvailableSocket, - findSocketByClientId, - getFirstActiveConnection, - isClientConnected, - getConnectedClientIds - }; -} - -// src/socketIO/manager.ts -var DEFAULT_CORS = { - origin: "*", - methods: ["GET", "POST"] -}; -function createSocketIOManager(httpServer, config2 = {}) { - const cors = config2.cors ?? DEFAULT_CORS; - const io2 = new Server(httpServer, { - cors, - path: config2.path ?? "/socket.io/" - }); - registerSocketIOConnectionHandlers(io2, config2.logger); - const messaging = createSocketIOMessaging(io2, config2.logger); - return { - io: io2, - broadcast: messaging.broadcast, - acknowledge: messaging.acknowledge, - getSocketIOInstance: () => io2, - getFirstAvailableSocket: messaging.getFirstAvailableSocket, - findSocketByClientId: messaging.findSocketByClientId, - getFirstActiveConnection: messaging.getFirstActiveConnection, - isClientConnected: messaging.isClientConnected, - getConnectedClientIds: messaging.getConnectedClientIds - }; -} -// ../../node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/mini/schemas.js -var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => { - if (!inst._zod) - throw new Error("Uninitialized schema in ZodMiniType."); - $ZodType.init(inst, def); - inst.def = def; - inst.type = def.type; - inst.parse = (data, params) => parse(inst, data, params, { callee: inst.parse }); - inst.safeParse = (data, params) => safeParse(inst, data, params); - inst.parseAsync = async (data, params) => parseAsync(inst, data, params, { callee: inst.parseAsync }); - inst.safeParseAsync = async (data, params) => safeParseAsync(inst, data, params); - inst.check = (...checks3) => { - return inst.clone({ - ...def, - checks: [ - ...def.checks ?? [], - ...checks3.map((ch) => typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch) - ] - }, { parent: true }); - }; - inst.with = inst.check; - inst.clone = (_def, params) => clone(inst, _def, params); - inst.brand = () => inst; - inst.register = (reg, meta3) => { - reg.add(inst, meta3); - return inst; - }; - inst.apply = (fn) => fn(inst); -}); -var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => { - $ZodObject.init(inst, def); - ZodMiniType.init(inst, def); - defineLazy(inst, "shape", () => def.shape); -}); -function object3(shape, params) { - const def = { - type: "object", - shape: shape ?? {}, - ...normalizeParams(params) - }; - return new ZodMiniObject(def); -} -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js -function isZ4Schema(s) { - const schema = s; - return !!schema._zod; -} -function objectFromShape(shape) { - const values = Object.values(shape); - if (values.length === 0) - return object3({}); - const allV4 = values.every(isZ4Schema); - const allV3 = values.every((s) => !isZ4Schema(s)); - if (allV4) - return object3(shape); - if (allV3) - return objectType(shape); - throw new Error("Mixed Zod versions detected in object shape."); -} -function safeParse3(schema, data) { - if (isZ4Schema(schema)) { - const result2 = safeParse(schema, data); - return result2; - } - const v3Schema = schema; - const result = v3Schema.safeParse(data); - return result; -} -async function safeParseAsync3(schema, data) { - if (isZ4Schema(schema)) { - const result2 = await safeParseAsync(schema, data); - return result2; - } - const v3Schema = schema; - const result = await v3Schema.safeParseAsync(data); - return result; -} -function getObjectShape(schema) { - if (!schema) - return; - let rawShape; - if (isZ4Schema(schema)) { - const v4Schema = schema; - rawShape = v4Schema._zod?.def?.shape; - } else { - const v3Schema = schema; - rawShape = v3Schema.shape; - } - if (!rawShape) - return; - if (typeof rawShape === "function") { - try { - return rawShape(); - } catch { - return; - } - } - return rawShape; -} -function normalizeObjectSchema(schema) { - if (!schema) - return; - if (typeof schema === "object") { - const asV3 = schema; - const asV4 = schema; - if (!asV3._def && !asV4._zod) { - const values = Object.values(schema); - if (values.length > 0 && values.every((v) => typeof v === "object" && v !== null && (v._def !== undefined || v._zod !== undefined || typeof v.parse === "function"))) { - return objectFromShape(schema); - } - } - } - if (isZ4Schema(schema)) { - const v4Schema = schema; - const def = v4Schema._zod?.def; - if (def && (def.type === "object" || def.shape !== undefined)) { - return schema; - } - } else { - const v3Schema = schema; - if (v3Schema.shape !== undefined) { - return schema; - } - } - return; -} -function getParseErrorMessage(error48) { - if (error48 && typeof error48 === "object") { - if ("message" in error48 && typeof error48.message === "string") { - return error48.message; - } - if ("issues" in error48 && Array.isArray(error48.issues) && error48.issues.length > 0) { - const firstIssue = error48.issues[0]; - if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) { - return String(firstIssue.message); - } - } - try { - return JSON.stringify(error48); - } catch { - return String(error48); - } - } - return String(error48); -} -function getSchemaDescription(schema) { - return schema.description; -} -function isSchemaOptional(schema) { - if (isZ4Schema(schema)) { - const v4Schema = schema; - return v4Schema._zod?.def?.type === "optional"; - } - const v3Schema = schema; - if (typeof schema.isOptional === "function") { - return schema.isOptional(); - } - return v3Schema._def?.typeName === "ZodOptional"; -} -function getLiteralValue(schema) { - if (isZ4Schema(schema)) { - const v4Schema = schema; - const def2 = v4Schema._zod?.def; - if (def2) { - if (def2.value !== undefined) - return def2.value; - if (Array.isArray(def2.values) && def2.values.length > 0) { - return def2.values[0]; - } - } - } - const v3Schema = schema; - const def = v3Schema._def; - if (def) { - if (def.value !== undefined) - return def.value; - if (Array.isArray(def.values) && def.values.length > 0) { - return def.values[0]; - } - } - const directValue = schema.value; - if (directValue !== undefined) - return directValue; - return; -} - -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/types.js -var LATEST_PROTOCOL_VERSION = "2025-11-25"; -var DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"; -var SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]; -var RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; -var JSONRPC_VERSION = "2.0"; -var AssertObjectSchema = custom((v) => v !== null && (typeof v === "object" || typeof v === "function")); -var ProgressTokenSchema = union([string2(), number2().int()]); -var CursorSchema = string2(); -var TaskCreationParamsSchema = looseObject({ - ttl: union([number2(), _null3()]).optional(), - pollInterval: number2().optional() -}); -var TaskMetadataSchema = object({ - ttl: number2().optional() -}); -var RelatedTaskMetadataSchema = object({ - taskId: string2() -}); -var RequestMetaSchema = looseObject({ - progressToken: ProgressTokenSchema.optional(), - [RELATED_TASK_META_KEY]: RelatedTaskMetadataSchema.optional() -}); -var BaseRequestParamsSchema = object({ - _meta: RequestMetaSchema.optional() -}); -var TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ - task: TaskMetadataSchema.optional() -}); -var isTaskAugmentedRequestParams = (value) => TaskAugmentedRequestParamsSchema.safeParse(value).success; -var RequestSchema = object({ - method: string2(), - params: BaseRequestParamsSchema.loose().optional() -}); -var NotificationsParamsSchema = object({ - _meta: RequestMetaSchema.optional() -}); -var NotificationSchema = object({ - method: string2(), - params: NotificationsParamsSchema.loose().optional() -}); -var ResultSchema = looseObject({ - _meta: RequestMetaSchema.optional() -}); -var RequestIdSchema = union([string2(), number2().int()]); -var JSONRPCRequestSchema = object({ - jsonrpc: literal(JSONRPC_VERSION), - id: RequestIdSchema, - ...RequestSchema.shape -}).strict(); -var isJSONRPCRequest = (value) => JSONRPCRequestSchema.safeParse(value).success; -var JSONRPCNotificationSchema = object({ - jsonrpc: literal(JSONRPC_VERSION), - ...NotificationSchema.shape -}).strict(); -var isJSONRPCNotification = (value) => JSONRPCNotificationSchema.safeParse(value).success; -var JSONRPCResultResponseSchema = object({ - jsonrpc: literal(JSONRPC_VERSION), - id: RequestIdSchema, - result: ResultSchema -}).strict(); -var isJSONRPCResultResponse = (value) => JSONRPCResultResponseSchema.safeParse(value).success; -var ErrorCode; -(function(ErrorCode2) { - ErrorCode2[ErrorCode2["ConnectionClosed"] = -32000] = "ConnectionClosed"; - ErrorCode2[ErrorCode2["RequestTimeout"] = -32001] = "RequestTimeout"; - ErrorCode2[ErrorCode2["ParseError"] = -32700] = "ParseError"; - ErrorCode2[ErrorCode2["InvalidRequest"] = -32600] = "InvalidRequest"; - ErrorCode2[ErrorCode2["MethodNotFound"] = -32601] = "MethodNotFound"; - ErrorCode2[ErrorCode2["InvalidParams"] = -32602] = "InvalidParams"; - ErrorCode2[ErrorCode2["InternalError"] = -32603] = "InternalError"; - ErrorCode2[ErrorCode2["UrlElicitationRequired"] = -32042] = "UrlElicitationRequired"; -})(ErrorCode || (ErrorCode = {})); -var JSONRPCErrorResponseSchema = object({ - jsonrpc: literal(JSONRPC_VERSION), - id: RequestIdSchema.optional(), - error: object({ - code: number2().int(), - message: string2(), - data: unknown().optional() - }) -}).strict(); -var isJSONRPCErrorResponse = (value) => JSONRPCErrorResponseSchema.safeParse(value).success; -var JSONRPCMessageSchema = union([ - JSONRPCRequestSchema, - JSONRPCNotificationSchema, - JSONRPCResultResponseSchema, - JSONRPCErrorResponseSchema -]); -var JSONRPCResponseSchema = union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); -var EmptyResultSchema = ResultSchema.strict(); -var CancelledNotificationParamsSchema = NotificationsParamsSchema.extend({ - requestId: RequestIdSchema.optional(), - reason: string2().optional() -}); -var CancelledNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/cancelled"), - params: CancelledNotificationParamsSchema -}); -var IconSchema = object({ - src: string2(), - mimeType: string2().optional(), - sizes: array(string2()).optional(), - theme: _enum2(["light", "dark"]).optional() -}); -var IconsSchema = object({ - icons: array(IconSchema).optional() -}); -var BaseMetadataSchema = object({ - name: string2(), - title: string2().optional() -}); -var ImplementationSchema = BaseMetadataSchema.extend({ - ...BaseMetadataSchema.shape, - ...IconsSchema.shape, - version: string2(), - websiteUrl: string2().optional(), - description: string2().optional() -}); -var FormElicitationCapabilitySchema = intersection(object({ - applyDefaults: boolean2().optional() -}), record(string2(), unknown())); -var ElicitationCapabilitySchema = preprocess((value) => { - if (value && typeof value === "object" && !Array.isArray(value)) { - if (Object.keys(value).length === 0) { - return { form: {} }; - } - } - return value; -}, intersection(object({ - form: FormElicitationCapabilitySchema.optional(), - url: AssertObjectSchema.optional() -}), record(string2(), unknown()).optional())); -var ClientTasksCapabilitySchema = looseObject({ - list: AssertObjectSchema.optional(), - cancel: AssertObjectSchema.optional(), - requests: looseObject({ - sampling: looseObject({ - createMessage: AssertObjectSchema.optional() - }).optional(), - elicitation: looseObject({ - create: AssertObjectSchema.optional() - }).optional() - }).optional() -}); -var ServerTasksCapabilitySchema = looseObject({ - list: AssertObjectSchema.optional(), - cancel: AssertObjectSchema.optional(), - requests: looseObject({ - tools: looseObject({ - call: AssertObjectSchema.optional() - }).optional() - }).optional() -}); -var ClientCapabilitiesSchema = object({ - experimental: record(string2(), AssertObjectSchema).optional(), - sampling: object({ - context: AssertObjectSchema.optional(), - tools: AssertObjectSchema.optional() - }).optional(), - elicitation: ElicitationCapabilitySchema.optional(), - roots: object({ - listChanged: boolean2().optional() - }).optional(), - tasks: ClientTasksCapabilitySchema.optional() -}); -var InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ - protocolVersion: string2(), - capabilities: ClientCapabilitiesSchema, - clientInfo: ImplementationSchema -}); -var InitializeRequestSchema = RequestSchema.extend({ - method: literal("initialize"), - params: InitializeRequestParamsSchema -}); -var isInitializeRequest = (value) => InitializeRequestSchema.safeParse(value).success; -var ServerCapabilitiesSchema = object({ - experimental: record(string2(), AssertObjectSchema).optional(), - logging: AssertObjectSchema.optional(), - completions: AssertObjectSchema.optional(), - prompts: object({ - listChanged: boolean2().optional() - }).optional(), - resources: object({ - subscribe: boolean2().optional(), - listChanged: boolean2().optional() - }).optional(), - tools: object({ - listChanged: boolean2().optional() - }).optional(), - tasks: ServerTasksCapabilitySchema.optional() -}); -var InitializeResultSchema = ResultSchema.extend({ - protocolVersion: string2(), - capabilities: ServerCapabilitiesSchema, - serverInfo: ImplementationSchema, - instructions: string2().optional() -}); -var InitializedNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/initialized"), - params: NotificationsParamsSchema.optional() -}); -var PingRequestSchema = RequestSchema.extend({ - method: literal("ping"), - params: BaseRequestParamsSchema.optional() -}); -var ProgressSchema = object({ - progress: number2(), - total: optional(number2()), - message: optional(string2()) -}); -var ProgressNotificationParamsSchema = object({ - ...NotificationsParamsSchema.shape, - ...ProgressSchema.shape, - progressToken: ProgressTokenSchema -}); -var ProgressNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/progress"), - params: ProgressNotificationParamsSchema -}); -var PaginatedRequestParamsSchema = BaseRequestParamsSchema.extend({ - cursor: CursorSchema.optional() -}); -var PaginatedRequestSchema = RequestSchema.extend({ - params: PaginatedRequestParamsSchema.optional() -}); -var PaginatedResultSchema = ResultSchema.extend({ - nextCursor: CursorSchema.optional() -}); -var TaskStatusSchema = _enum2(["working", "input_required", "completed", "failed", "cancelled"]); -var TaskSchema = object({ - taskId: string2(), - status: TaskStatusSchema, - ttl: union([number2(), _null3()]), - createdAt: string2(), - lastUpdatedAt: string2(), - pollInterval: optional(number2()), - statusMessage: optional(string2()) -}); -var CreateTaskResultSchema = ResultSchema.extend({ - task: TaskSchema -}); -var TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); -var TaskStatusNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/tasks/status"), - params: TaskStatusNotificationParamsSchema -}); -var GetTaskRequestSchema = RequestSchema.extend({ - method: literal("tasks/get"), - params: BaseRequestParamsSchema.extend({ - taskId: string2() - }) -}); -var GetTaskResultSchema = ResultSchema.merge(TaskSchema); -var GetTaskPayloadRequestSchema = RequestSchema.extend({ - method: literal("tasks/result"), - params: BaseRequestParamsSchema.extend({ - taskId: string2() - }) -}); -var GetTaskPayloadResultSchema = ResultSchema.loose(); -var ListTasksRequestSchema = PaginatedRequestSchema.extend({ - method: literal("tasks/list") -}); -var ListTasksResultSchema = PaginatedResultSchema.extend({ - tasks: array(TaskSchema) -}); -var CancelTaskRequestSchema = RequestSchema.extend({ - method: literal("tasks/cancel"), - params: BaseRequestParamsSchema.extend({ - taskId: string2() - }) -}); -var CancelTaskResultSchema = ResultSchema.merge(TaskSchema); -var ResourceContentsSchema = object({ - uri: string2(), - mimeType: optional(string2()), - _meta: record(string2(), unknown()).optional() -}); -var TextResourceContentsSchema = ResourceContentsSchema.extend({ - text: string2() -}); -var Base64Schema = string2().refine((val) => { - try { - atob(val); - return true; - } catch { - return false; - } -}, { message: "Invalid Base64 string" }); -var BlobResourceContentsSchema = ResourceContentsSchema.extend({ - blob: Base64Schema -}); -var RoleSchema = _enum2(["user", "assistant"]); -var AnnotationsSchema = object({ - audience: array(RoleSchema).optional(), - priority: number2().min(0).max(1).optional(), - lastModified: exports_iso.datetime({ offset: true }).optional() -}); -var ResourceSchema = object({ - ...BaseMetadataSchema.shape, - ...IconsSchema.shape, - uri: string2(), - description: optional(string2()), - mimeType: optional(string2()), - annotations: AnnotationsSchema.optional(), - _meta: optional(looseObject({})) -}); -var ResourceTemplateSchema = object({ - ...BaseMetadataSchema.shape, - ...IconsSchema.shape, - uriTemplate: string2(), - description: optional(string2()), - mimeType: optional(string2()), - annotations: AnnotationsSchema.optional(), - _meta: optional(looseObject({})) -}); -var ListResourcesRequestSchema = PaginatedRequestSchema.extend({ - method: literal("resources/list") -}); -var ListResourcesResultSchema = PaginatedResultSchema.extend({ - resources: array(ResourceSchema) -}); -var ListResourceTemplatesRequestSchema = PaginatedRequestSchema.extend({ - method: literal("resources/templates/list") -}); -var ListResourceTemplatesResultSchema = PaginatedResultSchema.extend({ - resourceTemplates: array(ResourceTemplateSchema) -}); -var ResourceRequestParamsSchema = BaseRequestParamsSchema.extend({ - uri: string2() -}); -var ReadResourceRequestParamsSchema = ResourceRequestParamsSchema; -var ReadResourceRequestSchema = RequestSchema.extend({ - method: literal("resources/read"), - params: ReadResourceRequestParamsSchema -}); -var ReadResourceResultSchema = ResultSchema.extend({ - contents: array(union([TextResourceContentsSchema, BlobResourceContentsSchema])) -}); -var ResourceListChangedNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/resources/list_changed"), - params: NotificationsParamsSchema.optional() -}); -var SubscribeRequestParamsSchema = ResourceRequestParamsSchema; -var SubscribeRequestSchema = RequestSchema.extend({ - method: literal("resources/subscribe"), - params: SubscribeRequestParamsSchema -}); -var UnsubscribeRequestParamsSchema = ResourceRequestParamsSchema; -var UnsubscribeRequestSchema = RequestSchema.extend({ - method: literal("resources/unsubscribe"), - params: UnsubscribeRequestParamsSchema -}); -var ResourceUpdatedNotificationParamsSchema = NotificationsParamsSchema.extend({ - uri: string2() -}); -var ResourceUpdatedNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/resources/updated"), - params: ResourceUpdatedNotificationParamsSchema -}); -var PromptArgumentSchema = object({ - name: string2(), - description: optional(string2()), - required: optional(boolean2()) -}); -var PromptSchema = object({ - ...BaseMetadataSchema.shape, - ...IconsSchema.shape, - description: optional(string2()), - arguments: optional(array(PromptArgumentSchema)), - _meta: optional(looseObject({})) -}); -var ListPromptsRequestSchema = PaginatedRequestSchema.extend({ - method: literal("prompts/list") -}); -var ListPromptsResultSchema = PaginatedResultSchema.extend({ - prompts: array(PromptSchema) -}); -var GetPromptRequestParamsSchema = BaseRequestParamsSchema.extend({ - name: string2(), - arguments: record(string2(), string2()).optional() -}); -var GetPromptRequestSchema = RequestSchema.extend({ - method: literal("prompts/get"), - params: GetPromptRequestParamsSchema -}); -var TextContentSchema = object({ - type: literal("text"), - text: string2(), - annotations: AnnotationsSchema.optional(), - _meta: record(string2(), unknown()).optional() -}); -var ImageContentSchema = object({ - type: literal("image"), - data: Base64Schema, - mimeType: string2(), - annotations: AnnotationsSchema.optional(), - _meta: record(string2(), unknown()).optional() -}); -var AudioContentSchema = object({ - type: literal("audio"), - data: Base64Schema, - mimeType: string2(), - annotations: AnnotationsSchema.optional(), - _meta: record(string2(), unknown()).optional() -}); -var ToolUseContentSchema = object({ - type: literal("tool_use"), - name: string2(), - id: string2(), - input: record(string2(), unknown()), - _meta: record(string2(), unknown()).optional() -}); -var EmbeddedResourceSchema = object({ - type: literal("resource"), - resource: union([TextResourceContentsSchema, BlobResourceContentsSchema]), - annotations: AnnotationsSchema.optional(), - _meta: record(string2(), unknown()).optional() -}); -var ResourceLinkSchema = ResourceSchema.extend({ - type: literal("resource_link") -}); -var ContentBlockSchema = union([ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - ResourceLinkSchema, - EmbeddedResourceSchema -]); -var PromptMessageSchema = object({ - role: RoleSchema, - content: ContentBlockSchema -}); -var GetPromptResultSchema = ResultSchema.extend({ - description: string2().optional(), - messages: array(PromptMessageSchema) -}); -var PromptListChangedNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/prompts/list_changed"), - params: NotificationsParamsSchema.optional() -}); -var ToolAnnotationsSchema = object({ - title: string2().optional(), - readOnlyHint: boolean2().optional(), - destructiveHint: boolean2().optional(), - idempotentHint: boolean2().optional(), - openWorldHint: boolean2().optional() -}); -var ToolExecutionSchema = object({ - taskSupport: _enum2(["required", "optional", "forbidden"]).optional() -}); -var ToolSchema = object({ - ...BaseMetadataSchema.shape, - ...IconsSchema.shape, - description: string2().optional(), - inputSchema: object({ - type: literal("object"), - properties: record(string2(), AssertObjectSchema).optional(), - required: array(string2()).optional() - }).catchall(unknown()), - outputSchema: object({ - type: literal("object"), - properties: record(string2(), AssertObjectSchema).optional(), - required: array(string2()).optional() - }).catchall(unknown()).optional(), - annotations: ToolAnnotationsSchema.optional(), - execution: ToolExecutionSchema.optional(), - _meta: record(string2(), unknown()).optional() -}); -var ListToolsRequestSchema = PaginatedRequestSchema.extend({ - method: literal("tools/list") -}); -var ListToolsResultSchema = PaginatedResultSchema.extend({ - tools: array(ToolSchema) -}); -var CallToolResultSchema = ResultSchema.extend({ - content: array(ContentBlockSchema).default([]), - structuredContent: record(string2(), unknown()).optional(), - isError: boolean2().optional() -}); -var CompatibilityCallToolResultSchema = CallToolResultSchema.or(ResultSchema.extend({ - toolResult: unknown() -})); -var CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ - name: string2(), - arguments: record(string2(), unknown()).optional() -}); -var CallToolRequestSchema = RequestSchema.extend({ - method: literal("tools/call"), - params: CallToolRequestParamsSchema -}); -var ToolListChangedNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/tools/list_changed"), - params: NotificationsParamsSchema.optional() -}); -var ListChangedOptionsBaseSchema = object({ - autoRefresh: boolean2().default(true), - debounceMs: number2().int().nonnegative().default(300) -}); -var LoggingLevelSchema = _enum2(["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]); -var SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ - level: LoggingLevelSchema -}); -var SetLevelRequestSchema = RequestSchema.extend({ - method: literal("logging/setLevel"), - params: SetLevelRequestParamsSchema -}); -var LoggingMessageNotificationParamsSchema = NotificationsParamsSchema.extend({ - level: LoggingLevelSchema, - logger: string2().optional(), - data: unknown() -}); -var LoggingMessageNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/message"), - params: LoggingMessageNotificationParamsSchema -}); -var ModelHintSchema = object({ - name: string2().optional() -}); -var ModelPreferencesSchema = object({ - hints: array(ModelHintSchema).optional(), - costPriority: number2().min(0).max(1).optional(), - speedPriority: number2().min(0).max(1).optional(), - intelligencePriority: number2().min(0).max(1).optional() -}); -var ToolChoiceSchema = object({ - mode: _enum2(["auto", "required", "none"]).optional() -}); -var ToolResultContentSchema = object({ - type: literal("tool_result"), - toolUseId: string2().describe("The unique identifier for the corresponding tool call."), - content: array(ContentBlockSchema).default([]), - structuredContent: object({}).loose().optional(), - isError: boolean2().optional(), - _meta: record(string2(), unknown()).optional() -}); -var SamplingContentSchema = discriminatedUnion("type", [TextContentSchema, ImageContentSchema, AudioContentSchema]); -var SamplingMessageContentBlockSchema = discriminatedUnion("type", [ - TextContentSchema, - ImageContentSchema, - AudioContentSchema, - ToolUseContentSchema, - ToolResultContentSchema -]); -var SamplingMessageSchema = object({ - role: RoleSchema, - content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]), - _meta: record(string2(), unknown()).optional() -}); -var CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ - messages: array(SamplingMessageSchema), - modelPreferences: ModelPreferencesSchema.optional(), - systemPrompt: string2().optional(), - includeContext: _enum2(["none", "thisServer", "allServers"]).optional(), - temperature: number2().optional(), - maxTokens: number2().int(), - stopSequences: array(string2()).optional(), - metadata: AssertObjectSchema.optional(), - tools: array(ToolSchema).optional(), - toolChoice: ToolChoiceSchema.optional() -}); -var CreateMessageRequestSchema = RequestSchema.extend({ - method: literal("sampling/createMessage"), - params: CreateMessageRequestParamsSchema -}); -var CreateMessageResultSchema = ResultSchema.extend({ - model: string2(), - stopReason: optional(_enum2(["endTurn", "stopSequence", "maxTokens"]).or(string2())), - role: RoleSchema, - content: SamplingContentSchema -}); -var CreateMessageResultWithToolsSchema = ResultSchema.extend({ - model: string2(), - stopReason: optional(_enum2(["endTurn", "stopSequence", "maxTokens", "toolUse"]).or(string2())), - role: RoleSchema, - content: union([SamplingMessageContentBlockSchema, array(SamplingMessageContentBlockSchema)]) -}); -var BooleanSchemaSchema = object({ - type: literal("boolean"), - title: string2().optional(), - description: string2().optional(), - default: boolean2().optional() -}); -var StringSchemaSchema = object({ - type: literal("string"), - title: string2().optional(), - description: string2().optional(), - minLength: number2().optional(), - maxLength: number2().optional(), - format: _enum2(["email", "uri", "date", "date-time"]).optional(), - default: string2().optional() -}); -var NumberSchemaSchema = object({ - type: _enum2(["number", "integer"]), - title: string2().optional(), - description: string2().optional(), - minimum: number2().optional(), - maximum: number2().optional(), - default: number2().optional() -}); -var UntitledSingleSelectEnumSchemaSchema = object({ - type: literal("string"), - title: string2().optional(), - description: string2().optional(), - enum: array(string2()), - default: string2().optional() -}); -var TitledSingleSelectEnumSchemaSchema = object({ - type: literal("string"), - title: string2().optional(), - description: string2().optional(), - oneOf: array(object({ - const: string2(), - title: string2() - })), - default: string2().optional() -}); -var LegacyTitledEnumSchemaSchema = object({ - type: literal("string"), - title: string2().optional(), - description: string2().optional(), - enum: array(string2()), - enumNames: array(string2()).optional(), - default: string2().optional() -}); -var SingleSelectEnumSchemaSchema = union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); -var UntitledMultiSelectEnumSchemaSchema = object({ - type: literal("array"), - title: string2().optional(), - description: string2().optional(), - minItems: number2().optional(), - maxItems: number2().optional(), - items: object({ - type: literal("string"), - enum: array(string2()) - }), - default: array(string2()).optional() -}); -var TitledMultiSelectEnumSchemaSchema = object({ - type: literal("array"), - title: string2().optional(), - description: string2().optional(), - minItems: number2().optional(), - maxItems: number2().optional(), - items: object({ - anyOf: array(object({ - const: string2(), - title: string2() - })) - }), - default: array(string2()).optional() -}); -var MultiSelectEnumSchemaSchema = union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); -var EnumSchemaSchema = union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); -var PrimitiveSchemaDefinitionSchema = union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); -var ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ - mode: literal("form").optional(), - message: string2(), - requestedSchema: object({ - type: literal("object"), - properties: record(string2(), PrimitiveSchemaDefinitionSchema), - required: array(string2()).optional() - }) -}); -var ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ - mode: literal("url"), - message: string2(), - elicitationId: string2(), - url: string2().url() -}); -var ElicitRequestParamsSchema = union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); -var ElicitRequestSchema = RequestSchema.extend({ - method: literal("elicitation/create"), - params: ElicitRequestParamsSchema -}); -var ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({ - elicitationId: string2() -}); -var ElicitationCompleteNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/elicitation/complete"), - params: ElicitationCompleteNotificationParamsSchema -}); -var ElicitResultSchema = ResultSchema.extend({ - action: _enum2(["accept", "decline", "cancel"]), - content: preprocess((val) => val === null ? undefined : val, record(string2(), union([string2(), number2(), boolean2(), array(string2())])).optional()) -}); -var ResourceTemplateReferenceSchema = object({ - type: literal("ref/resource"), - uri: string2() -}); -var PromptReferenceSchema = object({ - type: literal("ref/prompt"), - name: string2() -}); -var CompleteRequestParamsSchema = BaseRequestParamsSchema.extend({ - ref: union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), - argument: object({ - name: string2(), - value: string2() - }), - context: object({ - arguments: record(string2(), string2()).optional() - }).optional() -}); -var CompleteRequestSchema = RequestSchema.extend({ - method: literal("completion/complete"), - params: CompleteRequestParamsSchema -}); -function assertCompleteRequestPrompt(request) { - if (request.params.ref.type !== "ref/prompt") { - throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`); - } -} -function assertCompleteRequestResourceTemplate(request) { - if (request.params.ref.type !== "ref/resource") { - throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`); - } -} -var CompleteResultSchema = ResultSchema.extend({ - completion: looseObject({ - values: array(string2()).max(100), - total: optional(number2().int()), - hasMore: optional(boolean2()) - }) -}); -var RootSchema = object({ - uri: string2().startsWith("file://"), - name: string2().optional(), - _meta: record(string2(), unknown()).optional() -}); -var ListRootsRequestSchema = RequestSchema.extend({ - method: literal("roots/list"), - params: BaseRequestParamsSchema.optional() -}); -var ListRootsResultSchema = ResultSchema.extend({ - roots: array(RootSchema) -}); -var RootsListChangedNotificationSchema = NotificationSchema.extend({ - method: literal("notifications/roots/list_changed"), - params: NotificationsParamsSchema.optional() -}); -var ClientRequestSchema = union([ - PingRequestSchema, - InitializeRequestSchema, - CompleteRequestSchema, - SetLevelRequestSchema, - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema, - SubscribeRequestSchema, - UnsubscribeRequestSchema, - CallToolRequestSchema, - ListToolsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); -var ClientNotificationSchema = union([ - CancelledNotificationSchema, - ProgressNotificationSchema, - InitializedNotificationSchema, - RootsListChangedNotificationSchema, - TaskStatusNotificationSchema -]); -var ClientResultSchema = union([ - EmptyResultSchema, - CreateMessageResultSchema, - CreateMessageResultWithToolsSchema, - ElicitResultSchema, - ListRootsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema -]); -var ServerRequestSchema = union([ - PingRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - CancelTaskRequestSchema -]); -var ServerNotificationSchema = union([ - CancelledNotificationSchema, - ProgressNotificationSchema, - LoggingMessageNotificationSchema, - ResourceUpdatedNotificationSchema, - ResourceListChangedNotificationSchema, - ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema, - TaskStatusNotificationSchema, - ElicitationCompleteNotificationSchema -]); -var ServerResultSchema = union([ - EmptyResultSchema, - InitializeResultSchema, - CompleteResultSchema, - GetPromptResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ReadResourceResultSchema, - CallToolResultSchema, - ListToolsResultSchema, - GetTaskResultSchema, - ListTasksResultSchema, - CreateTaskResultSchema -]); - -class McpError extends Error { - constructor(code, message, data) { - super(`MCP error ${code}: ${message}`); - this.code = code; - this.data = data; - this.name = "McpError"; - } - static fromError(code, message, data) { - if (code === ErrorCode.UrlElicitationRequired && data) { - const errorData = data; - if (errorData.elicitations) { - return new UrlElicitationRequiredError(errorData.elicitations, message); - } - } - return new McpError(code, message, data); - } -} - -class UrlElicitationRequiredError extends McpError { - constructor(elicitations, message = `URL elicitation${elicitations.length > 1 ? "s" : ""} required`) { - super(ErrorCode.UrlElicitationRequired, message, { - elicitations - }); - } - get elicitations() { - return this.data?.elicitations ?? []; - } -} - -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js -function isTerminal(status) { - return status === "completed" || status === "failed" || status === "cancelled"; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/Options.js -var ignoreOverride2 = Symbol("Let zodToJsonSchema decide on which parser to use"); -var defaultOptions2 = { - name: undefined, - $refStrategy: "root", - basePath: ["#"], - effectStrategy: "input", - pipeStrategy: "all", - dateStrategy: "format:date-time", - mapStrategy: "entries", - removeAdditionalStrategy: "passthrough", - allowedAdditionalProperties: true, - rejectedAdditionalProperties: false, - definitionPath: "definitions", - target: "jsonSchema7", - strictUnions: false, - definitions: {}, - errorMessages: false, - markdownDescription: false, - patternStrategy: "escape", - applyRegexFlags: false, - emailStrategy: "format:email", - base64Strategy: "contentEncoding:base64", - nameStrategy: "ref", - openAiAnyTypeName: "OpenAiAnyType" -}; -var getDefaultOptions2 = (options) => typeof options === "string" ? { - ...defaultOptions2, - name: options -} : { - ...defaultOptions2, - ...options -}; -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/Refs.js -var getRefs2 = (options) => { - const _options = getDefaultOptions2(options); - const currentPath = _options.name !== undefined ? [..._options.basePath, _options.definitionPath, _options.name] : _options.basePath; - return { - ..._options, - flags: { hasReferencedOpenAiAnyType: false }, - currentPath, - propertyPath: undefined, - seen: new Map(Object.entries(_options.definitions).map(([name21, def]) => [ - def._def, - { - def: def._def, - path: [..._options.basePath, _options.definitionPath, name21], - jsonSchema: undefined - } - ])) - }; -}; -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/errorMessages.js -function addErrorMessage(res, key, errorMessage, refs) { - if (!refs?.errorMessages) - return; - if (errorMessage) { - res.errorMessage = { - ...res.errorMessage, - [key]: errorMessage - }; - } -} -function setResponseValueAndErrors(res, key, value, errorMessage, refs) { - res[key] = value; - addErrorMessage(res, key, errorMessage, refs); -} -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/getRelativePath.js -var getRelativePath2 = (pathA, pathB) => { - let i = 0; - for (;i < pathA.length && i < pathB.length; i++) { - if (pathA[i] !== pathB[i]) - break; - } - return [(pathA.length - i).toString(), ...pathB.slice(i)].join("/"); -}; -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/any.js -function parseAnyDef2(refs) { - if (refs.target !== "openAi") { - return {}; - } - const anyDefinitionPath = [ - ...refs.basePath, - refs.definitionPath, - refs.openAiAnyTypeName - ]; - refs.flags.hasReferencedOpenAiAnyType = true; - return { - $ref: refs.$refStrategy === "relative" ? getRelativePath2(anyDefinitionPath, refs.currentPath) : anyDefinitionPath.join("/") - }; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/array.js -function parseArrayDef2(def, refs) { - const res = { - type: "array" - }; - if (def.type?._def && def.type?._def?.typeName !== ZodFirstPartyTypeKind2.ZodAny) { - res.items = parseDef2(def.type._def, { - ...refs, - currentPath: [...refs.currentPath, "items"] - }); - } - if (def.minLength) { - setResponseValueAndErrors(res, "minItems", def.minLength.value, def.minLength.message, refs); - } - if (def.maxLength) { - setResponseValueAndErrors(res, "maxItems", def.maxLength.value, def.maxLength.message, refs); - } - if (def.exactLength) { - setResponseValueAndErrors(res, "minItems", def.exactLength.value, def.exactLength.message, refs); - setResponseValueAndErrors(res, "maxItems", def.exactLength.value, def.exactLength.message, refs); - } - return res; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/bigint.js -function parseBigintDef2(def, refs) { - const res = { - type: "integer", - format: "int64" - }; - if (!def.checks) - return res; - for (const check2 of def.checks) { - switch (check2.kind) { - case "min": - if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); - } else { - setResponseValueAndErrors(res, "exclusiveMinimum", check2.value, check2.message, refs); + + if (${i}.value === undefined) { + if (${M} in input) { + newResult[${M}] = undefined; } } else { - if (!check2.inclusive) { - res.exclusiveMinimum = true; - } - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); + newResult[${M}] = ${i}.value; } - break; - case "max": - if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); - } else { - setResponseValueAndErrors(res, "exclusiveMaximum", check2.value, check2.message, refs); + + `);else V.write(` + if (${i}.issues.length) { + payload.issues = payload.issues.concat(${i}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${M}, ...iss.path] : [${M}] + }))); + } + + if (${i}.value === undefined) { + if (${M} in input) { + newResult[${M}] = undefined; } } else { - if (!check2.inclusive) { - res.exclusiveMaximum = true; - } - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); + newResult[${M}] = ${i}.value; } - break; - case "multipleOf": - setResponseValueAndErrors(res, "multipleOf", check2.value, check2.message, refs); - break; - } - } - return res; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/boolean.js -function parseBooleanDef2() { - return { - type: "boolean" - }; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/branded.js -function parseBrandedDef2(_def, refs) { - return parseDef2(_def.type._def, refs); -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/catch.js -var parseCatchDef2 = (def, refs) => { - return parseDef2(def.innerType._def, refs); -}; - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/date.js -function parseDateDef2(def, refs, overrideDateStrategy) { - const strategy = overrideDateStrategy ?? refs.dateStrategy; - if (Array.isArray(strategy)) { - return { - anyOf: strategy.map((item, i) => parseDateDef2(def, refs, item)) - }; - } - switch (strategy) { - case "string": - case "format:date-time": - return { - type: "string", - format: "date-time" - }; - case "format:date": - return { - type: "string", - format: "date" - }; - case "integer": - return integerDateParser2(def, refs); - } -} -var integerDateParser2 = (def, refs) => { - const res = { - type: "integer", - format: "unix-time" - }; - if (refs.target === "openApi3") { - return res; - } - for (const check2 of def.checks) { - switch (check2.kind) { - case "min": - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); - break; - case "max": - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); - break; - } - } - return res; -}; - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/default.js -function parseDefaultDef2(_def, refs) { - return { - ...parseDef2(_def.innerType._def, refs), - default: _def.defaultValue() - }; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/effects.js -function parseEffectsDef2(_def, refs) { - return refs.effectStrategy === "input" ? parseDef2(_def.schema._def, refs) : parseAnyDef2(refs); -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/enum.js -function parseEnumDef2(def) { - return { - type: "string", - enum: Array.from(def.values) - }; -} - -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/intersection.js -var isJsonSchema7AllOfType2 = (type) => { - if ("type" in type && type.type === "string") - return false; - return "allOf" in type; -}; -function parseIntersectionDef2(def, refs) { - const allOf = [ - parseDef2(def.left._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", "0"] - }), - parseDef2(def.right._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", "1"] - }) - ].filter((x) => !!x); - let unevaluatedProperties = refs.target === "jsonSchema2019-09" ? { unevaluatedProperties: false } : undefined; - const mergedAllOf = []; - allOf.forEach((schema) => { - if (isJsonSchema7AllOfType2(schema)) { - mergedAllOf.push(...schema.allOf); - if (schema.unevaluatedProperties === undefined) { - unevaluatedProperties = undefined; - } - } else { - let nestedSchema = schema; - if ("additionalProperties" in schema && schema.additionalProperties === false) { - const { additionalProperties, ...rest2 } = schema; - nestedSchema = rest2; - } else { - unevaluatedProperties = undefined; - } - mergedAllOf.push(nestedSchema); - } - }); - return mergedAllOf.length ? { - allOf: mergedAllOf, - ...unevaluatedProperties - } : undefined; -} + + `)}V.write("payload.value = newResult;"),V.write("return payload;");let A=V.compile();return(O,i)=>A(j,O,i)},Y,z=_9,W=!PJ.jitless,U=W&&gV.value,w=X.catchall,H;$._zod.parse=(j,V)=>{H??(H=J.value);let B=j.value;if(!z(B))return j.issues.push({expected:"object",code:"invalid_type",input:B,inst:$}),j;if(W&&U&&V?.async===!1&&V.jitless!==!0){if(!Y)Y=v(X.shape);if(j=Y(j,V),!w)return j;return _2([],B,j,V,H,$)}return Q(j,V)}});function q2($,X,Q,J){for(let Y of $)if(Y.issues.length===0)return X.value=Y.value,X;let v=$.filter((Y)=>!E8(Y));if(v.length===1)return X.value=v[0].value,v[0];return X.issues.push({code:"invalid_union",input:X.value,inst:Q,errors:$.map((Y)=>Y.issues.map((z)=>T6(z,J,U4())))}),X}var rX=q("$ZodUnion",($,X)=>{J$.init($,X),K$($._zod,"optin",()=>X.options.some((v)=>v._zod.optin==="optional")?"optional":void 0),K$($._zod,"optout",()=>X.options.some((v)=>v._zod.optout==="optional")?"optional":void 0),K$($._zod,"values",()=>{if(X.options.every((v)=>v._zod.values))return new Set(X.options.flatMap((v)=>Array.from(v._zod.values)));return}),K$($._zod,"pattern",()=>{if(X.options.every((v)=>v._zod.pattern)){let v=X.options.map((Y)=>Y._zod.pattern);return new RegExp(`^(${v.map((Y)=>CJ(Y.source)).join("|")})$`)}return});let Q=X.options.length===1,J=X.options[0]._zod.run;$._zod.parse=(v,Y)=>{if(Q)return J(v,Y);let z=!1,W=[];for(let G of X.options){let U=G._zod.run({value:v.value,issues:[]},Y);if(U instanceof Promise)W.push(U),z=!0;else{if(U.issues.length===0)return U;W.push(U)}}if(!z)return q2(W,v,$,Y);return Promise.all(W).then((G)=>{return q2(G,v,$,Y)})}});function I2($,X,Q,J){let v=$.filter((Y)=>Y.issues.length===0);if(v.length===1)return X.value=v[0].value,X;if(v.length===0)X.issues.push({code:"invalid_union",input:X.value,inst:Q,errors:$.map((Y)=>Y.issues.map((z)=>T6(z,J,U4())))});else X.issues.push({code:"invalid_union",input:X.value,inst:Q,errors:[],inclusive:!1});return X}var VW=q("$ZodXor",($,X)=>{rX.init($,X),X.inclusive=!1;let Q=X.options.length===1,J=X.options[0]._zod.run;$._zod.parse=(v,Y)=>{if(Q)return J(v,Y);let z=!1,W=[];for(let G of X.options){let U=G._zod.run({value:v.value,issues:[]},Y);if(U instanceof Promise)W.push(U),z=!0;else W.push(U)}if(!z)return I2(W,v,$,Y);return Promise.all(W).then((G)=>{return I2(G,v,$,Y)})}}),BW=q("$ZodDiscriminatedUnion",($,X)=>{X.inclusive=!1,rX.init($,X);let Q=$._zod.parse;K$($._zod,"propValues",()=>{let v={};for(let Y of X.options){let z=Y._zod.propValues;if(!z||Object.keys(z).length===0)throw Error(`Invalid discriminated union option at index "${X.options.indexOf(Y)}"`);for(let[W,G]of Object.entries(z)){if(!v[W])v[W]=new Set;for(let U of G)v[W].add(U)}}return v});let J=cX(()=>{let v=X.options,Y=new Map;for(let z of v){let W=z._zod.propValues?.[X.discriminator];if(!W||W.size===0)throw Error(`Invalid discriminated union option at index "${X.options.indexOf(z)}"`);for(let G of W){if(Y.has(G))throw Error(`Duplicate discriminator value "${String(G)}"`);Y.set(G,z)}}return Y});$._zod.parse=(v,Y)=>{let z=v.value;if(!_9(z))return v.issues.push({code:"invalid_type",expected:"object",input:z,inst:$}),v;let W=J.value.get(z?.[X.discriminator]);if(W)return W._zod.run(v,Y);if(X.unionFallback)return Q(v,Y);return v.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:X.discriminator,input:z,path:[X.discriminator],inst:$}),v}}),DW=q("$ZodIntersection",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value,Y=X.left._zod.run({value:v,issues:[]},J),z=X.right._zod.run({value:v,issues:[]},J);if(Y instanceof Promise||z instanceof Promise)return Promise.all([Y,z]).then(([G,U])=>{return M2(Q,G,U)});return M2(Q,Y,z)}});function gB($,X){if($===X)return{valid:!0,data:$};if($ instanceof Date&&X instanceof Date&&+$===+X)return{valid:!0,data:$};if(R8($)&&R8(X)){let Q=Object.keys(X),J=Object.keys($).filter((Y)=>Q.indexOf(Y)!==-1),v={...$,...X};for(let Y of J){let z=gB($[Y],X[Y]);if(!z.valid)return{valid:!1,mergeErrorPath:[Y,...z.mergeErrorPath]};v[Y]=z.data}return{valid:!0,data:v}}if(Array.isArray($)&&Array.isArray(X)){if($.length!==X.length)return{valid:!1,mergeErrorPath:[]};let Q=[];for(let J=0;J<$.length;J++){let v=$[J],Y=X[J],z=gB(v,Y);if(!z.valid)return{valid:!1,mergeErrorPath:[J,...z.mergeErrorPath]};Q.push(z.data)}return{valid:!0,data:Q}}return{valid:!1,mergeErrorPath:[]}}function M2($,X,Q){let J=new Map,v;for(let W of X.issues)if(W.code==="unrecognized_keys"){v??(v=W);for(let G of W.keys){if(!J.has(G))J.set(G,{});J.get(G).l=!0}}else $.issues.push(W);for(let W of Q.issues)if(W.code==="unrecognized_keys")for(let G of W.keys){if(!J.has(G))J.set(G,{});J.get(G).r=!0}else $.issues.push(W);let Y=[...J].filter(([,W])=>W.l&&W.r).map(([W])=>W);if(Y.length&&v)$.issues.push({...v,keys:Y});if(E8($))return $;let z=gB(X.value,Q.value);if(!z.valid)throw Error(`Unmergable intersection. Error path: ${JSON.stringify(z.mergeErrorPath)}`);return $.value=z.data,$}var pJ=q("$ZodTuple",($,X)=>{J$.init($,X);let Q=X.items;$._zod.parse=(J,v)=>{let Y=J.value;if(!Array.isArray(Y))return J.issues.push({input:Y,inst:$,expected:"tuple",code:"invalid_type"}),J;J.value=[];let z=[],W=[...Q].reverse().findIndex((w)=>w._zod.optin!=="optional"),G=W===-1?0:Q.length-W;if(!X.rest){let w=Y.length>Q.length,H=Y.length=Y.length){if(U>=G)continue}let H=w._zod.run({value:Y[U],issues:[]},v);if(H instanceof Promise)z.push(H.then((j)=>E5(j,J,U)));else E5(H,J,U)}if(X.rest){let w=Y.slice(Q.length);for(let H of w){U++;let j=X.rest._zod.run({value:H,issues:[]},v);if(j instanceof Promise)z.push(j.then((V)=>E5(V,J,U)));else E5(j,J,U)}}if(z.length)return Promise.all(z).then(()=>J);return J}});function E5($,X,Q){if($.issues.length)X.issues.push(...r6(Q,$.issues));X.value[Q]=$.value}var FW=q("$ZodRecord",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(!R8(v))return Q.issues.push({expected:"record",code:"invalid_type",input:v,inst:$}),Q;let Y=[],z=X.keyType._zod.values;if(z){Q.value={};let W=new Set;for(let U of z)if(typeof U==="string"||typeof U==="number"||typeof U==="symbol"){W.add(typeof U==="number"?U.toString():U);let w=X.valueType._zod.run({value:v[U],issues:[]},J);if(w instanceof Promise)Y.push(w.then((H)=>{if(H.issues.length)Q.issues.push(...r6(U,H.issues));Q.value[U]=H.value}));else{if(w.issues.length)Q.issues.push(...r6(U,w.issues));Q.value[U]=w.value}}let G;for(let U in v)if(!W.has(U))G=G??[],G.push(U);if(G&&G.length>0)Q.issues.push({code:"unrecognized_keys",input:v,inst:$,keys:G})}else{Q.value={};for(let W of Reflect.ownKeys(v)){if(W==="__proto__")continue;let G=X.keyType._zod.run({value:W,issues:[]},J);if(G instanceof Promise)throw Error("Async schemas not supported in object keys currently");if(typeof W==="string"&&gJ.test(W)&&G.issues.length){let H=X.keyType._zod.run({value:Number(W),issues:[]},J);if(H instanceof Promise)throw Error("Async schemas not supported in object keys currently");if(H.issues.length===0)G=H}if(G.issues.length){if(X.mode==="loose")Q.value[W]=v[W];else Q.issues.push({code:"invalid_key",origin:"record",issues:G.issues.map((H)=>T6(H,J,U4())),input:W,path:[W],inst:$});continue}let w=X.valueType._zod.run({value:v[W],issues:[]},J);if(w instanceof Promise)Y.push(w.then((H)=>{if(H.issues.length)Q.issues.push(...r6(W,H.issues));Q.value[G.value]=H.value}));else{if(w.issues.length)Q.issues.push(...r6(W,w.issues));Q.value[G.value]=w.value}}}if(Y.length)return Promise.all(Y).then(()=>Q);return Q}}),NW=q("$ZodMap",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(!(v instanceof Map))return Q.issues.push({expected:"map",code:"invalid_type",input:v,inst:$}),Q;let Y=[];Q.value=new Map;for(let[z,W]of v){let G=X.keyType._zod.run({value:z,issues:[]},J),U=X.valueType._zod.run({value:W,issues:[]},J);if(G instanceof Promise||U instanceof Promise)Y.push(Promise.all([G,U]).then(([w,H])=>{x2(w,H,Q,z,v,$,J)}));else x2(G,U,Q,z,v,$,J)}if(Y.length)return Promise.all(Y).then(()=>Q);return Q}});function x2($,X,Q,J,v,Y,z){if($.issues.length)if(uJ.has(typeof J))Q.issues.push(...r6(J,$.issues));else Q.issues.push({code:"invalid_key",origin:"map",input:v,inst:Y,issues:$.issues.map((W)=>T6(W,z,U4()))});if(X.issues.length)if(uJ.has(typeof J))Q.issues.push(...r6(J,X.issues));else Q.issues.push({origin:"map",code:"invalid_element",input:v,inst:Y,key:J,issues:X.issues.map((W)=>T6(W,z,U4()))});Q.value.set($.value,X.value)}var OW=q("$ZodSet",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(!(v instanceof Set))return Q.issues.push({input:v,inst:$,expected:"set",code:"invalid_type"}),Q;let Y=[];Q.value=new Set;for(let z of v){let W=X.valueType._zod.run({value:z,issues:[]},J);if(W instanceof Promise)Y.push(W.then((G)=>b2(G,Q)));else b2(W,Q)}if(Y.length)return Promise.all(Y).then(()=>Q);return Q}});function b2($,X){if($.issues.length)X.issues.push(...$.issues);X.value.add($.value)}var LW=q("$ZodEnum",($,X)=>{J$.init($,X);let Q=SJ(X.entries),J=new Set(Q);$._zod.values=J,$._zod.pattern=new RegExp(`^(${Q.filter((v)=>uJ.has(typeof v)).map((v)=>typeof v==="string"?F1(v):v.toString()).join("|")})$`),$._zod.parse=(v,Y)=>{let z=v.value;if(J.has(z))return v;return v.issues.push({code:"invalid_value",values:Q,input:z,inst:$}),v}}),AW=q("$ZodLiteral",($,X)=>{if(J$.init($,X),X.values.length===0)throw Error("Cannot create literal schema with no valid values");let Q=new Set(X.values);$._zod.values=Q,$._zod.pattern=new RegExp(`^(${X.values.map((J)=>typeof J==="string"?F1(J):J?F1(J.toString()):String(J)).join("|")})$`),$._zod.parse=(J,v)=>{let Y=J.value;if(Q.has(Y))return J;return J.issues.push({code:"invalid_value",values:X.values,input:Y,inst:$}),J}}),ZW=q("$ZodFile",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{let v=Q.value;if(v instanceof File)return Q;return Q.issues.push({expected:"file",code:"invalid_type",input:v,inst:$}),Q}}),qW=q("$ZodTransform",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{if(J.direction==="backward")throw new u9($.constructor.name);let v=X.transform(Q.value,Q);if(J.async)return(v instanceof Promise?v:Promise.resolve(v)).then((z)=>{return Q.value=z,Q});if(v instanceof Promise)throw new B0;return Q.value=v,Q}});function i2($,X){if($.issues.length&&X===void 0)return{issues:[],value:void 0};return $}var dJ=q("$ZodOptional",($,X)=>{J$.init($,X),$._zod.optin="optional",$._zod.optout="optional",K$($._zod,"values",()=>{return X.innerType._zod.values?new Set([...X.innerType._zod.values,void 0]):void 0}),K$($._zod,"pattern",()=>{let Q=X.innerType._zod.pattern;return Q?new RegExp(`^(${CJ(Q.source)})?$`):void 0}),$._zod.parse=(Q,J)=>{if(X.innerType._zod.optin==="optional"){let v=X.innerType._zod.run(Q,J);if(v instanceof Promise)return v.then((Y)=>i2(Y,Q.value));return i2(v,Q.value)}if(Q.value===void 0)return Q;return X.innerType._zod.run(Q,J)}}),IW=q("$ZodExactOptional",($,X)=>{dJ.init($,X),K$($._zod,"values",()=>X.innerType._zod.values),K$($._zod,"pattern",()=>X.innerType._zod.pattern),$._zod.parse=(Q,J)=>{return X.innerType._zod.run(Q,J)}}),MW=q("$ZodNullable",($,X)=>{J$.init($,X),K$($._zod,"optin",()=>X.innerType._zod.optin),K$($._zod,"optout",()=>X.innerType._zod.optout),K$($._zod,"pattern",()=>{let Q=X.innerType._zod.pattern;return Q?new RegExp(`^(${CJ(Q.source)}|null)$`):void 0}),K$($._zod,"values",()=>{return X.innerType._zod.values?new Set([...X.innerType._zod.values,null]):void 0}),$._zod.parse=(Q,J)=>{if(Q.value===null)return Q;return X.innerType._zod.run(Q,J)}}),xW=q("$ZodDefault",($,X)=>{J$.init($,X),$._zod.optin="optional",K$($._zod,"values",()=>X.innerType._zod.values),$._zod.parse=(Q,J)=>{if(J.direction==="backward")return X.innerType._zod.run(Q,J);if(Q.value===void 0)return Q.value=X.defaultValue,Q;let v=X.innerType._zod.run(Q,J);if(v instanceof Promise)return v.then((Y)=>R2(Y,X));return R2(v,X)}});function R2($,X){if($.value===void 0)$.value=X.defaultValue;return $}var bW=q("$ZodPrefault",($,X)=>{J$.init($,X),$._zod.optin="optional",K$($._zod,"values",()=>X.innerType._zod.values),$._zod.parse=(Q,J)=>{if(J.direction==="backward")return X.innerType._zod.run(Q,J);if(Q.value===void 0)Q.value=X.defaultValue;return X.innerType._zod.run(Q,J)}}),iW=q("$ZodNonOptional",($,X)=>{J$.init($,X),K$($._zod,"values",()=>{let Q=X.innerType._zod.values;return Q?new Set([...Q].filter((J)=>J!==void 0)):void 0}),$._zod.parse=(Q,J)=>{let v=X.innerType._zod.run(Q,J);if(v instanceof Promise)return v.then((Y)=>E2(Y,$));return E2(v,$)}});function E2($,X){if(!$.issues.length&&$.value===void 0)$.issues.push({code:"invalid_type",expected:"nonoptional",input:$.value,inst:X});return $}var RW=q("$ZodSuccess",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{if(J.direction==="backward")throw new u9("ZodSuccess");let v=X.innerType._zod.run(Q,J);if(v instanceof Promise)return v.then((Y)=>{return Q.value=Y.issues.length===0,Q});return Q.value=v.issues.length===0,Q}}),EW=q("$ZodCatch",($,X)=>{J$.init($,X),K$($._zod,"optin",()=>X.innerType._zod.optin),K$($._zod,"optout",()=>X.innerType._zod.optout),K$($._zod,"values",()=>X.innerType._zod.values),$._zod.parse=(Q,J)=>{if(J.direction==="backward")return X.innerType._zod.run(Q,J);let v=X.innerType._zod.run(Q,J);if(v instanceof Promise)return v.then((Y)=>{if(Q.value=Y.value,Y.issues.length)Q.value=X.catchValue({...Q,error:{issues:Y.issues.map((z)=>T6(z,J,U4()))},input:Q.value}),Q.issues=[];return Q});if(Q.value=v.value,v.issues.length)Q.value=X.catchValue({...Q,error:{issues:v.issues.map((Y)=>T6(Y,J,U4()))},input:Q.value}),Q.issues=[];return Q}}),PW=q("$ZodNaN",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{if(typeof Q.value!=="number"||!Number.isNaN(Q.value))return Q.issues.push({input:Q.value,inst:$,expected:"nan",code:"invalid_type"}),Q;return Q}}),TW=q("$ZodPipe",($,X)=>{J$.init($,X),K$($._zod,"values",()=>X.in._zod.values),K$($._zod,"optin",()=>X.in._zod.optin),K$($._zod,"optout",()=>X.out._zod.optout),K$($._zod,"propValues",()=>X.in._zod.propValues),$._zod.parse=(Q,J)=>{if(J.direction==="backward"){let Y=X.out._zod.run(Q,J);if(Y instanceof Promise)return Y.then((z)=>P5(z,X.in,J));return P5(Y,X.in,J)}let v=X.in._zod.run(Q,J);if(v instanceof Promise)return v.then((Y)=>P5(Y,X.out,J));return P5(v,X.out,J)}});function P5($,X,Q){if($.issues.length)return $.aborted=!0,$;return X._zod.run({value:$.value,issues:$.issues},Q)}var tX=q("$ZodCodec",($,X)=>{J$.init($,X),K$($._zod,"values",()=>X.in._zod.values),K$($._zod,"optin",()=>X.in._zod.optin),K$($._zod,"optout",()=>X.out._zod.optout),K$($._zod,"propValues",()=>X.in._zod.propValues),$._zod.parse=(Q,J)=>{if((J.direction||"forward")==="forward"){let Y=X.in._zod.run(Q,J);if(Y instanceof Promise)return Y.then((z)=>T5(z,X,J));return T5(Y,X,J)}else{let Y=X.out._zod.run(Q,J);if(Y instanceof Promise)return Y.then((z)=>T5(z,X,J));return T5(Y,X,J)}}});function T5($,X,Q){if($.issues.length)return $.aborted=!0,$;if((Q.direction||"forward")==="forward"){let v=X.transform($.value,$);if(v instanceof Promise)return v.then((Y)=>S5($,Y,X.out,Q));return S5($,v,X.out,Q)}else{let v=X.reverseTransform($.value,$);if(v instanceof Promise)return v.then((Y)=>S5($,Y,X.in,Q));return S5($,v,X.in,Q)}}function S5($,X,Q,J){if($.issues.length)return $.aborted=!0,$;return Q._zod.run({value:X,issues:$.issues},J)}var SW=q("$ZodReadonly",($,X)=>{J$.init($,X),K$($._zod,"propValues",()=>X.innerType._zod.propValues),K$($._zod,"values",()=>X.innerType._zod.values),K$($._zod,"optin",()=>X.innerType?._zod?.optin),K$($._zod,"optout",()=>X.innerType?._zod?.optout),$._zod.parse=(Q,J)=>{if(J.direction==="backward")return X.innerType._zod.run(Q,J);let v=X.innerType._zod.run(Q,J);if(v instanceof Promise)return v.then(P2);return P2(v)}});function P2($){return $.value=Object.freeze($.value),$}var CW=q("$ZodTemplateLiteral",($,X)=>{J$.init($,X);let Q=[];for(let J of X.parts)if(typeof J==="object"&&J!==null){if(!J._zod.pattern)throw Error(`Invalid template literal part, no pattern found: ${[...J._zod.traits].shift()}`);let v=J._zod.pattern instanceof RegExp?J._zod.pattern.source:J._zod.pattern;if(!v)throw Error(`Invalid template literal part: ${J._zod.traits}`);let Y=v.startsWith("^")?1:0,z=v.endsWith("$")?v.length-1:v.length;Q.push(v.slice(Y,z))}else if(J===null||cV.has(typeof J))Q.push(F1(`${J}`));else throw Error(`Invalid template literal part: ${J}`);$._zod.pattern=new RegExp(`^${Q.join("")}$`),$._zod.parse=(J,v)=>{if(typeof J.value!=="string")return J.issues.push({input:J.value,inst:$,expected:"string",code:"invalid_type"}),J;if($._zod.pattern.lastIndex=0,!$._zod.pattern.test(J.value))return J.issues.push({input:J.value,inst:$,code:"invalid_format",format:X.format??"template_literal",pattern:$._zod.pattern.source}),J;return J}}),uW=q("$ZodFunction",($,X)=>{return J$.init($,X),$._def=X,$._zod.def=X,$.implement=(Q)=>{if(typeof Q!=="function")throw Error("implement() must be called with a function");return function(...J){let v=$._def.input?y9($._def.input,J):J,Y=Reflect.apply(Q,this,v);if($._def.output)return y9($._def.output,Y);return Y}},$.implementAsync=(Q)=>{if(typeof Q!=="function")throw Error("implementAsync() must be called with a function");return async function(...J){let v=$._def.input?await f9($._def.input,J):J,Y=await Reflect.apply(Q,this,v);if($._def.output)return await f9($._def.output,Y);return Y}},$._zod.parse=(Q,J)=>{if(typeof Q.value!=="function")return Q.issues.push({code:"invalid_type",expected:"function",input:Q.value,inst:$}),Q;if($._def.output&&$._def.output._zod.def.type==="promise")Q.value=$.implementAsync(Q.value);else Q.value=$.implement(Q.value);return Q},$.input=(...Q)=>{let J=$.constructor;if(Array.isArray(Q[0]))return new J({type:"function",input:new pJ({type:"tuple",items:Q[0],rest:Q[1]}),output:$._def.output});return new J({type:"function",input:Q[0],output:$._def.output})},$.output=(Q)=>{return new $.constructor({type:"function",input:$._def.input,output:Q})},$}),_W=q("$ZodPromise",($,X)=>{J$.init($,X),$._zod.parse=(Q,J)=>{return Promise.resolve(Q.value).then((v)=>X.innerType._zod.run({value:v,issues:[]},J))}}),yW=q("$ZodLazy",($,X)=>{J$.init($,X),K$($._zod,"innerType",()=>X.getter()),K$($._zod,"pattern",()=>$._zod.innerType?._zod?.pattern),K$($._zod,"propValues",()=>$._zod.innerType?._zod?.propValues),K$($._zod,"optin",()=>$._zod.innerType?._zod?.optin??void 0),K$($._zod,"optout",()=>$._zod.innerType?._zod?.optout??void 0),$._zod.parse=(Q,J)=>{return $._zod.innerType._zod.run(Q,J)}}),fW=q("$ZodCustom",($,X)=>{_$.init($,X),J$.init($,X),$._zod.parse=(Q,J)=>{return Q},$._zod.check=(Q)=>{let J=Q.value,v=X.fn(J);if(v instanceof Promise)return v.then((Y)=>T2(Y,Q,J,$));T2(v,Q,J,$);return}});function T2($,X,Q,J){if(!$){let v={code:"custom",input:Q,inst:J,path:[...J._zod.def.path??[]],continue:!J._zod.def.abort};if(J._zod.def.params)v.params=J._zod.def.params;X.issues.push(mX(v))}}var eX={};u1(eX,{zhTW:()=>fD,zhCN:()=>yD,yo:()=>kD,vi:()=>_D,uz:()=>uD,ur:()=>CD,uk:()=>tJ,ua:()=>SD,tr:()=>TD,th:()=>PD,ta:()=>ED,sv:()=>RD,sl:()=>iD,ru:()=>bD,pt:()=>xD,ps:()=>ID,pl:()=>MD,ota:()=>qD,no:()=>ZD,nl:()=>AD,ms:()=>LD,mk:()=>OD,lt:()=>ND,ko:()=>FD,km:()=>aJ,kh:()=>DD,ka:()=>BD,ja:()=>VD,it:()=>jD,is:()=>HD,id:()=>wD,hy:()=>KD,hu:()=>UD,he:()=>GD,frCA:()=>WD,fr:()=>zD,fi:()=>YD,fa:()=>JD,es:()=>vD,eo:()=>QD,en:()=>oJ,de:()=>XD,da:()=>$D,cs:()=>eB,ca:()=>sB,bg:()=>tB,be:()=>rB,az:()=>aB,ar:()=>oB});var eo=()=>{let $={string:{unit:"حرف",verb:"أن يحوي"},file:{unit:"بايت",verb:"أن يحوي"},array:{unit:"عنصر",verb:"أن يحوي"},set:{unit:"عنصر",verb:"أن يحوي"}};function X(v){return $[v]??null}let Q={regex:"مدخل",email:"بريد إلكتروني",url:"رابط",emoji:"إيموجي",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"تاريخ ووقت بمعيار ISO",date:"تاريخ بمعيار ISO",time:"وقت بمعيار ISO",duration:"مدة بمعيار ISO",ipv4:"عنوان IPv4",ipv6:"عنوان IPv6",cidrv4:"مدى عناوين بصيغة IPv4",cidrv6:"مدى عناوين بصيغة IPv6",base64:"نَص بترميز base64-encoded",base64url:"نَص بترميز base64url-encoded",json_string:"نَص على هيئة JSON",e164:"رقم هاتف بمعيار E.164",jwt:"JWT",template_literal:"مدخل"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`مدخلات غير مقبولة: يفترض إدخال instanceof ${v.expected}، ولكن تم إدخال ${W}`;return`مدخلات غير مقبولة: يفترض إدخال ${Y}، ولكن تم إدخال ${W}`}case"invalid_value":if(v.values.length===1)return`مدخلات غير مقبولة: يفترض إدخال ${P(v.values[0])}`;return`اختيار غير مقبول: يتوقع انتقاء أحد هذه الخيارات: ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return` أكبر من اللازم: يفترض أن تكون ${v.origin??"القيمة"} ${Y} ${v.maximum.toString()} ${z.unit??"عنصر"}`;return`أكبر من اللازم: يفترض أن تكون ${v.origin??"القيمة"} ${Y} ${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`أصغر من اللازم: يفترض لـ ${v.origin} أن يكون ${Y} ${v.minimum.toString()} ${z.unit}`;return`أصغر من اللازم: يفترض لـ ${v.origin} أن يكون ${Y} ${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`نَص غير مقبول: يجب أن يبدأ بـ "${v.prefix}"`;if(Y.format==="ends_with")return`نَص غير مقبول: يجب أن ينتهي بـ "${Y.suffix}"`;if(Y.format==="includes")return`نَص غير مقبول: يجب أن يتضمَّن "${Y.includes}"`;if(Y.format==="regex")return`نَص غير مقبول: يجب أن يطابق النمط ${Y.pattern}`;return`${Q[Y.format]??v.format} غير مقبول`}case"not_multiple_of":return`رقم غير مقبول: يجب أن يكون من مضاعفات ${v.divisor}`;case"unrecognized_keys":return`معرف${v.keys.length>1?"ات":""} غريب${v.keys.length>1?"ة":""}: ${I(v.keys,"، ")}`;case"invalid_key":return`معرف غير مقبول في ${v.origin}`;case"invalid_union":return"مدخل غير مقبول";case"invalid_element":return`مدخل غير مقبول في ${v.origin}`;default:return"مدخل غير مقبول"}}};function oB(){return{localeError:eo()}}var $a=()=>{let $={string:{unit:"simvol",verb:"olmalıdır"},file:{unit:"bayt",verb:"olmalıdır"},array:{unit:"element",verb:"olmalıdır"},set:{unit:"element",verb:"olmalıdır"}};function X(v){return $[v]??null}let Q={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Yanlış dəyər: gözlənilən instanceof ${v.expected}, daxil olan ${W}`;return`Yanlış dəyər: gözlənilən ${Y}, daxil olan ${W}`}case"invalid_value":if(v.values.length===1)return`Yanlış dəyər: gözlənilən ${P(v.values[0])}`;return`Yanlış seçim: aşağıdakılardan biri olmalıdır: ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Çox böyük: gözlənilən ${v.origin??"dəyər"} ${Y}${v.maximum.toString()} ${z.unit??"element"}`;return`Çox böyük: gözlənilən ${v.origin??"dəyər"} ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Çox kiçik: gözlənilən ${v.origin} ${Y}${v.minimum.toString()} ${z.unit}`;return`Çox kiçik: gözlənilən ${v.origin} ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Yanlış mətn: "${Y.prefix}" ilə başlamalıdır`;if(Y.format==="ends_with")return`Yanlış mətn: "${Y.suffix}" ilə bitməlidir`;if(Y.format==="includes")return`Yanlış mətn: "${Y.includes}" daxil olmalıdır`;if(Y.format==="regex")return`Yanlış mətn: ${Y.pattern} şablonuna uyğun olmalıdır`;return`Yanlış ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Yanlış ədəd: ${v.divisor} ilə bölünə bilən olmalıdır`;case"unrecognized_keys":return`Tanınmayan açar${v.keys.length>1?"lar":""}: ${I(v.keys,", ")}`;case"invalid_key":return`${v.origin} daxilində yanlış açar`;case"invalid_union":return"Yanlış dəyər";case"invalid_element":return`${v.origin} daxilində yanlış dəyər`;default:return"Yanlış dəyər"}}};function aB(){return{localeError:$a()}}function y2($,X,Q,J){let v=Math.abs($),Y=v%10,z=v%100;if(z>=11&&z<=19)return J;if(Y===1)return X;if(Y>=2&&Y<=4)return Q;return J}var Xa=()=>{let $={string:{unit:{one:"сімвал",few:"сімвалы",many:"сімвалаў"},verb:"мець"},array:{unit:{one:"элемент",few:"элементы",many:"элементаў"},verb:"мець"},set:{unit:{one:"элемент",few:"элементы",many:"элементаў"},verb:"мець"},file:{unit:{one:"байт",few:"байты",many:"байтаў"},verb:"мець"}};function X(v){return $[v]??null}let Q={regex:"увод",email:"email адрас",url:"URL",emoji:"эмодзі",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO дата і час",date:"ISO дата",time:"ISO час",duration:"ISO працягласць",ipv4:"IPv4 адрас",ipv6:"IPv6 адрас",cidrv4:"IPv4 дыяпазон",cidrv6:"IPv6 дыяпазон",base64:"радок у фармаце base64",base64url:"радок у фармаце base64url",json_string:"JSON радок",e164:"нумар E.164",jwt:"JWT",template_literal:"увод"},J={nan:"NaN",number:"лік",array:"масіў"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Няправільны ўвод: чакаўся instanceof ${v.expected}, атрымана ${W}`;return`Няправільны ўвод: чакаўся ${Y}, атрымана ${W}`}case"invalid_value":if(v.values.length===1)return`Няправільны ўвод: чакалася ${P(v.values[0])}`;return`Няправільны варыянт: чакаўся адзін з ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z){let W=Number(v.maximum),G=y2(W,z.unit.one,z.unit.few,z.unit.many);return`Занадта вялікі: чакалася, што ${v.origin??"значэнне"} павінна ${z.verb} ${Y}${v.maximum.toString()} ${G}`}return`Занадта вялікі: чакалася, што ${v.origin??"значэнне"} павінна быць ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z){let W=Number(v.minimum),G=y2(W,z.unit.one,z.unit.few,z.unit.many);return`Занадта малы: чакалася, што ${v.origin} павінна ${z.verb} ${Y}${v.minimum.toString()} ${G}`}return`Занадта малы: чакалася, што ${v.origin} павінна быць ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Няправільны радок: павінен пачынацца з "${Y.prefix}"`;if(Y.format==="ends_with")return`Няправільны радок: павінен заканчвацца на "${Y.suffix}"`;if(Y.format==="includes")return`Няправільны радок: павінен змяшчаць "${Y.includes}"`;if(Y.format==="regex")return`Няправільны радок: павінен адпавядаць шаблону ${Y.pattern}`;return`Няправільны ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Няправільны лік: павінен быць кратным ${v.divisor}`;case"unrecognized_keys":return`Нераспазнаны ${v.keys.length>1?"ключы":"ключ"}: ${I(v.keys,", ")}`;case"invalid_key":return`Няправільны ключ у ${v.origin}`;case"invalid_union":return"Няправільны ўвод";case"invalid_element":return`Няправільнае значэнне ў ${v.origin}`;default:return"Няправільны ўвод"}}};function rB(){return{localeError:Xa()}}var Qa=()=>{let $={string:{unit:"символа",verb:"да съдържа"},file:{unit:"байта",verb:"да съдържа"},array:{unit:"елемента",verb:"да съдържа"},set:{unit:"елемента",verb:"да съдържа"}};function X(v){return $[v]??null}let Q={regex:"вход",email:"имейл адрес",url:"URL",emoji:"емоджи",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO време",date:"ISO дата",time:"ISO време",duration:"ISO продължителност",ipv4:"IPv4 адрес",ipv6:"IPv6 адрес",cidrv4:"IPv4 диапазон",cidrv6:"IPv6 диапазон",base64:"base64-кодиран низ",base64url:"base64url-кодиран низ",json_string:"JSON низ",e164:"E.164 номер",jwt:"JWT",template_literal:"вход"},J={nan:"NaN",number:"число",array:"масив"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Невалиден вход: очакван instanceof ${v.expected}, получен ${W}`;return`Невалиден вход: очакван ${Y}, получен ${W}`}case"invalid_value":if(v.values.length===1)return`Невалиден вход: очакван ${P(v.values[0])}`;return`Невалидна опция: очаквано едно от ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Твърде голямо: очаква се ${v.origin??"стойност"} да съдържа ${Y}${v.maximum.toString()} ${z.unit??"елемента"}`;return`Твърде голямо: очаква се ${v.origin??"стойност"} да бъде ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Твърде малко: очаква се ${v.origin} да съдържа ${Y}${v.minimum.toString()} ${z.unit}`;return`Твърде малко: очаква се ${v.origin} да бъде ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Невалиден низ: трябва да започва с "${Y.prefix}"`;if(Y.format==="ends_with")return`Невалиден низ: трябва да завършва с "${Y.suffix}"`;if(Y.format==="includes")return`Невалиден низ: трябва да включва "${Y.includes}"`;if(Y.format==="regex")return`Невалиден низ: трябва да съвпада с ${Y.pattern}`;let z="Невалиден";if(Y.format==="emoji")z="Невалидно";if(Y.format==="datetime")z="Невалидно";if(Y.format==="date")z="Невалидна";if(Y.format==="time")z="Невалидно";if(Y.format==="duration")z="Невалидна";return`${z} ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Невалидно число: трябва да бъде кратно на ${v.divisor}`;case"unrecognized_keys":return`Неразпознат${v.keys.length>1?"и":""} ключ${v.keys.length>1?"ове":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Невалиден ключ в ${v.origin}`;case"invalid_union":return"Невалиден вход";case"invalid_element":return`Невалидна стойност в ${v.origin}`;default:return"Невалиден вход"}}};function tB(){return{localeError:Qa()}}var va=()=>{let $={string:{unit:"caràcters",verb:"contenir"},file:{unit:"bytes",verb:"contenir"},array:{unit:"elements",verb:"contenir"},set:{unit:"elements",verb:"contenir"}};function X(v){return $[v]??null}let Q={regex:"entrada",email:"adreça electrònica",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"data i hora ISO",date:"data ISO",time:"hora ISO",duration:"durada ISO",ipv4:"adreça IPv4",ipv6:"adreça IPv6",cidrv4:"rang IPv4",cidrv6:"rang IPv6",base64:"cadena codificada en base64",base64url:"cadena codificada en base64url",json_string:"cadena JSON",e164:"número E.164",jwt:"JWT",template_literal:"entrada"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Tipus invàlid: s'esperava instanceof ${v.expected}, s'ha rebut ${W}`;return`Tipus invàlid: s'esperava ${Y}, s'ha rebut ${W}`}case"invalid_value":if(v.values.length===1)return`Valor invàlid: s'esperava ${P(v.values[0])}`;return`Opció invàlida: s'esperava una de ${I(v.values," o ")}`;case"too_big":{let Y=v.inclusive?"com a màxim":"menys de",z=X(v.origin);if(z)return`Massa gran: s'esperava que ${v.origin??"el valor"} contingués ${Y} ${v.maximum.toString()} ${z.unit??"elements"}`;return`Massa gran: s'esperava que ${v.origin??"el valor"} fos ${Y} ${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?"com a mínim":"més de",z=X(v.origin);if(z)return`Massa petit: s'esperava que ${v.origin} contingués ${Y} ${v.minimum.toString()} ${z.unit}`;return`Massa petit: s'esperava que ${v.origin} fos ${Y} ${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Format invàlid: ha de començar amb "${Y.prefix}"`;if(Y.format==="ends_with")return`Format invàlid: ha d'acabar amb "${Y.suffix}"`;if(Y.format==="includes")return`Format invàlid: ha d'incloure "${Y.includes}"`;if(Y.format==="regex")return`Format invàlid: ha de coincidir amb el patró ${Y.pattern}`;return`Format invàlid per a ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Número invàlid: ha de ser múltiple de ${v.divisor}`;case"unrecognized_keys":return`Clau${v.keys.length>1?"s":""} no reconeguda${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Clau invàlida a ${v.origin}`;case"invalid_union":return"Entrada invàlida";case"invalid_element":return`Element invàlid a ${v.origin}`;default:return"Entrada invàlida"}}};function sB(){return{localeError:va()}}var Ja=()=>{let $={string:{unit:"znaků",verb:"mít"},file:{unit:"bajtů",verb:"mít"},array:{unit:"prvků",verb:"mít"},set:{unit:"prvků",verb:"mít"}};function X(v){return $[v]??null}let Q={regex:"regulární výraz",email:"e-mailová adresa",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"datum a čas ve formátu ISO",date:"datum ve formátu ISO",time:"čas ve formátu ISO",duration:"doba trvání ISO",ipv4:"IPv4 adresa",ipv6:"IPv6 adresa",cidrv4:"rozsah IPv4",cidrv6:"rozsah IPv6",base64:"řetězec zakódovaný ve formátu base64",base64url:"řetězec zakódovaný ve formátu base64url",json_string:"řetězec ve formátu JSON",e164:"číslo E.164",jwt:"JWT",template_literal:"vstup"},J={nan:"NaN",number:"číslo",string:"řetězec",function:"funkce",array:"pole"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Neplatný vstup: očekáváno instanceof ${v.expected}, obdrženo ${W}`;return`Neplatný vstup: očekáváno ${Y}, obdrženo ${W}`}case"invalid_value":if(v.values.length===1)return`Neplatný vstup: očekáváno ${P(v.values[0])}`;return`Neplatná možnost: očekávána jedna z hodnot ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Hodnota je příliš velká: ${v.origin??"hodnota"} musí mít ${Y}${v.maximum.toString()} ${z.unit??"prvků"}`;return`Hodnota je příliš velká: ${v.origin??"hodnota"} musí být ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Hodnota je příliš malá: ${v.origin??"hodnota"} musí mít ${Y}${v.minimum.toString()} ${z.unit??"prvků"}`;return`Hodnota je příliš malá: ${v.origin??"hodnota"} musí být ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Neplatný řetězec: musí začínat na "${Y.prefix}"`;if(Y.format==="ends_with")return`Neplatný řetězec: musí končit na "${Y.suffix}"`;if(Y.format==="includes")return`Neplatný řetězec: musí obsahovat "${Y.includes}"`;if(Y.format==="regex")return`Neplatný řetězec: musí odpovídat vzoru ${Y.pattern}`;return`Neplatný formát ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Neplatné číslo: musí být násobkem ${v.divisor}`;case"unrecognized_keys":return`Neznámé klíče: ${I(v.keys,", ")}`;case"invalid_key":return`Neplatný klíč v ${v.origin}`;case"invalid_union":return"Neplatný vstup";case"invalid_element":return`Neplatná hodnota v ${v.origin}`;default:return"Neplatný vstup"}}};function eB(){return{localeError:Ja()}}var Ya=()=>{let $={string:{unit:"tegn",verb:"havde"},file:{unit:"bytes",verb:"havde"},array:{unit:"elementer",verb:"indeholdt"},set:{unit:"elementer",verb:"indeholdt"}};function X(v){return $[v]??null}let Q={regex:"input",email:"e-mailadresse",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO dato- og klokkeslæt",date:"ISO-dato",time:"ISO-klokkeslæt",duration:"ISO-varighed",ipv4:"IPv4-område",ipv6:"IPv6-område",cidrv4:"IPv4-spektrum",cidrv6:"IPv6-spektrum",base64:"base64-kodet streng",base64url:"base64url-kodet streng",json_string:"JSON-streng",e164:"E.164-nummer",jwt:"JWT",template_literal:"input"},J={nan:"NaN",string:"streng",number:"tal",boolean:"boolean",array:"liste",object:"objekt",set:"sæt",file:"fil"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Ugyldigt input: forventede instanceof ${v.expected}, fik ${W}`;return`Ugyldigt input: forventede ${Y}, fik ${W}`}case"invalid_value":if(v.values.length===1)return`Ugyldig værdi: forventede ${P(v.values[0])}`;return`Ugyldigt valg: forventede en af følgende ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin),W=J[v.origin]??v.origin;if(z)return`For stor: forventede ${W??"value"} ${z.verb} ${Y} ${v.maximum.toString()} ${z.unit??"elementer"}`;return`For stor: forventede ${W??"value"} havde ${Y} ${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin),W=J[v.origin]??v.origin;if(z)return`For lille: forventede ${W} ${z.verb} ${Y} ${v.minimum.toString()} ${z.unit}`;return`For lille: forventede ${W} havde ${Y} ${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ugyldig streng: skal starte med "${Y.prefix}"`;if(Y.format==="ends_with")return`Ugyldig streng: skal ende med "${Y.suffix}"`;if(Y.format==="includes")return`Ugyldig streng: skal indeholde "${Y.includes}"`;if(Y.format==="regex")return`Ugyldig streng: skal matche mønsteret ${Y.pattern}`;return`Ugyldig ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Ugyldigt tal: skal være deleligt med ${v.divisor}`;case"unrecognized_keys":return`${v.keys.length>1?"Ukendte nøgler":"Ukendt nøgle"}: ${I(v.keys,", ")}`;case"invalid_key":return`Ugyldig nøgle i ${v.origin}`;case"invalid_union":return"Ugyldigt input: matcher ingen af de tilladte typer";case"invalid_element":return`Ugyldig værdi i ${v.origin}`;default:return"Ugyldigt input"}}};function $D(){return{localeError:Ya()}}var za=()=>{let $={string:{unit:"Zeichen",verb:"zu haben"},file:{unit:"Bytes",verb:"zu haben"},array:{unit:"Elemente",verb:"zu haben"},set:{unit:"Elemente",verb:"zu haben"}};function X(v){return $[v]??null}let Q={regex:"Eingabe",email:"E-Mail-Adresse",url:"URL",emoji:"Emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO-Datum und -Uhrzeit",date:"ISO-Datum",time:"ISO-Uhrzeit",duration:"ISO-Dauer",ipv4:"IPv4-Adresse",ipv6:"IPv6-Adresse",cidrv4:"IPv4-Bereich",cidrv6:"IPv6-Bereich",base64:"Base64-codierter String",base64url:"Base64-URL-codierter String",json_string:"JSON-String",e164:"E.164-Nummer",jwt:"JWT",template_literal:"Eingabe"},J={nan:"NaN",number:"Zahl",array:"Array"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Ungültige Eingabe: erwartet instanceof ${v.expected}, erhalten ${W}`;return`Ungültige Eingabe: erwartet ${Y}, erhalten ${W}`}case"invalid_value":if(v.values.length===1)return`Ungültige Eingabe: erwartet ${P(v.values[0])}`;return`Ungültige Option: erwartet eine von ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Zu groß: erwartet, dass ${v.origin??"Wert"} ${Y}${v.maximum.toString()} ${z.unit??"Elemente"} hat`;return`Zu groß: erwartet, dass ${v.origin??"Wert"} ${Y}${v.maximum.toString()} ist`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Zu klein: erwartet, dass ${v.origin} ${Y}${v.minimum.toString()} ${z.unit} hat`;return`Zu klein: erwartet, dass ${v.origin} ${Y}${v.minimum.toString()} ist`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ungültiger String: muss mit "${Y.prefix}" beginnen`;if(Y.format==="ends_with")return`Ungültiger String: muss mit "${Y.suffix}" enden`;if(Y.format==="includes")return`Ungültiger String: muss "${Y.includes}" enthalten`;if(Y.format==="regex")return`Ungültiger String: muss dem Muster ${Y.pattern} entsprechen`;return`Ungültig: ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Ungültige Zahl: muss ein Vielfaches von ${v.divisor} sein`;case"unrecognized_keys":return`${v.keys.length>1?"Unbekannte Schlüssel":"Unbekannter Schlüssel"}: ${I(v.keys,", ")}`;case"invalid_key":return`Ungültiger Schlüssel in ${v.origin}`;case"invalid_union":return"Ungültige Eingabe";case"invalid_element":return`Ungültiger Wert in ${v.origin}`;default:return"Ungültige Eingabe"}}};function XD(){return{localeError:za()}}var Wa=()=>{let $={string:{unit:"characters",verb:"to have"},file:{unit:"bytes",verb:"to have"},array:{unit:"items",verb:"to have"},set:{unit:"items",verb:"to have"},map:{unit:"entries",verb:"to have"}};function X(v){return $[v]??null}let Q={regex:"input",email:"email address",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datetime",date:"ISO date",time:"ISO time",duration:"ISO duration",ipv4:"IPv4 address",ipv6:"IPv6 address",mac:"MAC address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded string",base64url:"base64url-encoded string",json_string:"JSON string",e164:"E.164 number",jwt:"JWT",template_literal:"input"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;return`Invalid input: expected ${Y}, received ${W}`}case"invalid_value":if(v.values.length===1)return`Invalid input: expected ${P(v.values[0])}`;return`Invalid option: expected one of ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Too big: expected ${v.origin??"value"} to have ${Y}${v.maximum.toString()} ${z.unit??"elements"}`;return`Too big: expected ${v.origin??"value"} to be ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Too small: expected ${v.origin} to have ${Y}${v.minimum.toString()} ${z.unit}`;return`Too small: expected ${v.origin} to be ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Invalid string: must start with "${Y.prefix}"`;if(Y.format==="ends_with")return`Invalid string: must end with "${Y.suffix}"`;if(Y.format==="includes")return`Invalid string: must include "${Y.includes}"`;if(Y.format==="regex")return`Invalid string: must match pattern ${Y.pattern}`;return`Invalid ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Invalid number: must be a multiple of ${v.divisor}`;case"unrecognized_keys":return`Unrecognized key${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Invalid key in ${v.origin}`;case"invalid_union":return"Invalid input";case"invalid_element":return`Invalid value in ${v.origin}`;default:return"Invalid input"}}};function oJ(){return{localeError:Wa()}}var Ga=()=>{let $={string:{unit:"karaktrojn",verb:"havi"},file:{unit:"bajtojn",verb:"havi"},array:{unit:"elementojn",verb:"havi"},set:{unit:"elementojn",verb:"havi"}};function X(v){return $[v]??null}let Q={regex:"enigo",email:"retadreso",url:"URL",emoji:"emoĝio",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO-datotempo",date:"ISO-dato",time:"ISO-tempo",duration:"ISO-daŭro",ipv4:"IPv4-adreso",ipv6:"IPv6-adreso",cidrv4:"IPv4-rango",cidrv6:"IPv6-rango",base64:"64-ume kodita karaktraro",base64url:"URL-64-ume kodita karaktraro",json_string:"JSON-karaktraro",e164:"E.164-nombro",jwt:"JWT",template_literal:"enigo"},J={nan:"NaN",number:"nombro",array:"tabelo",null:"senvalora"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Nevalida enigo: atendiĝis instanceof ${v.expected}, riceviĝis ${W}`;return`Nevalida enigo: atendiĝis ${Y}, riceviĝis ${W}`}case"invalid_value":if(v.values.length===1)return`Nevalida enigo: atendiĝis ${P(v.values[0])}`;return`Nevalida opcio: atendiĝis unu el ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Tro granda: atendiĝis ke ${v.origin??"valoro"} havu ${Y}${v.maximum.toString()} ${z.unit??"elementojn"}`;return`Tro granda: atendiĝis ke ${v.origin??"valoro"} havu ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Tro malgranda: atendiĝis ke ${v.origin} havu ${Y}${v.minimum.toString()} ${z.unit}`;return`Tro malgranda: atendiĝis ke ${v.origin} estu ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Nevalida karaktraro: devas komenciĝi per "${Y.prefix}"`;if(Y.format==="ends_with")return`Nevalida karaktraro: devas finiĝi per "${Y.suffix}"`;if(Y.format==="includes")return`Nevalida karaktraro: devas inkluzivi "${Y.includes}"`;if(Y.format==="regex")return`Nevalida karaktraro: devas kongrui kun la modelo ${Y.pattern}`;return`Nevalida ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Nevalida nombro: devas esti oblo de ${v.divisor}`;case"unrecognized_keys":return`Nekonata${v.keys.length>1?"j":""} ŝlosilo${v.keys.length>1?"j":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Nevalida ŝlosilo en ${v.origin}`;case"invalid_union":return"Nevalida enigo";case"invalid_element":return`Nevalida valoro en ${v.origin}`;default:return"Nevalida enigo"}}};function QD(){return{localeError:Ga()}}var Ua=()=>{let $={string:{unit:"caracteres",verb:"tener"},file:{unit:"bytes",verb:"tener"},array:{unit:"elementos",verb:"tener"},set:{unit:"elementos",verb:"tener"}};function X(v){return $[v]??null}let Q={regex:"entrada",email:"dirección de correo electrónico",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"fecha y hora ISO",date:"fecha ISO",time:"hora ISO",duration:"duración ISO",ipv4:"dirección IPv4",ipv6:"dirección IPv6",cidrv4:"rango IPv4",cidrv6:"rango IPv6",base64:"cadena codificada en base64",base64url:"URL codificada en base64",json_string:"cadena JSON",e164:"número E.164",jwt:"JWT",template_literal:"entrada"},J={nan:"NaN",string:"texto",number:"número",boolean:"booleano",array:"arreglo",object:"objeto",set:"conjunto",file:"archivo",date:"fecha",bigint:"número grande",symbol:"símbolo",undefined:"indefinido",null:"nulo",function:"función",map:"mapa",record:"registro",tuple:"tupla",enum:"enumeración",union:"unión",literal:"literal",promise:"promesa",void:"vacío",never:"nunca",unknown:"desconocido",any:"cualquiera"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Entrada inválida: se esperaba instanceof ${v.expected}, recibido ${W}`;return`Entrada inválida: se esperaba ${Y}, recibido ${W}`}case"invalid_value":if(v.values.length===1)return`Entrada inválida: se esperaba ${P(v.values[0])}`;return`Opción inválida: se esperaba una de ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin),W=J[v.origin]??v.origin;if(z)return`Demasiado grande: se esperaba que ${W??"valor"} tuviera ${Y}${v.maximum.toString()} ${z.unit??"elementos"}`;return`Demasiado grande: se esperaba que ${W??"valor"} fuera ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin),W=J[v.origin]??v.origin;if(z)return`Demasiado pequeño: se esperaba que ${W} tuviera ${Y}${v.minimum.toString()} ${z.unit}`;return`Demasiado pequeño: se esperaba que ${W} fuera ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Cadena inválida: debe comenzar con "${Y.prefix}"`;if(Y.format==="ends_with")return`Cadena inválida: debe terminar en "${Y.suffix}"`;if(Y.format==="includes")return`Cadena inválida: debe incluir "${Y.includes}"`;if(Y.format==="regex")return`Cadena inválida: debe coincidir con el patrón ${Y.pattern}`;return`Inválido ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Número inválido: debe ser múltiplo de ${v.divisor}`;case"unrecognized_keys":return`Llave${v.keys.length>1?"s":""} desconocida${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Llave inválida en ${J[v.origin]??v.origin}`;case"invalid_union":return"Entrada inválida";case"invalid_element":return`Valor inválido en ${J[v.origin]??v.origin}`;default:return"Entrada inválida"}}};function vD(){return{localeError:Ua()}}var Ka=()=>{let $={string:{unit:"کاراکتر",verb:"داشته باشد"},file:{unit:"بایت",verb:"داشته باشد"},array:{unit:"آیتم",verb:"داشته باشد"},set:{unit:"آیتم",verb:"داشته باشد"}};function X(v){return $[v]??null}let Q={regex:"ورودی",email:"آدرس ایمیل",url:"URL",emoji:"ایموجی",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"تاریخ و زمان ایزو",date:"تاریخ ایزو",time:"زمان ایزو",duration:"مدت زمان ایزو",ipv4:"IPv4 آدرس",ipv6:"IPv6 آدرس",cidrv4:"IPv4 دامنه",cidrv6:"IPv6 دامنه",base64:"base64-encoded رشته",base64url:"base64url-encoded رشته",json_string:"JSON رشته",e164:"E.164 عدد",jwt:"JWT",template_literal:"ورودی"},J={nan:"NaN",number:"عدد",array:"آرایه"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`ورودی نامعتبر: می‌بایست instanceof ${v.expected} می‌بود، ${W} دریافت شد`;return`ورودی نامعتبر: می‌بایست ${Y} می‌بود، ${W} دریافت شد`}case"invalid_value":if(v.values.length===1)return`ورودی نامعتبر: می‌بایست ${P(v.values[0])} می‌بود`;return`گزینه نامعتبر: می‌بایست یکی از ${I(v.values,"|")} می‌بود`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`خیلی بزرگ: ${v.origin??"مقدار"} باید ${Y}${v.maximum.toString()} ${z.unit??"عنصر"} باشد`;return`خیلی بزرگ: ${v.origin??"مقدار"} باید ${Y}${v.maximum.toString()} باشد`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`خیلی کوچک: ${v.origin} باید ${Y}${v.minimum.toString()} ${z.unit} باشد`;return`خیلی کوچک: ${v.origin} باید ${Y}${v.minimum.toString()} باشد`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`رشته نامعتبر: باید با "${Y.prefix}" شروع شود`;if(Y.format==="ends_with")return`رشته نامعتبر: باید با "${Y.suffix}" تمام شود`;if(Y.format==="includes")return`رشته نامعتبر: باید شامل "${Y.includes}" باشد`;if(Y.format==="regex")return`رشته نامعتبر: باید با الگوی ${Y.pattern} مطابقت داشته باشد`;return`${Q[Y.format]??v.format} نامعتبر`}case"not_multiple_of":return`عدد نامعتبر: باید مضرب ${v.divisor} باشد`;case"unrecognized_keys":return`کلید${v.keys.length>1?"های":""} ناشناس: ${I(v.keys,", ")}`;case"invalid_key":return`کلید ناشناس در ${v.origin}`;case"invalid_union":return"ورودی نامعتبر";case"invalid_element":return`مقدار نامعتبر در ${v.origin}`;default:return"ورودی نامعتبر"}}};function JD(){return{localeError:Ka()}}var wa=()=>{let $={string:{unit:"merkkiä",subject:"merkkijonon"},file:{unit:"tavua",subject:"tiedoston"},array:{unit:"alkiota",subject:"listan"},set:{unit:"alkiota",subject:"joukon"},number:{unit:"",subject:"luvun"},bigint:{unit:"",subject:"suuren kokonaisluvun"},int:{unit:"",subject:"kokonaisluvun"},date:{unit:"",subject:"päivämäärän"}};function X(v){return $[v]??null}let Q={regex:"säännöllinen lauseke",email:"sähköpostiosoite",url:"URL-osoite",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO-aikaleima",date:"ISO-päivämäärä",time:"ISO-aika",duration:"ISO-kesto",ipv4:"IPv4-osoite",ipv6:"IPv6-osoite",cidrv4:"IPv4-alue",cidrv6:"IPv6-alue",base64:"base64-koodattu merkkijono",base64url:"base64url-koodattu merkkijono",json_string:"JSON-merkkijono",e164:"E.164-luku",jwt:"JWT",template_literal:"templaattimerkkijono"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Virheellinen tyyppi: odotettiin instanceof ${v.expected}, oli ${W}`;return`Virheellinen tyyppi: odotettiin ${Y}, oli ${W}`}case"invalid_value":if(v.values.length===1)return`Virheellinen syöte: täytyy olla ${P(v.values[0])}`;return`Virheellinen valinta: täytyy olla yksi seuraavista: ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Liian suuri: ${z.subject} täytyy olla ${Y}${v.maximum.toString()} ${z.unit}`.trim();return`Liian suuri: arvon täytyy olla ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Liian pieni: ${z.subject} täytyy olla ${Y}${v.minimum.toString()} ${z.unit}`.trim();return`Liian pieni: arvon täytyy olla ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Virheellinen syöte: täytyy alkaa "${Y.prefix}"`;if(Y.format==="ends_with")return`Virheellinen syöte: täytyy loppua "${Y.suffix}"`;if(Y.format==="includes")return`Virheellinen syöte: täytyy sisältää "${Y.includes}"`;if(Y.format==="regex")return`Virheellinen syöte: täytyy vastata säännöllistä lauseketta ${Y.pattern}`;return`Virheellinen ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Virheellinen luku: täytyy olla luvun ${v.divisor} monikerta`;case"unrecognized_keys":return`${v.keys.length>1?"Tuntemattomat avaimet":"Tuntematon avain"}: ${I(v.keys,", ")}`;case"invalid_key":return"Virheellinen avain tietueessa";case"invalid_union":return"Virheellinen unioni";case"invalid_element":return"Virheellinen arvo joukossa";default:return"Virheellinen syöte"}}};function YD(){return{localeError:wa()}}var Ha=()=>{let $={string:{unit:"caractères",verb:"avoir"},file:{unit:"octets",verb:"avoir"},array:{unit:"éléments",verb:"avoir"},set:{unit:"éléments",verb:"avoir"}};function X(v){return $[v]??null}let Q={regex:"entrée",email:"adresse e-mail",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"date et heure ISO",date:"date ISO",time:"heure ISO",duration:"durée ISO",ipv4:"adresse IPv4",ipv6:"adresse IPv6",cidrv4:"plage IPv4",cidrv6:"plage IPv6",base64:"chaîne encodée en base64",base64url:"chaîne encodée en base64url",json_string:"chaîne JSON",e164:"numéro E.164",jwt:"JWT",template_literal:"entrée"},J={nan:"NaN",number:"nombre",array:"tableau"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Entrée invalide : instanceof ${v.expected} attendu, ${W} reçu`;return`Entrée invalide : ${Y} attendu, ${W} reçu`}case"invalid_value":if(v.values.length===1)return`Entrée invalide : ${P(v.values[0])} attendu`;return`Option invalide : une valeur parmi ${I(v.values,"|")} attendue`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Trop grand : ${v.origin??"valeur"} doit ${z.verb} ${Y}${v.maximum.toString()} ${z.unit??"élément(s)"}`;return`Trop grand : ${v.origin??"valeur"} doit être ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Trop petit : ${v.origin} doit ${z.verb} ${Y}${v.minimum.toString()} ${z.unit}`;return`Trop petit : ${v.origin} doit être ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Chaîne invalide : doit commencer par "${Y.prefix}"`;if(Y.format==="ends_with")return`Chaîne invalide : doit se terminer par "${Y.suffix}"`;if(Y.format==="includes")return`Chaîne invalide : doit inclure "${Y.includes}"`;if(Y.format==="regex")return`Chaîne invalide : doit correspondre au modèle ${Y.pattern}`;return`${Q[Y.format]??v.format} invalide`}case"not_multiple_of":return`Nombre invalide : doit être un multiple de ${v.divisor}`;case"unrecognized_keys":return`Clé${v.keys.length>1?"s":""} non reconnue${v.keys.length>1?"s":""} : ${I(v.keys,", ")}`;case"invalid_key":return`Clé invalide dans ${v.origin}`;case"invalid_union":return"Entrée invalide";case"invalid_element":return`Valeur invalide dans ${v.origin}`;default:return"Entrée invalide"}}};function zD(){return{localeError:Ha()}}var ja=()=>{let $={string:{unit:"caractères",verb:"avoir"},file:{unit:"octets",verb:"avoir"},array:{unit:"éléments",verb:"avoir"},set:{unit:"éléments",verb:"avoir"}};function X(v){return $[v]??null}let Q={regex:"entrée",email:"adresse courriel",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"date-heure ISO",date:"date ISO",time:"heure ISO",duration:"durée ISO",ipv4:"adresse IPv4",ipv6:"adresse IPv6",cidrv4:"plage IPv4",cidrv6:"plage IPv6",base64:"chaîne encodée en base64",base64url:"chaîne encodée en base64url",json_string:"chaîne JSON",e164:"numéro E.164",jwt:"JWT",template_literal:"entrée"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Entrée invalide : attendu instanceof ${v.expected}, reçu ${W}`;return`Entrée invalide : attendu ${Y}, reçu ${W}`}case"invalid_value":if(v.values.length===1)return`Entrée invalide : attendu ${P(v.values[0])}`;return`Option invalide : attendu l'une des valeurs suivantes ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"≤":"<",z=X(v.origin);if(z)return`Trop grand : attendu que ${v.origin??"la valeur"} ait ${Y}${v.maximum.toString()} ${z.unit}`;return`Trop grand : attendu que ${v.origin??"la valeur"} soit ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?"≥":">",z=X(v.origin);if(z)return`Trop petit : attendu que ${v.origin} ait ${Y}${v.minimum.toString()} ${z.unit}`;return`Trop petit : attendu que ${v.origin} soit ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Chaîne invalide : doit commencer par "${Y.prefix}"`;if(Y.format==="ends_with")return`Chaîne invalide : doit se terminer par "${Y.suffix}"`;if(Y.format==="includes")return`Chaîne invalide : doit inclure "${Y.includes}"`;if(Y.format==="regex")return`Chaîne invalide : doit correspondre au motif ${Y.pattern}`;return`${Q[Y.format]??v.format} invalide`}case"not_multiple_of":return`Nombre invalide : doit être un multiple de ${v.divisor}`;case"unrecognized_keys":return`Clé${v.keys.length>1?"s":""} non reconnue${v.keys.length>1?"s":""} : ${I(v.keys,", ")}`;case"invalid_key":return`Clé invalide dans ${v.origin}`;case"invalid_union":return"Entrée invalide";case"invalid_element":return`Valeur invalide dans ${v.origin}`;default:return"Entrée invalide"}}};function WD(){return{localeError:ja()}}var Va=()=>{let $={string:{label:"מחרוזת",gender:"f"},number:{label:"מספר",gender:"m"},boolean:{label:"ערך בוליאני",gender:"m"},bigint:{label:"BigInt",gender:"m"},date:{label:"תאריך",gender:"m"},array:{label:"מערך",gender:"m"},object:{label:"אובייקט",gender:"m"},null:{label:"ערך ריק (null)",gender:"m"},undefined:{label:"ערך לא מוגדר (undefined)",gender:"m"},symbol:{label:"סימבול (Symbol)",gender:"m"},function:{label:"פונקציה",gender:"f"},map:{label:"מפה (Map)",gender:"f"},set:{label:"קבוצה (Set)",gender:"f"},file:{label:"קובץ",gender:"m"},promise:{label:"Promise",gender:"m"},NaN:{label:"NaN",gender:"m"},unknown:{label:"ערך לא ידוע",gender:"m"},value:{label:"ערך",gender:"m"}},X={string:{unit:"תווים",shortLabel:"קצר",longLabel:"ארוך"},file:{unit:"בייטים",shortLabel:"קטן",longLabel:"גדול"},array:{unit:"פריטים",shortLabel:"קטן",longLabel:"גדול"},set:{unit:"פריטים",shortLabel:"קטן",longLabel:"גדול"},number:{unit:"",shortLabel:"קטן",longLabel:"גדול"}},Q=(U)=>U?$[U]:void 0,J=(U)=>{let w=Q(U);if(w)return w.label;return U??$.unknown.label},v=(U)=>`ה${J(U)}`,Y=(U)=>{return(Q(U)?.gender??"m")==="f"?"צריכה להיות":"צריך להיות"},z=(U)=>{if(!U)return null;return X[U]??null},W={regex:{label:"קלט",gender:"m"},email:{label:"כתובת אימייל",gender:"f"},url:{label:"כתובת רשת",gender:"f"},emoji:{label:"אימוג'י",gender:"m"},uuid:{label:"UUID",gender:"m"},nanoid:{label:"nanoid",gender:"m"},guid:{label:"GUID",gender:"m"},cuid:{label:"cuid",gender:"m"},cuid2:{label:"cuid2",gender:"m"},ulid:{label:"ULID",gender:"m"},xid:{label:"XID",gender:"m"},ksuid:{label:"KSUID",gender:"m"},datetime:{label:"תאריך וזמן ISO",gender:"m"},date:{label:"תאריך ISO",gender:"m"},time:{label:"זמן ISO",gender:"m"},duration:{label:"משך זמן ISO",gender:"m"},ipv4:{label:"כתובת IPv4",gender:"f"},ipv6:{label:"כתובת IPv6",gender:"f"},cidrv4:{label:"טווח IPv4",gender:"m"},cidrv6:{label:"טווח IPv6",gender:"m"},base64:{label:"מחרוזת בבסיס 64",gender:"f"},base64url:{label:"מחרוזת בבסיס 64 לכתובות רשת",gender:"f"},json_string:{label:"מחרוזת JSON",gender:"f"},e164:{label:"מספר E.164",gender:"m"},jwt:{label:"JWT",gender:"m"},ends_with:{label:"קלט",gender:"m"},includes:{label:"קלט",gender:"m"},lowercase:{label:"קלט",gender:"m"},starts_with:{label:"קלט",gender:"m"},uppercase:{label:"קלט",gender:"m"}},G={nan:"NaN"};return(U)=>{switch(U.code){case"invalid_type":{let w=U.expected,H=G[w??""]??J(w),j=S(U.input),V=G[j]??$[j]?.label??j;if(/^[A-Z]/.test(U.expected))return`קלט לא תקין: צריך להיות instanceof ${U.expected}, התקבל ${V}`;return`קלט לא תקין: צריך להיות ${H}, התקבל ${V}`}case"invalid_value":{if(U.values.length===1)return`ערך לא תקין: הערך חייב להיות ${P(U.values[0])}`;let w=U.values.map((V)=>P(V));if(U.values.length===2)return`ערך לא תקין: האפשרויות המתאימות הן ${w[0]} או ${w[1]}`;let H=w[w.length-1];return`ערך לא תקין: האפשרויות המתאימות הן ${w.slice(0,-1).join(", ")} או ${H}`}case"too_big":{let w=z(U.origin),H=v(U.origin??"value");if(U.origin==="string")return`${w?.longLabel??"ארוך"} מדי: ${H} צריכה להכיל ${U.maximum.toString()} ${w?.unit??""} ${U.inclusive?"או פחות":"לכל היותר"}`.trim();if(U.origin==="number"){let B=U.inclusive?`קטן או שווה ל-${U.maximum}`:`קטן מ-${U.maximum}`;return`גדול מדי: ${H} צריך להיות ${B}`}if(U.origin==="array"||U.origin==="set"){let B=U.origin==="set"?"צריכה":"צריך",D=U.inclusive?`${U.maximum} ${w?.unit??""} או פחות`:`פחות מ-${U.maximum} ${w?.unit??""}`;return`גדול מדי: ${H} ${B} להכיל ${D}`.trim()}let j=U.inclusive?"<=":"<",V=Y(U.origin??"value");if(w?.unit)return`${w.longLabel} מדי: ${H} ${V} ${j}${U.maximum.toString()} ${w.unit}`;return`${w?.longLabel??"גדול"} מדי: ${H} ${V} ${j}${U.maximum.toString()}`}case"too_small":{let w=z(U.origin),H=v(U.origin??"value");if(U.origin==="string")return`${w?.shortLabel??"קצר"} מדי: ${H} צריכה להכיל ${U.minimum.toString()} ${w?.unit??""} ${U.inclusive?"או יותר":"לפחות"}`.trim();if(U.origin==="number"){let B=U.inclusive?`גדול או שווה ל-${U.minimum}`:`גדול מ-${U.minimum}`;return`קטן מדי: ${H} צריך להיות ${B}`}if(U.origin==="array"||U.origin==="set"){let B=U.origin==="set"?"צריכה":"צריך";if(U.minimum===1&&U.inclusive){let F=U.origin==="set"?"לפחות פריט אחד":"לפחות פריט אחד";return`קטן מדי: ${H} ${B} להכיל ${F}`}let D=U.inclusive?`${U.minimum} ${w?.unit??""} או יותר`:`יותר מ-${U.minimum} ${w?.unit??""}`;return`קטן מדי: ${H} ${B} להכיל ${D}`.trim()}let j=U.inclusive?">=":">",V=Y(U.origin??"value");if(w?.unit)return`${w.shortLabel} מדי: ${H} ${V} ${j}${U.minimum.toString()} ${w.unit}`;return`${w?.shortLabel??"קטן"} מדי: ${H} ${V} ${j}${U.minimum.toString()}`}case"invalid_format":{let w=U;if(w.format==="starts_with")return`המחרוזת חייבת להתחיל ב "${w.prefix}"`;if(w.format==="ends_with")return`המחרוזת חייבת להסתיים ב "${w.suffix}"`;if(w.format==="includes")return`המחרוזת חייבת לכלול "${w.includes}"`;if(w.format==="regex")return`המחרוזת חייבת להתאים לתבנית ${w.pattern}`;let H=W[w.format],j=H?.label??w.format,B=(H?.gender??"m")==="f"?"תקינה":"תקין";return`${j} לא ${B}`}case"not_multiple_of":return`מספר לא תקין: חייב להיות מכפלה של ${U.divisor}`;case"unrecognized_keys":return`מפתח${U.keys.length>1?"ות":""} לא מזוה${U.keys.length>1?"ים":"ה"}: ${I(U.keys,", ")}`;case"invalid_key":return"שדה לא תקין באובייקט";case"invalid_union":return"קלט לא תקין";case"invalid_element":return`ערך לא תקין ב${v(U.origin??"array")}`;default:return"קלט לא תקין"}}};function GD(){return{localeError:Va()}}var Ba=()=>{let $={string:{unit:"karakter",verb:"legyen"},file:{unit:"byte",verb:"legyen"},array:{unit:"elem",verb:"legyen"},set:{unit:"elem",verb:"legyen"}};function X(v){return $[v]??null}let Q={regex:"bemenet",email:"email cím",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO időbélyeg",date:"ISO dátum",time:"ISO idő",duration:"ISO időintervallum",ipv4:"IPv4 cím",ipv6:"IPv6 cím",cidrv4:"IPv4 tartomány",cidrv6:"IPv6 tartomány",base64:"base64-kódolt string",base64url:"base64url-kódolt string",json_string:"JSON string",e164:"E.164 szám",jwt:"JWT",template_literal:"bemenet"},J={nan:"NaN",number:"szám",array:"tömb"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Érvénytelen bemenet: a várt érték instanceof ${v.expected}, a kapott érték ${W}`;return`Érvénytelen bemenet: a várt érték ${Y}, a kapott érték ${W}`}case"invalid_value":if(v.values.length===1)return`Érvénytelen bemenet: a várt érték ${P(v.values[0])}`;return`Érvénytelen opció: valamelyik érték várt ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Túl nagy: ${v.origin??"érték"} mérete túl nagy ${Y}${v.maximum.toString()} ${z.unit??"elem"}`;return`Túl nagy: a bemeneti érték ${v.origin??"érték"} túl nagy: ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Túl kicsi: a bemeneti érték ${v.origin} mérete túl kicsi ${Y}${v.minimum.toString()} ${z.unit}`;return`Túl kicsi: a bemeneti érték ${v.origin} túl kicsi ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Érvénytelen string: "${Y.prefix}" értékkel kell kezdődnie`;if(Y.format==="ends_with")return`Érvénytelen string: "${Y.suffix}" értékkel kell végződnie`;if(Y.format==="includes")return`Érvénytelen string: "${Y.includes}" értéket kell tartalmaznia`;if(Y.format==="regex")return`Érvénytelen string: ${Y.pattern} mintának kell megfelelnie`;return`Érvénytelen ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Érvénytelen szám: ${v.divisor} többszörösének kell lennie`;case"unrecognized_keys":return`Ismeretlen kulcs${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Érvénytelen kulcs ${v.origin}`;case"invalid_union":return"Érvénytelen bemenet";case"invalid_element":return`Érvénytelen érték: ${v.origin}`;default:return"Érvénytelen bemenet"}}};function UD(){return{localeError:Ba()}}function f2($,X,Q){return Math.abs($)===1?X:Q}function sX($){if(!$)return"";let X=["ա","ե","ը","ի","ո","ու","օ"],Q=$[$.length-1];return $+(X.includes(Q)?"ն":"ը")}var Da=()=>{let $={string:{unit:{one:"նշան",many:"նշաններ"},verb:"ունենալ"},file:{unit:{one:"բայթ",many:"բայթեր"},verb:"ունենալ"},array:{unit:{one:"տարր",many:"տարրեր"},verb:"ունենալ"},set:{unit:{one:"տարր",many:"տարրեր"},verb:"ունենալ"}};function X(v){return $[v]??null}let Q={regex:"մուտք",email:"էլ. հասցե",url:"URL",emoji:"էմոջի",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO ամսաթիվ և ժամ",date:"ISO ամսաթիվ",time:"ISO ժամ",duration:"ISO տևողություն",ipv4:"IPv4 հասցե",ipv6:"IPv6 հասցե",cidrv4:"IPv4 միջակայք",cidrv6:"IPv6 միջակայք",base64:"base64 ձևաչափով տող",base64url:"base64url ձևաչափով տող",json_string:"JSON տող",e164:"E.164 համար",jwt:"JWT",template_literal:"մուտք"},J={nan:"NaN",number:"թիվ",array:"զանգված"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Սխալ մուտքագրում․ սպասվում էր instanceof ${v.expected}, ստացվել է ${W}`;return`Սխալ մուտքագրում․ սպասվում էր ${Y}, ստացվել է ${W}`}case"invalid_value":if(v.values.length===1)return`Սխալ մուտքագրում․ սպասվում էր ${P(v.values[1])}`;return`Սխալ տարբերակ․ սպասվում էր հետևյալներից մեկը՝ ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z){let W=Number(v.maximum),G=f2(W,z.unit.one,z.unit.many);return`Չափազանց մեծ արժեք․ սպասվում է, որ ${sX(v.origin??"արժեք")} կունենա ${Y}${v.maximum.toString()} ${G}`}return`Չափազանց մեծ արժեք․ սպասվում է, որ ${sX(v.origin??"արժեք")} լինի ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z){let W=Number(v.minimum),G=f2(W,z.unit.one,z.unit.many);return`Չափազանց փոքր արժեք․ սպասվում է, որ ${sX(v.origin)} կունենա ${Y}${v.minimum.toString()} ${G}`}return`Չափազանց փոքր արժեք․ սպասվում է, որ ${sX(v.origin)} լինի ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Սխալ տող․ պետք է սկսվի "${Y.prefix}"-ով`;if(Y.format==="ends_with")return`Սխալ տող․ պետք է ավարտվի "${Y.suffix}"-ով`;if(Y.format==="includes")return`Սխալ տող․ պետք է պարունակի "${Y.includes}"`;if(Y.format==="regex")return`Սխալ տող․ պետք է համապատասխանի ${Y.pattern} ձևաչափին`;return`Սխալ ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Սխալ թիվ․ պետք է բազմապատիկ լինի ${v.divisor}-ի`;case"unrecognized_keys":return`Չճանաչված բանալի${v.keys.length>1?"ներ":""}. ${I(v.keys,", ")}`;case"invalid_key":return`Սխալ բանալի ${sX(v.origin)}-ում`;case"invalid_union":return"Սխալ մուտքագրում";case"invalid_element":return`Սխալ արժեք ${sX(v.origin)}-ում`;default:return"Սխալ մուտքագրում"}}};function KD(){return{localeError:Da()}}var Fa=()=>{let $={string:{unit:"karakter",verb:"memiliki"},file:{unit:"byte",verb:"memiliki"},array:{unit:"item",verb:"memiliki"},set:{unit:"item",verb:"memiliki"}};function X(v){return $[v]??null}let Q={regex:"input",email:"alamat email",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"tanggal dan waktu format ISO",date:"tanggal format ISO",time:"jam format ISO",duration:"durasi format ISO",ipv4:"alamat IPv4",ipv6:"alamat IPv6",cidrv4:"rentang alamat IPv4",cidrv6:"rentang alamat IPv6",base64:"string dengan enkode base64",base64url:"string dengan enkode base64url",json_string:"string JSON",e164:"angka E.164",jwt:"JWT",template_literal:"input"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Input tidak valid: diharapkan instanceof ${v.expected}, diterima ${W}`;return`Input tidak valid: diharapkan ${Y}, diterima ${W}`}case"invalid_value":if(v.values.length===1)return`Input tidak valid: diharapkan ${P(v.values[0])}`;return`Pilihan tidak valid: diharapkan salah satu dari ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Terlalu besar: diharapkan ${v.origin??"value"} memiliki ${Y}${v.maximum.toString()} ${z.unit??"elemen"}`;return`Terlalu besar: diharapkan ${v.origin??"value"} menjadi ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Terlalu kecil: diharapkan ${v.origin} memiliki ${Y}${v.minimum.toString()} ${z.unit}`;return`Terlalu kecil: diharapkan ${v.origin} menjadi ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`String tidak valid: harus dimulai dengan "${Y.prefix}"`;if(Y.format==="ends_with")return`String tidak valid: harus berakhir dengan "${Y.suffix}"`;if(Y.format==="includes")return`String tidak valid: harus menyertakan "${Y.includes}"`;if(Y.format==="regex")return`String tidak valid: harus sesuai pola ${Y.pattern}`;return`${Q[Y.format]??v.format} tidak valid`}case"not_multiple_of":return`Angka tidak valid: harus kelipatan dari ${v.divisor}`;case"unrecognized_keys":return`Kunci tidak dikenali ${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Kunci tidak valid di ${v.origin}`;case"invalid_union":return"Input tidak valid";case"invalid_element":return`Nilai tidak valid di ${v.origin}`;default:return"Input tidak valid"}}};function wD(){return{localeError:Fa()}}var Na=()=>{let $={string:{unit:"stafi",verb:"að hafa"},file:{unit:"bæti",verb:"að hafa"},array:{unit:"hluti",verb:"að hafa"},set:{unit:"hluti",verb:"að hafa"}};function X(v){return $[v]??null}let Q={regex:"gildi",email:"netfang",url:"vefslóð",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO dagsetning og tími",date:"ISO dagsetning",time:"ISO tími",duration:"ISO tímalengd",ipv4:"IPv4 address",ipv6:"IPv6 address",cidrv4:"IPv4 range",cidrv6:"IPv6 range",base64:"base64-encoded strengur",base64url:"base64url-encoded strengur",json_string:"JSON strengur",e164:"E.164 tölugildi",jwt:"JWT",template_literal:"gildi"},J={nan:"NaN",number:"númer",array:"fylki"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Rangt gildi: Þú slóst inn ${W} þar sem á að vera instanceof ${v.expected}`;return`Rangt gildi: Þú slóst inn ${W} þar sem á að vera ${Y}`}case"invalid_value":if(v.values.length===1)return`Rangt gildi: gert ráð fyrir ${P(v.values[0])}`;return`Ógilt val: má vera eitt af eftirfarandi ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Of stórt: gert er ráð fyrir að ${v.origin??"gildi"} hafi ${Y}${v.maximum.toString()} ${z.unit??"hluti"}`;return`Of stórt: gert er ráð fyrir að ${v.origin??"gildi"} sé ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Of lítið: gert er ráð fyrir að ${v.origin} hafi ${Y}${v.minimum.toString()} ${z.unit}`;return`Of lítið: gert er ráð fyrir að ${v.origin} sé ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ógildur strengur: verður að byrja á "${Y.prefix}"`;if(Y.format==="ends_with")return`Ógildur strengur: verður að enda á "${Y.suffix}"`;if(Y.format==="includes")return`Ógildur strengur: verður að innihalda "${Y.includes}"`;if(Y.format==="regex")return`Ógildur strengur: verður að fylgja mynstri ${Y.pattern}`;return`Rangt ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Röng tala: verður að vera margfeldi af ${v.divisor}`;case"unrecognized_keys":return`Óþekkt ${v.keys.length>1?"ir lyklar":"ur lykill"}: ${I(v.keys,", ")}`;case"invalid_key":return`Rangur lykill í ${v.origin}`;case"invalid_union":return"Rangt gildi";case"invalid_element":return`Rangt gildi í ${v.origin}`;default:return"Rangt gildi"}}};function HD(){return{localeError:Na()}}var Oa=()=>{let $={string:{unit:"caratteri",verb:"avere"},file:{unit:"byte",verb:"avere"},array:{unit:"elementi",verb:"avere"},set:{unit:"elementi",verb:"avere"}};function X(v){return $[v]??null}let Q={regex:"input",email:"indirizzo email",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"data e ora ISO",date:"data ISO",time:"ora ISO",duration:"durata ISO",ipv4:"indirizzo IPv4",ipv6:"indirizzo IPv6",cidrv4:"intervallo IPv4",cidrv6:"intervallo IPv6",base64:"stringa codificata in base64",base64url:"URL codificata in base64",json_string:"stringa JSON",e164:"numero E.164",jwt:"JWT",template_literal:"input"},J={nan:"NaN",number:"numero",array:"vettore"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Input non valido: atteso instanceof ${v.expected}, ricevuto ${W}`;return`Input non valido: atteso ${Y}, ricevuto ${W}`}case"invalid_value":if(v.values.length===1)return`Input non valido: atteso ${P(v.values[0])}`;return`Opzione non valida: atteso uno tra ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Troppo grande: ${v.origin??"valore"} deve avere ${Y}${v.maximum.toString()} ${z.unit??"elementi"}`;return`Troppo grande: ${v.origin??"valore"} deve essere ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Troppo piccolo: ${v.origin} deve avere ${Y}${v.minimum.toString()} ${z.unit}`;return`Troppo piccolo: ${v.origin} deve essere ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Stringa non valida: deve iniziare con "${Y.prefix}"`;if(Y.format==="ends_with")return`Stringa non valida: deve terminare con "${Y.suffix}"`;if(Y.format==="includes")return`Stringa non valida: deve includere "${Y.includes}"`;if(Y.format==="regex")return`Stringa non valida: deve corrispondere al pattern ${Y.pattern}`;return`Invalid ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Numero non valido: deve essere un multiplo di ${v.divisor}`;case"unrecognized_keys":return`Chiav${v.keys.length>1?"i":"e"} non riconosciut${v.keys.length>1?"e":"a"}: ${I(v.keys,", ")}`;case"invalid_key":return`Chiave non valida in ${v.origin}`;case"invalid_union":return"Input non valido";case"invalid_element":return`Valore non valido in ${v.origin}`;default:return"Input non valido"}}};function jD(){return{localeError:Oa()}}var La=()=>{let $={string:{unit:"文字",verb:"である"},file:{unit:"バイト",verb:"である"},array:{unit:"要素",verb:"である"},set:{unit:"要素",verb:"である"}};function X(v){return $[v]??null}let Q={regex:"入力値",email:"メールアドレス",url:"URL",emoji:"絵文字",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO日時",date:"ISO日付",time:"ISO時刻",duration:"ISO期間",ipv4:"IPv4アドレス",ipv6:"IPv6アドレス",cidrv4:"IPv4範囲",cidrv6:"IPv6範囲",base64:"base64エンコード文字列",base64url:"base64urlエンコード文字列",json_string:"JSON文字列",e164:"E.164番号",jwt:"JWT",template_literal:"入力値"},J={nan:"NaN",number:"数値",array:"配列"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`無効な入力: instanceof ${v.expected}が期待されましたが、${W}が入力されました`;return`無効な入力: ${Y}が期待されましたが、${W}が入力されました`}case"invalid_value":if(v.values.length===1)return`無効な入力: ${P(v.values[0])}が期待されました`;return`無効な選択: ${I(v.values,"、")}のいずれかである必要があります`;case"too_big":{let Y=v.inclusive?"以下である":"より小さい",z=X(v.origin);if(z)return`大きすぎる値: ${v.origin??"値"}は${v.maximum.toString()}${z.unit??"要素"}${Y}必要があります`;return`大きすぎる値: ${v.origin??"値"}は${v.maximum.toString()}${Y}必要があります`}case"too_small":{let Y=v.inclusive?"以上である":"より大きい",z=X(v.origin);if(z)return`小さすぎる値: ${v.origin}は${v.minimum.toString()}${z.unit}${Y}必要があります`;return`小さすぎる値: ${v.origin}は${v.minimum.toString()}${Y}必要があります`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`無効な文字列: "${Y.prefix}"で始まる必要があります`;if(Y.format==="ends_with")return`無効な文字列: "${Y.suffix}"で終わる必要があります`;if(Y.format==="includes")return`無効な文字列: "${Y.includes}"を含む必要があります`;if(Y.format==="regex")return`無効な文字列: パターン${Y.pattern}に一致する必要があります`;return`無効な${Q[Y.format]??v.format}`}case"not_multiple_of":return`無効な数値: ${v.divisor}の倍数である必要があります`;case"unrecognized_keys":return`認識されていないキー${v.keys.length>1?"群":""}: ${I(v.keys,"、")}`;case"invalid_key":return`${v.origin}内の無効なキー`;case"invalid_union":return"無効な入力";case"invalid_element":return`${v.origin}内の無効な値`;default:return"無効な入力"}}};function VD(){return{localeError:La()}}var Aa=()=>{let $={string:{unit:"სიმბოლო",verb:"უნდა შეიცავდეს"},file:{unit:"ბაიტი",verb:"უნდა შეიცავდეს"},array:{unit:"ელემენტი",verb:"უნდა შეიცავდეს"},set:{unit:"ელემენტი",verb:"უნდა შეიცავდეს"}};function X(v){return $[v]??null}let Q={regex:"შეყვანა",email:"ელ-ფოსტის მისამართი",url:"URL",emoji:"ემოჯი",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"თარიღი-დრო",date:"თარიღი",time:"დრო",duration:"ხანგრძლივობა",ipv4:"IPv4 მისამართი",ipv6:"IPv6 მისამართი",cidrv4:"IPv4 დიაპაზონი",cidrv6:"IPv6 დიაპაზონი",base64:"base64-კოდირებული სტრინგი",base64url:"base64url-კოდირებული სტრინგი",json_string:"JSON სტრინგი",e164:"E.164 ნომერი",jwt:"JWT",template_literal:"შეყვანა"},J={nan:"NaN",number:"რიცხვი",string:"სტრინგი",boolean:"ბულეანი",function:"ფუნქცია",array:"მასივი"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`არასწორი შეყვანა: მოსალოდნელი instanceof ${v.expected}, მიღებული ${W}`;return`არასწორი შეყვანა: მოსალოდნელი ${Y}, მიღებული ${W}`}case"invalid_value":if(v.values.length===1)return`არასწორი შეყვანა: მოსალოდნელი ${P(v.values[0])}`;return`არასწორი ვარიანტი: მოსალოდნელია ერთ-ერთი ${I(v.values,"|")}-დან`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`ზედმეტად დიდი: მოსალოდნელი ${v.origin??"მნიშვნელობა"} ${z.verb} ${Y}${v.maximum.toString()} ${z.unit}`;return`ზედმეტად დიდი: მოსალოდნელი ${v.origin??"მნიშვნელობა"} იყოს ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`ზედმეტად პატარა: მოსალოდნელი ${v.origin} ${z.verb} ${Y}${v.minimum.toString()} ${z.unit}`;return`ზედმეტად პატარა: მოსალოდნელი ${v.origin} იყოს ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`არასწორი სტრინგი: უნდა იწყებოდეს "${Y.prefix}"-ით`;if(Y.format==="ends_with")return`არასწორი სტრინგი: უნდა მთავრდებოდეს "${Y.suffix}"-ით`;if(Y.format==="includes")return`არასწორი სტრინგი: უნდა შეიცავდეს "${Y.includes}"-ს`;if(Y.format==="regex")return`არასწორი სტრინგი: უნდა შეესაბამებოდეს შაბლონს ${Y.pattern}`;return`არასწორი ${Q[Y.format]??v.format}`}case"not_multiple_of":return`არასწორი რიცხვი: უნდა იყოს ${v.divisor}-ის ჯერადი`;case"unrecognized_keys":return`უცნობი გასაღებ${v.keys.length>1?"ები":"ი"}: ${I(v.keys,", ")}`;case"invalid_key":return`არასწორი გასაღები ${v.origin}-ში`;case"invalid_union":return"არასწორი შეყვანა";case"invalid_element":return`არასწორი მნიშვნელობა ${v.origin}-ში`;default:return"არასწორი შეყვანა"}}};function BD(){return{localeError:Aa()}}var Za=()=>{let $={string:{unit:"តួអក្សរ",verb:"គួរមាន"},file:{unit:"បៃ",verb:"គួរមាន"},array:{unit:"ធាតុ",verb:"គួរមាន"},set:{unit:"ធាតុ",verb:"គួរមាន"}};function X(v){return $[v]??null}let Q={regex:"ទិន្នន័យបញ្ចូល",email:"អាសយដ្ឋានអ៊ីមែល",url:"URL",emoji:"សញ្ញាអារម្មណ៍",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"កាលបរិច្ឆេទ និងម៉ោង ISO",date:"កាលបរិច្ឆេទ ISO",time:"ម៉ោង ISO",duration:"រយៈពេល ISO",ipv4:"អាសយដ្ឋាន IPv4",ipv6:"អាសយដ្ឋាន IPv6",cidrv4:"ដែនអាសយដ្ឋាន IPv4",cidrv6:"ដែនអាសយដ្ឋាន IPv6",base64:"ខ្សែអក្សរអ៊ិកូដ base64",base64url:"ខ្សែអក្សរអ៊ិកូដ base64url",json_string:"ខ្សែអក្សរ JSON",e164:"លេខ E.164",jwt:"JWT",template_literal:"ទិន្នន័យបញ្ចូល"},J={nan:"NaN",number:"លេខ",array:"អារេ (Array)",null:"គ្មានតម្លៃ (null)"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`ទិន្នន័យបញ្ចូលមិនត្រឹមត្រូវ៖ ត្រូវការ instanceof ${v.expected} ប៉ុន្តែទទួលបាន ${W}`;return`ទិន្នន័យបញ្ចូលមិនត្រឹមត្រូវ៖ ត្រូវការ ${Y} ប៉ុន្តែទទួលបាន ${W}`}case"invalid_value":if(v.values.length===1)return`ទិន្នន័យបញ្ចូលមិនត្រឹមត្រូវ៖ ត្រូវការ ${P(v.values[0])}`;return`ជម្រើសមិនត្រឹមត្រូវ៖ ត្រូវជាមួយក្នុងចំណោម ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`ធំពេក៖ ត្រូវការ ${v.origin??"តម្លៃ"} ${Y} ${v.maximum.toString()} ${z.unit??"ធាតុ"}`;return`ធំពេក៖ ត្រូវការ ${v.origin??"តម្លៃ"} ${Y} ${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`តូចពេក៖ ត្រូវការ ${v.origin} ${Y} ${v.minimum.toString()} ${z.unit}`;return`តូចពេក៖ ត្រូវការ ${v.origin} ${Y} ${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវចាប់ផ្តើមដោយ "${Y.prefix}"`;if(Y.format==="ends_with")return`ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវបញ្ចប់ដោយ "${Y.suffix}"`;if(Y.format==="includes")return`ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវមាន "${Y.includes}"`;if(Y.format==="regex")return`ខ្សែអក្សរមិនត្រឹមត្រូវ៖ ត្រូវតែផ្គូផ្គងនឹងទម្រង់ដែលបានកំណត់ ${Y.pattern}`;return`មិនត្រឹមត្រូវ៖ ${Q[Y.format]??v.format}`}case"not_multiple_of":return`លេខមិនត្រឹមត្រូវ៖ ត្រូវតែជាពហុគុណនៃ ${v.divisor}`;case"unrecognized_keys":return`រកឃើញសោមិនស្គាល់៖ ${I(v.keys,", ")}`;case"invalid_key":return`សោមិនត្រឹមត្រូវនៅក្នុង ${v.origin}`;case"invalid_union":return"ទិន្នន័យមិនត្រឹមត្រូវ";case"invalid_element":return`ទិន្នន័យមិនត្រឹមត្រូវនៅក្នុង ${v.origin}`;default:return"ទិន្នន័យមិនត្រឹមត្រូវ"}}};function aJ(){return{localeError:Za()}}function DD(){return aJ()}var qa=()=>{let $={string:{unit:"문자",verb:"to have"},file:{unit:"바이트",verb:"to have"},array:{unit:"개",verb:"to have"},set:{unit:"개",verb:"to have"}};function X(v){return $[v]??null}let Q={regex:"입력",email:"이메일 주소",url:"URL",emoji:"이모지",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO 날짜시간",date:"ISO 날짜",time:"ISO 시간",duration:"ISO 기간",ipv4:"IPv4 주소",ipv6:"IPv6 주소",cidrv4:"IPv4 범위",cidrv6:"IPv6 범위",base64:"base64 인코딩 문자열",base64url:"base64url 인코딩 문자열",json_string:"JSON 문자열",e164:"E.164 번호",jwt:"JWT",template_literal:"입력"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`잘못된 입력: 예상 타입은 instanceof ${v.expected}, 받은 타입은 ${W}입니다`;return`잘못된 입력: 예상 타입은 ${Y}, 받은 타입은 ${W}입니다`}case"invalid_value":if(v.values.length===1)return`잘못된 입력: 값은 ${P(v.values[0])} 이어야 합니다`;return`잘못된 옵션: ${I(v.values,"또는 ")} 중 하나여야 합니다`;case"too_big":{let Y=v.inclusive?"이하":"미만",z=Y==="미만"?"이어야 합니다":"여야 합니다",W=X(v.origin),G=W?.unit??"요소";if(W)return`${v.origin??"값"}이 너무 큽니다: ${v.maximum.toString()}${G} ${Y}${z}`;return`${v.origin??"값"}이 너무 큽니다: ${v.maximum.toString()} ${Y}${z}`}case"too_small":{let Y=v.inclusive?"이상":"초과",z=Y==="이상"?"이어야 합니다":"여야 합니다",W=X(v.origin),G=W?.unit??"요소";if(W)return`${v.origin??"값"}이 너무 작습니다: ${v.minimum.toString()}${G} ${Y}${z}`;return`${v.origin??"값"}이 너무 작습니다: ${v.minimum.toString()} ${Y}${z}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`잘못된 문자열: "${Y.prefix}"(으)로 시작해야 합니다`;if(Y.format==="ends_with")return`잘못된 문자열: "${Y.suffix}"(으)로 끝나야 합니다`;if(Y.format==="includes")return`잘못된 문자열: "${Y.includes}"을(를) 포함해야 합니다`;if(Y.format==="regex")return`잘못된 문자열: 정규식 ${Y.pattern} 패턴과 일치해야 합니다`;return`잘못된 ${Q[Y.format]??v.format}`}case"not_multiple_of":return`잘못된 숫자: ${v.divisor}의 배수여야 합니다`;case"unrecognized_keys":return`인식할 수 없는 키: ${I(v.keys,", ")}`;case"invalid_key":return`잘못된 키: ${v.origin}`;case"invalid_union":return"잘못된 입력";case"invalid_element":return`잘못된 값: ${v.origin}`;default:return"잘못된 입력"}}};function FD(){return{localeError:qa()}}var rJ=($)=>{return $.charAt(0).toUpperCase()+$.slice(1)};function k2($){let X=Math.abs($),Q=X%10,J=X%100;if(J>=11&&J<=19||Q===0)return"many";if(Q===1)return"one";return"few"}var Ia=()=>{let $={string:{unit:{one:"simbolis",few:"simboliai",many:"simbolių"},verb:{smaller:{inclusive:"turi būti ne ilgesnė kaip",notInclusive:"turi būti trumpesnė kaip"},bigger:{inclusive:"turi būti ne trumpesnė kaip",notInclusive:"turi būti ilgesnė kaip"}}},file:{unit:{one:"baitas",few:"baitai",many:"baitų"},verb:{smaller:{inclusive:"turi būti ne didesnis kaip",notInclusive:"turi būti mažesnis kaip"},bigger:{inclusive:"turi būti ne mažesnis kaip",notInclusive:"turi būti didesnis kaip"}}},array:{unit:{one:"elementą",few:"elementus",many:"elementų"},verb:{smaller:{inclusive:"turi turėti ne daugiau kaip",notInclusive:"turi turėti mažiau kaip"},bigger:{inclusive:"turi turėti ne mažiau kaip",notInclusive:"turi turėti daugiau kaip"}}},set:{unit:{one:"elementą",few:"elementus",many:"elementų"},verb:{smaller:{inclusive:"turi turėti ne daugiau kaip",notInclusive:"turi turėti mažiau kaip"},bigger:{inclusive:"turi turėti ne mažiau kaip",notInclusive:"turi turėti daugiau kaip"}}}};function X(v,Y,z,W){let G=$[v]??null;if(G===null)return G;return{unit:G.unit[Y],verb:G.verb[W][z?"inclusive":"notInclusive"]}}let Q={regex:"įvestis",email:"el. pašto adresas",url:"URL",emoji:"jaustukas",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO data ir laikas",date:"ISO data",time:"ISO laikas",duration:"ISO trukmė",ipv4:"IPv4 adresas",ipv6:"IPv6 adresas",cidrv4:"IPv4 tinklo prefiksas (CIDR)",cidrv6:"IPv6 tinklo prefiksas (CIDR)",base64:"base64 užkoduota eilutė",base64url:"base64url užkoduota eilutė",json_string:"JSON eilutė",e164:"E.164 numeris",jwt:"JWT",template_literal:"įvestis"},J={nan:"NaN",number:"skaičius",bigint:"sveikasis skaičius",string:"eilutė",boolean:"loginė reikšmė",undefined:"neapibrėžta reikšmė",function:"funkcija",symbol:"simbolis",array:"masyvas",object:"objektas",null:"nulinė reikšmė"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Gautas tipas ${W}, o tikėtasi - instanceof ${v.expected}`;return`Gautas tipas ${W}, o tikėtasi - ${Y}`}case"invalid_value":if(v.values.length===1)return`Privalo būti ${P(v.values[0])}`;return`Privalo būti vienas iš ${I(v.values,"|")} pasirinkimų`;case"too_big":{let Y=J[v.origin]??v.origin,z=X(v.origin,k2(Number(v.maximum)),v.inclusive??!1,"smaller");if(z?.verb)return`${rJ(Y??v.origin??"reikšmė")} ${z.verb} ${v.maximum.toString()} ${z.unit??"elementų"}`;let W=v.inclusive?"ne didesnis kaip":"mažesnis kaip";return`${rJ(Y??v.origin??"reikšmė")} turi būti ${W} ${v.maximum.toString()} ${z?.unit}`}case"too_small":{let Y=J[v.origin]??v.origin,z=X(v.origin,k2(Number(v.minimum)),v.inclusive??!1,"bigger");if(z?.verb)return`${rJ(Y??v.origin??"reikšmė")} ${z.verb} ${v.minimum.toString()} ${z.unit??"elementų"}`;let W=v.inclusive?"ne mažesnis kaip":"didesnis kaip";return`${rJ(Y??v.origin??"reikšmė")} turi būti ${W} ${v.minimum.toString()} ${z?.unit}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Eilutė privalo prasidėti "${Y.prefix}"`;if(Y.format==="ends_with")return`Eilutė privalo pasibaigti "${Y.suffix}"`;if(Y.format==="includes")return`Eilutė privalo įtraukti "${Y.includes}"`;if(Y.format==="regex")return`Eilutė privalo atitikti ${Y.pattern}`;return`Neteisingas ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Skaičius privalo būti ${v.divisor} kartotinis.`;case"unrecognized_keys":return`Neatpažint${v.keys.length>1?"i":"as"} rakt${v.keys.length>1?"ai":"as"}: ${I(v.keys,", ")}`;case"invalid_key":return"Rastas klaidingas raktas";case"invalid_union":return"Klaidinga įvestis";case"invalid_element":{let Y=J[v.origin]??v.origin;return`${rJ(Y??v.origin??"reikšmė")} turi klaidingą įvestį`}default:return"Klaidinga įvestis"}}};function ND(){return{localeError:Ia()}}var Ma=()=>{let $={string:{unit:"знаци",verb:"да имаат"},file:{unit:"бајти",verb:"да имаат"},array:{unit:"ставки",verb:"да имаат"},set:{unit:"ставки",verb:"да имаат"}};function X(v){return $[v]??null}let Q={regex:"внес",email:"адреса на е-пошта",url:"URL",emoji:"емоџи",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO датум и време",date:"ISO датум",time:"ISO време",duration:"ISO времетраење",ipv4:"IPv4 адреса",ipv6:"IPv6 адреса",cidrv4:"IPv4 опсег",cidrv6:"IPv6 опсег",base64:"base64-енкодирана низа",base64url:"base64url-енкодирана низа",json_string:"JSON низа",e164:"E.164 број",jwt:"JWT",template_literal:"внес"},J={nan:"NaN",number:"број",array:"низа"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Грешен внес: се очекува instanceof ${v.expected}, примено ${W}`;return`Грешен внес: се очекува ${Y}, примено ${W}`}case"invalid_value":if(v.values.length===1)return`Invalid input: expected ${P(v.values[0])}`;return`Грешана опција: се очекува една ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Премногу голем: се очекува ${v.origin??"вредноста"} да има ${Y}${v.maximum.toString()} ${z.unit??"елементи"}`;return`Премногу голем: се очекува ${v.origin??"вредноста"} да биде ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Премногу мал: се очекува ${v.origin} да има ${Y}${v.minimum.toString()} ${z.unit}`;return`Премногу мал: се очекува ${v.origin} да биде ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Неважечка низа: мора да започнува со "${Y.prefix}"`;if(Y.format==="ends_with")return`Неважечка низа: мора да завршува со "${Y.suffix}"`;if(Y.format==="includes")return`Неважечка низа: мора да вклучува "${Y.includes}"`;if(Y.format==="regex")return`Неважечка низа: мора да одгоара на патернот ${Y.pattern}`;return`Invalid ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Грешен број: мора да биде делив со ${v.divisor}`;case"unrecognized_keys":return`${v.keys.length>1?"Непрепознаени клучеви":"Непрепознаен клуч"}: ${I(v.keys,", ")}`;case"invalid_key":return`Грешен клуч во ${v.origin}`;case"invalid_union":return"Грешен внес";case"invalid_element":return`Грешна вредност во ${v.origin}`;default:return"Грешен внес"}}};function OD(){return{localeError:Ma()}}var xa=()=>{let $={string:{unit:"aksara",verb:"mempunyai"},file:{unit:"bait",verb:"mempunyai"},array:{unit:"elemen",verb:"mempunyai"},set:{unit:"elemen",verb:"mempunyai"}};function X(v){return $[v]??null}let Q={regex:"input",email:"alamat e-mel",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"tarikh masa ISO",date:"tarikh ISO",time:"masa ISO",duration:"tempoh ISO",ipv4:"alamat IPv4",ipv6:"alamat IPv6",cidrv4:"julat IPv4",cidrv6:"julat IPv6",base64:"string dikodkan base64",base64url:"string dikodkan base64url",json_string:"string JSON",e164:"nombor E.164",jwt:"JWT",template_literal:"input"},J={nan:"NaN",number:"nombor"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Input tidak sah: dijangka instanceof ${v.expected}, diterima ${W}`;return`Input tidak sah: dijangka ${Y}, diterima ${W}`}case"invalid_value":if(v.values.length===1)return`Input tidak sah: dijangka ${P(v.values[0])}`;return`Pilihan tidak sah: dijangka salah satu daripada ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Terlalu besar: dijangka ${v.origin??"nilai"} ${z.verb} ${Y}${v.maximum.toString()} ${z.unit??"elemen"}`;return`Terlalu besar: dijangka ${v.origin??"nilai"} adalah ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Terlalu kecil: dijangka ${v.origin} ${z.verb} ${Y}${v.minimum.toString()} ${z.unit}`;return`Terlalu kecil: dijangka ${v.origin} adalah ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`String tidak sah: mesti bermula dengan "${Y.prefix}"`;if(Y.format==="ends_with")return`String tidak sah: mesti berakhir dengan "${Y.suffix}"`;if(Y.format==="includes")return`String tidak sah: mesti mengandungi "${Y.includes}"`;if(Y.format==="regex")return`String tidak sah: mesti sepadan dengan corak ${Y.pattern}`;return`${Q[Y.format]??v.format} tidak sah`}case"not_multiple_of":return`Nombor tidak sah: perlu gandaan ${v.divisor}`;case"unrecognized_keys":return`Kunci tidak dikenali: ${I(v.keys,", ")}`;case"invalid_key":return`Kunci tidak sah dalam ${v.origin}`;case"invalid_union":return"Input tidak sah";case"invalid_element":return`Nilai tidak sah dalam ${v.origin}`;default:return"Input tidak sah"}}};function LD(){return{localeError:xa()}}var ba=()=>{let $={string:{unit:"tekens",verb:"heeft"},file:{unit:"bytes",verb:"heeft"},array:{unit:"elementen",verb:"heeft"},set:{unit:"elementen",verb:"heeft"}};function X(v){return $[v]??null}let Q={regex:"invoer",email:"emailadres",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datum en tijd",date:"ISO datum",time:"ISO tijd",duration:"ISO duur",ipv4:"IPv4-adres",ipv6:"IPv6-adres",cidrv4:"IPv4-bereik",cidrv6:"IPv6-bereik",base64:"base64-gecodeerde tekst",base64url:"base64 URL-gecodeerde tekst",json_string:"JSON string",e164:"E.164-nummer",jwt:"JWT",template_literal:"invoer"},J={nan:"NaN",number:"getal"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Ongeldige invoer: verwacht instanceof ${v.expected}, ontving ${W}`;return`Ongeldige invoer: verwacht ${Y}, ontving ${W}`}case"invalid_value":if(v.values.length===1)return`Ongeldige invoer: verwacht ${P(v.values[0])}`;return`Ongeldige optie: verwacht één van ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin),W=v.origin==="date"?"laat":v.origin==="string"?"lang":"groot";if(z)return`Te ${W}: verwacht dat ${v.origin??"waarde"} ${Y}${v.maximum.toString()} ${z.unit??"elementen"} ${z.verb}`;return`Te ${W}: verwacht dat ${v.origin??"waarde"} ${Y}${v.maximum.toString()} is`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin),W=v.origin==="date"?"vroeg":v.origin==="string"?"kort":"klein";if(z)return`Te ${W}: verwacht dat ${v.origin} ${Y}${v.minimum.toString()} ${z.unit} ${z.verb}`;return`Te ${W}: verwacht dat ${v.origin} ${Y}${v.minimum.toString()} is`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ongeldige tekst: moet met "${Y.prefix}" beginnen`;if(Y.format==="ends_with")return`Ongeldige tekst: moet op "${Y.suffix}" eindigen`;if(Y.format==="includes")return`Ongeldige tekst: moet "${Y.includes}" bevatten`;if(Y.format==="regex")return`Ongeldige tekst: moet overeenkomen met patroon ${Y.pattern}`;return`Ongeldig: ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Ongeldig getal: moet een veelvoud van ${v.divisor} zijn`;case"unrecognized_keys":return`Onbekende key${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Ongeldige key in ${v.origin}`;case"invalid_union":return"Ongeldige invoer";case"invalid_element":return`Ongeldige waarde in ${v.origin}`;default:return"Ongeldige invoer"}}};function AD(){return{localeError:ba()}}var ia=()=>{let $={string:{unit:"tegn",verb:"å ha"},file:{unit:"bytes",verb:"å ha"},array:{unit:"elementer",verb:"å inneholde"},set:{unit:"elementer",verb:"å inneholde"}};function X(v){return $[v]??null}let Q={regex:"input",email:"e-postadresse",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO dato- og klokkeslett",date:"ISO-dato",time:"ISO-klokkeslett",duration:"ISO-varighet",ipv4:"IPv4-område",ipv6:"IPv6-område",cidrv4:"IPv4-spekter",cidrv6:"IPv6-spekter",base64:"base64-enkodet streng",base64url:"base64url-enkodet streng",json_string:"JSON-streng",e164:"E.164-nummer",jwt:"JWT",template_literal:"input"},J={nan:"NaN",number:"tall",array:"liste"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Ugyldig input: forventet instanceof ${v.expected}, fikk ${W}`;return`Ugyldig input: forventet ${Y}, fikk ${W}`}case"invalid_value":if(v.values.length===1)return`Ugyldig verdi: forventet ${P(v.values[0])}`;return`Ugyldig valg: forventet en av ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`For stor(t): forventet ${v.origin??"value"} til å ha ${Y}${v.maximum.toString()} ${z.unit??"elementer"}`;return`For stor(t): forventet ${v.origin??"value"} til å ha ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`For lite(n): forventet ${v.origin} til å ha ${Y}${v.minimum.toString()} ${z.unit}`;return`For lite(n): forventet ${v.origin} til å ha ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ugyldig streng: må starte med "${Y.prefix}"`;if(Y.format==="ends_with")return`Ugyldig streng: må ende med "${Y.suffix}"`;if(Y.format==="includes")return`Ugyldig streng: må inneholde "${Y.includes}"`;if(Y.format==="regex")return`Ugyldig streng: må matche mønsteret ${Y.pattern}`;return`Ugyldig ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Ugyldig tall: må være et multiplum av ${v.divisor}`;case"unrecognized_keys":return`${v.keys.length>1?"Ukjente nøkler":"Ukjent nøkkel"}: ${I(v.keys,", ")}`;case"invalid_key":return`Ugyldig nøkkel i ${v.origin}`;case"invalid_union":return"Ugyldig input";case"invalid_element":return`Ugyldig verdi i ${v.origin}`;default:return"Ugyldig input"}}};function ZD(){return{localeError:ia()}}var Ra=()=>{let $={string:{unit:"harf",verb:"olmalıdır"},file:{unit:"bayt",verb:"olmalıdır"},array:{unit:"unsur",verb:"olmalıdır"},set:{unit:"unsur",verb:"olmalıdır"}};function X(v){return $[v]??null}let Q={regex:"giren",email:"epostagâh",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO hengâmı",date:"ISO tarihi",time:"ISO zamanı",duration:"ISO müddeti",ipv4:"IPv4 nişânı",ipv6:"IPv6 nişânı",cidrv4:"IPv4 menzili",cidrv6:"IPv6 menzili",base64:"base64-şifreli metin",base64url:"base64url-şifreli metin",json_string:"JSON metin",e164:"E.164 sayısı",jwt:"JWT",template_literal:"giren"},J={nan:"NaN",number:"numara",array:"saf",null:"gayb"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Fâsit giren: umulan instanceof ${v.expected}, alınan ${W}`;return`Fâsit giren: umulan ${Y}, alınan ${W}`}case"invalid_value":if(v.values.length===1)return`Fâsit giren: umulan ${P(v.values[0])}`;return`Fâsit tercih: mûteberler ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Fazla büyük: ${v.origin??"value"}, ${Y}${v.maximum.toString()} ${z.unit??"elements"} sahip olmalıydı.`;return`Fazla büyük: ${v.origin??"value"}, ${Y}${v.maximum.toString()} olmalıydı.`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Fazla küçük: ${v.origin}, ${Y}${v.minimum.toString()} ${z.unit} sahip olmalıydı.`;return`Fazla küçük: ${v.origin}, ${Y}${v.minimum.toString()} olmalıydı.`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Fâsit metin: "${Y.prefix}" ile başlamalı.`;if(Y.format==="ends_with")return`Fâsit metin: "${Y.suffix}" ile bitmeli.`;if(Y.format==="includes")return`Fâsit metin: "${Y.includes}" ihtivâ etmeli.`;if(Y.format==="regex")return`Fâsit metin: ${Y.pattern} nakşına uymalı.`;return`Fâsit ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Fâsit sayı: ${v.divisor} katı olmalıydı.`;case"unrecognized_keys":return`Tanınmayan anahtar ${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`${v.origin} için tanınmayan anahtar var.`;case"invalid_union":return"Giren tanınamadı.";case"invalid_element":return`${v.origin} için tanınmayan kıymet var.`;default:return"Kıymet tanınamadı."}}};function qD(){return{localeError:Ra()}}var Ea=()=>{let $={string:{unit:"توکي",verb:"ولري"},file:{unit:"بایټس",verb:"ولري"},array:{unit:"توکي",verb:"ولري"},set:{unit:"توکي",verb:"ولري"}};function X(v){return $[v]??null}let Q={regex:"ورودي",email:"بریښنالیک",url:"یو آر ال",emoji:"ایموجي",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"نیټه او وخت",date:"نېټه",time:"وخت",duration:"موده",ipv4:"د IPv4 پته",ipv6:"د IPv6 پته",cidrv4:"د IPv4 ساحه",cidrv6:"د IPv6 ساحه",base64:"base64-encoded متن",base64url:"base64url-encoded متن",json_string:"JSON متن",e164:"د E.164 شمېره",jwt:"JWT",template_literal:"ورودي"},J={nan:"NaN",number:"عدد",array:"ارې"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`ناسم ورودي: باید instanceof ${v.expected} وای, مګر ${W} ترلاسه شو`;return`ناسم ورودي: باید ${Y} وای, مګر ${W} ترلاسه شو`}case"invalid_value":if(v.values.length===1)return`ناسم ورودي: باید ${P(v.values[0])} وای`;return`ناسم انتخاب: باید یو له ${I(v.values,"|")} څخه وای`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`ډیر لوی: ${v.origin??"ارزښت"} باید ${Y}${v.maximum.toString()} ${z.unit??"عنصرونه"} ولري`;return`ډیر لوی: ${v.origin??"ارزښت"} باید ${Y}${v.maximum.toString()} وي`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`ډیر کوچنی: ${v.origin} باید ${Y}${v.minimum.toString()} ${z.unit} ولري`;return`ډیر کوچنی: ${v.origin} باید ${Y}${v.minimum.toString()} وي`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`ناسم متن: باید د "${Y.prefix}" سره پیل شي`;if(Y.format==="ends_with")return`ناسم متن: باید د "${Y.suffix}" سره پای ته ورسيږي`;if(Y.format==="includes")return`ناسم متن: باید "${Y.includes}" ولري`;if(Y.format==="regex")return`ناسم متن: باید د ${Y.pattern} سره مطابقت ولري`;return`${Q[Y.format]??v.format} ناسم دی`}case"not_multiple_of":return`ناسم عدد: باید د ${v.divisor} مضرب وي`;case"unrecognized_keys":return`ناسم ${v.keys.length>1?"کلیډونه":"کلیډ"}: ${I(v.keys,", ")}`;case"invalid_key":return`ناسم کلیډ په ${v.origin} کې`;case"invalid_union":return"ناسمه ورودي";case"invalid_element":return`ناسم عنصر په ${v.origin} کې`;default:return"ناسمه ورودي"}}};function ID(){return{localeError:Ea()}}var Pa=()=>{let $={string:{unit:"znaków",verb:"mieć"},file:{unit:"bajtów",verb:"mieć"},array:{unit:"elementów",verb:"mieć"},set:{unit:"elementów",verb:"mieć"}};function X(v){return $[v]??null}let Q={regex:"wyrażenie",email:"adres email",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"data i godzina w formacie ISO",date:"data w formacie ISO",time:"godzina w formacie ISO",duration:"czas trwania ISO",ipv4:"adres IPv4",ipv6:"adres IPv6",cidrv4:"zakres IPv4",cidrv6:"zakres IPv6",base64:"ciąg znaków zakodowany w formacie base64",base64url:"ciąg znaków zakodowany w formacie base64url",json_string:"ciąg znaków w formacie JSON",e164:"liczba E.164",jwt:"JWT",template_literal:"wejście"},J={nan:"NaN",number:"liczba",array:"tablica"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Nieprawidłowe dane wejściowe: oczekiwano instanceof ${v.expected}, otrzymano ${W}`;return`Nieprawidłowe dane wejściowe: oczekiwano ${Y}, otrzymano ${W}`}case"invalid_value":if(v.values.length===1)return`Nieprawidłowe dane wejściowe: oczekiwano ${P(v.values[0])}`;return`Nieprawidłowa opcja: oczekiwano jednej z wartości ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Za duża wartość: oczekiwano, że ${v.origin??"wartość"} będzie mieć ${Y}${v.maximum.toString()} ${z.unit??"elementów"}`;return`Zbyt duż(y/a/e): oczekiwano, że ${v.origin??"wartość"} będzie wynosić ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Za mała wartość: oczekiwano, że ${v.origin??"wartość"} będzie mieć ${Y}${v.minimum.toString()} ${z.unit??"elementów"}`;return`Zbyt mał(y/a/e): oczekiwano, że ${v.origin??"wartość"} będzie wynosić ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Nieprawidłowy ciąg znaków: musi zaczynać się od "${Y.prefix}"`;if(Y.format==="ends_with")return`Nieprawidłowy ciąg znaków: musi kończyć się na "${Y.suffix}"`;if(Y.format==="includes")return`Nieprawidłowy ciąg znaków: musi zawierać "${Y.includes}"`;if(Y.format==="regex")return`Nieprawidłowy ciąg znaków: musi odpowiadać wzorcowi ${Y.pattern}`;return`Nieprawidłow(y/a/e) ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Nieprawidłowa liczba: musi być wielokrotnością ${v.divisor}`;case"unrecognized_keys":return`Nierozpoznane klucze${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Nieprawidłowy klucz w ${v.origin}`;case"invalid_union":return"Nieprawidłowe dane wejściowe";case"invalid_element":return`Nieprawidłowa wartość w ${v.origin}`;default:return"Nieprawidłowe dane wejściowe"}}};function MD(){return{localeError:Pa()}}var Ta=()=>{let $={string:{unit:"caracteres",verb:"ter"},file:{unit:"bytes",verb:"ter"},array:{unit:"itens",verb:"ter"},set:{unit:"itens",verb:"ter"}};function X(v){return $[v]??null}let Q={regex:"padrão",email:"endereço de e-mail",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"data e hora ISO",date:"data ISO",time:"hora ISO",duration:"duração ISO",ipv4:"endereço IPv4",ipv6:"endereço IPv6",cidrv4:"faixa de IPv4",cidrv6:"faixa de IPv6",base64:"texto codificado em base64",base64url:"URL codificada em base64",json_string:"texto JSON",e164:"número E.164",jwt:"JWT",template_literal:"entrada"},J={nan:"NaN",number:"número",null:"nulo"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Tipo inválido: esperado instanceof ${v.expected}, recebido ${W}`;return`Tipo inválido: esperado ${Y}, recebido ${W}`}case"invalid_value":if(v.values.length===1)return`Entrada inválida: esperado ${P(v.values[0])}`;return`Opção inválida: esperada uma das ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Muito grande: esperado que ${v.origin??"valor"} tivesse ${Y}${v.maximum.toString()} ${z.unit??"elementos"}`;return`Muito grande: esperado que ${v.origin??"valor"} fosse ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Muito pequeno: esperado que ${v.origin} tivesse ${Y}${v.minimum.toString()} ${z.unit}`;return`Muito pequeno: esperado que ${v.origin} fosse ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Texto inválido: deve começar com "${Y.prefix}"`;if(Y.format==="ends_with")return`Texto inválido: deve terminar com "${Y.suffix}"`;if(Y.format==="includes")return`Texto inválido: deve incluir "${Y.includes}"`;if(Y.format==="regex")return`Texto inválido: deve corresponder ao padrão ${Y.pattern}`;return`${Q[Y.format]??v.format} inválido`}case"not_multiple_of":return`Número inválido: deve ser múltiplo de ${v.divisor}`;case"unrecognized_keys":return`Chave${v.keys.length>1?"s":""} desconhecida${v.keys.length>1?"s":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Chave inválida em ${v.origin}`;case"invalid_union":return"Entrada inválida";case"invalid_element":return`Valor inválido em ${v.origin}`;default:return"Campo inválido"}}};function xD(){return{localeError:Ta()}}function n2($,X,Q,J){let v=Math.abs($),Y=v%10,z=v%100;if(z>=11&&z<=19)return J;if(Y===1)return X;if(Y>=2&&Y<=4)return Q;return J}var Sa=()=>{let $={string:{unit:{one:"символ",few:"символа",many:"символов"},verb:"иметь"},file:{unit:{one:"байт",few:"байта",many:"байт"},verb:"иметь"},array:{unit:{one:"элемент",few:"элемента",many:"элементов"},verb:"иметь"},set:{unit:{one:"элемент",few:"элемента",many:"элементов"},verb:"иметь"}};function X(v){return $[v]??null}let Q={regex:"ввод",email:"email адрес",url:"URL",emoji:"эмодзи",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO дата и время",date:"ISO дата",time:"ISO время",duration:"ISO длительность",ipv4:"IPv4 адрес",ipv6:"IPv6 адрес",cidrv4:"IPv4 диапазон",cidrv6:"IPv6 диапазон",base64:"строка в формате base64",base64url:"строка в формате base64url",json_string:"JSON строка",e164:"номер E.164",jwt:"JWT",template_literal:"ввод"},J={nan:"NaN",number:"число",array:"массив"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Неверный ввод: ожидалось instanceof ${v.expected}, получено ${W}`;return`Неверный ввод: ожидалось ${Y}, получено ${W}`}case"invalid_value":if(v.values.length===1)return`Неверный ввод: ожидалось ${P(v.values[0])}`;return`Неверный вариант: ожидалось одно из ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z){let W=Number(v.maximum),G=n2(W,z.unit.one,z.unit.few,z.unit.many);return`Слишком большое значение: ожидалось, что ${v.origin??"значение"} будет иметь ${Y}${v.maximum.toString()} ${G}`}return`Слишком большое значение: ожидалось, что ${v.origin??"значение"} будет ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z){let W=Number(v.minimum),G=n2(W,z.unit.one,z.unit.few,z.unit.many);return`Слишком маленькое значение: ожидалось, что ${v.origin} будет иметь ${Y}${v.minimum.toString()} ${G}`}return`Слишком маленькое значение: ожидалось, что ${v.origin} будет ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Неверная строка: должна начинаться с "${Y.prefix}"`;if(Y.format==="ends_with")return`Неверная строка: должна заканчиваться на "${Y.suffix}"`;if(Y.format==="includes")return`Неверная строка: должна содержать "${Y.includes}"`;if(Y.format==="regex")return`Неверная строка: должна соответствовать шаблону ${Y.pattern}`;return`Неверный ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Неверное число: должно быть кратным ${v.divisor}`;case"unrecognized_keys":return`Нераспознанн${v.keys.length>1?"ые":"ый"} ключ${v.keys.length>1?"и":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Неверный ключ в ${v.origin}`;case"invalid_union":return"Неверные входные данные";case"invalid_element":return`Неверное значение в ${v.origin}`;default:return"Неверные входные данные"}}};function bD(){return{localeError:Sa()}}var Ca=()=>{let $={string:{unit:"znakov",verb:"imeti"},file:{unit:"bajtov",verb:"imeti"},array:{unit:"elementov",verb:"imeti"},set:{unit:"elementov",verb:"imeti"}};function X(v){return $[v]??null}let Q={regex:"vnos",email:"e-poštni naslov",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO datum in čas",date:"ISO datum",time:"ISO čas",duration:"ISO trajanje",ipv4:"IPv4 naslov",ipv6:"IPv6 naslov",cidrv4:"obseg IPv4",cidrv6:"obseg IPv6",base64:"base64 kodiran niz",base64url:"base64url kodiran niz",json_string:"JSON niz",e164:"E.164 številka",jwt:"JWT",template_literal:"vnos"},J={nan:"NaN",number:"število",array:"tabela"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Neveljaven vnos: pričakovano instanceof ${v.expected}, prejeto ${W}`;return`Neveljaven vnos: pričakovano ${Y}, prejeto ${W}`}case"invalid_value":if(v.values.length===1)return`Neveljaven vnos: pričakovano ${P(v.values[0])}`;return`Neveljavna možnost: pričakovano eno izmed ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Preveliko: pričakovano, da bo ${v.origin??"vrednost"} imelo ${Y}${v.maximum.toString()} ${z.unit??"elementov"}`;return`Preveliko: pričakovano, da bo ${v.origin??"vrednost"} ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Premajhno: pričakovano, da bo ${v.origin} imelo ${Y}${v.minimum.toString()} ${z.unit}`;return`Premajhno: pričakovano, da bo ${v.origin} ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Neveljaven niz: mora se začeti z "${Y.prefix}"`;if(Y.format==="ends_with")return`Neveljaven niz: mora se končati z "${Y.suffix}"`;if(Y.format==="includes")return`Neveljaven niz: mora vsebovati "${Y.includes}"`;if(Y.format==="regex")return`Neveljaven niz: mora ustrezati vzorcu ${Y.pattern}`;return`Neveljaven ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Neveljavno število: mora biti večkratnik ${v.divisor}`;case"unrecognized_keys":return`Neprepoznan${v.keys.length>1?"i ključi":" ključ"}: ${I(v.keys,", ")}`;case"invalid_key":return`Neveljaven ključ v ${v.origin}`;case"invalid_union":return"Neveljaven vnos";case"invalid_element":return`Neveljavna vrednost v ${v.origin}`;default:return"Neveljaven vnos"}}};function iD(){return{localeError:Ca()}}var ua=()=>{let $={string:{unit:"tecken",verb:"att ha"},file:{unit:"bytes",verb:"att ha"},array:{unit:"objekt",verb:"att innehålla"},set:{unit:"objekt",verb:"att innehålla"}};function X(v){return $[v]??null}let Q={regex:"reguljärt uttryck",email:"e-postadress",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO-datum och tid",date:"ISO-datum",time:"ISO-tid",duration:"ISO-varaktighet",ipv4:"IPv4-intervall",ipv6:"IPv6-intervall",cidrv4:"IPv4-spektrum",cidrv6:"IPv6-spektrum",base64:"base64-kodad sträng",base64url:"base64url-kodad sträng",json_string:"JSON-sträng",e164:"E.164-nummer",jwt:"JWT",template_literal:"mall-literal"},J={nan:"NaN",number:"antal",array:"lista"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Ogiltig inmatning: förväntat instanceof ${v.expected}, fick ${W}`;return`Ogiltig inmatning: förväntat ${Y}, fick ${W}`}case"invalid_value":if(v.values.length===1)return`Ogiltig inmatning: förväntat ${P(v.values[0])}`;return`Ogiltigt val: förväntade en av ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`För stor(t): förväntade ${v.origin??"värdet"} att ha ${Y}${v.maximum.toString()} ${z.unit??"element"}`;return`För stor(t): förväntat ${v.origin??"värdet"} att ha ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`För lite(t): förväntade ${v.origin??"värdet"} att ha ${Y}${v.minimum.toString()} ${z.unit}`;return`För lite(t): förväntade ${v.origin??"värdet"} att ha ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ogiltig sträng: måste börja med "${Y.prefix}"`;if(Y.format==="ends_with")return`Ogiltig sträng: måste sluta med "${Y.suffix}"`;if(Y.format==="includes")return`Ogiltig sträng: måste innehålla "${Y.includes}"`;if(Y.format==="regex")return`Ogiltig sträng: måste matcha mönstret "${Y.pattern}"`;return`Ogiltig(t) ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Ogiltigt tal: måste vara en multipel av ${v.divisor}`;case"unrecognized_keys":return`${v.keys.length>1?"Okända nycklar":"Okänd nyckel"}: ${I(v.keys,", ")}`;case"invalid_key":return`Ogiltig nyckel i ${v.origin??"värdet"}`;case"invalid_union":return"Ogiltig input";case"invalid_element":return`Ogiltigt värde i ${v.origin??"värdet"}`;default:return"Ogiltig input"}}};function RD(){return{localeError:ua()}}var _a=()=>{let $={string:{unit:"எழுத்துக்கள்",verb:"கொண்டிருக்க வேண்டும்"},file:{unit:"பைட்டுகள்",verb:"கொண்டிருக்க வேண்டும்"},array:{unit:"உறுப்புகள்",verb:"கொண்டிருக்க வேண்டும்"},set:{unit:"உறுப்புகள்",verb:"கொண்டிருக்க வேண்டும்"}};function X(v){return $[v]??null}let Q={regex:"உள்ளீடு",email:"மின்னஞ்சல் முகவரி",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO தேதி நேரம்",date:"ISO தேதி",time:"ISO நேரம்",duration:"ISO கால அளவு",ipv4:"IPv4 முகவரி",ipv6:"IPv6 முகவரி",cidrv4:"IPv4 வரம்பு",cidrv6:"IPv6 வரம்பு",base64:"base64-encoded சரம்",base64url:"base64url-encoded சரம்",json_string:"JSON சரம்",e164:"E.164 எண்",jwt:"JWT",template_literal:"input"},J={nan:"NaN",number:"எண்",array:"அணி",null:"வெறுமை"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`தவறான உள்ளீடு: எதிர்பார்க்கப்பட்டது instanceof ${v.expected}, பெறப்பட்டது ${W}`;return`தவறான உள்ளீடு: எதிர்பார்க்கப்பட்டது ${Y}, பெறப்பட்டது ${W}`}case"invalid_value":if(v.values.length===1)return`தவறான உள்ளீடு: எதிர்பார்க்கப்பட்டது ${P(v.values[0])}`;return`தவறான விருப்பம்: எதிர்பார்க்கப்பட்டது ${I(v.values,"|")} இல் ஒன்று`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`மிக பெரியது: எதிர்பார்க்கப்பட்டது ${v.origin??"மதிப்பு"} ${Y}${v.maximum.toString()} ${z.unit??"உறுப்புகள்"} ஆக இருக்க வேண்டும்`;return`மிக பெரியது: எதிர்பார்க்கப்பட்டது ${v.origin??"மதிப்பு"} ${Y}${v.maximum.toString()} ஆக இருக்க வேண்டும்`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`மிகச் சிறியது: எதிர்பார்க்கப்பட்டது ${v.origin} ${Y}${v.minimum.toString()} ${z.unit} ஆக இருக்க வேண்டும்`;return`மிகச் சிறியது: எதிர்பார்க்கப்பட்டது ${v.origin} ${Y}${v.minimum.toString()} ஆக இருக்க வேண்டும்`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`தவறான சரம்: "${Y.prefix}" இல் தொடங்க வேண்டும்`;if(Y.format==="ends_with")return`தவறான சரம்: "${Y.suffix}" இல் முடிவடைய வேண்டும்`;if(Y.format==="includes")return`தவறான சரம்: "${Y.includes}" ஐ உள்ளடக்க வேண்டும்`;if(Y.format==="regex")return`தவறான சரம்: ${Y.pattern} முறைபாட்டுடன் பொருந்த வேண்டும்`;return`தவறான ${Q[Y.format]??v.format}`}case"not_multiple_of":return`தவறான எண்: ${v.divisor} இன் பலமாக இருக்க வேண்டும்`;case"unrecognized_keys":return`அடையாளம் தெரியாத விசை${v.keys.length>1?"கள்":""}: ${I(v.keys,", ")}`;case"invalid_key":return`${v.origin} இல் தவறான விசை`;case"invalid_union":return"தவறான உள்ளீடு";case"invalid_element":return`${v.origin} இல் தவறான மதிப்பு`;default:return"தவறான உள்ளீடு"}}};function ED(){return{localeError:_a()}}var ya=()=>{let $={string:{unit:"ตัวอักษร",verb:"ควรมี"},file:{unit:"ไบต์",verb:"ควรมี"},array:{unit:"รายการ",verb:"ควรมี"},set:{unit:"รายการ",verb:"ควรมี"}};function X(v){return $[v]??null}let Q={regex:"ข้อมูลที่ป้อน",email:"ที่อยู่อีเมล",url:"URL",emoji:"อิโมจิ",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"วันที่เวลาแบบ ISO",date:"วันที่แบบ ISO",time:"เวลาแบบ ISO",duration:"ช่วงเวลาแบบ ISO",ipv4:"ที่อยู่ IPv4",ipv6:"ที่อยู่ IPv6",cidrv4:"ช่วง IP แบบ IPv4",cidrv6:"ช่วง IP แบบ IPv6",base64:"ข้อความแบบ Base64",base64url:"ข้อความแบบ Base64 สำหรับ URL",json_string:"ข้อความแบบ JSON",e164:"เบอร์โทรศัพท์ระหว่างประเทศ (E.164)",jwt:"โทเคน JWT",template_literal:"ข้อมูลที่ป้อน"},J={nan:"NaN",number:"ตัวเลข",array:"อาร์เรย์ (Array)",null:"ไม่มีค่า (null)"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`ประเภทข้อมูลไม่ถูกต้อง: ควรเป็น instanceof ${v.expected} แต่ได้รับ ${W}`;return`ประเภทข้อมูลไม่ถูกต้อง: ควรเป็น ${Y} แต่ได้รับ ${W}`}case"invalid_value":if(v.values.length===1)return`ค่าไม่ถูกต้อง: ควรเป็น ${P(v.values[0])}`;return`ตัวเลือกไม่ถูกต้อง: ควรเป็นหนึ่งใน ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"ไม่เกิน":"น้อยกว่า",z=X(v.origin);if(z)return`เกินกำหนด: ${v.origin??"ค่า"} ควรมี${Y} ${v.maximum.toString()} ${z.unit??"รายการ"}`;return`เกินกำหนด: ${v.origin??"ค่า"} ควรมี${Y} ${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?"อย่างน้อย":"มากกว่า",z=X(v.origin);if(z)return`น้อยกว่ากำหนด: ${v.origin} ควรมี${Y} ${v.minimum.toString()} ${z.unit}`;return`น้อยกว่ากำหนด: ${v.origin} ควรมี${Y} ${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`รูปแบบไม่ถูกต้อง: ข้อความต้องขึ้นต้นด้วย "${Y.prefix}"`;if(Y.format==="ends_with")return`รูปแบบไม่ถูกต้อง: ข้อความต้องลงท้ายด้วย "${Y.suffix}"`;if(Y.format==="includes")return`รูปแบบไม่ถูกต้อง: ข้อความต้องมี "${Y.includes}" อยู่ในข้อความ`;if(Y.format==="regex")return`รูปแบบไม่ถูกต้อง: ต้องตรงกับรูปแบบที่กำหนด ${Y.pattern}`;return`รูปแบบไม่ถูกต้อง: ${Q[Y.format]??v.format}`}case"not_multiple_of":return`ตัวเลขไม่ถูกต้อง: ต้องเป็นจำนวนที่หารด้วย ${v.divisor} ได้ลงตัว`;case"unrecognized_keys":return`พบคีย์ที่ไม่รู้จัก: ${I(v.keys,", ")}`;case"invalid_key":return`คีย์ไม่ถูกต้องใน ${v.origin}`;case"invalid_union":return"ข้อมูลไม่ถูกต้อง: ไม่ตรงกับรูปแบบยูเนียนที่กำหนดไว้";case"invalid_element":return`ข้อมูลไม่ถูกต้องใน ${v.origin}`;default:return"ข้อมูลไม่ถูกต้อง"}}};function PD(){return{localeError:ya()}}var fa=()=>{let $={string:{unit:"karakter",verb:"olmalı"},file:{unit:"bayt",verb:"olmalı"},array:{unit:"öğe",verb:"olmalı"},set:{unit:"öğe",verb:"olmalı"}};function X(v){return $[v]??null}let Q={regex:"girdi",email:"e-posta adresi",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO tarih ve saat",date:"ISO tarih",time:"ISO saat",duration:"ISO süre",ipv4:"IPv4 adresi",ipv6:"IPv6 adresi",cidrv4:"IPv4 aralığı",cidrv6:"IPv6 aralığı",base64:"base64 ile şifrelenmiş metin",base64url:"base64url ile şifrelenmiş metin",json_string:"JSON dizesi",e164:"E.164 sayısı",jwt:"JWT",template_literal:"Şablon dizesi"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Geçersiz değer: beklenen instanceof ${v.expected}, alınan ${W}`;return`Geçersiz değer: beklenen ${Y}, alınan ${W}`}case"invalid_value":if(v.values.length===1)return`Geçersiz değer: beklenen ${P(v.values[0])}`;return`Geçersiz seçenek: aşağıdakilerden biri olmalı: ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Çok büyük: beklenen ${v.origin??"değer"} ${Y}${v.maximum.toString()} ${z.unit??"öğe"}`;return`Çok büyük: beklenen ${v.origin??"değer"} ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Çok küçük: beklenen ${v.origin} ${Y}${v.minimum.toString()} ${z.unit}`;return`Çok küçük: beklenen ${v.origin} ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Geçersiz metin: "${Y.prefix}" ile başlamalı`;if(Y.format==="ends_with")return`Geçersiz metin: "${Y.suffix}" ile bitmeli`;if(Y.format==="includes")return`Geçersiz metin: "${Y.includes}" içermeli`;if(Y.format==="regex")return`Geçersiz metin: ${Y.pattern} desenine uymalı`;return`Geçersiz ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Geçersiz sayı: ${v.divisor} ile tam bölünebilmeli`;case"unrecognized_keys":return`Tanınmayan anahtar${v.keys.length>1?"lar":""}: ${I(v.keys,", ")}`;case"invalid_key":return`${v.origin} içinde geçersiz anahtar`;case"invalid_union":return"Geçersiz değer";case"invalid_element":return`${v.origin} içinde geçersiz değer`;default:return"Geçersiz değer"}}};function TD(){return{localeError:fa()}}var ka=()=>{let $={string:{unit:"символів",verb:"матиме"},file:{unit:"байтів",verb:"матиме"},array:{unit:"елементів",verb:"матиме"},set:{unit:"елементів",verb:"матиме"}};function X(v){return $[v]??null}let Q={regex:"вхідні дані",email:"адреса електронної пошти",url:"URL",emoji:"емодзі",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"дата та час ISO",date:"дата ISO",time:"час ISO",duration:"тривалість ISO",ipv4:"адреса IPv4",ipv6:"адреса IPv6",cidrv4:"діапазон IPv4",cidrv6:"діапазон IPv6",base64:"рядок у кодуванні base64",base64url:"рядок у кодуванні base64url",json_string:"рядок JSON",e164:"номер E.164",jwt:"JWT",template_literal:"вхідні дані"},J={nan:"NaN",number:"число",array:"масив"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Неправильні вхідні дані: очікується instanceof ${v.expected}, отримано ${W}`;return`Неправильні вхідні дані: очікується ${Y}, отримано ${W}`}case"invalid_value":if(v.values.length===1)return`Неправильні вхідні дані: очікується ${P(v.values[0])}`;return`Неправильна опція: очікується одне з ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Занадто велике: очікується, що ${v.origin??"значення"} ${z.verb} ${Y}${v.maximum.toString()} ${z.unit??"елементів"}`;return`Занадто велике: очікується, що ${v.origin??"значення"} буде ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Занадто мале: очікується, що ${v.origin} ${z.verb} ${Y}${v.minimum.toString()} ${z.unit}`;return`Занадто мале: очікується, що ${v.origin} буде ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Неправильний рядок: повинен починатися з "${Y.prefix}"`;if(Y.format==="ends_with")return`Неправильний рядок: повинен закінчуватися на "${Y.suffix}"`;if(Y.format==="includes")return`Неправильний рядок: повинен містити "${Y.includes}"`;if(Y.format==="regex")return`Неправильний рядок: повинен відповідати шаблону ${Y.pattern}`;return`Неправильний ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Неправильне число: повинно бути кратним ${v.divisor}`;case"unrecognized_keys":return`Нерозпізнаний ключ${v.keys.length>1?"і":""}: ${I(v.keys,", ")}`;case"invalid_key":return`Неправильний ключ у ${v.origin}`;case"invalid_union":return"Неправильні вхідні дані";case"invalid_element":return`Неправильне значення у ${v.origin}`;default:return"Неправильні вхідні дані"}}};function tJ(){return{localeError:ka()}}function SD(){return tJ()}var na=()=>{let $={string:{unit:"حروف",verb:"ہونا"},file:{unit:"بائٹس",verb:"ہونا"},array:{unit:"آئٹمز",verb:"ہونا"},set:{unit:"آئٹمز",verb:"ہونا"}};function X(v){return $[v]??null}let Q={regex:"ان پٹ",email:"ای میل ایڈریس",url:"یو آر ایل",emoji:"ایموجی",uuid:"یو یو آئی ڈی",uuidv4:"یو یو آئی ڈی وی 4",uuidv6:"یو یو آئی ڈی وی 6",nanoid:"نینو آئی ڈی",guid:"جی یو آئی ڈی",cuid:"سی یو آئی ڈی",cuid2:"سی یو آئی ڈی 2",ulid:"یو ایل آئی ڈی",xid:"ایکس آئی ڈی",ksuid:"کے ایس یو آئی ڈی",datetime:"آئی ایس او ڈیٹ ٹائم",date:"آئی ایس او تاریخ",time:"آئی ایس او وقت",duration:"آئی ایس او مدت",ipv4:"آئی پی وی 4 ایڈریس",ipv6:"آئی پی وی 6 ایڈریس",cidrv4:"آئی پی وی 4 رینج",cidrv6:"آئی پی وی 6 رینج",base64:"بیس 64 ان کوڈڈ سٹرنگ",base64url:"بیس 64 یو آر ایل ان کوڈڈ سٹرنگ",json_string:"جے ایس او این سٹرنگ",e164:"ای 164 نمبر",jwt:"جے ڈبلیو ٹی",template_literal:"ان پٹ"},J={nan:"NaN",number:"نمبر",array:"آرے",null:"نل"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`غلط ان پٹ: instanceof ${v.expected} متوقع تھا، ${W} موصول ہوا`;return`غلط ان پٹ: ${Y} متوقع تھا، ${W} موصول ہوا`}case"invalid_value":if(v.values.length===1)return`غلط ان پٹ: ${P(v.values[0])} متوقع تھا`;return`غلط آپشن: ${I(v.values,"|")} میں سے ایک متوقع تھا`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`بہت بڑا: ${v.origin??"ویلیو"} کے ${Y}${v.maximum.toString()} ${z.unit??"عناصر"} ہونے متوقع تھے`;return`بہت بڑا: ${v.origin??"ویلیو"} کا ${Y}${v.maximum.toString()} ہونا متوقع تھا`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`بہت چھوٹا: ${v.origin} کے ${Y}${v.minimum.toString()} ${z.unit} ہونے متوقع تھے`;return`بہت چھوٹا: ${v.origin} کا ${Y}${v.minimum.toString()} ہونا متوقع تھا`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`غلط سٹرنگ: "${Y.prefix}" سے شروع ہونا چاہیے`;if(Y.format==="ends_with")return`غلط سٹرنگ: "${Y.suffix}" پر ختم ہونا چاہیے`;if(Y.format==="includes")return`غلط سٹرنگ: "${Y.includes}" شامل ہونا چاہیے`;if(Y.format==="regex")return`غلط سٹرنگ: پیٹرن ${Y.pattern} سے میچ ہونا چاہیے`;return`غلط ${Q[Y.format]??v.format}`}case"not_multiple_of":return`غلط نمبر: ${v.divisor} کا مضاعف ہونا چاہیے`;case"unrecognized_keys":return`غیر تسلیم شدہ کی${v.keys.length>1?"ز":""}: ${I(v.keys,"، ")}`;case"invalid_key":return`${v.origin} میں غلط کی`;case"invalid_union":return"غلط ان پٹ";case"invalid_element":return`${v.origin} میں غلط ویلیو`;default:return"غلط ان پٹ"}}};function CD(){return{localeError:na()}}var ga=()=>{let $={string:{unit:"belgi",verb:"bo‘lishi kerak"},file:{unit:"bayt",verb:"bo‘lishi kerak"},array:{unit:"element",verb:"bo‘lishi kerak"},set:{unit:"element",verb:"bo‘lishi kerak"}};function X(v){return $[v]??null}let Q={regex:"kirish",email:"elektron pochta manzili",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO sana va vaqti",date:"ISO sana",time:"ISO vaqt",duration:"ISO davomiylik",ipv4:"IPv4 manzil",ipv6:"IPv6 manzil",mac:"MAC manzil",cidrv4:"IPv4 diapazon",cidrv6:"IPv6 diapazon",base64:"base64 kodlangan satr",base64url:"base64url kodlangan satr",json_string:"JSON satr",e164:"E.164 raqam",jwt:"JWT",template_literal:"kirish"},J={nan:"NaN",number:"raqam",array:"massiv"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Noto‘g‘ri kirish: kutilgan instanceof ${v.expected}, qabul qilingan ${W}`;return`Noto‘g‘ri kirish: kutilgan ${Y}, qabul qilingan ${W}`}case"invalid_value":if(v.values.length===1)return`Noto‘g‘ri kirish: kutilgan ${P(v.values[0])}`;return`Noto‘g‘ri variant: quyidagilardan biri kutilgan ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Juda katta: kutilgan ${v.origin??"qiymat"} ${Y}${v.maximum.toString()} ${z.unit} ${z.verb}`;return`Juda katta: kutilgan ${v.origin??"qiymat"} ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Juda kichik: kutilgan ${v.origin} ${Y}${v.minimum.toString()} ${z.unit} ${z.verb}`;return`Juda kichik: kutilgan ${v.origin} ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Noto‘g‘ri satr: "${Y.prefix}" bilan boshlanishi kerak`;if(Y.format==="ends_with")return`Noto‘g‘ri satr: "${Y.suffix}" bilan tugashi kerak`;if(Y.format==="includes")return`Noto‘g‘ri satr: "${Y.includes}" ni o‘z ichiga olishi kerak`;if(Y.format==="regex")return`Noto‘g‘ri satr: ${Y.pattern} shabloniga mos kelishi kerak`;return`Noto‘g‘ri ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Noto‘g‘ri raqam: ${v.divisor} ning karralisi bo‘lishi kerak`;case"unrecognized_keys":return`Noma’lum kalit${v.keys.length>1?"lar":""}: ${I(v.keys,", ")}`;case"invalid_key":return`${v.origin} dagi kalit noto‘g‘ri`;case"invalid_union":return"Noto‘g‘ri kirish";case"invalid_element":return`${v.origin} da noto‘g‘ri qiymat`;default:return"Noto‘g‘ri kirish"}}};function uD(){return{localeError:ga()}}var ca=()=>{let $={string:{unit:"ký tự",verb:"có"},file:{unit:"byte",verb:"có"},array:{unit:"phần tử",verb:"có"},set:{unit:"phần tử",verb:"có"}};function X(v){return $[v]??null}let Q={regex:"đầu vào",email:"địa chỉ email",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ngày giờ ISO",date:"ngày ISO",time:"giờ ISO",duration:"khoảng thời gian ISO",ipv4:"địa chỉ IPv4",ipv6:"địa chỉ IPv6",cidrv4:"dải IPv4",cidrv6:"dải IPv6",base64:"chuỗi mã hóa base64",base64url:"chuỗi mã hóa base64url",json_string:"chuỗi JSON",e164:"số E.164",jwt:"JWT",template_literal:"đầu vào"},J={nan:"NaN",number:"số",array:"mảng"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Đầu vào không hợp lệ: mong đợi instanceof ${v.expected}, nhận được ${W}`;return`Đầu vào không hợp lệ: mong đợi ${Y}, nhận được ${W}`}case"invalid_value":if(v.values.length===1)return`Đầu vào không hợp lệ: mong đợi ${P(v.values[0])}`;return`Tùy chọn không hợp lệ: mong đợi một trong các giá trị ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Quá lớn: mong đợi ${v.origin??"giá trị"} ${z.verb} ${Y}${v.maximum.toString()} ${z.unit??"phần tử"}`;return`Quá lớn: mong đợi ${v.origin??"giá trị"} ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Quá nhỏ: mong đợi ${v.origin} ${z.verb} ${Y}${v.minimum.toString()} ${z.unit}`;return`Quá nhỏ: mong đợi ${v.origin} ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Chuỗi không hợp lệ: phải bắt đầu bằng "${Y.prefix}"`;if(Y.format==="ends_with")return`Chuỗi không hợp lệ: phải kết thúc bằng "${Y.suffix}"`;if(Y.format==="includes")return`Chuỗi không hợp lệ: phải bao gồm "${Y.includes}"`;if(Y.format==="regex")return`Chuỗi không hợp lệ: phải khớp với mẫu ${Y.pattern}`;return`${Q[Y.format]??v.format} không hợp lệ`}case"not_multiple_of":return`Số không hợp lệ: phải là bội số của ${v.divisor}`;case"unrecognized_keys":return`Khóa không được nhận dạng: ${I(v.keys,", ")}`;case"invalid_key":return`Khóa không hợp lệ trong ${v.origin}`;case"invalid_union":return"Đầu vào không hợp lệ";case"invalid_element":return`Giá trị không hợp lệ trong ${v.origin}`;default:return"Đầu vào không hợp lệ"}}};function _D(){return{localeError:ca()}}var ma=()=>{let $={string:{unit:"字符",verb:"包含"},file:{unit:"字节",verb:"包含"},array:{unit:"项",verb:"包含"},set:{unit:"项",verb:"包含"}};function X(v){return $[v]??null}let Q={regex:"输入",email:"电子邮件",url:"URL",emoji:"表情符号",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO日期时间",date:"ISO日期",time:"ISO时间",duration:"ISO时长",ipv4:"IPv4地址",ipv6:"IPv6地址",cidrv4:"IPv4网段",cidrv6:"IPv6网段",base64:"base64编码字符串",base64url:"base64url编码字符串",json_string:"JSON字符串",e164:"E.164号码",jwt:"JWT",template_literal:"输入"},J={nan:"NaN",number:"数字",array:"数组",null:"空值(null)"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`无效输入:期望 instanceof ${v.expected},实际接收 ${W}`;return`无效输入:期望 ${Y},实际接收 ${W}`}case"invalid_value":if(v.values.length===1)return`无效输入:期望 ${P(v.values[0])}`;return`无效选项:期望以下之一 ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`数值过大:期望 ${v.origin??"值"} ${Y}${v.maximum.toString()} ${z.unit??"个元素"}`;return`数值过大:期望 ${v.origin??"值"} ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`数值过小:期望 ${v.origin} ${Y}${v.minimum.toString()} ${z.unit}`;return`数值过小:期望 ${v.origin} ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`无效字符串:必须以 "${Y.prefix}" 开头`;if(Y.format==="ends_with")return`无效字符串:必须以 "${Y.suffix}" 结尾`;if(Y.format==="includes")return`无效字符串:必须包含 "${Y.includes}"`;if(Y.format==="regex")return`无效字符串:必须满足正则表达式 ${Y.pattern}`;return`无效${Q[Y.format]??v.format}`}case"not_multiple_of":return`无效数字:必须是 ${v.divisor} 的倍数`;case"unrecognized_keys":return`出现未知的键(key): ${I(v.keys,", ")}`;case"invalid_key":return`${v.origin} 中的键(key)无效`;case"invalid_union":return"无效输入";case"invalid_element":return`${v.origin} 中包含无效值(value)`;default:return"无效输入"}}};function yD(){return{localeError:ma()}}var ha=()=>{let $={string:{unit:"字元",verb:"擁有"},file:{unit:"位元組",verb:"擁有"},array:{unit:"項目",verb:"擁有"},set:{unit:"項目",verb:"擁有"}};function X(v){return $[v]??null}let Q={regex:"輸入",email:"郵件地址",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"ISO 日期時間",date:"ISO 日期",time:"ISO 時間",duration:"ISO 期間",ipv4:"IPv4 位址",ipv6:"IPv6 位址",cidrv4:"IPv4 範圍",cidrv6:"IPv6 範圍",base64:"base64 編碼字串",base64url:"base64url 編碼字串",json_string:"JSON 字串",e164:"E.164 數值",jwt:"JWT",template_literal:"輸入"},J={nan:"NaN"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`無效的輸入值:預期為 instanceof ${v.expected},但收到 ${W}`;return`無效的輸入值:預期為 ${Y},但收到 ${W}`}case"invalid_value":if(v.values.length===1)return`無效的輸入值:預期為 ${P(v.values[0])}`;return`無效的選項:預期為以下其中之一 ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`數值過大:預期 ${v.origin??"值"} 應為 ${Y}${v.maximum.toString()} ${z.unit??"個元素"}`;return`數值過大:預期 ${v.origin??"值"} 應為 ${Y}${v.maximum.toString()}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`數值過小:預期 ${v.origin} 應為 ${Y}${v.minimum.toString()} ${z.unit}`;return`數值過小:預期 ${v.origin} 應為 ${Y}${v.minimum.toString()}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`無效的字串:必須以 "${Y.prefix}" 開頭`;if(Y.format==="ends_with")return`無效的字串:必須以 "${Y.suffix}" 結尾`;if(Y.format==="includes")return`無效的字串:必須包含 "${Y.includes}"`;if(Y.format==="regex")return`無效的字串:必須符合格式 ${Y.pattern}`;return`無效的 ${Q[Y.format]??v.format}`}case"not_multiple_of":return`無效的數字:必須為 ${v.divisor} 的倍數`;case"unrecognized_keys":return`無法識別的鍵值${v.keys.length>1?"們":""}:${I(v.keys,"、")}`;case"invalid_key":return`${v.origin} 中有無效的鍵值`;case"invalid_union":return"無效的輸入值";case"invalid_element":return`${v.origin} 中有無效的值`;default:return"無效的輸入值"}}};function fD(){return{localeError:ha()}}var la=()=>{let $={string:{unit:"àmi",verb:"ní"},file:{unit:"bytes",verb:"ní"},array:{unit:"nkan",verb:"ní"},set:{unit:"nkan",verb:"ní"}};function X(v){return $[v]??null}let Q={regex:"ẹ̀rọ ìbáwọlé",email:"àdírẹ́sì ìmẹ́lì",url:"URL",emoji:"emoji",uuid:"UUID",uuidv4:"UUIDv4",uuidv6:"UUIDv6",nanoid:"nanoid",guid:"GUID",cuid:"cuid",cuid2:"cuid2",ulid:"ULID",xid:"XID",ksuid:"KSUID",datetime:"àkókò ISO",date:"ọjọ́ ISO",time:"àkókò ISO",duration:"àkókò tó pé ISO",ipv4:"àdírẹ́sì IPv4",ipv6:"àdírẹ́sì IPv6",cidrv4:"àgbègbè IPv4",cidrv6:"àgbègbè IPv6",base64:"ọ̀rọ̀ tí a kọ́ ní base64",base64url:"ọ̀rọ̀ base64url",json_string:"ọ̀rọ̀ JSON",e164:"nọ́mbà E.164",jwt:"JWT",template_literal:"ẹ̀rọ ìbáwọlé"},J={nan:"NaN",number:"nọ́mbà",array:"akopọ"};return(v)=>{switch(v.code){case"invalid_type":{let Y=J[v.expected]??v.expected,z=S(v.input),W=J[z]??z;if(/^[A-Z]/.test(v.expected))return`Ìbáwọlé aṣìṣe: a ní láti fi instanceof ${v.expected}, àmọ̀ a rí ${W}`;return`Ìbáwọlé aṣìṣe: a ní láti fi ${Y}, àmọ̀ a rí ${W}`}case"invalid_value":if(v.values.length===1)return`Ìbáwọlé aṣìṣe: a ní láti fi ${P(v.values[0])}`;return`Àṣàyàn aṣìṣe: yan ọ̀kan lára ${I(v.values,"|")}`;case"too_big":{let Y=v.inclusive?"<=":"<",z=X(v.origin);if(z)return`Tó pọ̀ jù: a ní láti jẹ́ pé ${v.origin??"iye"} ${z.verb} ${Y}${v.maximum} ${z.unit}`;return`Tó pọ̀ jù: a ní láti jẹ́ ${Y}${v.maximum}`}case"too_small":{let Y=v.inclusive?">=":">",z=X(v.origin);if(z)return`Kéré ju: a ní láti jẹ́ pé ${v.origin} ${z.verb} ${Y}${v.minimum} ${z.unit}`;return`Kéré ju: a ní láti jẹ́ ${Y}${v.minimum}`}case"invalid_format":{let Y=v;if(Y.format==="starts_with")return`Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ bẹ̀rẹ̀ pẹ̀lú "${Y.prefix}"`;if(Y.format==="ends_with")return`Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ parí pẹ̀lú "${Y.suffix}"`;if(Y.format==="includes")return`Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ ní "${Y.includes}"`;if(Y.format==="regex")return`Ọ̀rọ̀ aṣìṣe: gbọ́dọ̀ bá àpẹẹrẹ mu ${Y.pattern}`;return`Aṣìṣe: ${Q[Y.format]??v.format}`}case"not_multiple_of":return`Nọ́mbà aṣìṣe: gbọ́dọ̀ jẹ́ èyà pípín ti ${v.divisor}`;case"unrecognized_keys":return`Bọtìnì àìmọ̀: ${I(v.keys,", ")}`;case"invalid_key":return`Bọtìnì aṣìṣe nínú ${v.origin}`;case"invalid_union":return"Ìbáwọlé aṣìṣe";case"invalid_element":return`Iye aṣìṣe nínú ${v.origin}`;default:return"Ìbáwọlé aṣìṣe"}}};function kD(){return{localeError:la()}}var g2,nD=Symbol("ZodOutput"),gD=Symbol("ZodInput");class cD{constructor(){this._map=new WeakMap,this._idmap=new Map}add($,...X){let Q=X[0];if(this._map.set($,Q),Q&&typeof Q==="object"&&"id"in Q)this._idmap.set(Q.id,$);return this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove($){let X=this._map.get($);if(X&&typeof X==="object"&&"id"in X)this._idmap.delete(X.id);return this._map.delete($),this}get($){let X=$._zod.parent;if(X){let Q={...this.get(X)??{}};delete Q.id;let J={...Q,...this._map.get($)};return Object.keys(J).length?J:void 0}return this._map.get($)}has($){return this._map.has($)}}function kW(){return new cD}(g2=globalThis).__zod_globalRegistry??(g2.__zod_globalRegistry=kW());var h4=globalThis.__zod_globalRegistry;function nW($,X){return new $({type:"string",...u(X)})}function mD($,X){return new $({type:"string",coerce:!0,...u(X)})}function sJ($,X){return new $({type:"string",format:"email",check:"string_format",abort:!1,...u(X)})}function $Q($,X){return new $({type:"string",format:"guid",check:"string_format",abort:!1,...u(X)})}function eJ($,X){return new $({type:"string",format:"uuid",check:"string_format",abort:!1,...u(X)})}function $Y($,X){return new $({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v4",...u(X)})}function XY($,X){return new $({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v6",...u(X)})}function QY($,X){return new $({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v7",...u(X)})}function XQ($,X){return new $({type:"string",format:"url",check:"string_format",abort:!1,...u(X)})}function vY($,X){return new $({type:"string",format:"emoji",check:"string_format",abort:!1,...u(X)})}function JY($,X){return new $({type:"string",format:"nanoid",check:"string_format",abort:!1,...u(X)})}function YY($,X){return new $({type:"string",format:"cuid",check:"string_format",abort:!1,...u(X)})}function zY($,X){return new $({type:"string",format:"cuid2",check:"string_format",abort:!1,...u(X)})}function WY($,X){return new $({type:"string",format:"ulid",check:"string_format",abort:!1,...u(X)})}function GY($,X){return new $({type:"string",format:"xid",check:"string_format",abort:!1,...u(X)})}function UY($,X){return new $({type:"string",format:"ksuid",check:"string_format",abort:!1,...u(X)})}function KY($,X){return new $({type:"string",format:"ipv4",check:"string_format",abort:!1,...u(X)})}function wY($,X){return new $({type:"string",format:"ipv6",check:"string_format",abort:!1,...u(X)})}function gW($,X){return new $({type:"string",format:"mac",check:"string_format",abort:!1,...u(X)})}function HY($,X){return new $({type:"string",format:"cidrv4",check:"string_format",abort:!1,...u(X)})}function jY($,X){return new $({type:"string",format:"cidrv6",check:"string_format",abort:!1,...u(X)})}function VY($,X){return new $({type:"string",format:"base64",check:"string_format",abort:!1,...u(X)})}function BY($,X){return new $({type:"string",format:"base64url",check:"string_format",abort:!1,...u(X)})}function DY($,X){return new $({type:"string",format:"e164",check:"string_format",abort:!1,...u(X)})}function FY($,X){return new $({type:"string",format:"jwt",check:"string_format",abort:!1,...u(X)})}var hD={Any:null,Minute:-1,Second:0,Millisecond:3,Microsecond:6};function lD($,X){return new $({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...u(X)})}function pD($,X){return new $({type:"string",format:"date",check:"string_format",...u(X)})}function dD($,X){return new $({type:"string",format:"time",check:"string_format",precision:null,...u(X)})}function oD($,X){return new $({type:"string",format:"duration",check:"string_format",...u(X)})}function cW($,X){return new $({type:"number",checks:[],...u(X)})}function aD($,X){return new $({type:"number",coerce:!0,checks:[],...u(X)})}function mW($,X){return new $({type:"number",check:"number_format",abort:!1,format:"safeint",...u(X)})}function hW($,X){return new $({type:"number",check:"number_format",abort:!1,format:"float32",...u(X)})}function lW($,X){return new $({type:"number",check:"number_format",abort:!1,format:"float64",...u(X)})}function pW($,X){return new $({type:"number",check:"number_format",abort:!1,format:"int32",...u(X)})}function dW($,X){return new $({type:"number",check:"number_format",abort:!1,format:"uint32",...u(X)})}function oW($,X){return new $({type:"boolean",...u(X)})}function rD($,X){return new $({type:"boolean",coerce:!0,...u(X)})}function aW($,X){return new $({type:"bigint",...u(X)})}function tD($,X){return new $({type:"bigint",coerce:!0,...u(X)})}function rW($,X){return new $({type:"bigint",check:"bigint_format",abort:!1,format:"int64",...u(X)})}function tW($,X){return new $({type:"bigint",check:"bigint_format",abort:!1,format:"uint64",...u(X)})}function sW($,X){return new $({type:"symbol",...u(X)})}function eW($,X){return new $({type:"undefined",...u(X)})}function $G($,X){return new $({type:"null",...u(X)})}function XG($){return new $({type:"any"})}function QG($){return new $({type:"unknown"})}function vG($,X){return new $({type:"never",...u(X)})}function JG($,X){return new $({type:"void",...u(X)})}function YG($,X){return new $({type:"date",...u(X)})}function sD($,X){return new $({type:"date",coerce:!0,...u(X)})}function zG($,X){return new $({type:"nan",...u(X)})}function D0($,X){return new b5({check:"less_than",...u(X),value:$,inclusive:!1})}function s6($,X){return new b5({check:"less_than",...u(X),value:$,inclusive:!0})}function F0($,X){return new i5({check:"greater_than",...u(X),value:$,inclusive:!1})}function D6($,X){return new i5({check:"greater_than",...u(X),value:$,inclusive:!0})}function WG($){return F0(0,$)}function GG($){return D0(0,$)}function UG($){return s6(0,$)}function KG($){return D6(0,$)}function C8($,X){return new ZB({check:"multiple_of",...u(X),value:$})}function u8($,X){return new MB({check:"max_size",...u(X),maximum:$})}function N0($,X){return new xB({check:"min_size",...u(X),minimum:$})}function n9($,X){return new bB({check:"size_equals",...u(X),size:$})}function g9($,X){return new iB({check:"max_length",...u(X),maximum:$})}function t0($,X){return new RB({check:"min_length",...u(X),minimum:$})}function c9($,X){return new EB({check:"length_equals",...u(X),length:$})}function QQ($,X){return new PB({check:"string_format",format:"regex",...u(X),pattern:$})}function vQ($){return new TB({check:"string_format",format:"lowercase",...u($)})}function JQ($){return new SB({check:"string_format",format:"uppercase",...u($)})}function YQ($,X){return new CB({check:"string_format",format:"includes",...u(X),includes:$})}function zQ($,X){return new uB({check:"string_format",format:"starts_with",...u(X),prefix:$})}function WQ($,X){return new _B({check:"string_format",format:"ends_with",...u(X),suffix:$})}function wG($,X,Q){return new yB({check:"property",property:$,schema:X,...u(Q)})}function GQ($,X){return new fB({check:"mime_type",mime:$,...u(X)})}function f1($){return new kB({check:"overwrite",tx:$})}function UQ($){return f1((X)=>X.normalize($))}function KQ(){return f1(($)=>$.trim())}function wQ(){return f1(($)=>$.toLowerCase())}function HQ(){return f1(($)=>$.toUpperCase())}function jQ(){return f1(($)=>nV($))}function eD($,X,Q){return new $({type:"array",element:X,...u(Q)})}function da($,X,Q){return new $({type:"union",options:X,...u(Q)})}function oa($,X,Q){return new $({type:"union",options:X,inclusive:!1,...u(Q)})}function aa($,X,Q,J){return new $({type:"union",options:Q,discriminator:X,...u(J)})}function ra($,X,Q){return new $({type:"intersection",left:X,right:Q})}function ta($,X,Q,J){let v=Q instanceof J$;return new $({type:"tuple",items:X,rest:v?Q:null,...u(v?J:Q)})}function sa($,X,Q,J){return new $({type:"record",keyType:X,valueType:Q,...u(J)})}function ea($,X,Q,J){return new $({type:"map",keyType:X,valueType:Q,...u(J)})}function $r($,X,Q){return new $({type:"set",valueType:X,...u(Q)})}function Xr($,X,Q){let J=Array.isArray(X)?Object.fromEntries(X.map((v)=>[v,v])):X;return new $({type:"enum",entries:J,...u(Q)})}function Qr($,X,Q){return new $({type:"enum",entries:X,...u(Q)})}function vr($,X,Q){return new $({type:"literal",values:Array.isArray(X)?X:[X],...u(Q)})}function HG($,X){return new $({type:"file",...u(X)})}function Jr($,X){return new $({type:"transform",transform:X})}function Yr($,X){return new $({type:"optional",innerType:X})}function zr($,X){return new $({type:"nullable",innerType:X})}function Wr($,X,Q){return new $({type:"default",innerType:X,get defaultValue(){return typeof Q==="function"?Q():F5(Q)}})}function Gr($,X,Q){return new $({type:"nonoptional",innerType:X,...u(Q)})}function Ur($,X){return new $({type:"success",innerType:X})}function Kr($,X,Q){return new $({type:"catch",innerType:X,catchValue:typeof Q==="function"?Q:()=>Q})}function wr($,X,Q){return new $({type:"pipe",in:X,out:Q})}function Hr($,X){return new $({type:"readonly",innerType:X})}function jr($,X,Q){return new $({type:"template_literal",parts:X,...u(Q)})}function Vr($,X){return new $({type:"lazy",getter:X})}function Br($,X){return new $({type:"promise",innerType:X})}function jG($,X,Q){let J=u(Q);return J.abort??(J.abort=!0),new $({type:"custom",check:"custom",fn:X,...J})}function VG($,X,Q){return new $({type:"custom",check:"custom",fn:X,...u(Q)})}function BG($){let X=c2((Q)=>{return Q.addIssue=(J)=>{if(typeof J==="string")Q.issues.push(mX(J,Q.value,X._zod.def));else{let v=J;if(v.fatal)v.continue=!1;v.code??(v.code="custom"),v.input??(v.input=Q.value),v.inst??(v.inst=X),v.continue??(v.continue=!X._zod.def.abort),Q.issues.push(mX(v))}},$(Q.value,Q)});return X}function c2($,X){let Q=new _$({check:"custom",...u(X)});return Q._zod.check=$,Q}function DG($){let X=new _$({check:"describe"});return X._zod.onattach=[(Q)=>{let J=h4.get(Q)??{};h4.add(Q,{...J,description:$})}],X._zod.check=()=>{},X}function FG($){let X=new _$({check:"meta"});return X._zod.onattach=[(Q)=>{let J=h4.get(Q)??{};h4.add(Q,{...J,...$})}],X._zod.check=()=>{},X}function NG($,X){let Q=u(X),J=Q.truthy??["true","1","yes","on","y","enabled"],v=Q.falsy??["false","0","no","off","n","disabled"];if(Q.case!=="sensitive")J=J.map((V)=>typeof V==="string"?V.toLowerCase():V),v=v.map((V)=>typeof V==="string"?V.toLowerCase():V);let Y=new Set(J),z=new Set(v),W=$.Codec??tX,G=$.Boolean??aX,w=new($.String??S8)({type:"string",error:Q.error}),H=new G({type:"boolean",error:Q.error}),j=new W({type:"pipe",in:w,out:H,transform:(V,B)=>{let D=V;if(Q.case!=="sensitive")D=D.toLowerCase();if(Y.has(D))return!0;else if(z.has(D))return!1;else return B.issues.push({code:"invalid_value",expected:"stringbool",values:[...Y,...z],input:B.value,inst:j,continue:!1}),{}},reverseTransform:(V,B)=>{if(V===!0)return J[0]||"true";else return v[0]||"false"},error:Q.error});return j}function m9($,X,Q,J={}){let v=u(J),Y={...u(J),check:"string_format",type:"string",format:X,fn:typeof Q==="function"?Q:(W)=>Q.test(W),...v};if(Q instanceof RegExp)Y.pattern=Q;return new $(Y)}function _8($){let X=$?.target??"draft-2020-12";if(X==="draft-4")X="draft-04";if(X==="draft-7")X="draft-07";return{processors:$.processors??{},metadataRegistry:$?.metadata??h4,target:X,unrepresentable:$?.unrepresentable??"throw",override:$?.override??(()=>{}),io:$?.io??"output",counter:0,seen:new Map,cycles:$?.cycles??"ref",reused:$?.reused??"inline",external:$?.external??void 0}}function T$($,X,Q={path:[],schemaPath:[]}){var J;let v=$._zod.def,Y=X.seen.get($);if(Y){if(Y.count++,Q.schemaPath.includes($))Y.cycle=Q.path;return Y.schema}let z={schema:{},count:1,cycle:void 0,path:Q.path};X.seen.set($,z);let W=$._zod.toJSONSchema?.();if(W)z.schema=W;else{let w={...Q,schemaPath:[...Q.schemaPath,$],path:Q.path};if($._zod.processJSONSchema)$._zod.processJSONSchema(X,z.schema,w);else{let j=z.schema,V=X.processors[v.type];if(!V)throw Error(`[toJSONSchema]: Non-representable type encountered: ${v.type}`);V($,X,j,w)}let H=$._zod.parent;if(H){if(!z.ref)z.ref=H;T$(H,X,w),X.seen.get(H).isParent=!0}}let G=X.metadataRegistry.get($);if(G)Object.assign(z.schema,G);if(X.io==="input"&&F6($))delete z.schema.examples,delete z.schema.default;if(X.io==="input"&&z.schema._prefault)(J=z.schema).default??(J.default=z.schema._prefault);return delete z.schema._prefault,X.seen.get($).schema}function y8($,X){let Q=$.seen.get(X);if(!Q)throw Error("Unprocessed schema. This is a bug in Zod.");let J=new Map;for(let z of $.seen.entries()){let W=$.metadataRegistry.get(z[0])?.id;if(W){let G=J.get(W);if(G&&G!==z[0])throw Error(`Duplicate schema id "${W}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`);J.set(W,z[0])}}let v=(z)=>{let W=$.target==="draft-2020-12"?"$defs":"definitions";if($.external){let H=$.external.registry.get(z[0])?.id,j=$.external.uri??((B)=>B);if(H)return{ref:j(H)};let V=z[1].defId??z[1].schema.id??`schema${$.counter++}`;return z[1].defId=V,{defId:V,ref:`${j("__shared")}#/${W}/${V}`}}if(z[1]===Q)return{ref:"#"};let U=`${"#"}/${W}/`,w=z[1].schema.id??`__schema${$.counter++}`;return{defId:w,ref:U+w}},Y=(z)=>{if(z[1].schema.$ref)return;let W=z[1],{ref:G,defId:U}=v(z);if(W.def={...W.schema},U)W.defId=U;let w=W.schema;for(let H in w)delete w[H];w.$ref=G};if($.cycles==="throw")for(let z of $.seen.entries()){let W=z[1];if(W.cycle)throw Error(`Cycle detected: #/${W.cycle?.join("/")}/ + +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(let z of $.seen.entries()){let W=z[1];if(X===z[0]){Y(z);continue}if($.external){let U=$.external.registry.get(z[0])?.id;if(X!==z[0]&&U){Y(z);continue}}if($.metadataRegistry.get(z[0])?.id){Y(z);continue}if(W.cycle){Y(z);continue}if(W.count>1){if($.reused==="ref"){Y(z);continue}}}}function f8($,X){let Q=$.seen.get(X);if(!Q)throw Error("Unprocessed schema. This is a bug in Zod.");let J=(z)=>{let W=$.seen.get(z);if(W.ref===null)return;let G=W.def??W.schema,U={...G},w=W.ref;if(W.ref=null,w){J(w);let j=$.seen.get(w),V=j.schema;if(V.$ref&&($.target==="draft-07"||$.target==="draft-04"||$.target==="openapi-3.0"))G.allOf=G.allOf??[],G.allOf.push(V);else Object.assign(G,V);if(Object.assign(G,U),z._zod.parent===w)for(let D in G){if(D==="$ref"||D==="allOf")continue;if(!(D in U))delete G[D]}if(V.$ref&&j.def)for(let D in G){if(D==="$ref"||D==="allOf")continue;if(D in j.def&&JSON.stringify(G[D])===JSON.stringify(j.def[D]))delete G[D]}}let H=z._zod.parent;if(H&&H!==w){J(H);let j=$.seen.get(H);if(j?.schema.$ref){if(G.$ref=j.schema.$ref,j.def)for(let V in G){if(V==="$ref"||V==="allOf")continue;if(V in j.def&&JSON.stringify(G[V])===JSON.stringify(j.def[V]))delete G[V]}}}$.override({zodSchema:z,jsonSchema:G,path:W.path??[]})};for(let z of[...$.seen.entries()].reverse())J(z[0]);let v={};if($.target==="draft-2020-12")v.$schema="https://json-schema.org/draft/2020-12/schema";else if($.target==="draft-07")v.$schema="http://json-schema.org/draft-07/schema#";else if($.target==="draft-04")v.$schema="http://json-schema.org/draft-04/schema#";else if($.target==="openapi-3.0");if($.external?.uri){let z=$.external.registry.get(X)?.id;if(!z)throw Error("Schema is missing an `id` property");v.$id=$.external.uri(z)}Object.assign(v,Q.def??Q.schema);let Y=$.external?.defs??{};for(let z of $.seen.entries()){let W=z[1];if(W.def&&W.defId)Y[W.defId]=W.def}if($.external);else if(Object.keys(Y).length>0)if($.target==="draft-2020-12")v.$defs=Y;else v.definitions=Y;try{let z=JSON.parse(JSON.stringify(v));return Object.defineProperty(z,"~standard",{value:{...X["~standard"],jsonSchema:{input:VQ(X,"input",$.processors),output:VQ(X,"output",$.processors)}},enumerable:!1,writable:!1}),z}catch(z){throw Error("Error converting schema to JSON.")}}function F6($,X){let Q=X??{seen:new Set};if(Q.seen.has($))return!1;Q.seen.add($);let J=$._zod.def;if(J.type==="transform")return!0;if(J.type==="array")return F6(J.element,Q);if(J.type==="set")return F6(J.valueType,Q);if(J.type==="lazy")return F6(J.getter(),Q);if(J.type==="promise"||J.type==="optional"||J.type==="nonoptional"||J.type==="nullable"||J.type==="readonly"||J.type==="default"||J.type==="prefault")return F6(J.innerType,Q);if(J.type==="intersection")return F6(J.left,Q)||F6(J.right,Q);if(J.type==="record"||J.type==="map")return F6(J.keyType,Q)||F6(J.valueType,Q);if(J.type==="pipe")return F6(J.in,Q)||F6(J.out,Q);if(J.type==="object"){for(let v in J.shape)if(F6(J.shape[v],Q))return!0;return!1}if(J.type==="union"){for(let v of J.options)if(F6(v,Q))return!0;return!1}if(J.type==="tuple"){for(let v of J.items)if(F6(v,Q))return!0;if(J.rest&&F6(J.rest,Q))return!0;return!1}return!1}var $F=($,X={})=>(Q)=>{let J=_8({...Q,processors:X});return T$($,J),y8(J,$),f8(J,$)},VQ=($,X,Q={})=>(J)=>{let{libraryOptions:v,target:Y}=J??{},z=_8({...v??{},target:Y,io:X,processors:Q});return T$($,z),y8(z,$),f8(z,$)};var Dr={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},XF=($,X,Q,J)=>{let v=Q;v.type="string";let{minimum:Y,maximum:z,format:W,patterns:G,contentEncoding:U}=$._zod.bag;if(typeof Y==="number")v.minLength=Y;if(typeof z==="number")v.maxLength=z;if(W){if(v.format=Dr[W]??W,v.format==="")delete v.format;if(W==="time")delete v.format}if(U)v.contentEncoding=U;if(G&&G.size>0){let w=[...G];if(w.length===1)v.pattern=w[0].source;else if(w.length>1)v.allOf=[...w.map((H)=>({...X.target==="draft-07"||X.target==="draft-04"||X.target==="openapi-3.0"?{type:"string"}:{},pattern:H.source}))]}},QF=($,X,Q,J)=>{let v=Q,{minimum:Y,maximum:z,format:W,multipleOf:G,exclusiveMaximum:U,exclusiveMinimum:w}=$._zod.bag;if(typeof W==="string"&&W.includes("int"))v.type="integer";else v.type="number";if(typeof w==="number")if(X.target==="draft-04"||X.target==="openapi-3.0")v.minimum=w,v.exclusiveMinimum=!0;else v.exclusiveMinimum=w;if(typeof Y==="number"){if(v.minimum=Y,typeof w==="number"&&X.target!=="draft-04")if(w>=Y)delete v.minimum;else delete v.exclusiveMinimum}if(typeof U==="number")if(X.target==="draft-04"||X.target==="openapi-3.0")v.maximum=U,v.exclusiveMaximum=!0;else v.exclusiveMaximum=U;if(typeof z==="number"){if(v.maximum=z,typeof U==="number"&&X.target!=="draft-04")if(U<=z)delete v.maximum;else delete v.exclusiveMaximum}if(typeof G==="number")v.multipleOf=G},vF=($,X,Q,J)=>{Q.type="boolean"},JF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("BigInt cannot be represented in JSON Schema")},YF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Symbols cannot be represented in JSON Schema")},zF=($,X,Q,J)=>{if(X.target==="openapi-3.0")Q.type="string",Q.nullable=!0,Q.enum=[null];else Q.type="null"},WF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Undefined cannot be represented in JSON Schema")},GF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Void cannot be represented in JSON Schema")},UF=($,X,Q,J)=>{Q.not={}},KF=($,X,Q,J)=>{},wF=($,X,Q,J)=>{},HF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Date cannot be represented in JSON Schema")},jF=($,X,Q,J)=>{let v=$._zod.def,Y=SJ(v.entries);if(Y.every((z)=>typeof z==="number"))Q.type="number";if(Y.every((z)=>typeof z==="string"))Q.type="string";Q.enum=Y},VF=($,X,Q,J)=>{let v=$._zod.def,Y=[];for(let z of v.values)if(z===void 0){if(X.unrepresentable==="throw")throw Error("Literal `undefined` cannot be represented in JSON Schema")}else if(typeof z==="bigint")if(X.unrepresentable==="throw")throw Error("BigInt literals cannot be represented in JSON Schema");else Y.push(Number(z));else Y.push(z);if(Y.length===0);else if(Y.length===1){let z=Y[0];if(Q.type=z===null?"null":typeof z,X.target==="draft-04"||X.target==="openapi-3.0")Q.enum=[z];else Q.const=z}else{if(Y.every((z)=>typeof z==="number"))Q.type="number";if(Y.every((z)=>typeof z==="string"))Q.type="string";if(Y.every((z)=>typeof z==="boolean"))Q.type="boolean";if(Y.every((z)=>z===null))Q.type="null";Q.enum=Y}},BF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("NaN cannot be represented in JSON Schema")},DF=($,X,Q,J)=>{let v=Q,Y=$._zod.pattern;if(!Y)throw Error("Pattern not found in template literal");v.type="string",v.pattern=Y.source},FF=($,X,Q,J)=>{let v=Q,Y={type:"string",format:"binary",contentEncoding:"binary"},{minimum:z,maximum:W,mime:G}=$._zod.bag;if(z!==void 0)Y.minLength=z;if(W!==void 0)Y.maxLength=W;if(G)if(G.length===1)Y.contentMediaType=G[0],Object.assign(v,Y);else Object.assign(v,Y),v.anyOf=G.map((U)=>({contentMediaType:U}));else Object.assign(v,Y)},NF=($,X,Q,J)=>{Q.type="boolean"},OF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Custom types cannot be represented in JSON Schema")},LF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Function types cannot be represented in JSON Schema")},AF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Transforms cannot be represented in JSON Schema")},ZF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Map cannot be represented in JSON Schema")},qF=($,X,Q,J)=>{if(X.unrepresentable==="throw")throw Error("Set cannot be represented in JSON Schema")},IF=($,X,Q,J)=>{let v=Q,Y=$._zod.def,{minimum:z,maximum:W}=$._zod.bag;if(typeof z==="number")v.minItems=z;if(typeof W==="number")v.maxItems=W;v.type="array",v.items=T$(Y.element,X,{...J,path:[...J.path,"items"]})},MF=($,X,Q,J)=>{let v=Q,Y=$._zod.def;v.type="object",v.properties={};let z=Y.shape;for(let U in z)v.properties[U]=T$(z[U],X,{...J,path:[...J.path,"properties",U]});let W=new Set(Object.keys(z)),G=new Set([...W].filter((U)=>{let w=Y.shape[U]._zod;if(X.io==="input")return w.optin===void 0;else return w.optout===void 0}));if(G.size>0)v.required=Array.from(G);if(Y.catchall?._zod.def.type==="never")v.additionalProperties=!1;else if(!Y.catchall){if(X.io==="output")v.additionalProperties=!1}else if(Y.catchall)v.additionalProperties=T$(Y.catchall,X,{...J,path:[...J.path,"additionalProperties"]})},LG=($,X,Q,J)=>{let v=$._zod.def,Y=v.inclusive===!1,z=v.options.map((W,G)=>T$(W,X,{...J,path:[...J.path,Y?"oneOf":"anyOf",G]}));if(Y)Q.oneOf=z;else Q.anyOf=z},xF=($,X,Q,J)=>{let v=$._zod.def,Y=T$(v.left,X,{...J,path:[...J.path,"allOf",0]}),z=T$(v.right,X,{...J,path:[...J.path,"allOf",1]}),W=(U)=>("allOf"in U)&&Object.keys(U).length===1,G=[...W(Y)?Y.allOf:[Y],...W(z)?z.allOf:[z]];Q.allOf=G},bF=($,X,Q,J)=>{let v=Q,Y=$._zod.def;v.type="array";let z=X.target==="draft-2020-12"?"prefixItems":"items",W=X.target==="draft-2020-12"?"items":X.target==="openapi-3.0"?"items":"additionalItems",G=Y.items.map((j,V)=>T$(j,X,{...J,path:[...J.path,z,V]})),U=Y.rest?T$(Y.rest,X,{...J,path:[...J.path,W,...X.target==="openapi-3.0"?[Y.items.length]:[]]}):null;if(X.target==="draft-2020-12"){if(v.prefixItems=G,U)v.items=U}else if(X.target==="openapi-3.0"){if(v.items={anyOf:G},U)v.items.anyOf.push(U);if(v.minItems=G.length,!U)v.maxItems=G.length}else if(v.items=G,U)v.additionalItems=U;let{minimum:w,maximum:H}=$._zod.bag;if(typeof w==="number")v.minItems=w;if(typeof H==="number")v.maxItems=H},iF=($,X,Q,J)=>{let v=Q,Y=$._zod.def;v.type="object";let z=Y.keyType,G=z._zod.bag?.patterns;if(Y.mode==="loose"&&G&&G.size>0){let w=T$(Y.valueType,X,{...J,path:[...J.path,"patternProperties","*"]});v.patternProperties={};for(let H of G)v.patternProperties[H.source]=w}else{if(X.target==="draft-07"||X.target==="draft-2020-12")v.propertyNames=T$(Y.keyType,X,{...J,path:[...J.path,"propertyNames"]});v.additionalProperties=T$(Y.valueType,X,{...J,path:[...J.path,"additionalProperties"]})}let U=z._zod.values;if(U){let w=[...U].filter((H)=>typeof H==="string"||typeof H==="number");if(w.length>0)v.required=w}},RF=($,X,Q,J)=>{let v=$._zod.def,Y=T$(v.innerType,X,J),z=X.seen.get($);if(X.target==="openapi-3.0")z.ref=v.innerType,Q.nullable=!0;else Q.anyOf=[Y,{type:"null"}]},EF=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);Y.ref=v.innerType},PF=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);Y.ref=v.innerType,Q.default=JSON.parse(JSON.stringify(v.defaultValue))},TF=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);if(Y.ref=v.innerType,X.io==="input")Q._prefault=JSON.parse(JSON.stringify(v.defaultValue))},SF=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);Y.ref=v.innerType;let z;try{z=v.catchValue(void 0)}catch{throw Error("Dynamic catch values are not supported in JSON Schema")}Q.default=z},CF=($,X,Q,J)=>{let v=$._zod.def,Y=X.io==="input"?v.in._zod.def.type==="transform"?v.out:v.in:v.out;T$(Y,X,J);let z=X.seen.get($);z.ref=Y},uF=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);Y.ref=v.innerType,Q.readOnly=!0},_F=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);Y.ref=v.innerType},AG=($,X,Q,J)=>{let v=$._zod.def;T$(v.innerType,X,J);let Y=X.seen.get($);Y.ref=v.innerType},yF=($,X,Q,J)=>{let v=$._zod.innerType;T$(v,X,J);let Y=X.seen.get($);Y.ref=v},OG={string:XF,number:QF,boolean:vF,bigint:JF,symbol:YF,null:zF,undefined:WF,void:GF,never:UF,any:KF,unknown:wF,date:HF,enum:jF,literal:VF,nan:BF,template_literal:DF,file:FF,success:NF,custom:OF,function:LF,transform:AF,map:ZF,set:qF,array:IF,object:MF,union:LG,intersection:xF,tuple:bF,record:iF,nullable:RF,nonoptional:EF,default:PF,prefault:TF,catch:SF,pipe:CF,readonly:uF,promise:_F,optional:AG,lazy:yF};function s0($,X){if("_idmap"in $){let J=$,v=_8({...X,processors:OG}),Y={};for(let G of J._idmap.entries()){let[U,w]=G;T$(w,v)}let z={},W={registry:J,uri:X?.uri,defs:Y};v.external=W;for(let G of J._idmap.entries()){let[U,w]=G;y8(v,w),z[U]=f8(v,w)}if(Object.keys(Y).length>0){let G=v.target==="draft-2020-12"?"$defs":"definitions";z.__shared={[G]:Y}}return{schemas:z}}let Q=_8({...X,processors:OG});return T$($,Q),y8(Q,$),f8(Q,$)}class fF{get metadataRegistry(){return this.ctx.metadataRegistry}get target(){return this.ctx.target}get unrepresentable(){return this.ctx.unrepresentable}get override(){return this.ctx.override}get io(){return this.ctx.io}get counter(){return this.ctx.counter}set counter($){this.ctx.counter=$}get seen(){return this.ctx.seen}constructor($){let X=$?.target??"draft-2020-12";if(X==="draft-4")X="draft-04";if(X==="draft-7")X="draft-07";this.ctx=_8({processors:OG,target:X,...$?.metadata&&{metadata:$.metadata},...$?.unrepresentable&&{unrepresentable:$.unrepresentable},...$?.override&&{override:$.override},...$?.io&&{io:$.io}})}process($,X={path:[],schemaPath:[]}){return T$($,this.ctx,X)}emit($,X){if(X){if(X.cycles)this.ctx.cycles=X.cycles;if(X.reused)this.ctx.reused=X.reused;if(X.external)this.ctx.external=X.external}y8(this.ctx,$);let Q=f8(this.ctx,$),{"~standard":J,...v}=Q;return v}}var m2={};var NY={};u1(NY,{xor:()=>Sx,xid:()=>Jx,void:()=>Rx,uuidv7:()=>r2,uuidv6:()=>a2,uuidv4:()=>o2,uuid:()=>d2,url:()=>t2,unknown:()=>f$,union:()=>C$,undefined:()=>bx,ulid:()=>vx,uint64:()=>Mx,uint32:()=>Zx,tuple:()=>j3,transform:()=>dG,templateLiteral:()=>hx,symbol:()=>xx,superRefine:()=>k3,success:()=>gx,stringbool:()=>tx,stringFormat:()=>Bx,string:()=>x,strictObject:()=>Tx,set:()=>yx,refine:()=>f3,record:()=>l$,readonly:()=>T3,promise:()=>lx,preprocess:()=>EY,prefault:()=>M3,pipe:()=>ZY,partialRecord:()=>Cx,optional:()=>a$,object:()=>h,number:()=>P$,nullish:()=>nx,nullable:()=>AY,null:()=>AQ,nonoptional:()=>x3,never:()=>pG,nativeEnum:()=>fx,nanoid:()=>$x,nan:()=>cx,meta:()=>ax,map:()=>_x,mac:()=>Wx,looseRecord:()=>ux,looseObject:()=>l4,literal:()=>d,lazy:()=>u3,ksuid:()=>Yx,keyof:()=>Px,jwt:()=>Vx,json:()=>sx,ipv6:()=>Gx,ipv4:()=>zx,intersection:()=>ZQ,int64:()=>Ix,int32:()=>Ax,int:()=>bG,instanceof:()=>rx,httpUrl:()=>s2,hostname:()=>Dx,hex:()=>Fx,hash:()=>Nx,guid:()=>p2,function:()=>px,float64:()=>Lx,float32:()=>Ox,file:()=>kx,exactOptional:()=>L3,enum:()=>p4,emoji:()=>e2,email:()=>l2,e164:()=>jx,discriminatedUnion:()=>bY,describe:()=>ox,date:()=>Ex,custom:()=>tG,cuid2:()=>Qx,cuid:()=>Xx,codec:()=>mx,cidrv6:()=>Kx,cidrv4:()=>Ux,check:()=>dx,catch:()=>R3,boolean:()=>K4,bigint:()=>qx,base64url:()=>Hx,base64:()=>wx,array:()=>N$,any:()=>ix,_function:()=>px,_default:()=>q3,_ZodString:()=>iG,ZodXor:()=>U3,ZodXID:()=>uG,ZodVoid:()=>W3,ZodUnknown:()=>Y3,ZodUnion:()=>xY,ZodUndefined:()=>Q3,ZodUUID:()=>O0,ZodURL:()=>qY,ZodULID:()=>CG,ZodType:()=>w$,ZodTuple:()=>H3,ZodTransform:()=>N3,ZodTemplateLiteral:()=>S3,ZodSymbol:()=>X3,ZodSuccess:()=>b3,ZodStringFormat:()=>y$,ZodString:()=>DQ,ZodSet:()=>B3,ZodRecord:()=>iY,ZodReadonly:()=>P3,ZodPromise:()=>_3,ZodPrefault:()=>I3,ZodPipe:()=>aG,ZodOptional:()=>qQ,ZodObject:()=>MY,ZodNumberFormat:()=>h9,ZodNumber:()=>NQ,ZodNullable:()=>A3,ZodNull:()=>v3,ZodNonOptional:()=>oG,ZodNever:()=>z3,ZodNanoID:()=>PG,ZodNaN:()=>E3,ZodMap:()=>V3,ZodMAC:()=>$3,ZodLiteral:()=>D3,ZodLazy:()=>C3,ZodKSUID:()=>_G,ZodJWT:()=>hG,ZodIntersection:()=>w3,ZodIPv6:()=>fG,ZodIPv4:()=>yG,ZodGUID:()=>LY,ZodFunction:()=>y3,ZodFile:()=>F3,ZodExactOptional:()=>O3,ZodEnum:()=>BQ,ZodEmoji:()=>EG,ZodEmail:()=>RG,ZodE164:()=>mG,ZodDiscriminatedUnion:()=>K3,ZodDefault:()=>Z3,ZodDate:()=>IY,ZodCustomStringFormat:()=>FQ,ZodCustom:()=>RY,ZodCodec:()=>rG,ZodCatch:()=>i3,ZodCUID2:()=>SG,ZodCUID:()=>TG,ZodCIDRv6:()=>nG,ZodCIDRv4:()=>kG,ZodBoolean:()=>OQ,ZodBigIntFormat:()=>lG,ZodBigInt:()=>LQ,ZodBase64URL:()=>cG,ZodBase64:()=>gG,ZodArray:()=>G3,ZodAny:()=>J3});var ZG={};u1(ZG,{uppercase:()=>JQ,trim:()=>KQ,toUpperCase:()=>HQ,toLowerCase:()=>wQ,startsWith:()=>zQ,slugify:()=>jQ,size:()=>n9,regex:()=>QQ,property:()=>wG,positive:()=>WG,overwrite:()=>f1,normalize:()=>UQ,nonpositive:()=>UG,nonnegative:()=>KG,negative:()=>GG,multipleOf:()=>C8,minSize:()=>N0,minLength:()=>t0,mime:()=>GQ,maxSize:()=>u8,maxLength:()=>g9,lte:()=>s6,lt:()=>D0,lowercase:()=>vQ,length:()=>c9,includes:()=>YQ,gte:()=>D6,gt:()=>F0,endsWith:()=>WQ});var k8={};u1(k8,{time:()=>gF,duration:()=>cF,datetime:()=>kF,date:()=>nF,ZodISOTime:()=>MG,ZodISODuration:()=>xG,ZodISODateTime:()=>qG,ZodISODate:()=>IG});var qG=q("ZodISODateTime",($,X)=>{cB.init($,X),y$.init($,X)});function kF($){return lD(qG,$)}var IG=q("ZodISODate",($,X)=>{mB.init($,X),y$.init($,X)});function nF($){return pD(IG,$)}var MG=q("ZodISOTime",($,X)=>{hB.init($,X),y$.init($,X)});function gF($){return dD(MG,$)}var xG=q("ZodISODuration",($,X)=>{lB.init($,X),y$.init($,X)});function cF($){return oD(xG,$)}var h2=($,X)=>{fJ.init($,X),$.name="ZodError",Object.defineProperties($,{format:{value:(Q)=>nJ($,Q)},flatten:{value:(Q)=>kJ($,Q)},addIssue:{value:(Q)=>{$.issues.push(Q),$.message=JSON.stringify($.issues,gX,2)}},addIssues:{value:(Q)=>{$.issues.push(...Q),$.message=JSON.stringify($.issues,gX,2)}},isEmpty:{get(){return $.issues.length===0}}})},Nr=q("ZodError",h2),C6=q("ZodError",h2,{Parent:Error});var mF=hX(C6),hF=lX(C6),lF=pX(C6),OY=dX(C6),pF=N5(C6),dF=O5(C6),oF=L5(C6),aF=A5(C6),rF=Z5(C6),tF=q5(C6),sF=I5(C6),eF=M5(C6);var w$=q("ZodType",($,X)=>{return J$.init($,X),Object.assign($["~standard"],{jsonSchema:{input:VQ($,"input"),output:VQ($,"output")}}),$.toJSONSchema=$F($,{}),$.def=X,$.type=X.type,Object.defineProperty($,"_def",{value:X}),$.check=(...Q)=>{return $.clone(C.mergeDefs(X,{checks:[...X.checks??[],...Q.map((J)=>typeof J==="function"?{_zod:{check:J,def:{check:"custom"},onattach:[]}}:J)]}),{parent:!0})},$.with=$.check,$.clone=(Q,J)=>m4($,Q,J),$.brand=()=>$,$.register=(Q,J)=>{return Q.add($,J),$},$.parse=(Q,J)=>mF($,Q,J,{callee:$.parse}),$.safeParse=(Q,J)=>lF($,Q,J),$.parseAsync=async(Q,J)=>hF($,Q,J,{callee:$.parseAsync}),$.safeParseAsync=async(Q,J)=>OY($,Q,J),$.spa=$.safeParseAsync,$.encode=(Q,J)=>pF($,Q,J),$.decode=(Q,J)=>dF($,Q,J),$.encodeAsync=async(Q,J)=>oF($,Q,J),$.decodeAsync=async(Q,J)=>aF($,Q,J),$.safeEncode=(Q,J)=>rF($,Q,J),$.safeDecode=(Q,J)=>tF($,Q,J),$.safeEncodeAsync=async(Q,J)=>sF($,Q,J),$.safeDecodeAsync=async(Q,J)=>eF($,Q,J),$.refine=(Q,J)=>$.check(f3(Q,J)),$.superRefine=(Q)=>$.check(k3(Q)),$.overwrite=(Q)=>$.check(f1(Q)),$.optional=()=>a$($),$.exactOptional=()=>L3($),$.nullable=()=>AY($),$.nullish=()=>a$(AY($)),$.nonoptional=(Q)=>x3($,Q),$.array=()=>N$($),$.or=(Q)=>C$([$,Q]),$.and=(Q)=>ZQ($,Q),$.transform=(Q)=>ZY($,dG(Q)),$.default=(Q)=>q3($,Q),$.prefault=(Q)=>M3($,Q),$.catch=(Q)=>R3($,Q),$.pipe=(Q)=>ZY($,Q),$.readonly=()=>T3($),$.describe=(Q)=>{let J=$.clone();return h4.add(J,{description:Q}),J},Object.defineProperty($,"description",{get(){return h4.get($)?.description},configurable:!0}),$.meta=(...Q)=>{if(Q.length===0)return h4.get($);let J=$.clone();return h4.add(J,Q[0]),J},$.isOptional=()=>$.safeParse(void 0).success,$.isNullable=()=>$.safeParse(null).success,$.apply=(Q)=>Q($),$}),iG=q("_ZodString",($,X)=>{S8.init($,X),w$.init($,X),$._zod.processJSONSchema=(J,v,Y)=>XF($,J,v,Y);let Q=$._zod.bag;$.format=Q.format??null,$.minLength=Q.minimum??null,$.maxLength=Q.maximum??null,$.regex=(...J)=>$.check(QQ(...J)),$.includes=(...J)=>$.check(YQ(...J)),$.startsWith=(...J)=>$.check(zQ(...J)),$.endsWith=(...J)=>$.check(WQ(...J)),$.min=(...J)=>$.check(t0(...J)),$.max=(...J)=>$.check(g9(...J)),$.length=(...J)=>$.check(c9(...J)),$.nonempty=(...J)=>$.check(t0(1,...J)),$.lowercase=(J)=>$.check(vQ(J)),$.uppercase=(J)=>$.check(JQ(J)),$.trim=()=>$.check(KQ()),$.normalize=(...J)=>$.check(UQ(...J)),$.toLowerCase=()=>$.check(wQ()),$.toUpperCase=()=>$.check(HQ()),$.slugify=()=>$.check(jQ())}),DQ=q("ZodString",($,X)=>{S8.init($,X),iG.init($,X),$.email=(Q)=>$.check(sJ(RG,Q)),$.url=(Q)=>$.check(XQ(qY,Q)),$.jwt=(Q)=>$.check(FY(hG,Q)),$.emoji=(Q)=>$.check(vY(EG,Q)),$.guid=(Q)=>$.check($Q(LY,Q)),$.uuid=(Q)=>$.check(eJ(O0,Q)),$.uuidv4=(Q)=>$.check($Y(O0,Q)),$.uuidv6=(Q)=>$.check(XY(O0,Q)),$.uuidv7=(Q)=>$.check(QY(O0,Q)),$.nanoid=(Q)=>$.check(JY(PG,Q)),$.guid=(Q)=>$.check($Q(LY,Q)),$.cuid=(Q)=>$.check(YY(TG,Q)),$.cuid2=(Q)=>$.check(zY(SG,Q)),$.ulid=(Q)=>$.check(WY(CG,Q)),$.base64=(Q)=>$.check(VY(gG,Q)),$.base64url=(Q)=>$.check(BY(cG,Q)),$.xid=(Q)=>$.check(GY(uG,Q)),$.ksuid=(Q)=>$.check(UY(_G,Q)),$.ipv4=(Q)=>$.check(KY(yG,Q)),$.ipv6=(Q)=>$.check(wY(fG,Q)),$.cidrv4=(Q)=>$.check(HY(kG,Q)),$.cidrv6=(Q)=>$.check(jY(nG,Q)),$.e164=(Q)=>$.check(DY(mG,Q)),$.datetime=(Q)=>$.check(kF(Q)),$.date=(Q)=>$.check(nF(Q)),$.time=(Q)=>$.check(gF(Q)),$.duration=(Q)=>$.check(cF(Q))});function x($){return nW(DQ,$)}var y$=q("ZodStringFormat",($,X)=>{S$.init($,X),iG.init($,X)}),RG=q("ZodEmail",($,X)=>{y5.init($,X),y$.init($,X)});function l2($){return sJ(RG,$)}var LY=q("ZodGUID",($,X)=>{u5.init($,X),y$.init($,X)});function p2($){return $Q(LY,$)}var O0=q("ZodUUID",($,X)=>{_5.init($,X),y$.init($,X)});function d2($){return eJ(O0,$)}function o2($){return $Y(O0,$)}function a2($){return XY(O0,$)}function r2($){return QY(O0,$)}var qY=q("ZodURL",($,X)=>{f5.init($,X),y$.init($,X)});function t2($){return XQ(qY,$)}function s2($){return XQ(qY,{protocol:/^https?$/,hostname:t6.domain,...C.normalizeParams($)})}var EG=q("ZodEmoji",($,X)=>{k5.init($,X),y$.init($,X)});function e2($){return vY(EG,$)}var PG=q("ZodNanoID",($,X)=>{n5.init($,X),y$.init($,X)});function $x($){return JY(PG,$)}var TG=q("ZodCUID",($,X)=>{g5.init($,X),y$.init($,X)});function Xx($){return YY(TG,$)}var SG=q("ZodCUID2",($,X)=>{c5.init($,X),y$.init($,X)});function Qx($){return zY(SG,$)}var CG=q("ZodULID",($,X)=>{m5.init($,X),y$.init($,X)});function vx($){return WY(CG,$)}var uG=q("ZodXID",($,X)=>{h5.init($,X),y$.init($,X)});function Jx($){return GY(uG,$)}var _G=q("ZodKSUID",($,X)=>{l5.init($,X),y$.init($,X)});function Yx($){return UY(_G,$)}var yG=q("ZodIPv4",($,X)=>{p5.init($,X),y$.init($,X)});function zx($){return KY(yG,$)}var $3=q("ZodMAC",($,X)=>{o5.init($,X),y$.init($,X)});function Wx($){return gW($3,$)}var fG=q("ZodIPv6",($,X)=>{d5.init($,X),y$.init($,X)});function Gx($){return wY(fG,$)}var kG=q("ZodCIDRv4",($,X)=>{a5.init($,X),y$.init($,X)});function Ux($){return HY(kG,$)}var nG=q("ZodCIDRv6",($,X)=>{r5.init($,X),y$.init($,X)});function Kx($){return jY(nG,$)}var gG=q("ZodBase64",($,X)=>{t5.init($,X),y$.init($,X)});function wx($){return VY(gG,$)}var cG=q("ZodBase64URL",($,X)=>{s5.init($,X),y$.init($,X)});function Hx($){return BY(cG,$)}var mG=q("ZodE164",($,X)=>{e5.init($,X),y$.init($,X)});function jx($){return DY(mG,$)}var hG=q("ZodJWT",($,X)=>{$W.init($,X),y$.init($,X)});function Vx($){return FY(hG,$)}var FQ=q("ZodCustomStringFormat",($,X)=>{XW.init($,X),y$.init($,X)});function Bx($,X,Q={}){return m9(FQ,$,X,Q)}function Dx($){return m9(FQ,"hostname",t6.hostname,$)}function Fx($){return m9(FQ,"hex",t6.hex,$)}function Nx($,X){let Q=X?.enc??"hex",J=`${$}_${Q}`,v=t6[J];if(!v)throw Error(`Unrecognized hash format: ${J}`);return m9(FQ,J,v,X)}var NQ=q("ZodNumber",($,X)=>{hJ.init($,X),w$.init($,X),$._zod.processJSONSchema=(J,v,Y)=>QF($,J,v,Y),$.gt=(J,v)=>$.check(F0(J,v)),$.gte=(J,v)=>$.check(D6(J,v)),$.min=(J,v)=>$.check(D6(J,v)),$.lt=(J,v)=>$.check(D0(J,v)),$.lte=(J,v)=>$.check(s6(J,v)),$.max=(J,v)=>$.check(s6(J,v)),$.int=(J)=>$.check(bG(J)),$.safe=(J)=>$.check(bG(J)),$.positive=(J)=>$.check(F0(0,J)),$.nonnegative=(J)=>$.check(D6(0,J)),$.negative=(J)=>$.check(D0(0,J)),$.nonpositive=(J)=>$.check(s6(0,J)),$.multipleOf=(J,v)=>$.check(C8(J,v)),$.step=(J,v)=>$.check(C8(J,v)),$.finite=()=>$;let Q=$._zod.bag;$.minValue=Math.max(Q.minimum??Number.NEGATIVE_INFINITY,Q.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,$.maxValue=Math.min(Q.maximum??Number.POSITIVE_INFINITY,Q.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,$.isInt=(Q.format??"").includes("int")||Number.isSafeInteger(Q.multipleOf??0.5),$.isFinite=!0,$.format=Q.format??null});function P$($){return cW(NQ,$)}var h9=q("ZodNumberFormat",($,X)=>{QW.init($,X),NQ.init($,X)});function bG($){return mW(h9,$)}function Ox($){return hW(h9,$)}function Lx($){return lW(h9,$)}function Ax($){return pW(h9,$)}function Zx($){return dW(h9,$)}var OQ=q("ZodBoolean",($,X)=>{aX.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>vF($,Q,J,v)});function K4($){return oW(OQ,$)}var LQ=q("ZodBigInt",($,X)=>{lJ.init($,X),w$.init($,X),$._zod.processJSONSchema=(J,v,Y)=>JF($,J,v,Y),$.gte=(J,v)=>$.check(D6(J,v)),$.min=(J,v)=>$.check(D6(J,v)),$.gt=(J,v)=>$.check(F0(J,v)),$.gte=(J,v)=>$.check(D6(J,v)),$.min=(J,v)=>$.check(D6(J,v)),$.lt=(J,v)=>$.check(D0(J,v)),$.lte=(J,v)=>$.check(s6(J,v)),$.max=(J,v)=>$.check(s6(J,v)),$.positive=(J)=>$.check(F0(BigInt(0),J)),$.negative=(J)=>$.check(D0(BigInt(0),J)),$.nonpositive=(J)=>$.check(s6(BigInt(0),J)),$.nonnegative=(J)=>$.check(D6(BigInt(0),J)),$.multipleOf=(J,v)=>$.check(C8(J,v));let Q=$._zod.bag;$.minValue=Q.minimum??null,$.maxValue=Q.maximum??null,$.format=Q.format??null});function qx($){return aW(LQ,$)}var lG=q("ZodBigIntFormat",($,X)=>{vW.init($,X),LQ.init($,X)});function Ix($){return rW(lG,$)}function Mx($){return tW(lG,$)}var X3=q("ZodSymbol",($,X)=>{JW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>YF($,Q,J,v)});function xx($){return sW(X3,$)}var Q3=q("ZodUndefined",($,X)=>{YW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>WF($,Q,J,v)});function bx($){return eW(Q3,$)}var v3=q("ZodNull",($,X)=>{zW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>zF($,Q,J,v)});function AQ($){return $G(v3,$)}var J3=q("ZodAny",($,X)=>{WW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>KF($,Q,J,v)});function ix(){return XG(J3)}var Y3=q("ZodUnknown",($,X)=>{GW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>wF($,Q,J,v)});function f$(){return QG(Y3)}var z3=q("ZodNever",($,X)=>{UW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>UF($,Q,J,v)});function pG($){return vG(z3,$)}var W3=q("ZodVoid",($,X)=>{KW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>GF($,Q,J,v)});function Rx($){return JG(W3,$)}var IY=q("ZodDate",($,X)=>{wW.init($,X),w$.init($,X),$._zod.processJSONSchema=(J,v,Y)=>HF($,J,v,Y),$.min=(J,v)=>$.check(D6(J,v)),$.max=(J,v)=>$.check(s6(J,v));let Q=$._zod.bag;$.minDate=Q.minimum?new Date(Q.minimum):null,$.maxDate=Q.maximum?new Date(Q.maximum):null});function Ex($){return YG(IY,$)}var G3=q("ZodArray",($,X)=>{HW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>IF($,Q,J,v),$.element=X.element,$.min=(Q,J)=>$.check(t0(Q,J)),$.nonempty=(Q)=>$.check(t0(1,Q)),$.max=(Q,J)=>$.check(g9(Q,J)),$.length=(Q,J)=>$.check(c9(Q,J)),$.unwrap=()=>$.element});function N$($,X){return eD(G3,$,X)}function Px($){let X=$._zod.def.shape;return p4(Object.keys(X))}var MY=q("ZodObject",($,X)=>{dB.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>MF($,Q,J,v),C.defineLazy($,"shape",()=>{return X.shape}),$.keyof=()=>p4(Object.keys($._zod.def.shape)),$.catchall=(Q)=>$.clone({...$._zod.def,catchall:Q}),$.passthrough=()=>$.clone({...$._zod.def,catchall:f$()}),$.loose=()=>$.clone({...$._zod.def,catchall:f$()}),$.strict=()=>$.clone({...$._zod.def,catchall:pG()}),$.strip=()=>$.clone({...$._zod.def,catchall:void 0}),$.extend=(Q)=>{return C.extend($,Q)},$.safeExtend=(Q)=>{return C.safeExtend($,Q)},$.merge=(Q)=>C.merge($,Q),$.pick=(Q)=>C.pick($,Q),$.omit=(Q)=>C.omit($,Q),$.partial=(...Q)=>C.partial(qQ,$,Q[0]),$.required=(...Q)=>C.required(oG,$,Q[0])});function h($,X){let Q={type:"object",shape:$??{},...C.normalizeParams(X)};return new MY(Q)}function Tx($,X){return new MY({type:"object",shape:$,catchall:pG(),...C.normalizeParams(X)})}function l4($,X){return new MY({type:"object",shape:$,catchall:f$(),...C.normalizeParams(X)})}var xY=q("ZodUnion",($,X)=>{rX.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>LG($,Q,J,v),$.options=X.options});function C$($,X){return new xY({type:"union",options:$,...C.normalizeParams(X)})}var U3=q("ZodXor",($,X)=>{xY.init($,X),VW.init($,X),$._zod.processJSONSchema=(Q,J,v)=>LG($,Q,J,v),$.options=X.options});function Sx($,X){return new U3({type:"union",options:$,inclusive:!1,...C.normalizeParams(X)})}var K3=q("ZodDiscriminatedUnion",($,X)=>{xY.init($,X),BW.init($,X)});function bY($,X,Q){return new K3({type:"union",options:X,discriminator:$,...C.normalizeParams(Q)})}var w3=q("ZodIntersection",($,X)=>{DW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>xF($,Q,J,v)});function ZQ($,X){return new w3({type:"intersection",left:$,right:X})}var H3=q("ZodTuple",($,X)=>{pJ.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>bF($,Q,J,v),$.rest=(Q)=>$.clone({...$._zod.def,rest:Q})});function j3($,X,Q){let J=X instanceof J$,v=J?Q:X;return new H3({type:"tuple",items:$,rest:J?X:null,...C.normalizeParams(v)})}var iY=q("ZodRecord",($,X)=>{FW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>iF($,Q,J,v),$.keyType=X.keyType,$.valueType=X.valueType});function l$($,X,Q){return new iY({type:"record",keyType:$,valueType:X,...C.normalizeParams(Q)})}function Cx($,X,Q){let J=m4($);return J._zod.values=void 0,new iY({type:"record",keyType:J,valueType:X,...C.normalizeParams(Q)})}function ux($,X,Q){return new iY({type:"record",keyType:$,valueType:X,mode:"loose",...C.normalizeParams(Q)})}var V3=q("ZodMap",($,X)=>{NW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>ZF($,Q,J,v),$.keyType=X.keyType,$.valueType=X.valueType,$.min=(...Q)=>$.check(N0(...Q)),$.nonempty=(Q)=>$.check(N0(1,Q)),$.max=(...Q)=>$.check(u8(...Q)),$.size=(...Q)=>$.check(n9(...Q))});function _x($,X,Q){return new V3({type:"map",keyType:$,valueType:X,...C.normalizeParams(Q)})}var B3=q("ZodSet",($,X)=>{OW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>qF($,Q,J,v),$.min=(...Q)=>$.check(N0(...Q)),$.nonempty=(Q)=>$.check(N0(1,Q)),$.max=(...Q)=>$.check(u8(...Q)),$.size=(...Q)=>$.check(n9(...Q))});function yx($,X){return new B3({type:"set",valueType:$,...C.normalizeParams(X)})}var BQ=q("ZodEnum",($,X)=>{LW.init($,X),w$.init($,X),$._zod.processJSONSchema=(J,v,Y)=>jF($,J,v,Y),$.enum=X.entries,$.options=Object.values(X.entries);let Q=new Set(Object.keys(X.entries));$.extract=(J,v)=>{let Y={};for(let z of J)if(Q.has(z))Y[z]=X.entries[z];else throw Error(`Key ${z} not found in enum`);return new BQ({...X,checks:[],...C.normalizeParams(v),entries:Y})},$.exclude=(J,v)=>{let Y={...X.entries};for(let z of J)if(Q.has(z))delete Y[z];else throw Error(`Key ${z} not found in enum`);return new BQ({...X,checks:[],...C.normalizeParams(v),entries:Y})}});function p4($,X){let Q=Array.isArray($)?Object.fromEntries($.map((J)=>[J,J])):$;return new BQ({type:"enum",entries:Q,...C.normalizeParams(X)})}function fx($,X){return new BQ({type:"enum",entries:$,...C.normalizeParams(X)})}var D3=q("ZodLiteral",($,X)=>{AW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>VF($,Q,J,v),$.values=new Set(X.values),Object.defineProperty($,"value",{get(){if(X.values.length>1)throw Error("This schema contains multiple valid literal values. Use `.values` instead.");return X.values[0]}})});function d($,X){return new D3({type:"literal",values:Array.isArray($)?$:[$],...C.normalizeParams(X)})}var F3=q("ZodFile",($,X)=>{ZW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>FF($,Q,J,v),$.min=(Q,J)=>$.check(N0(Q,J)),$.max=(Q,J)=>$.check(u8(Q,J)),$.mime=(Q,J)=>$.check(GQ(Array.isArray(Q)?Q:[Q],J))});function kx($){return HG(F3,$)}var N3=q("ZodTransform",($,X)=>{qW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>AF($,Q,J,v),$._zod.parse=(Q,J)=>{if(J.direction==="backward")throw new u9($.constructor.name);Q.addIssue=(Y)=>{if(typeof Y==="string")Q.issues.push(C.issue(Y,Q.value,X));else{let z=Y;if(z.fatal)z.continue=!1;z.code??(z.code="custom"),z.input??(z.input=Q.value),z.inst??(z.inst=$),Q.issues.push(C.issue(z))}};let v=X.transform(Q.value,Q);if(v instanceof Promise)return v.then((Y)=>{return Q.value=Y,Q});return Q.value=v,Q}});function dG($){return new N3({type:"transform",transform:$})}var qQ=q("ZodOptional",($,X)=>{dJ.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>AG($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function a$($){return new qQ({type:"optional",innerType:$})}var O3=q("ZodExactOptional",($,X)=>{IW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>AG($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function L3($){return new O3({type:"optional",innerType:$})}var A3=q("ZodNullable",($,X)=>{MW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>RF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function AY($){return new A3({type:"nullable",innerType:$})}function nx($){return a$(AY($))}var Z3=q("ZodDefault",($,X)=>{xW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>PF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType,$.removeDefault=$.unwrap});function q3($,X){return new Z3({type:"default",innerType:$,get defaultValue(){return typeof X==="function"?X():C.shallowClone(X)}})}var I3=q("ZodPrefault",($,X)=>{bW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>TF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function M3($,X){return new I3({type:"prefault",innerType:$,get defaultValue(){return typeof X==="function"?X():C.shallowClone(X)}})}var oG=q("ZodNonOptional",($,X)=>{iW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>EF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function x3($,X){return new oG({type:"nonoptional",innerType:$,...C.normalizeParams(X)})}var b3=q("ZodSuccess",($,X)=>{RW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>NF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function gx($){return new b3({type:"success",innerType:$})}var i3=q("ZodCatch",($,X)=>{EW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>SF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType,$.removeCatch=$.unwrap});function R3($,X){return new i3({type:"catch",innerType:$,catchValue:typeof X==="function"?X:()=>X})}var E3=q("ZodNaN",($,X)=>{PW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>BF($,Q,J,v)});function cx($){return zG(E3,$)}var aG=q("ZodPipe",($,X)=>{TW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>CF($,Q,J,v),$.in=X.in,$.out=X.out});function ZY($,X){return new aG({type:"pipe",in:$,out:X})}var rG=q("ZodCodec",($,X)=>{aG.init($,X),tX.init($,X)});function mx($,X,Q){return new rG({type:"pipe",in:$,out:X,transform:Q.decode,reverseTransform:Q.encode})}var P3=q("ZodReadonly",($,X)=>{SW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>uF($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function T3($){return new P3({type:"readonly",innerType:$})}var S3=q("ZodTemplateLiteral",($,X)=>{CW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>DF($,Q,J,v)});function hx($,X){return new S3({type:"template_literal",parts:$,...C.normalizeParams(X)})}var C3=q("ZodLazy",($,X)=>{yW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>yF($,Q,J,v),$.unwrap=()=>$._zod.def.getter()});function u3($){return new C3({type:"lazy",getter:$})}var _3=q("ZodPromise",($,X)=>{_W.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>_F($,Q,J,v),$.unwrap=()=>$._zod.def.innerType});function lx($){return new _3({type:"promise",innerType:$})}var y3=q("ZodFunction",($,X)=>{uW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>LF($,Q,J,v)});function px($){return new y3({type:"function",input:Array.isArray($?.input)?j3($?.input):$?.input??N$(f$()),output:$?.output??f$()})}var RY=q("ZodCustom",($,X)=>{fW.init($,X),w$.init($,X),$._zod.processJSONSchema=(Q,J,v)=>OF($,Q,J,v)});function dx($){let X=new _$({check:"custom"});return X._zod.check=$,X}function tG($,X){return jG(RY,$??(()=>!0),X)}function f3($,X={}){return VG(RY,$,X)}function k3($){return BG($)}var ox=DG,ax=FG;function rx($,X={}){let Q=new RY({type:"custom",check:"custom",fn:(J)=>J instanceof $,abort:!0,...C.normalizeParams(X)});return Q._zod.bag.Class=$,Q._zod.check=(J)=>{if(!(J.value instanceof $))J.issues.push({code:"invalid_type",expected:$.name,input:J.value,inst:Q,path:[...Q._zod.def.path??[]]})},Q}var tx=(...$)=>NG({Codec:rG,Boolean:OQ,String:DQ},...$);function sx($){let X=u3(()=>{return C$([x($),P$(),K4(),AQ(),N$(X),l$(x(),X)])});return X}function EY($,X){return ZY(dG($),X)}var Lr={invalid_type:"invalid_type",too_big:"too_big",too_small:"too_small",invalid_format:"invalid_format",not_multiple_of:"not_multiple_of",unrecognized_keys:"unrecognized_keys",invalid_union:"invalid_union",invalid_key:"invalid_key",invalid_element:"invalid_element",invalid_value:"invalid_value",custom:"custom"};function Ar($){U4({customError:$})}function Zr(){return U4().customError}var n3;(function($){})(n3||(n3={}));var c={...NY,...ZG,iso:k8},qr=new Set(["$schema","$ref","$defs","definitions","$id","id","$comment","$anchor","$vocabulary","$dynamicRef","$dynamicAnchor","type","enum","const","anyOf","oneOf","allOf","not","properties","required","additionalProperties","patternProperties","propertyNames","minProperties","maxProperties","items","prefixItems","additionalItems","minItems","maxItems","uniqueItems","contains","minContains","maxContains","minLength","maxLength","pattern","format","minimum","maximum","exclusiveMinimum","exclusiveMaximum","multipleOf","description","default","contentEncoding","contentMediaType","contentSchema","unevaluatedItems","unevaluatedProperties","if","then","else","dependentSchemas","dependentRequired","nullable","readOnly"]);function Ir($,X){let Q=$.$schema;if(Q==="https://json-schema.org/draft/2020-12/schema")return"draft-2020-12";if(Q==="http://json-schema.org/draft-07/schema#")return"draft-7";if(Q==="http://json-schema.org/draft-04/schema#")return"draft-4";return X??"draft-2020-12"}function Mr($,X){if(!$.startsWith("#"))throw Error("External $ref is not supported, only local refs (#/...) are allowed");let Q=$.slice(1).split("/").filter(Boolean);if(Q.length===0)return X.rootSchema;let J=X.version==="draft-2020-12"?"$defs":"definitions";if(Q[0]===J){let v=Q[1];if(!v||!X.defs[v])throw Error(`Reference not found: ${$}`);return X.defs[v]}throw Error(`Reference not found: ${$}`)}function ex($,X){if($.not!==void 0){if(typeof $.not==="object"&&Object.keys($.not).length===0)return c.never();throw Error("not is not supported in Zod (except { not: {} } for never)")}if($.unevaluatedItems!==void 0)throw Error("unevaluatedItems is not supported");if($.unevaluatedProperties!==void 0)throw Error("unevaluatedProperties is not supported");if($.if!==void 0||$.then!==void 0||$.else!==void 0)throw Error("Conditional schemas (if/then/else) are not supported");if($.dependentSchemas!==void 0||$.dependentRequired!==void 0)throw Error("dependentSchemas and dependentRequired are not supported");if($.$ref){let v=$.$ref;if(X.refs.has(v))return X.refs.get(v);if(X.processing.has(v))return c.lazy(()=>{if(!X.refs.has(v))throw Error(`Circular reference not resolved: ${v}`);return X.refs.get(v)});X.processing.add(v);let Y=Mr(v,X),z=X6(Y,X);return X.refs.set(v,z),X.processing.delete(v),z}if($.enum!==void 0){let v=$.enum;if(X.version==="openapi-3.0"&&$.nullable===!0&&v.length===1&&v[0]===null)return c.null();if(v.length===0)return c.never();if(v.length===1)return c.literal(v[0]);if(v.every((z)=>typeof z==="string"))return c.enum(v);let Y=v.map((z)=>c.literal(z));if(Y.length<2)return Y[0];return c.union([Y[0],Y[1],...Y.slice(2)])}if($.const!==void 0)return c.literal($.const);let Q=$.type;if(Array.isArray(Q)){let v=Q.map((Y)=>{let z={...$,type:Y};return ex(z,X)});if(v.length===0)return c.never();if(v.length===1)return v[0];return c.union(v)}if(!Q)return c.any();let J;switch(Q){case"string":{let v=c.string();if($.format){let Y=$.format;if(Y==="email")v=v.check(c.email());else if(Y==="uri"||Y==="uri-reference")v=v.check(c.url());else if(Y==="uuid"||Y==="guid")v=v.check(c.uuid());else if(Y==="date-time")v=v.check(c.iso.datetime());else if(Y==="date")v=v.check(c.iso.date());else if(Y==="time")v=v.check(c.iso.time());else if(Y==="duration")v=v.check(c.iso.duration());else if(Y==="ipv4")v=v.check(c.ipv4());else if(Y==="ipv6")v=v.check(c.ipv6());else if(Y==="mac")v=v.check(c.mac());else if(Y==="cidr")v=v.check(c.cidrv4());else if(Y==="cidr-v6")v=v.check(c.cidrv6());else if(Y==="base64")v=v.check(c.base64());else if(Y==="base64url")v=v.check(c.base64url());else if(Y==="e164")v=v.check(c.e164());else if(Y==="jwt")v=v.check(c.jwt());else if(Y==="emoji")v=v.check(c.emoji());else if(Y==="nanoid")v=v.check(c.nanoid());else if(Y==="cuid")v=v.check(c.cuid());else if(Y==="cuid2")v=v.check(c.cuid2());else if(Y==="ulid")v=v.check(c.ulid());else if(Y==="xid")v=v.check(c.xid());else if(Y==="ksuid")v=v.check(c.ksuid())}if(typeof $.minLength==="number")v=v.min($.minLength);if(typeof $.maxLength==="number")v=v.max($.maxLength);if($.pattern)v=v.regex(new RegExp($.pattern));J=v;break}case"number":case"integer":{let v=Q==="integer"?c.number().int():c.number();if(typeof $.minimum==="number")v=v.min($.minimum);if(typeof $.maximum==="number")v=v.max($.maximum);if(typeof $.exclusiveMinimum==="number")v=v.gt($.exclusiveMinimum);else if($.exclusiveMinimum===!0&&typeof $.minimum==="number")v=v.gt($.minimum);if(typeof $.exclusiveMaximum==="number")v=v.lt($.exclusiveMaximum);else if($.exclusiveMaximum===!0&&typeof $.maximum==="number")v=v.lt($.maximum);if(typeof $.multipleOf==="number")v=v.multipleOf($.multipleOf);J=v;break}case"boolean":{J=c.boolean();break}case"null":{J=c.null();break}case"object":{let v={},Y=$.properties||{},z=new Set($.required||[]);for(let[G,U]of Object.entries(Y)){let w=X6(U,X);v[G]=z.has(G)?w:w.optional()}if($.propertyNames){let G=X6($.propertyNames,X),U=$.additionalProperties&&typeof $.additionalProperties==="object"?X6($.additionalProperties,X):c.any();if(Object.keys(v).length===0){J=c.record(G,U);break}let w=c.object(v).passthrough(),H=c.looseRecord(G,U);J=c.intersection(w,H);break}if($.patternProperties){let G=$.patternProperties,U=Object.keys(G),w=[];for(let j of U){let V=X6(G[j],X),B=c.string().regex(new RegExp(j));w.push(c.looseRecord(B,V))}let H=[];if(Object.keys(v).length>0)H.push(c.object(v).passthrough());if(H.push(...w),H.length===0)J=c.object({}).passthrough();else if(H.length===1)J=H[0];else{let j=c.intersection(H[0],H[1]);for(let V=2;VX6(G,X)),W=Y&&typeof Y==="object"&&!Array.isArray(Y)?X6(Y,X):void 0;if(W)J=c.tuple(z).rest(W);else J=c.tuple(z);if(typeof $.minItems==="number")J=J.check(c.minLength($.minItems));if(typeof $.maxItems==="number")J=J.check(c.maxLength($.maxItems))}else if(Array.isArray(Y)){let z=Y.map((G)=>X6(G,X)),W=$.additionalItems&&typeof $.additionalItems==="object"?X6($.additionalItems,X):void 0;if(W)J=c.tuple(z).rest(W);else J=c.tuple(z);if(typeof $.minItems==="number")J=J.check(c.minLength($.minItems));if(typeof $.maxItems==="number")J=J.check(c.maxLength($.maxItems))}else if(Y!==void 0){let z=X6(Y,X),W=c.array(z);if(typeof $.minItems==="number")W=W.min($.minItems);if(typeof $.maxItems==="number")W=W.max($.maxItems);J=W}else J=c.array(c.any());break}default:throw Error(`Unsupported type: ${Q}`)}if($.description)J=J.describe($.description);if($.default!==void 0)J=J.default($.default);return J}function X6($,X){if(typeof $==="boolean")return $?c.any():c.never();let Q=ex($,X),J=$.type||$.enum!==void 0||$.const!==void 0;if($.anyOf&&Array.isArray($.anyOf)){let W=$.anyOf.map((U)=>X6(U,X)),G=c.union(W);Q=J?c.intersection(Q,G):G}if($.oneOf&&Array.isArray($.oneOf)){let W=$.oneOf.map((U)=>X6(U,X)),G=c.xor(W);Q=J?c.intersection(Q,G):G}if($.allOf&&Array.isArray($.allOf))if($.allOf.length===0)Q=J?Q:c.any();else{let W=J?Q:X6($.allOf[0],X),G=J?0:1;for(let U=G;U<$.allOf.length;U++)W=c.intersection(W,X6($.allOf[U],X));Q=W}if($.nullable===!0&&X.version==="openapi-3.0")Q=c.nullable(Q);if($.readOnly===!0)Q=c.readonly(Q);let v={},Y=["$id","id","$comment","$anchor","$vocabulary","$dynamicRef","$dynamicAnchor"];for(let W of Y)if(W in $)v[W]=$[W];let z=["contentEncoding","contentMediaType","contentSchema"];for(let W of z)if(W in $)v[W]=$[W];for(let W of Object.keys($))if(!qr.has(W))v[W]=$[W];if(Object.keys(v).length>0)X.registry.add(Q,v);return Q}function $b($,X){if(typeof $==="boolean")return $?c.any():c.never();let Q=Ir($,X?.defaultTarget),J=$.$defs||$.definitions||{},v={version:Q,defs:J,refs:new Map,processing:new Set,rootSchema:$,registry:X?.registry??h4};return X6($,v)}var g3={};u1(g3,{string:()=>xr,number:()=>br,date:()=>Er,boolean:()=>ir,bigint:()=>Rr});function xr($){return mD(DQ,$)}function br($){return aD(NQ,$)}function ir($){return rD(OQ,$)}function Rr($){return tD(LQ,$)}function Er($){return sD(IY,$)}U4(oJ());var k={};u1(k,{void:()=>Vt,util:()=>Z$,unknown:()=>Ht,union:()=>Ft,undefined:()=>Ut,tuple:()=>Lt,transformer:()=>Et,symbol:()=>Gt,string:()=>Ub,strictObject:()=>Dt,setErrorMap:()=>Sr,set:()=>qt,record:()=>At,quotelessJson:()=>Pr,promise:()=>Rt,preprocess:()=>St,pipeline:()=>Ct,ostring:()=>ut,optional:()=>Pt,onumber:()=>_t,oboolean:()=>yt,objectUtil:()=>c3,object:()=>l3,number:()=>Kb,nullable:()=>Tt,null:()=>Kt,never:()=>jt,nativeEnum:()=>it,nan:()=>Yt,map:()=>Zt,makeIssue:()=>PY,literal:()=>xt,lazy:()=>Mt,late:()=>vt,isValid:()=>n8,isDirty:()=>eG,isAsync:()=>MQ,isAborted:()=>sG,intersection:()=>Ot,instanceof:()=>Jt,getParsedType:()=>L0,getErrorMap:()=>IQ,function:()=>It,enum:()=>bt,effect:()=>Et,discriminatedUnion:()=>Nt,defaultErrorMap:()=>e0,datetimeRegex:()=>zb,date:()=>Wt,custom:()=>Gb,coerce:()=>ft,boolean:()=>wb,bigint:()=>zt,array:()=>Bt,any:()=>wt,addIssueToContext:()=>g,ZodVoid:()=>SY,ZodUnknown:()=>g8,ZodUnion:()=>PQ,ZodUndefined:()=>RQ,ZodType:()=>O$,ZodTuple:()=>Z0,ZodTransformer:()=>h1,ZodSymbol:()=>TY,ZodString:()=>n1,ZodSet:()=>o9,ZodSchema:()=>O$,ZodRecord:()=>CY,ZodReadonly:()=>fQ,ZodPromise:()=>a9,ZodPipeline:()=>yY,ZodParsedType:()=>y,ZodOptional:()=>c1,ZodObject:()=>Q4,ZodNumber:()=>c8,ZodNullable:()=>X8,ZodNull:()=>EQ,ZodNever:()=>A0,ZodNativeEnum:()=>uQ,ZodNaN:()=>_Y,ZodMap:()=>uY,ZodLiteral:()=>CQ,ZodLazy:()=>SQ,ZodIssueCode:()=>T,ZodIntersection:()=>TQ,ZodFunction:()=>bQ,ZodFirstPartyTypeKind:()=>b,ZodError:()=>u6,ZodEnum:()=>h8,ZodEffects:()=>h1,ZodDiscriminatedUnion:()=>$U,ZodDefault:()=>_Q,ZodDate:()=>p9,ZodCatch:()=>yQ,ZodBranded:()=>XU,ZodBoolean:()=>iQ,ZodBigInt:()=>m8,ZodArray:()=>g1,ZodAny:()=>d9,Schema:()=>O$,ParseStatus:()=>d4,OK:()=>Q6,NEVER:()=>kt,INVALID:()=>X$,EMPTY_PATH:()=>Cr,DIRTY:()=>l9,BRAND:()=>Qt});var Z$;(function($){$.assertEqual=(v)=>{};function X(v){}$.assertIs=X;function Q(v){throw Error()}$.assertNever=Q,$.arrayToEnum=(v)=>{let Y={};for(let z of v)Y[z]=z;return Y},$.getValidEnumValues=(v)=>{let Y=$.objectKeys(v).filter((W)=>typeof v[v[W]]!=="number"),z={};for(let W of Y)z[W]=v[W];return $.objectValues(z)},$.objectValues=(v)=>{return $.objectKeys(v).map(function(Y){return v[Y]})},$.objectKeys=typeof Object.keys==="function"?(v)=>Object.keys(v):(v)=>{let Y=[];for(let z in v)if(Object.prototype.hasOwnProperty.call(v,z))Y.push(z);return Y},$.find=(v,Y)=>{for(let z of v)if(Y(z))return z;return},$.isInteger=typeof Number.isInteger==="function"?(v)=>Number.isInteger(v):(v)=>typeof v==="number"&&Number.isFinite(v)&&Math.floor(v)===v;function J(v,Y=" | "){return v.map((z)=>typeof z==="string"?`'${z}'`:z).join(Y)}$.joinValues=J,$.jsonStringifyReplacer=(v,Y)=>{if(typeof Y==="bigint")return Y.toString();return Y}})(Z$||(Z$={}));var c3;(function($){$.mergeShapes=(X,Q)=>{return{...X,...Q}}})(c3||(c3={}));var y=Z$.arrayToEnum(["string","nan","number","integer","float","boolean","date","bigint","symbol","function","undefined","null","array","object","unknown","promise","void","never","map","set"]),L0=($)=>{switch(typeof $){case"undefined":return y.undefined;case"string":return y.string;case"number":return Number.isNaN($)?y.nan:y.number;case"boolean":return y.boolean;case"function":return y.function;case"bigint":return y.bigint;case"symbol":return y.symbol;case"object":if(Array.isArray($))return y.array;if($===null)return y.null;if($.then&&typeof $.then==="function"&&$.catch&&typeof $.catch==="function")return y.promise;if(typeof Map<"u"&&$ instanceof Map)return y.map;if(typeof Set<"u"&&$ instanceof Set)return y.set;if(typeof Date<"u"&&$ instanceof Date)return y.date;return y.object;default:return y.unknown}};var T=Z$.arrayToEnum(["invalid_type","invalid_literal","custom","invalid_union","invalid_union_discriminator","invalid_enum_value","unrecognized_keys","invalid_arguments","invalid_return_type","invalid_date","invalid_string","too_small","too_big","invalid_intersection_types","not_multiple_of","not_finite"]),Pr=($)=>{return JSON.stringify($,null,2).replace(/"([^"]+)":/g,"$1:")};class u6 extends Error{get errors(){return this.issues}constructor($){super();this.issues=[],this.addIssue=(Q)=>{this.issues=[...this.issues,Q]},this.addIssues=(Q=[])=>{this.issues=[...this.issues,...Q]};let X=new.target.prototype;if(Object.setPrototypeOf)Object.setPrototypeOf(this,X);else this.__proto__=X;this.name="ZodError",this.issues=$}format($){let X=$||function(v){return v.message},Q={_errors:[]},J=(v)=>{for(let Y of v.issues)if(Y.code==="invalid_union")Y.unionErrors.map(J);else if(Y.code==="invalid_return_type")J(Y.returnTypeError);else if(Y.code==="invalid_arguments")J(Y.argumentsError);else if(Y.path.length===0)Q._errors.push(X(Y));else{let z=Q,W=0;while(WX.message){let X=Object.create(null),Q=[];for(let J of this.issues)if(J.path.length>0){let v=J.path[0];X[v]=X[v]||[],X[v].push($(J))}else Q.push($(J));return{formErrors:Q,fieldErrors:X}}get formErrors(){return this.flatten()}}u6.create=($)=>{return new u6($)};var Tr=($,X)=>{let Q;switch($.code){case T.invalid_type:if($.received===y.undefined)Q="Required";else Q=`Expected ${$.expected}, received ${$.received}`;break;case T.invalid_literal:Q=`Invalid literal value, expected ${JSON.stringify($.expected,Z$.jsonStringifyReplacer)}`;break;case T.unrecognized_keys:Q=`Unrecognized key(s) in object: ${Z$.joinValues($.keys,", ")}`;break;case T.invalid_union:Q="Invalid input";break;case T.invalid_union_discriminator:Q=`Invalid discriminator value. Expected ${Z$.joinValues($.options)}`;break;case T.invalid_enum_value:Q=`Invalid enum value. Expected ${Z$.joinValues($.options)}, received '${$.received}'`;break;case T.invalid_arguments:Q="Invalid function arguments";break;case T.invalid_return_type:Q="Invalid function return type";break;case T.invalid_date:Q="Invalid date";break;case T.invalid_string:if(typeof $.validation==="object")if("includes"in $.validation){if(Q=`Invalid input: must include "${$.validation.includes}"`,typeof $.validation.position==="number")Q=`${Q} at one or more positions greater than or equal to ${$.validation.position}`}else if("startsWith"in $.validation)Q=`Invalid input: must start with "${$.validation.startsWith}"`;else if("endsWith"in $.validation)Q=`Invalid input: must end with "${$.validation.endsWith}"`;else Z$.assertNever($.validation);else if($.validation!=="regex")Q=`Invalid ${$.validation}`;else Q="Invalid";break;case T.too_small:if($.type==="array")Q=`Array must contain ${$.exact?"exactly":$.inclusive?"at least":"more than"} ${$.minimum} element(s)`;else if($.type==="string")Q=`String must contain ${$.exact?"exactly":$.inclusive?"at least":"over"} ${$.minimum} character(s)`;else if($.type==="number")Q=`Number must be ${$.exact?"exactly equal to ":$.inclusive?"greater than or equal to ":"greater than "}${$.minimum}`;else if($.type==="bigint")Q=`Number must be ${$.exact?"exactly equal to ":$.inclusive?"greater than or equal to ":"greater than "}${$.minimum}`;else if($.type==="date")Q=`Date must be ${$.exact?"exactly equal to ":$.inclusive?"greater than or equal to ":"greater than "}${new Date(Number($.minimum))}`;else Q="Invalid input";break;case T.too_big:if($.type==="array")Q=`Array must contain ${$.exact?"exactly":$.inclusive?"at most":"less than"} ${$.maximum} element(s)`;else if($.type==="string")Q=`String must contain ${$.exact?"exactly":$.inclusive?"at most":"under"} ${$.maximum} character(s)`;else if($.type==="number")Q=`Number must be ${$.exact?"exactly":$.inclusive?"less than or equal to":"less than"} ${$.maximum}`;else if($.type==="bigint")Q=`BigInt must be ${$.exact?"exactly":$.inclusive?"less than or equal to":"less than"} ${$.maximum}`;else if($.type==="date")Q=`Date must be ${$.exact?"exactly":$.inclusive?"smaller than or equal to":"smaller than"} ${new Date(Number($.maximum))}`;else Q="Invalid input";break;case T.custom:Q="Invalid input";break;case T.invalid_intersection_types:Q="Intersection results could not be merged";break;case T.not_multiple_of:Q=`Number must be a multiple of ${$.multipleOf}`;break;case T.not_finite:Q="Number must be finite";break;default:Q=X.defaultError,Z$.assertNever($)}return{message:Q}},e0=Tr;var Xb=e0;function Sr($){Xb=$}function IQ(){return Xb}var PY=($)=>{let{data:X,path:Q,errorMaps:J,issueData:v}=$,Y=[...Q,...v.path||[]],z={...v,path:Y};if(v.message!==void 0)return{...v,path:Y,message:v.message};let W="",G=J.filter((U)=>!!U).slice().reverse();for(let U of G)W=U(z,{data:X,defaultError:W}).message;return{...v,path:Y,message:W}},Cr=[];function g($,X){let Q=IQ(),J=PY({issueData:X,data:$.data,path:$.path,errorMaps:[$.common.contextualErrorMap,$.schemaErrorMap,Q,Q===e0?void 0:e0].filter((v)=>!!v)});$.common.issues.push(J)}class d4{constructor(){this.value="valid"}dirty(){if(this.value==="valid")this.value="dirty"}abort(){if(this.value!=="aborted")this.value="aborted"}static mergeArray($,X){let Q=[];for(let J of X){if(J.status==="aborted")return X$;if(J.status==="dirty")$.dirty();Q.push(J.value)}return{status:$.value,value:Q}}static async mergeObjectAsync($,X){let Q=[];for(let J of X){let v=await J.key,Y=await J.value;Q.push({key:v,value:Y})}return d4.mergeObjectSync($,Q)}static mergeObjectSync($,X){let Q={};for(let J of X){let{key:v,value:Y}=J;if(v.status==="aborted")return X$;if(Y.status==="aborted")return X$;if(v.status==="dirty")$.dirty();if(Y.status==="dirty")$.dirty();if(v.value!=="__proto__"&&(typeof Y.value<"u"||J.alwaysSet))Q[v.value]=Y.value}return{status:$.value,value:Q}}}var X$=Object.freeze({status:"aborted"}),l9=($)=>({status:"dirty",value:$}),Q6=($)=>({status:"valid",value:$}),sG=($)=>$.status==="aborted",eG=($)=>$.status==="dirty",n8=($)=>$.status==="valid",MQ=($)=>typeof Promise<"u"&&$ instanceof Promise;var p;(function($){$.errToObj=(X)=>typeof X==="string"?{message:X}:X||{},$.toString=(X)=>typeof X==="string"?X:X?.message})(p||(p={}));class m1{constructor($,X,Q,J){this._cachedPath=[],this.parent=$,this.data=X,this._path=Q,this._key=J}get path(){if(!this._cachedPath.length)if(Array.isArray(this._key))this._cachedPath.push(...this._path,...this._key);else this._cachedPath.push(...this._path,this._key);return this._cachedPath}}var Qb=($,X)=>{if(n8(X))return{success:!0,data:X.value};else{if(!$.common.issues.length)throw Error("Validation failed but no issues detected.");return{success:!1,get error(){if(this._error)return this._error;let Q=new u6($.common.issues);return this._error=Q,this._error}}}};function H$($){if(!$)return{};let{errorMap:X,invalid_type_error:Q,required_error:J,description:v}=$;if(X&&(Q||J))throw Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`);if(X)return{errorMap:X,description:v};return{errorMap:(z,W)=>{let{message:G}=$;if(z.code==="invalid_enum_value")return{message:G??W.defaultError};if(typeof W.data>"u")return{message:G??J??W.defaultError};if(z.code!=="invalid_type")return{message:W.defaultError};return{message:G??Q??W.defaultError}},description:v}}class O${get description(){return this._def.description}_getType($){return L0($.data)}_getOrReturnCtx($,X){return X||{common:$.parent.common,data:$.data,parsedType:L0($.data),schemaErrorMap:this._def.errorMap,path:$.path,parent:$.parent}}_processInputParams($){return{status:new d4,ctx:{common:$.parent.common,data:$.data,parsedType:L0($.data),schemaErrorMap:this._def.errorMap,path:$.path,parent:$.parent}}}_parseSync($){let X=this._parse($);if(MQ(X))throw Error("Synchronous parse encountered promise.");return X}_parseAsync($){let X=this._parse($);return Promise.resolve(X)}parse($,X){let Q=this.safeParse($,X);if(Q.success)return Q.data;throw Q.error}safeParse($,X){let Q={common:{issues:[],async:X?.async??!1,contextualErrorMap:X?.errorMap},path:X?.path||[],schemaErrorMap:this._def.errorMap,parent:null,data:$,parsedType:L0($)},J=this._parseSync({data:$,path:Q.path,parent:Q});return Qb(Q,J)}"~validate"($){let X={common:{issues:[],async:!!this["~standard"].async},path:[],schemaErrorMap:this._def.errorMap,parent:null,data:$,parsedType:L0($)};if(!this["~standard"].async)try{let Q=this._parseSync({data:$,path:[],parent:X});return n8(Q)?{value:Q.value}:{issues:X.common.issues}}catch(Q){if(Q?.message?.toLowerCase()?.includes("encountered"))this["~standard"].async=!0;X.common={issues:[],async:!0}}return this._parseAsync({data:$,path:[],parent:X}).then((Q)=>n8(Q)?{value:Q.value}:{issues:X.common.issues})}async parseAsync($,X){let Q=await this.safeParseAsync($,X);if(Q.success)return Q.data;throw Q.error}async safeParseAsync($,X){let Q={common:{issues:[],contextualErrorMap:X?.errorMap,async:!0},path:X?.path||[],schemaErrorMap:this._def.errorMap,parent:null,data:$,parsedType:L0($)},J=this._parse({data:$,path:Q.path,parent:Q}),v=await(MQ(J)?J:Promise.resolve(J));return Qb(Q,v)}refine($,X){let Q=(J)=>{if(typeof X==="string"||typeof X>"u")return{message:X};else if(typeof X==="function")return X(J);else return X};return this._refinement((J,v)=>{let Y=$(J),z=()=>v.addIssue({code:T.custom,...Q(J)});if(typeof Promise<"u"&&Y instanceof Promise)return Y.then((W)=>{if(!W)return z(),!1;else return!0});if(!Y)return z(),!1;else return!0})}refinement($,X){return this._refinement((Q,J)=>{if(!$(Q))return J.addIssue(typeof X==="function"?X(Q,J):X),!1;else return!0})}_refinement($){return new h1({schema:this,typeName:b.ZodEffects,effect:{type:"refinement",refinement:$}})}superRefine($){return this._refinement($)}constructor($){this.spa=this.safeParseAsync,this._def=$,this.parse=this.parse.bind(this),this.safeParse=this.safeParse.bind(this),this.parseAsync=this.parseAsync.bind(this),this.safeParseAsync=this.safeParseAsync.bind(this),this.spa=this.spa.bind(this),this.refine=this.refine.bind(this),this.refinement=this.refinement.bind(this),this.superRefine=this.superRefine.bind(this),this.optional=this.optional.bind(this),this.nullable=this.nullable.bind(this),this.nullish=this.nullish.bind(this),this.array=this.array.bind(this),this.promise=this.promise.bind(this),this.or=this.or.bind(this),this.and=this.and.bind(this),this.transform=this.transform.bind(this),this.brand=this.brand.bind(this),this.default=this.default.bind(this),this.catch=this.catch.bind(this),this.describe=this.describe.bind(this),this.pipe=this.pipe.bind(this),this.readonly=this.readonly.bind(this),this.isNullable=this.isNullable.bind(this),this.isOptional=this.isOptional.bind(this),this["~standard"]={version:1,vendor:"zod",validate:(X)=>this["~validate"](X)}}optional(){return c1.create(this,this._def)}nullable(){return X8.create(this,this._def)}nullish(){return this.nullable().optional()}array(){return g1.create(this)}promise(){return a9.create(this,this._def)}or($){return PQ.create([this,$],this._def)}and($){return TQ.create(this,$,this._def)}transform($){return new h1({...H$(this._def),schema:this,typeName:b.ZodEffects,effect:{type:"transform",transform:$}})}default($){let X=typeof $==="function"?$:()=>$;return new _Q({...H$(this._def),innerType:this,defaultValue:X,typeName:b.ZodDefault})}brand(){return new XU({typeName:b.ZodBranded,type:this,...H$(this._def)})}catch($){let X=typeof $==="function"?$:()=>$;return new yQ({...H$(this._def),innerType:this,catchValue:X,typeName:b.ZodCatch})}describe($){return new this.constructor({...this._def,description:$})}pipe($){return yY.create(this,$)}readonly(){return fQ.create(this)}isOptional(){return this.safeParse(void 0).success}isNullable(){return this.safeParse(null).success}}var ur=/^c[^\s-]{8,}$/i,_r=/^[0-9a-z]+$/,yr=/^[0-9A-HJKMNP-TV-Z]{26}$/i,fr=/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i,kr=/^[a-z0-9_-]{21}$/i,nr=/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/,gr=/^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/,cr=/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i,mr="^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$",m3,hr=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,lr=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/,pr=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/,dr=/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,or=/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,ar=/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,Jb="((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))",rr=new RegExp(`^${Jb}$`);function Yb($){let X="[0-5]\\d";if($.precision)X=`${X}\\.\\d{${$.precision}}`;else if($.precision==null)X=`${X}(\\.\\d+)?`;let Q=$.precision?"+":"?";return`([01]\\d|2[0-3]):[0-5]\\d(:${X})${Q}`}function tr($){return new RegExp(`^${Yb($)}$`)}function zb($){let X=`${Jb}T${Yb($)}`,Q=[];if(Q.push($.local?"Z?":"Z"),$.offset)Q.push("([+-]\\d{2}:?\\d{2})");return X=`${X}(${Q.join("|")})`,new RegExp(`^${X}$`)}function sr($,X){if((X==="v4"||!X)&&hr.test($))return!0;if((X==="v6"||!X)&&pr.test($))return!0;return!1}function er($,X){if(!nr.test($))return!1;try{let[Q]=$.split(".");if(!Q)return!1;let J=Q.replace(/-/g,"+").replace(/_/g,"/").padEnd(Q.length+(4-Q.length%4)%4,"="),v=JSON.parse(atob(J));if(typeof v!=="object"||v===null)return!1;if("typ"in v&&v?.typ!=="JWT")return!1;if(!v.alg)return!1;if(X&&v.alg!==X)return!1;return!0}catch{return!1}}function $t($,X){if((X==="v4"||!X)&&lr.test($))return!0;if((X==="v6"||!X)&&dr.test($))return!0;return!1}class n1 extends O${_parse($){if(this._def.coerce)$.data=String($.data);if(this._getType($)!==y.string){let v=this._getOrReturnCtx($);return g(v,{code:T.invalid_type,expected:y.string,received:v.parsedType}),X$}let Q=new d4,J=void 0;for(let v of this._def.checks)if(v.kind==="min"){if($.data.lengthv.value)J=this._getOrReturnCtx($,J),g(J,{code:T.too_big,maximum:v.value,type:"string",inclusive:!0,exact:!1,message:v.message}),Q.dirty()}else if(v.kind==="length"){let Y=$.data.length>v.value,z=$.data.length$.test(J),{validation:X,code:T.invalid_string,...p.errToObj(Q)})}_addCheck($){return new n1({...this._def,checks:[...this._def.checks,$]})}email($){return this._addCheck({kind:"email",...p.errToObj($)})}url($){return this._addCheck({kind:"url",...p.errToObj($)})}emoji($){return this._addCheck({kind:"emoji",...p.errToObj($)})}uuid($){return this._addCheck({kind:"uuid",...p.errToObj($)})}nanoid($){return this._addCheck({kind:"nanoid",...p.errToObj($)})}cuid($){return this._addCheck({kind:"cuid",...p.errToObj($)})}cuid2($){return this._addCheck({kind:"cuid2",...p.errToObj($)})}ulid($){return this._addCheck({kind:"ulid",...p.errToObj($)})}base64($){return this._addCheck({kind:"base64",...p.errToObj($)})}base64url($){return this._addCheck({kind:"base64url",...p.errToObj($)})}jwt($){return this._addCheck({kind:"jwt",...p.errToObj($)})}ip($){return this._addCheck({kind:"ip",...p.errToObj($)})}cidr($){return this._addCheck({kind:"cidr",...p.errToObj($)})}datetime($){if(typeof $==="string")return this._addCheck({kind:"datetime",precision:null,offset:!1,local:!1,message:$});return this._addCheck({kind:"datetime",precision:typeof $?.precision>"u"?null:$?.precision,offset:$?.offset??!1,local:$?.local??!1,...p.errToObj($?.message)})}date($){return this._addCheck({kind:"date",message:$})}time($){if(typeof $==="string")return this._addCheck({kind:"time",precision:null,message:$});return this._addCheck({kind:"time",precision:typeof $?.precision>"u"?null:$?.precision,...p.errToObj($?.message)})}duration($){return this._addCheck({kind:"duration",...p.errToObj($)})}regex($,X){return this._addCheck({kind:"regex",regex:$,...p.errToObj(X)})}includes($,X){return this._addCheck({kind:"includes",value:$,position:X?.position,...p.errToObj(X?.message)})}startsWith($,X){return this._addCheck({kind:"startsWith",value:$,...p.errToObj(X)})}endsWith($,X){return this._addCheck({kind:"endsWith",value:$,...p.errToObj(X)})}min($,X){return this._addCheck({kind:"min",value:$,...p.errToObj(X)})}max($,X){return this._addCheck({kind:"max",value:$,...p.errToObj(X)})}length($,X){return this._addCheck({kind:"length",value:$,...p.errToObj(X)})}nonempty($){return this.min(1,p.errToObj($))}trim(){return new n1({...this._def,checks:[...this._def.checks,{kind:"trim"}]})}toLowerCase(){return new n1({...this._def,checks:[...this._def.checks,{kind:"toLowerCase"}]})}toUpperCase(){return new n1({...this._def,checks:[...this._def.checks,{kind:"toUpperCase"}]})}get isDatetime(){return!!this._def.checks.find(($)=>$.kind==="datetime")}get isDate(){return!!this._def.checks.find(($)=>$.kind==="date")}get isTime(){return!!this._def.checks.find(($)=>$.kind==="time")}get isDuration(){return!!this._def.checks.find(($)=>$.kind==="duration")}get isEmail(){return!!this._def.checks.find(($)=>$.kind==="email")}get isURL(){return!!this._def.checks.find(($)=>$.kind==="url")}get isEmoji(){return!!this._def.checks.find(($)=>$.kind==="emoji")}get isUUID(){return!!this._def.checks.find(($)=>$.kind==="uuid")}get isNANOID(){return!!this._def.checks.find(($)=>$.kind==="nanoid")}get isCUID(){return!!this._def.checks.find(($)=>$.kind==="cuid")}get isCUID2(){return!!this._def.checks.find(($)=>$.kind==="cuid2")}get isULID(){return!!this._def.checks.find(($)=>$.kind==="ulid")}get isIP(){return!!this._def.checks.find(($)=>$.kind==="ip")}get isCIDR(){return!!this._def.checks.find(($)=>$.kind==="cidr")}get isBase64(){return!!this._def.checks.find(($)=>$.kind==="base64")}get isBase64url(){return!!this._def.checks.find(($)=>$.kind==="base64url")}get minLength(){let $=null;for(let X of this._def.checks)if(X.kind==="min"){if($===null||X.value>$)$=X.value}return $}get maxLength(){let $=null;for(let X of this._def.checks)if(X.kind==="max"){if($===null||X.value<$)$=X.value}return $}}n1.create=($)=>{return new n1({checks:[],typeName:b.ZodString,coerce:$?.coerce??!1,...H$($)})};function Xt($,X){let Q=($.toString().split(".")[1]||"").length,J=(X.toString().split(".")[1]||"").length,v=Q>J?Q:J,Y=Number.parseInt($.toFixed(v).replace(".","")),z=Number.parseInt(X.toFixed(v).replace(".",""));return Y%z/10**v}class c8 extends O${constructor(){super(...arguments);this.min=this.gte,this.max=this.lte,this.step=this.multipleOf}_parse($){if(this._def.coerce)$.data=Number($.data);if(this._getType($)!==y.number){let v=this._getOrReturnCtx($);return g(v,{code:T.invalid_type,expected:y.number,received:v.parsedType}),X$}let Q=void 0,J=new d4;for(let v of this._def.checks)if(v.kind==="int"){if(!Z$.isInteger($.data))Q=this._getOrReturnCtx($,Q),g(Q,{code:T.invalid_type,expected:"integer",received:"float",message:v.message}),J.dirty()}else if(v.kind==="min"){if(v.inclusive?$.datav.value:$.data>=v.value)Q=this._getOrReturnCtx($,Q),g(Q,{code:T.too_big,maximum:v.value,type:"number",inclusive:v.inclusive,exact:!1,message:v.message}),J.dirty()}else if(v.kind==="multipleOf"){if(Xt($.data,v.value)!==0)Q=this._getOrReturnCtx($,Q),g(Q,{code:T.not_multiple_of,multipleOf:v.value,message:v.message}),J.dirty()}else if(v.kind==="finite"){if(!Number.isFinite($.data))Q=this._getOrReturnCtx($,Q),g(Q,{code:T.not_finite,message:v.message}),J.dirty()}else Z$.assertNever(v);return{status:J.value,value:$.data}}gte($,X){return this.setLimit("min",$,!0,p.toString(X))}gt($,X){return this.setLimit("min",$,!1,p.toString(X))}lte($,X){return this.setLimit("max",$,!0,p.toString(X))}lt($,X){return this.setLimit("max",$,!1,p.toString(X))}setLimit($,X,Q,J){return new c8({...this._def,checks:[...this._def.checks,{kind:$,value:X,inclusive:Q,message:p.toString(J)}]})}_addCheck($){return new c8({...this._def,checks:[...this._def.checks,$]})}int($){return this._addCheck({kind:"int",message:p.toString($)})}positive($){return this._addCheck({kind:"min",value:0,inclusive:!1,message:p.toString($)})}negative($){return this._addCheck({kind:"max",value:0,inclusive:!1,message:p.toString($)})}nonpositive($){return this._addCheck({kind:"max",value:0,inclusive:!0,message:p.toString($)})}nonnegative($){return this._addCheck({kind:"min",value:0,inclusive:!0,message:p.toString($)})}multipleOf($,X){return this._addCheck({kind:"multipleOf",value:$,message:p.toString(X)})}finite($){return this._addCheck({kind:"finite",message:p.toString($)})}safe($){return this._addCheck({kind:"min",inclusive:!0,value:Number.MIN_SAFE_INTEGER,message:p.toString($)})._addCheck({kind:"max",inclusive:!0,value:Number.MAX_SAFE_INTEGER,message:p.toString($)})}get minValue(){let $=null;for(let X of this._def.checks)if(X.kind==="min"){if($===null||X.value>$)$=X.value}return $}get maxValue(){let $=null;for(let X of this._def.checks)if(X.kind==="max"){if($===null||X.value<$)$=X.value}return $}get isInt(){return!!this._def.checks.find(($)=>$.kind==="int"||$.kind==="multipleOf"&&Z$.isInteger($.value))}get isFinite(){let $=null,X=null;for(let Q of this._def.checks)if(Q.kind==="finite"||Q.kind==="int"||Q.kind==="multipleOf")return!0;else if(Q.kind==="min"){if(X===null||Q.value>X)X=Q.value}else if(Q.kind==="max"){if($===null||Q.value<$)$=Q.value}return Number.isFinite(X)&&Number.isFinite($)}}c8.create=($)=>{return new c8({checks:[],typeName:b.ZodNumber,coerce:$?.coerce||!1,...H$($)})};class m8 extends O${constructor(){super(...arguments);this.min=this.gte,this.max=this.lte}_parse($){if(this._def.coerce)try{$.data=BigInt($.data)}catch{return this._getInvalidInput($)}if(this._getType($)!==y.bigint)return this._getInvalidInput($);let Q=void 0,J=new d4;for(let v of this._def.checks)if(v.kind==="min"){if(v.inclusive?$.datav.value:$.data>=v.value)Q=this._getOrReturnCtx($,Q),g(Q,{code:T.too_big,type:"bigint",maximum:v.value,inclusive:v.inclusive,message:v.message}),J.dirty()}else if(v.kind==="multipleOf"){if($.data%v.value!==BigInt(0))Q=this._getOrReturnCtx($,Q),g(Q,{code:T.not_multiple_of,multipleOf:v.value,message:v.message}),J.dirty()}else Z$.assertNever(v);return{status:J.value,value:$.data}}_getInvalidInput($){let X=this._getOrReturnCtx($);return g(X,{code:T.invalid_type,expected:y.bigint,received:X.parsedType}),X$}gte($,X){return this.setLimit("min",$,!0,p.toString(X))}gt($,X){return this.setLimit("min",$,!1,p.toString(X))}lte($,X){return this.setLimit("max",$,!0,p.toString(X))}lt($,X){return this.setLimit("max",$,!1,p.toString(X))}setLimit($,X,Q,J){return new m8({...this._def,checks:[...this._def.checks,{kind:$,value:X,inclusive:Q,message:p.toString(J)}]})}_addCheck($){return new m8({...this._def,checks:[...this._def.checks,$]})}positive($){return this._addCheck({kind:"min",value:BigInt(0),inclusive:!1,message:p.toString($)})}negative($){return this._addCheck({kind:"max",value:BigInt(0),inclusive:!1,message:p.toString($)})}nonpositive($){return this._addCheck({kind:"max",value:BigInt(0),inclusive:!0,message:p.toString($)})}nonnegative($){return this._addCheck({kind:"min",value:BigInt(0),inclusive:!0,message:p.toString($)})}multipleOf($,X){return this._addCheck({kind:"multipleOf",value:$,message:p.toString(X)})}get minValue(){let $=null;for(let X of this._def.checks)if(X.kind==="min"){if($===null||X.value>$)$=X.value}return $}get maxValue(){let $=null;for(let X of this._def.checks)if(X.kind==="max"){if($===null||X.value<$)$=X.value}return $}}m8.create=($)=>{return new m8({checks:[],typeName:b.ZodBigInt,coerce:$?.coerce??!1,...H$($)})};class iQ extends O${_parse($){if(this._def.coerce)$.data=Boolean($.data);if(this._getType($)!==y.boolean){let Q=this._getOrReturnCtx($);return g(Q,{code:T.invalid_type,expected:y.boolean,received:Q.parsedType}),X$}return Q6($.data)}}iQ.create=($)=>{return new iQ({typeName:b.ZodBoolean,coerce:$?.coerce||!1,...H$($)})};class p9 extends O${_parse($){if(this._def.coerce)$.data=new Date($.data);if(this._getType($)!==y.date){let v=this._getOrReturnCtx($);return g(v,{code:T.invalid_type,expected:y.date,received:v.parsedType}),X$}if(Number.isNaN($.data.getTime())){let v=this._getOrReturnCtx($);return g(v,{code:T.invalid_date}),X$}let Q=new d4,J=void 0;for(let v of this._def.checks)if(v.kind==="min"){if($.data.getTime()v.value)J=this._getOrReturnCtx($,J),g(J,{code:T.too_big,message:v.message,inclusive:!0,exact:!1,maximum:v.value,type:"date"}),Q.dirty()}else Z$.assertNever(v);return{status:Q.value,value:new Date($.data.getTime())}}_addCheck($){return new p9({...this._def,checks:[...this._def.checks,$]})}min($,X){return this._addCheck({kind:"min",value:$.getTime(),message:p.toString(X)})}max($,X){return this._addCheck({kind:"max",value:$.getTime(),message:p.toString(X)})}get minDate(){let $=null;for(let X of this._def.checks)if(X.kind==="min"){if($===null||X.value>$)$=X.value}return $!=null?new Date($):null}get maxDate(){let $=null;for(let X of this._def.checks)if(X.kind==="max"){if($===null||X.value<$)$=X.value}return $!=null?new Date($):null}}p9.create=($)=>{return new p9({checks:[],coerce:$?.coerce||!1,typeName:b.ZodDate,...H$($)})};class TY extends O${_parse($){if(this._getType($)!==y.symbol){let Q=this._getOrReturnCtx($);return g(Q,{code:T.invalid_type,expected:y.symbol,received:Q.parsedType}),X$}return Q6($.data)}}TY.create=($)=>{return new TY({typeName:b.ZodSymbol,...H$($)})};class RQ extends O${_parse($){if(this._getType($)!==y.undefined){let Q=this._getOrReturnCtx($);return g(Q,{code:T.invalid_type,expected:y.undefined,received:Q.parsedType}),X$}return Q6($.data)}}RQ.create=($)=>{return new RQ({typeName:b.ZodUndefined,...H$($)})};class EQ extends O${_parse($){if(this._getType($)!==y.null){let Q=this._getOrReturnCtx($);return g(Q,{code:T.invalid_type,expected:y.null,received:Q.parsedType}),X$}return Q6($.data)}}EQ.create=($)=>{return new EQ({typeName:b.ZodNull,...H$($)})};class d9 extends O${constructor(){super(...arguments);this._any=!0}_parse($){return Q6($.data)}}d9.create=($)=>{return new d9({typeName:b.ZodAny,...H$($)})};class g8 extends O${constructor(){super(...arguments);this._unknown=!0}_parse($){return Q6($.data)}}g8.create=($)=>{return new g8({typeName:b.ZodUnknown,...H$($)})};class A0 extends O${_parse($){let X=this._getOrReturnCtx($);return g(X,{code:T.invalid_type,expected:y.never,received:X.parsedType}),X$}}A0.create=($)=>{return new A0({typeName:b.ZodNever,...H$($)})};class SY extends O${_parse($){if(this._getType($)!==y.undefined){let Q=this._getOrReturnCtx($);return g(Q,{code:T.invalid_type,expected:y.void,received:Q.parsedType}),X$}return Q6($.data)}}SY.create=($)=>{return new SY({typeName:b.ZodVoid,...H$($)})};class g1 extends O${_parse($){let{ctx:X,status:Q}=this._processInputParams($),J=this._def;if(X.parsedType!==y.array)return g(X,{code:T.invalid_type,expected:y.array,received:X.parsedType}),X$;if(J.exactLength!==null){let Y=X.data.length>J.exactLength.value,z=X.data.lengthJ.maxLength.value)g(X,{code:T.too_big,maximum:J.maxLength.value,type:"array",inclusive:!0,exact:!1,message:J.maxLength.message}),Q.dirty()}if(X.common.async)return Promise.all([...X.data].map((Y,z)=>{return J.type._parseAsync(new m1(X,Y,X.path,z))})).then((Y)=>{return d4.mergeArray(Q,Y)});let v=[...X.data].map((Y,z)=>{return J.type._parseSync(new m1(X,Y,X.path,z))});return d4.mergeArray(Q,v)}get element(){return this._def.type}min($,X){return new g1({...this._def,minLength:{value:$,message:p.toString(X)}})}max($,X){return new g1({...this._def,maxLength:{value:$,message:p.toString(X)}})}length($,X){return new g1({...this._def,exactLength:{value:$,message:p.toString(X)}})}nonempty($){return this.min(1,$)}}g1.create=($,X)=>{return new g1({type:$,minLength:null,maxLength:null,exactLength:null,typeName:b.ZodArray,...H$(X)})};function xQ($){if($ instanceof Q4){let X={};for(let Q in $.shape){let J=$.shape[Q];X[Q]=c1.create(xQ(J))}return new Q4({...$._def,shape:()=>X})}else if($ instanceof g1)return new g1({...$._def,type:xQ($.element)});else if($ instanceof c1)return c1.create(xQ($.unwrap()));else if($ instanceof X8)return X8.create(xQ($.unwrap()));else if($ instanceof Z0)return Z0.create($.items.map((X)=>xQ(X)));else return $}class Q4 extends O${constructor(){super(...arguments);this._cached=null,this.nonstrict=this.passthrough,this.augment=this.extend}_getCached(){if(this._cached!==null)return this._cached;let $=this._def.shape(),X=Z$.objectKeys($);return this._cached={shape:$,keys:X},this._cached}_parse($){if(this._getType($)!==y.object){let G=this._getOrReturnCtx($);return g(G,{code:T.invalid_type,expected:y.object,received:G.parsedType}),X$}let{status:Q,ctx:J}=this._processInputParams($),{shape:v,keys:Y}=this._getCached(),z=[];if(!(this._def.catchall instanceof A0&&this._def.unknownKeys==="strip")){for(let G in J.data)if(!Y.includes(G))z.push(G)}let W=[];for(let G of Y){let U=v[G],w=J.data[G];W.push({key:{status:"valid",value:G},value:U._parse(new m1(J,w,J.path,G)),alwaysSet:G in J.data})}if(this._def.catchall instanceof A0){let G=this._def.unknownKeys;if(G==="passthrough")for(let U of z)W.push({key:{status:"valid",value:U},value:{status:"valid",value:J.data[U]}});else if(G==="strict"){if(z.length>0)g(J,{code:T.unrecognized_keys,keys:z}),Q.dirty()}else if(G==="strip");else throw Error("Internal ZodObject error: invalid unknownKeys value.")}else{let G=this._def.catchall;for(let U of z){let w=J.data[U];W.push({key:{status:"valid",value:U},value:G._parse(new m1(J,w,J.path,U)),alwaysSet:U in J.data})}}if(J.common.async)return Promise.resolve().then(async()=>{let G=[];for(let U of W){let w=await U.key,H=await U.value;G.push({key:w,value:H,alwaysSet:U.alwaysSet})}return G}).then((G)=>{return d4.mergeObjectSync(Q,G)});else return d4.mergeObjectSync(Q,W)}get shape(){return this._def.shape()}strict($){return p.errToObj,new Q4({...this._def,unknownKeys:"strict",...$!==void 0?{errorMap:(X,Q)=>{let J=this._def.errorMap?.(X,Q).message??Q.defaultError;if(X.code==="unrecognized_keys")return{message:p.errToObj($).message??J};return{message:J}}}:{}})}strip(){return new Q4({...this._def,unknownKeys:"strip"})}passthrough(){return new Q4({...this._def,unknownKeys:"passthrough"})}extend($){return new Q4({...this._def,shape:()=>({...this._def.shape(),...$})})}merge($){return new Q4({unknownKeys:$._def.unknownKeys,catchall:$._def.catchall,shape:()=>({...this._def.shape(),...$._def.shape()}),typeName:b.ZodObject})}setKey($,X){return this.augment({[$]:X})}catchall($){return new Q4({...this._def,catchall:$})}pick($){let X={};for(let Q of Z$.objectKeys($))if($[Q]&&this.shape[Q])X[Q]=this.shape[Q];return new Q4({...this._def,shape:()=>X})}omit($){let X={};for(let Q of Z$.objectKeys(this.shape))if(!$[Q])X[Q]=this.shape[Q];return new Q4({...this._def,shape:()=>X})}deepPartial(){return xQ(this)}partial($){let X={};for(let Q of Z$.objectKeys(this.shape)){let J=this.shape[Q];if($&&!$[Q])X[Q]=J;else X[Q]=J.optional()}return new Q4({...this._def,shape:()=>X})}required($){let X={};for(let Q of Z$.objectKeys(this.shape))if($&&!$[Q])X[Q]=this.shape[Q];else{let v=this.shape[Q];while(v instanceof c1)v=v._def.innerType;X[Q]=v}return new Q4({...this._def,shape:()=>X})}keyof(){return Wb(Z$.objectKeys(this.shape))}}Q4.create=($,X)=>{return new Q4({shape:()=>$,unknownKeys:"strip",catchall:A0.create(),typeName:b.ZodObject,...H$(X)})};Q4.strictCreate=($,X)=>{return new Q4({shape:()=>$,unknownKeys:"strict",catchall:A0.create(),typeName:b.ZodObject,...H$(X)})};Q4.lazycreate=($,X)=>{return new Q4({shape:$,unknownKeys:"strip",catchall:A0.create(),typeName:b.ZodObject,...H$(X)})};class PQ extends O${_parse($){let{ctx:X}=this._processInputParams($),Q=this._def.options;function J(v){for(let z of v)if(z.result.status==="valid")return z.result;for(let z of v)if(z.result.status==="dirty")return X.common.issues.push(...z.ctx.common.issues),z.result;let Y=v.map((z)=>new u6(z.ctx.common.issues));return g(X,{code:T.invalid_union,unionErrors:Y}),X$}if(X.common.async)return Promise.all(Q.map(async(v)=>{let Y={...X,common:{...X.common,issues:[]},parent:null};return{result:await v._parseAsync({data:X.data,path:X.path,parent:Y}),ctx:Y}})).then(J);else{let v=void 0,Y=[];for(let W of Q){let G={...X,common:{...X.common,issues:[]},parent:null},U=W._parseSync({data:X.data,path:X.path,parent:G});if(U.status==="valid")return U;else if(U.status==="dirty"&&!v)v={result:U,ctx:G};if(G.common.issues.length)Y.push(G.common.issues)}if(v)return X.common.issues.push(...v.ctx.common.issues),v.result;let z=Y.map((W)=>new u6(W));return g(X,{code:T.invalid_union,unionErrors:z}),X$}}get options(){return this._def.options}}PQ.create=($,X)=>{return new PQ({options:$,typeName:b.ZodUnion,...H$(X)})};var $8=($)=>{if($ instanceof SQ)return $8($.schema);else if($ instanceof h1)return $8($.innerType());else if($ instanceof CQ)return[$.value];else if($ instanceof h8)return $.options;else if($ instanceof uQ)return Z$.objectValues($.enum);else if($ instanceof _Q)return $8($._def.innerType);else if($ instanceof RQ)return[void 0];else if($ instanceof EQ)return[null];else if($ instanceof c1)return[void 0,...$8($.unwrap())];else if($ instanceof X8)return[null,...$8($.unwrap())];else if($ instanceof XU)return $8($.unwrap());else if($ instanceof fQ)return $8($.unwrap());else if($ instanceof yQ)return $8($._def.innerType);else return[]};class $U extends O${_parse($){let{ctx:X}=this._processInputParams($);if(X.parsedType!==y.object)return g(X,{code:T.invalid_type,expected:y.object,received:X.parsedType}),X$;let Q=this.discriminator,J=X.data[Q],v=this.optionsMap.get(J);if(!v)return g(X,{code:T.invalid_union_discriminator,options:Array.from(this.optionsMap.keys()),path:[Q]}),X$;if(X.common.async)return v._parseAsync({data:X.data,path:X.path,parent:X});else return v._parseSync({data:X.data,path:X.path,parent:X})}get discriminator(){return this._def.discriminator}get options(){return this._def.options}get optionsMap(){return this._def.optionsMap}static create($,X,Q){let J=new Map;for(let v of X){let Y=$8(v.shape[$]);if(!Y.length)throw Error(`A discriminator value for key \`${$}\` could not be extracted from all schema options`);for(let z of Y){if(J.has(z))throw Error(`Discriminator property ${String($)} has duplicate value ${String(z)}`);J.set(z,v)}}return new $U({typeName:b.ZodDiscriminatedUnion,discriminator:$,options:X,optionsMap:J,...H$(Q)})}}function h3($,X){let Q=L0($),J=L0(X);if($===X)return{valid:!0,data:$};else if(Q===y.object&&J===y.object){let v=Z$.objectKeys(X),Y=Z$.objectKeys($).filter((W)=>v.indexOf(W)!==-1),z={...$,...X};for(let W of Y){let G=h3($[W],X[W]);if(!G.valid)return{valid:!1};z[W]=G.data}return{valid:!0,data:z}}else if(Q===y.array&&J===y.array){if($.length!==X.length)return{valid:!1};let v=[];for(let Y=0;Y<$.length;Y++){let z=$[Y],W=X[Y],G=h3(z,W);if(!G.valid)return{valid:!1};v.push(G.data)}return{valid:!0,data:v}}else if(Q===y.date&&J===y.date&&+$===+X)return{valid:!0,data:$};else return{valid:!1}}class TQ extends O${_parse($){let{status:X,ctx:Q}=this._processInputParams($),J=(v,Y)=>{if(sG(v)||sG(Y))return X$;let z=h3(v.value,Y.value);if(!z.valid)return g(Q,{code:T.invalid_intersection_types}),X$;if(eG(v)||eG(Y))X.dirty();return{status:X.value,value:z.data}};if(Q.common.async)return Promise.all([this._def.left._parseAsync({data:Q.data,path:Q.path,parent:Q}),this._def.right._parseAsync({data:Q.data,path:Q.path,parent:Q})]).then(([v,Y])=>J(v,Y));else return J(this._def.left._parseSync({data:Q.data,path:Q.path,parent:Q}),this._def.right._parseSync({data:Q.data,path:Q.path,parent:Q}))}}TQ.create=($,X,Q)=>{return new TQ({left:$,right:X,typeName:b.ZodIntersection,...H$(Q)})};class Z0 extends O${_parse($){let{status:X,ctx:Q}=this._processInputParams($);if(Q.parsedType!==y.array)return g(Q,{code:T.invalid_type,expected:y.array,received:Q.parsedType}),X$;if(Q.data.lengththis._def.items.length)g(Q,{code:T.too_big,maximum:this._def.items.length,inclusive:!0,exact:!1,type:"array"}),X.dirty();let v=[...Q.data].map((Y,z)=>{let W=this._def.items[z]||this._def.rest;if(!W)return null;return W._parse(new m1(Q,Y,Q.path,z))}).filter((Y)=>!!Y);if(Q.common.async)return Promise.all(v).then((Y)=>{return d4.mergeArray(X,Y)});else return d4.mergeArray(X,v)}get items(){return this._def.items}rest($){return new Z0({...this._def,rest:$})}}Z0.create=($,X)=>{if(!Array.isArray($))throw Error("You must pass an array of schemas to z.tuple([ ... ])");return new Z0({items:$,typeName:b.ZodTuple,rest:null,...H$(X)})};class CY extends O${get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse($){let{status:X,ctx:Q}=this._processInputParams($);if(Q.parsedType!==y.object)return g(Q,{code:T.invalid_type,expected:y.object,received:Q.parsedType}),X$;let J=[],v=this._def.keyType,Y=this._def.valueType;for(let z in Q.data)J.push({key:v._parse(new m1(Q,z,Q.path,z)),value:Y._parse(new m1(Q,Q.data[z],Q.path,z)),alwaysSet:z in Q.data});if(Q.common.async)return d4.mergeObjectAsync(X,J);else return d4.mergeObjectSync(X,J)}get element(){return this._def.valueType}static create($,X,Q){if(X instanceof O$)return new CY({keyType:$,valueType:X,typeName:b.ZodRecord,...H$(Q)});return new CY({keyType:n1.create(),valueType:$,typeName:b.ZodRecord,...H$(X)})}}class uY extends O${get keySchema(){return this._def.keyType}get valueSchema(){return this._def.valueType}_parse($){let{status:X,ctx:Q}=this._processInputParams($);if(Q.parsedType!==y.map)return g(Q,{code:T.invalid_type,expected:y.map,received:Q.parsedType}),X$;let J=this._def.keyType,v=this._def.valueType,Y=[...Q.data.entries()].map(([z,W],G)=>{return{key:J._parse(new m1(Q,z,Q.path,[G,"key"])),value:v._parse(new m1(Q,W,Q.path,[G,"value"]))}});if(Q.common.async){let z=new Map;return Promise.resolve().then(async()=>{for(let W of Y){let G=await W.key,U=await W.value;if(G.status==="aborted"||U.status==="aborted")return X$;if(G.status==="dirty"||U.status==="dirty")X.dirty();z.set(G.value,U.value)}return{status:X.value,value:z}})}else{let z=new Map;for(let W of Y){let{key:G,value:U}=W;if(G.status==="aborted"||U.status==="aborted")return X$;if(G.status==="dirty"||U.status==="dirty")X.dirty();z.set(G.value,U.value)}return{status:X.value,value:z}}}}uY.create=($,X,Q)=>{return new uY({valueType:X,keyType:$,typeName:b.ZodMap,...H$(Q)})};class o9 extends O${_parse($){let{status:X,ctx:Q}=this._processInputParams($);if(Q.parsedType!==y.set)return g(Q,{code:T.invalid_type,expected:y.set,received:Q.parsedType}),X$;let J=this._def;if(J.minSize!==null){if(Q.data.sizeJ.maxSize.value)g(Q,{code:T.too_big,maximum:J.maxSize.value,type:"set",inclusive:!0,exact:!1,message:J.maxSize.message}),X.dirty()}let v=this._def.valueType;function Y(W){let G=new Set;for(let U of W){if(U.status==="aborted")return X$;if(U.status==="dirty")X.dirty();G.add(U.value)}return{status:X.value,value:G}}let z=[...Q.data.values()].map((W,G)=>v._parse(new m1(Q,W,Q.path,G)));if(Q.common.async)return Promise.all(z).then((W)=>Y(W));else return Y(z)}min($,X){return new o9({...this._def,minSize:{value:$,message:p.toString(X)}})}max($,X){return new o9({...this._def,maxSize:{value:$,message:p.toString(X)}})}size($,X){return this.min($,X).max($,X)}nonempty($){return this.min(1,$)}}o9.create=($,X)=>{return new o9({valueType:$,minSize:null,maxSize:null,typeName:b.ZodSet,...H$(X)})};class bQ extends O${constructor(){super(...arguments);this.validate=this.implement}_parse($){let{ctx:X}=this._processInputParams($);if(X.parsedType!==y.function)return g(X,{code:T.invalid_type,expected:y.function,received:X.parsedType}),X$;function Q(z,W){return PY({data:z,path:X.path,errorMaps:[X.common.contextualErrorMap,X.schemaErrorMap,IQ(),e0].filter((G)=>!!G),issueData:{code:T.invalid_arguments,argumentsError:W}})}function J(z,W){return PY({data:z,path:X.path,errorMaps:[X.common.contextualErrorMap,X.schemaErrorMap,IQ(),e0].filter((G)=>!!G),issueData:{code:T.invalid_return_type,returnTypeError:W}})}let v={errorMap:X.common.contextualErrorMap},Y=X.data;if(this._def.returns instanceof a9){let z=this;return Q6(async function(...W){let G=new u6([]),U=await z._def.args.parseAsync(W,v).catch((j)=>{throw G.addIssue(Q(W,j)),G}),w=await Reflect.apply(Y,this,U);return await z._def.returns._def.type.parseAsync(w,v).catch((j)=>{throw G.addIssue(J(w,j)),G})})}else{let z=this;return Q6(function(...W){let G=z._def.args.safeParse(W,v);if(!G.success)throw new u6([Q(W,G.error)]);let U=Reflect.apply(Y,this,G.data),w=z._def.returns.safeParse(U,v);if(!w.success)throw new u6([J(U,w.error)]);return w.data})}}parameters(){return this._def.args}returnType(){return this._def.returns}args(...$){return new bQ({...this._def,args:Z0.create($).rest(g8.create())})}returns($){return new bQ({...this._def,returns:$})}implement($){return this.parse($)}strictImplement($){return this.parse($)}static create($,X,Q){return new bQ({args:$?$:Z0.create([]).rest(g8.create()),returns:X||g8.create(),typeName:b.ZodFunction,...H$(Q)})}}class SQ extends O${get schema(){return this._def.getter()}_parse($){let{ctx:X}=this._processInputParams($);return this._def.getter()._parse({data:X.data,path:X.path,parent:X})}}SQ.create=($,X)=>{return new SQ({getter:$,typeName:b.ZodLazy,...H$(X)})};class CQ extends O${_parse($){if($.data!==this._def.value){let X=this._getOrReturnCtx($);return g(X,{received:X.data,code:T.invalid_literal,expected:this._def.value}),X$}return{status:"valid",value:$.data}}get value(){return this._def.value}}CQ.create=($,X)=>{return new CQ({value:$,typeName:b.ZodLiteral,...H$(X)})};function Wb($,X){return new h8({values:$,typeName:b.ZodEnum,...H$(X)})}class h8 extends O${_parse($){if(typeof $.data!=="string"){let X=this._getOrReturnCtx($),Q=this._def.values;return g(X,{expected:Z$.joinValues(Q),received:X.parsedType,code:T.invalid_type}),X$}if(!this._cache)this._cache=new Set(this._def.values);if(!this._cache.has($.data)){let X=this._getOrReturnCtx($),Q=this._def.values;return g(X,{received:X.data,code:T.invalid_enum_value,options:Q}),X$}return Q6($.data)}get options(){return this._def.values}get enum(){let $={};for(let X of this._def.values)$[X]=X;return $}get Values(){let $={};for(let X of this._def.values)$[X]=X;return $}get Enum(){let $={};for(let X of this._def.values)$[X]=X;return $}extract($,X=this._def){return h8.create($,{...this._def,...X})}exclude($,X=this._def){return h8.create(this.options.filter((Q)=>!$.includes(Q)),{...this._def,...X})}}h8.create=Wb;class uQ extends O${_parse($){let X=Z$.getValidEnumValues(this._def.values),Q=this._getOrReturnCtx($);if(Q.parsedType!==y.string&&Q.parsedType!==y.number){let J=Z$.objectValues(X);return g(Q,{expected:Z$.joinValues(J),received:Q.parsedType,code:T.invalid_type}),X$}if(!this._cache)this._cache=new Set(Z$.getValidEnumValues(this._def.values));if(!this._cache.has($.data)){let J=Z$.objectValues(X);return g(Q,{received:Q.data,code:T.invalid_enum_value,options:J}),X$}return Q6($.data)}get enum(){return this._def.values}}uQ.create=($,X)=>{return new uQ({values:$,typeName:b.ZodNativeEnum,...H$(X)})};class a9 extends O${unwrap(){return this._def.type}_parse($){let{ctx:X}=this._processInputParams($);if(X.parsedType!==y.promise&&X.common.async===!1)return g(X,{code:T.invalid_type,expected:y.promise,received:X.parsedType}),X$;let Q=X.parsedType===y.promise?X.data:Promise.resolve(X.data);return Q6(Q.then((J)=>{return this._def.type.parseAsync(J,{path:X.path,errorMap:X.common.contextualErrorMap})}))}}a9.create=($,X)=>{return new a9({type:$,typeName:b.ZodPromise,...H$(X)})};class h1 extends O${innerType(){return this._def.schema}sourceType(){return this._def.schema._def.typeName===b.ZodEffects?this._def.schema.sourceType():this._def.schema}_parse($){let{status:X,ctx:Q}=this._processInputParams($),J=this._def.effect||null,v={addIssue:(Y)=>{if(g(Q,Y),Y.fatal)X.abort();else X.dirty()},get path(){return Q.path}};if(v.addIssue=v.addIssue.bind(v),J.type==="preprocess"){let Y=J.transform(Q.data,v);if(Q.common.async)return Promise.resolve(Y).then(async(z)=>{if(X.value==="aborted")return X$;let W=await this._def.schema._parseAsync({data:z,path:Q.path,parent:Q});if(W.status==="aborted")return X$;if(W.status==="dirty")return l9(W.value);if(X.value==="dirty")return l9(W.value);return W});else{if(X.value==="aborted")return X$;let z=this._def.schema._parseSync({data:Y,path:Q.path,parent:Q});if(z.status==="aborted")return X$;if(z.status==="dirty")return l9(z.value);if(X.value==="dirty")return l9(z.value);return z}}if(J.type==="refinement"){let Y=(z)=>{let W=J.refinement(z,v);if(Q.common.async)return Promise.resolve(W);if(W instanceof Promise)throw Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead.");return z};if(Q.common.async===!1){let z=this._def.schema._parseSync({data:Q.data,path:Q.path,parent:Q});if(z.status==="aborted")return X$;if(z.status==="dirty")X.dirty();return Y(z.value),{status:X.value,value:z.value}}else return this._def.schema._parseAsync({data:Q.data,path:Q.path,parent:Q}).then((z)=>{if(z.status==="aborted")return X$;if(z.status==="dirty")X.dirty();return Y(z.value).then(()=>{return{status:X.value,value:z.value}})})}if(J.type==="transform")if(Q.common.async===!1){let Y=this._def.schema._parseSync({data:Q.data,path:Q.path,parent:Q});if(!n8(Y))return X$;let z=J.transform(Y.value,v);if(z instanceof Promise)throw Error("Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.");return{status:X.value,value:z}}else return this._def.schema._parseAsync({data:Q.data,path:Q.path,parent:Q}).then((Y)=>{if(!n8(Y))return X$;return Promise.resolve(J.transform(Y.value,v)).then((z)=>({status:X.value,value:z}))});Z$.assertNever(J)}}h1.create=($,X,Q)=>{return new h1({schema:$,typeName:b.ZodEffects,effect:X,...H$(Q)})};h1.createWithPreprocess=($,X,Q)=>{return new h1({schema:X,effect:{type:"preprocess",transform:$},typeName:b.ZodEffects,...H$(Q)})};class c1 extends O${_parse($){if(this._getType($)===y.undefined)return Q6(void 0);return this._def.innerType._parse($)}unwrap(){return this._def.innerType}}c1.create=($,X)=>{return new c1({innerType:$,typeName:b.ZodOptional,...H$(X)})};class X8 extends O${_parse($){if(this._getType($)===y.null)return Q6(null);return this._def.innerType._parse($)}unwrap(){return this._def.innerType}}X8.create=($,X)=>{return new X8({innerType:$,typeName:b.ZodNullable,...H$(X)})};class _Q extends O${_parse($){let{ctx:X}=this._processInputParams($),Q=X.data;if(X.parsedType===y.undefined)Q=this._def.defaultValue();return this._def.innerType._parse({data:Q,path:X.path,parent:X})}removeDefault(){return this._def.innerType}}_Q.create=($,X)=>{return new _Q({innerType:$,typeName:b.ZodDefault,defaultValue:typeof X.default==="function"?X.default:()=>X.default,...H$(X)})};class yQ extends O${_parse($){let{ctx:X}=this._processInputParams($),Q={...X,common:{...X.common,issues:[]}},J=this._def.innerType._parse({data:Q.data,path:Q.path,parent:{...Q}});if(MQ(J))return J.then((v)=>{return{status:"valid",value:v.status==="valid"?v.value:this._def.catchValue({get error(){return new u6(Q.common.issues)},input:Q.data})}});else return{status:"valid",value:J.status==="valid"?J.value:this._def.catchValue({get error(){return new u6(Q.common.issues)},input:Q.data})}}removeCatch(){return this._def.innerType}}yQ.create=($,X)=>{return new yQ({innerType:$,typeName:b.ZodCatch,catchValue:typeof X.catch==="function"?X.catch:()=>X.catch,...H$(X)})};class _Y extends O${_parse($){if(this._getType($)!==y.nan){let Q=this._getOrReturnCtx($);return g(Q,{code:T.invalid_type,expected:y.nan,received:Q.parsedType}),X$}return{status:"valid",value:$.data}}}_Y.create=($)=>{return new _Y({typeName:b.ZodNaN,...H$($)})};var Qt=Symbol("zod_brand");class XU extends O${_parse($){let{ctx:X}=this._processInputParams($),Q=X.data;return this._def.type._parse({data:Q,path:X.path,parent:X})}unwrap(){return this._def.type}}class yY extends O${_parse($){let{status:X,ctx:Q}=this._processInputParams($);if(Q.common.async)return(async()=>{let v=await this._def.in._parseAsync({data:Q.data,path:Q.path,parent:Q});if(v.status==="aborted")return X$;if(v.status==="dirty")return X.dirty(),l9(v.value);else return this._def.out._parseAsync({data:v.value,path:Q.path,parent:Q})})();else{let J=this._def.in._parseSync({data:Q.data,path:Q.path,parent:Q});if(J.status==="aborted")return X$;if(J.status==="dirty")return X.dirty(),{status:"dirty",value:J.value};else return this._def.out._parseSync({data:J.value,path:Q.path,parent:Q})}}static create($,X){return new yY({in:$,out:X,typeName:b.ZodPipeline})}}class fQ extends O${_parse($){let X=this._def.innerType._parse($),Q=(J)=>{if(n8(J))J.value=Object.freeze(J.value);return J};return MQ(X)?X.then((J)=>Q(J)):Q(X)}unwrap(){return this._def.innerType}}fQ.create=($,X)=>{return new fQ({innerType:$,typeName:b.ZodReadonly,...H$(X)})};function vb($,X){let Q=typeof $==="function"?$(X):typeof $==="string"?{message:$}:$;return typeof Q==="string"?{message:Q}:Q}function Gb($,X={},Q){if($)return d9.create().superRefine((J,v)=>{let Y=$(J);if(Y instanceof Promise)return Y.then((z)=>{if(!z){let W=vb(X,J),G=W.fatal??Q??!0;v.addIssue({code:"custom",...W,fatal:G})}});if(!Y){let z=vb(X,J),W=z.fatal??Q??!0;v.addIssue({code:"custom",...z,fatal:W})}return});return d9.create()}var vt={object:Q4.lazycreate},b;(function($){$.ZodString="ZodString",$.ZodNumber="ZodNumber",$.ZodNaN="ZodNaN",$.ZodBigInt="ZodBigInt",$.ZodBoolean="ZodBoolean",$.ZodDate="ZodDate",$.ZodSymbol="ZodSymbol",$.ZodUndefined="ZodUndefined",$.ZodNull="ZodNull",$.ZodAny="ZodAny",$.ZodUnknown="ZodUnknown",$.ZodNever="ZodNever",$.ZodVoid="ZodVoid",$.ZodArray="ZodArray",$.ZodObject="ZodObject",$.ZodUnion="ZodUnion",$.ZodDiscriminatedUnion="ZodDiscriminatedUnion",$.ZodIntersection="ZodIntersection",$.ZodTuple="ZodTuple",$.ZodRecord="ZodRecord",$.ZodMap="ZodMap",$.ZodSet="ZodSet",$.ZodFunction="ZodFunction",$.ZodLazy="ZodLazy",$.ZodLiteral="ZodLiteral",$.ZodEnum="ZodEnum",$.ZodEffects="ZodEffects",$.ZodNativeEnum="ZodNativeEnum",$.ZodOptional="ZodOptional",$.ZodNullable="ZodNullable",$.ZodDefault="ZodDefault",$.ZodCatch="ZodCatch",$.ZodPromise="ZodPromise",$.ZodBranded="ZodBranded",$.ZodPipeline="ZodPipeline",$.ZodReadonly="ZodReadonly"})(b||(b={}));var Jt=($,X={message:`Input not instance of ${$.name}`})=>Gb((Q)=>Q instanceof $,X),Ub=n1.create,Kb=c8.create,Yt=_Y.create,zt=m8.create,wb=iQ.create,Wt=p9.create,Gt=TY.create,Ut=RQ.create,Kt=EQ.create,wt=d9.create,Ht=g8.create,jt=A0.create,Vt=SY.create,Bt=g1.create,l3=Q4.create,Dt=Q4.strictCreate,Ft=PQ.create,Nt=$U.create,Ot=TQ.create,Lt=Z0.create,At=CY.create,Zt=uY.create,qt=o9.create,It=bQ.create,Mt=SQ.create,xt=CQ.create,bt=h8.create,it=uQ.create,Rt=a9.create,Et=h1.create,Pt=c1.create,Tt=X8.create,St=h1.createWithPreprocess,Ct=yY.create,ut=()=>Ub().optional(),_t=()=>Kb().optional(),yt=()=>wb().optional(),ft={string:($)=>n1.create({...$,coerce:!0}),number:($)=>c8.create({...$,coerce:!0}),boolean:($)=>iQ.create({...$,coerce:!0}),bigint:($)=>m8.create({...$,coerce:!0}),date:($)=>p9.create({...$,coerce:!0})};var kt=X$;class d3 extends Error{constructor($,X){super($),this.name="ParseError",this.type=X.type,this.field=X.field,this.value=X.value,this.line=X.line}}function p3($){}function Hb($){if(typeof $=="function")throw TypeError("`callbacks` must be an object, got a function instead. Did you mean `{onEvent: fn}`?");let{onEvent:X=p3,onError:Q=p3,onRetry:J=p3,onComment:v}=$,Y="",z=!0,W,G="",U="";function w(D){let F=z?D.replace(/^\xEF\xBB\xBF/,""):D,[L,A]=gt(`${Y}${F}`);for(let O of L)H(O);Y=A,z=!1}function H(D){if(D===""){V();return}if(D.startsWith(":")){v&&v(D.slice(D.startsWith(": ")?2:1));return}let F=D.indexOf(":");if(F!==-1){let L=D.slice(0,F),A=D[F+1]===" "?2:1,O=D.slice(F+A);j(L,O,D);return}j(D,"",D)}function j(D,F,L){switch(D){case"event":U=F;break;case"data":G=`${G}${F} +`;break;case"id":W=F.includes("\x00")?void 0:F;break;case"retry":/^\d+$/.test(F)?J(parseInt(F,10)):Q(new d3(`Invalid \`retry\` value: "${F}"`,{type:"invalid-retry",value:F,line:L}));break;default:Q(new d3(`Unknown field "${D.length>20?`${D.slice(0,20)}…`:D}"`,{type:"unknown-field",field:D,value:F,line:L}));break}}function V(){G.length>0&&X({id:W,event:U||void 0,data:G.endsWith(` +`)?G.slice(0,-1):G}),W=void 0,G="",U=""}function B(D={}){Y&&D.consume&&H(Y),z=!0,W=void 0,G="",U="",Y=""}return{feed:w,reset:B}}function gt($){let X=[],Q="",J=0;for(;J<$.length;){let v=$.indexOf("\r",J),Y=$.indexOf(` +`,J),z=-1;if(v!==-1&&Y!==-1?z=Math.min(v,Y):v!==-1?v===$.length-1?z=-1:z=v:Y!==-1&&(z=Y),z===-1){Q=$.slice(J);break}else{let W=$.slice(J,z);X.push(W),J=z+1,$[J-1]==="\r"&&$[J]===` +`&&J++}}return[X,Q]}class o3 extends TransformStream{constructor({onError:$,onRetry:X,onComment:Q}={}){let J;super({start(v){J=Hb({onEvent:(Y)=>{v.enqueue(Y)},onError(Y){$==="terminate"?v.error(Y):typeof $=="function"&&$(Y)},onRetry:X,onComment:Q})},transform(v){J.feed(v)}})}}function A4(...$){return $.reduce((X,Q)=>({...X,...Q!=null?Q:{}}),{})}async function Ob($,X){if($==null)return Promise.resolve();let Q=X==null?void 0:X.abortSignal;return new Promise((J,v)=>{if(Q==null?void 0:Q.aborted){v(jb());return}let Y=setTimeout(()=>{z(),J()},$),z=()=>{clearTimeout(Y),Q==null||Q.removeEventListener("abort",W)},W=()=>{z(),v(jb())};Q==null||Q.addEventListener("abort",W)})}function jb(){return new DOMException("Delay was aborted","AbortError")}var t9=class{constructor(){this.status={type:"pending"},this._resolve=void 0,this._reject=void 0}get promise(){if(this._promise)return this._promise;return this._promise=new Promise(($,X)=>{if(this.status.type==="resolved")$(this.status.value);else if(this.status.type==="rejected")X(this.status.error);this._resolve=$,this._reject=X}),this._promise}resolve($){var X;if(this.status={type:"resolved",value:$},this._promise)(X=this._resolve)==null||X.call(this,$)}reject($){var X;if(this.status={type:"rejected",error:$},this._promise)(X=this._reject)==null||X.call(this,$)}isResolved(){return this.status.type==="resolved"}isRejected(){return this.status.type==="rejected"}isPending(){return this.status.type==="pending"}};function fY($){return Object.fromEntries([...$.headers])}var{btoa:mt,atob:ht}=globalThis;function l8($){let X=$.replace(/-/g,"+").replace(/_/g,"/"),Q=ht(X);return Uint8Array.from(Q,(J)=>J.codePointAt(0))}function q0($){let X="";for(let Q=0;Q<$.length;Q++)X+=String.fromCodePoint($[Q]);return mt(X)}function QU($){return $ instanceof Uint8Array?q0($):$}function Lb($,X={}){let{useArrayBrackets:Q=!0}=X,J=new FormData;for(let[v,Y]of Object.entries($)){if(Y==null)continue;if(Array.isArray(Y)){if(Y.length===1){J.append(v,Y[0]);continue}let z=Q?`${v}[]`:v;for(let W of Y)J.append(z,W);continue}J.append(v,Y)}return J}var Ab="AI_DownloadError",Zb=`vercel.ai.error.${Ab}`,lt=Symbol.for(Zb),Vb,Bb,Q8=class extends(Bb=m,Vb=lt,Bb){constructor({url:$,statusCode:X,statusText:Q,cause:J,message:v=J==null?`Failed to download ${$}: ${X} ${Q}`:`Failed to download ${$}: ${J}`}){super({name:Ab,message:v,cause:J});this[Vb]=!0,this.url=$,this.statusCode=X,this.statusText=Q}static isInstance($){return m.hasMarker($,Zb)}},vU=2147483648;async function e3({response:$,url:X,maxBytes:Q=vU}){let J=$.headers.get("content-length");if(J!=null){let w=parseInt(J,10);if(!isNaN(w)&&w>Q)throw new Q8({url:X,message:`Download of ${X} exceeded maximum size of ${Q} bytes (Content-Length: ${w}).`})}let v=$.body;if(v==null)return new Uint8Array(0);let Y=v.getReader(),z=[],W=0;try{while(!0){let{done:w,value:H}=await Y.read();if(w)break;if(W+=H.length,W>Q)throw new Q8({url:X,message:`Download of ${X} exceeded maximum size of ${Q} bytes.`});z.push(H)}}finally{try{await Y.cancel()}finally{Y.releaseLock()}}let G=new Uint8Array(W),U=0;for(let w of z)G.set(w,U),U+=w.length;return G}async function qb($,X){var Q,J;try{let v=await fetch($,{signal:X==null?void 0:X.abortSignal});if(!v.ok)throw new Q8({url:$,statusCode:v.status,statusText:v.statusText});let Y=await e3({response:v,url:$,maxBytes:(Q=X==null?void 0:X.maxBytes)!=null?Q:vU}),z=(J=v.headers.get("content-type"))!=null?J:void 0;return new Blob([Y],z?{type:z}:void 0)}catch(v){if(Q8.isInstance(v))throw v;throw new Q8({url:$,cause:v})}}var I0=({prefix:$,size:X=16,alphabet:Q="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",separator:J="-"}={})=>{let v=()=>{let Y=Q.length,z=Array(X);for(let W=0;W`${$}${J}${v()}`},kQ=I0();function kY($){if($==null)return"unknown error";if(typeof $==="string")return $;if($ instanceof Error)return $.message;return JSON.stringify($)}function v8($){return($ instanceof Error||$ instanceof DOMException)&&($.name==="AbortError"||$.name==="ResponseAborted"||$.name==="TimeoutError")}var pt=["fetch failed","failed to fetch"],dt=["ConnectionRefused","ConnectionClosed","FailedToOpenSocket","ECONNRESET","ECONNREFUSED","ETIMEDOUT","EPIPE"];function ot($){if(!($ instanceof Error))return!1;let X=$.code;if(typeof X==="string"&&dt.includes(X))return!0;return!1}function Ib({error:$,url:X,requestBodyValues:Q}){if(v8($))return $;if($ instanceof TypeError&&pt.includes($.message.toLowerCase())){let J=$.cause;if(J!=null)return new h$({message:`Cannot connect to API: ${J.message}`,cause:J,url:X,requestBodyValues:Q,isRetryable:!0})}if(ot($))return new h$({message:`Cannot connect to API: ${$.message}`,cause:$,url:X,requestBodyValues:Q,isRetryable:!0});return $}function JU($=globalThis){var X,Q,J;if($.window)return"runtime/browser";if((X=$.navigator)==null?void 0:X.userAgent)return`runtime/${$.navigator.userAgent.toLowerCase()}`;if((J=(Q=$.process)==null?void 0:Q.versions)==null?void 0:J.node)return`runtime/node.js/${$.process.version.substring(0)}`;if($.EdgeRuntime)return"runtime/vercel-edge";return"runtime/unknown"}function at($){if($==null)return{};let X={};if($ instanceof Headers)$.forEach((Q,J)=>{X[J.toLowerCase()]=Q});else{if(!Array.isArray($))$=Object.entries($);for(let[Q,J]of $)if(J!=null)X[Q.toLowerCase()]=J}return X}function M0($,...X){let Q=new Headers(at($)),J=Q.get("user-agent")||"";return Q.set("user-agent",[J,...X].filter(Boolean).join(" ")),Object.fromEntries(Q.entries())}var Mb="4.0.15",rt=()=>globalThis.fetch,$N=async({url:$,headers:X={},successfulResponseHandler:Q,failedResponseHandler:J,abortSignal:v,fetch:Y=rt()})=>{try{let z=await Y($,{method:"GET",headers:M0(X,`ai-sdk/provider-utils/${Mb}`,JU()),signal:v}),W=fY(z);if(!z.ok){let G;try{G=await J({response:z,url:$,requestBodyValues:{}})}catch(U){if(v8(U)||h$.isInstance(U))throw U;throw new h$({message:"Failed to process error response",cause:U,statusCode:z.status,url:$,responseHeaders:W,requestBodyValues:{}})}throw G.value}try{return await Q({response:z,url:$,requestBodyValues:{}})}catch(G){if(G instanceof Error){if(v8(G)||h$.isInstance(G))throw G}throw new h$({message:"Failed to process successful response",cause:G,statusCode:z.status,url:$,responseHeaders:W,requestBodyValues:{}})}}catch(z){throw Ib({error:z,url:$,requestBodyValues:{}})}};function xb($){return $!=null}function bb({mediaType:$,url:X,supportedUrls:Q}){return X=X.toLowerCase(),$=$.toLowerCase(),Object.entries(Q).map(([J,v])=>{let Y=J.toLowerCase();return Y==="*"||Y==="*/*"?{mediaTypePrefix:"",regexes:v}:{mediaTypePrefix:Y.replace(/\*/,""),regexes:v}}).filter(({mediaTypePrefix:J})=>$.startsWith(J)).flatMap(({regexes:J})=>J).some((J)=>J.test(X))}function nY({settingValue:$,environmentVariableName:X}){if(typeof $==="string")return $;if($!=null||typeof process>"u")return;if($=process.env[X],$==null||typeof $!=="string")return;return $}var tt=/"__proto__"\s*:/,st=/"constructor"\s*:/;function Db($){let X=JSON.parse($);if(X===null||typeof X!=="object")return X;if(tt.test($)===!1&&st.test($)===!1)return X;return et(X)}function et($){let X=[$];while(X.length){let Q=X;X=[];for(let J of Q){if(Object.prototype.hasOwnProperty.call(J,"__proto__"))throw SyntaxError("Object contains forbidden prototype property");if(Object.prototype.hasOwnProperty.call(J,"constructor")&&Object.prototype.hasOwnProperty.call(J.constructor,"prototype"))throw SyntaxError("Object contains forbidden prototype property");for(let v in J){let Y=J[v];if(Y&&typeof Y==="object")X.push(Y)}}}return $}function XN($){let{stackTraceLimit:X}=Error;try{Error.stackTraceLimit=0}catch(Q){return Db($)}try{return Db($)}finally{Error.stackTraceLimit=X}}function QN($){if($.type==="object"||Array.isArray($.type)&&$.type.includes("object")){$.additionalProperties=!1;let{properties:Q}=$;if(Q!=null)for(let J of Object.keys(Q))Q[J]=r9(Q[J])}if($.items!=null)$.items=Array.isArray($.items)?$.items.map(r9):r9($.items);if($.anyOf!=null)$.anyOf=$.anyOf.map(r9);if($.allOf!=null)$.allOf=$.allOf.map(r9);if($.oneOf!=null)$.oneOf=$.oneOf.map(r9);let{definitions:X}=$;if(X!=null)for(let Q of Object.keys(X))X[Q]=r9(X[Q]);return $}function r9($){if(typeof $==="boolean")return $;return QN($)}var $s=Symbol("Let zodToJsonSchema decide on which parser to use"),Fb={name:void 0,$refStrategy:"root",basePath:["#"],effectStrategy:"input",pipeStrategy:"all",dateStrategy:"format:date-time",mapStrategy:"entries",removeAdditionalStrategy:"passthrough",allowedAdditionalProperties:!0,rejectedAdditionalProperties:!1,definitionPath:"definitions",strictUnions:!1,definitions:{},errorMessages:!1,patternStrategy:"escape",applyRegexFlags:!1,emailStrategy:"format:email",base64Strategy:"contentEncoding:base64",nameStrategy:"ref"},Xs=($)=>typeof $==="string"?{...Fb,name:$}:{...Fb,...$};function e6(){return{}}function Qs($,X){var Q,J,v;let Y={type:"array"};if(((Q=$.type)==null?void 0:Q._def)&&((v=(J=$.type)==null?void 0:J._def)==null?void 0:v.typeName)!==b.ZodAny)Y.items=u$($.type._def,{...X,currentPath:[...X.currentPath,"items"]});if($.minLength)Y.minItems=$.minLength.value;if($.maxLength)Y.maxItems=$.maxLength.value;if($.exactLength)Y.minItems=$.exactLength.value,Y.maxItems=$.exactLength.value;return Y}function vs($){let X={type:"integer",format:"int64"};if(!$.checks)return X;for(let Q of $.checks)switch(Q.kind){case"min":if(Q.inclusive)X.minimum=Q.value;else X.exclusiveMinimum=Q.value;break;case"max":if(Q.inclusive)X.maximum=Q.value;else X.exclusiveMaximum=Q.value;break;case"multipleOf":X.multipleOf=Q.value;break}return X}function Js(){return{type:"boolean"}}function ib($,X){return u$($.type._def,X)}var Ys=($,X)=>{return u$($.innerType._def,X)};function Rb($,X,Q){let J=Q!=null?Q:X.dateStrategy;if(Array.isArray(J))return{anyOf:J.map((v,Y)=>Rb($,X,v))};switch(J){case"string":case"format:date-time":return{type:"string",format:"date-time"};case"format:date":return{type:"string",format:"date"};case"integer":return zs($)}}var zs=($)=>{let X={type:"integer",format:"unix-time"};for(let Q of $.checks)switch(Q.kind){case"min":X.minimum=Q.value;break;case"max":X.maximum=Q.value;break}return X};function Ws($,X){return{...u$($.innerType._def,X),default:$.defaultValue()}}function Gs($,X){return X.effectStrategy==="input"?u$($.schema._def,X):e6()}function Us($){return{type:"string",enum:Array.from($.values)}}var Ks=($)=>{if("type"in $&&$.type==="string")return!1;return"allOf"in $};function ws($,X){let Q=[u$($.left._def,{...X,currentPath:[...X.currentPath,"allOf","0"]}),u$($.right._def,{...X,currentPath:[...X.currentPath,"allOf","1"]})].filter((v)=>!!v),J=[];return Q.forEach((v)=>{if(Ks(v))J.push(...v.allOf);else{let Y=v;if("additionalProperties"in v&&v.additionalProperties===!1){let{additionalProperties:z,...W}=v;Y=W}J.push(Y)}}),J.length?{allOf:J}:void 0}function Hs($){let X=typeof $.value;if(X!=="bigint"&&X!=="number"&&X!=="boolean"&&X!=="string")return{type:Array.isArray($.value)?"array":"object"};return{type:X==="bigint"?"integer":X,const:$.value}}var a3=void 0,l1={cuid:/^[cC][^\s-]{8,}$/,cuid2:/^[0-9a-z]+$/,ulid:/^[0-9A-HJKMNP-TV-Z]{26}$/,email:/^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/,emoji:()=>{if(a3===void 0)a3=RegExp("^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$","u");return a3},uuid:/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/,ipv4:/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,ipv4Cidr:/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/,ipv6:/^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/,ipv6Cidr:/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,base64:/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,base64url:/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,nanoid:/^[a-zA-Z0-9_-]{21}$/,jwt:/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/};function Eb($,X){let Q={type:"string"};if($.checks)for(let J of $.checks)switch(J.kind){case"min":Q.minLength=typeof Q.minLength==="number"?Math.max(Q.minLength,J.value):J.value;break;case"max":Q.maxLength=typeof Q.maxLength==="number"?Math.min(Q.maxLength,J.value):J.value;break;case"email":switch(X.emailStrategy){case"format:email":p1(Q,"email",J.message,X);break;case"format:idn-email":p1(Q,"idn-email",J.message,X);break;case"pattern:zod":N6(Q,l1.email,J.message,X);break}break;case"url":p1(Q,"uri",J.message,X);break;case"uuid":p1(Q,"uuid",J.message,X);break;case"regex":N6(Q,J.regex,J.message,X);break;case"cuid":N6(Q,l1.cuid,J.message,X);break;case"cuid2":N6(Q,l1.cuid2,J.message,X);break;case"startsWith":N6(Q,RegExp(`^${r3(J.value,X)}`),J.message,X);break;case"endsWith":N6(Q,RegExp(`${r3(J.value,X)}$`),J.message,X);break;case"datetime":p1(Q,"date-time",J.message,X);break;case"date":p1(Q,"date",J.message,X);break;case"time":p1(Q,"time",J.message,X);break;case"duration":p1(Q,"duration",J.message,X);break;case"length":Q.minLength=typeof Q.minLength==="number"?Math.max(Q.minLength,J.value):J.value,Q.maxLength=typeof Q.maxLength==="number"?Math.min(Q.maxLength,J.value):J.value;break;case"includes":{N6(Q,RegExp(r3(J.value,X)),J.message,X);break}case"ip":{if(J.version!=="v6")p1(Q,"ipv4",J.message,X);if(J.version!=="v4")p1(Q,"ipv6",J.message,X);break}case"base64url":N6(Q,l1.base64url,J.message,X);break;case"jwt":N6(Q,l1.jwt,J.message,X);break;case"cidr":{if(J.version!=="v6")N6(Q,l1.ipv4Cidr,J.message,X);if(J.version!=="v4")N6(Q,l1.ipv6Cidr,J.message,X);break}case"emoji":N6(Q,l1.emoji(),J.message,X);break;case"ulid":{N6(Q,l1.ulid,J.message,X);break}case"base64":{switch(X.base64Strategy){case"format:binary":{p1(Q,"binary",J.message,X);break}case"contentEncoding:base64":{Q.contentEncoding="base64";break}case"pattern:zod":{N6(Q,l1.base64,J.message,X);break}}break}case"nanoid":N6(Q,l1.nanoid,J.message,X);case"toLowerCase":case"toUpperCase":case"trim":break;default:}return Q}function r3($,X){return X.patternStrategy==="escape"?Vs($):$}var js=new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789");function Vs($){let X="";for(let Q=0;Q<$.length;Q++){if(!js.has($[Q]))X+="\\";X+=$[Q]}return X}function p1($,X,Q,J){var v;if($.format||((v=$.anyOf)==null?void 0:v.some((Y)=>Y.format))){if(!$.anyOf)$.anyOf=[];if($.format)$.anyOf.push({format:$.format}),delete $.format;$.anyOf.push({format:X,...Q&&J.errorMessages&&{errorMessage:{format:Q}}})}else $.format=X}function N6($,X,Q,J){var v;if($.pattern||((v=$.allOf)==null?void 0:v.some((Y)=>Y.pattern))){if(!$.allOf)$.allOf=[];if($.pattern)$.allOf.push({pattern:$.pattern}),delete $.pattern;$.allOf.push({pattern:Nb(X,J),...Q&&J.errorMessages&&{errorMessage:{pattern:Q}}})}else $.pattern=Nb(X,J)}function Nb($,X){var Q;if(!X.applyRegexFlags||!$.flags)return $.source;let J={i:$.flags.includes("i"),m:$.flags.includes("m"),s:$.flags.includes("s")},v=J.i?$.source.toLowerCase():$.source,Y="",z=!1,W=!1,G=!1;for(let U=0;U{return typeof X[X[Y]]!=="number"}).map((Y)=>X[Y]),v=Array.from(new Set(J.map((Y)=>typeof Y)));return{type:v.length===1?v[0]==="string"?"string":"number":["string","number"],enum:J}}function Fs(){return{not:e6()}}function Ns(){return{type:"null"}}var t3={ZodString:"string",ZodNumber:"number",ZodBigInt:"integer",ZodBoolean:"boolean",ZodNull:"null"};function Os($,X){let Q=$.options instanceof Map?Array.from($.options.values()):$.options;if(Q.every((J)=>(J._def.typeName in t3)&&(!J._def.checks||!J._def.checks.length))){let J=Q.reduce((v,Y)=>{let z=t3[Y._def.typeName];return z&&!v.includes(z)?[...v,z]:v},[]);return{type:J.length>1?J:J[0]}}else if(Q.every((J)=>J._def.typeName==="ZodLiteral"&&!J.description)){let J=Q.reduce((v,Y)=>{let z=typeof Y._def.value;switch(z){case"string":case"number":case"boolean":return[...v,z];case"bigint":return[...v,"integer"];case"object":if(Y._def.value===null)return[...v,"null"];case"symbol":case"undefined":case"function":default:return v}},[]);if(J.length===Q.length){let v=J.filter((Y,z,W)=>W.indexOf(Y)===z);return{type:v.length>1?v:v[0],enum:Q.reduce((Y,z)=>{return Y.includes(z._def.value)?Y:[...Y,z._def.value]},[])}}}else if(Q.every((J)=>J._def.typeName==="ZodEnum"))return{type:"string",enum:Q.reduce((J,v)=>[...J,...v._def.values.filter((Y)=>!J.includes(Y))],[])};return Ls($,X)}var Ls=($,X)=>{let Q=($.options instanceof Map?Array.from($.options.values()):$.options).map((J,v)=>u$(J._def,{...X,currentPath:[...X.currentPath,"anyOf",`${v}`]})).filter((J)=>!!J&&(!X.strictUnions||typeof J==="object"&&Object.keys(J).length>0));return Q.length?{anyOf:Q}:void 0};function As($,X){if(["ZodString","ZodNumber","ZodBigInt","ZodBoolean","ZodNull"].includes($.innerType._def.typeName)&&(!$.innerType._def.checks||!$.innerType._def.checks.length))return{type:[t3[$.innerType._def.typeName],"null"]};let Q=u$($.innerType._def,{...X,currentPath:[...X.currentPath,"anyOf","0"]});return Q&&{anyOf:[Q,{type:"null"}]}}function Zs($){let X={type:"number"};if(!$.checks)return X;for(let Q of $.checks)switch(Q.kind){case"int":X.type="integer";break;case"min":if(Q.inclusive)X.minimum=Q.value;else X.exclusiveMinimum=Q.value;break;case"max":if(Q.inclusive)X.maximum=Q.value;else X.exclusiveMaximum=Q.value;break;case"multipleOf":X.multipleOf=Q.value;break}return X}function qs($,X){let Q={type:"object",properties:{}},J=[],v=$.shape();for(let z in v){let W=v[z];if(W===void 0||W._def===void 0)continue;let G=Ms(W),U=u$(W._def,{...X,currentPath:[...X.currentPath,"properties",z],propertyPath:[...X.currentPath,"properties",z]});if(U===void 0)continue;if(Q.properties[z]=U,!G)J.push(z)}if(J.length)Q.required=J;let Y=Is($,X);if(Y!==void 0)Q.additionalProperties=Y;return Q}function Is($,X){if($.catchall._def.typeName!=="ZodNever")return u$($.catchall._def,{...X,currentPath:[...X.currentPath,"additionalProperties"]});switch($.unknownKeys){case"passthrough":return X.allowedAdditionalProperties;case"strict":return X.rejectedAdditionalProperties;case"strip":return X.removeAdditionalStrategy==="strict"?X.allowedAdditionalProperties:X.rejectedAdditionalProperties}}function Ms($){try{return $.isOptional()}catch(X){return!0}}var xs=($,X)=>{var Q;if(X.currentPath.toString()===((Q=X.propertyPath)==null?void 0:Q.toString()))return u$($.innerType._def,X);let J=u$($.innerType._def,{...X,currentPath:[...X.currentPath,"anyOf","1"]});return J?{anyOf:[{not:e6()},J]}:e6()},bs=($,X)=>{if(X.pipeStrategy==="input")return u$($.in._def,X);else if(X.pipeStrategy==="output")return u$($.out._def,X);let Q=u$($.in._def,{...X,currentPath:[...X.currentPath,"allOf","0"]}),J=u$($.out._def,{...X,currentPath:[...X.currentPath,"allOf",Q?"1":"0"]});return{allOf:[Q,J].filter((v)=>v!==void 0)}};function is($,X){return u$($.type._def,X)}function Rs($,X){let J={type:"array",uniqueItems:!0,items:u$($.valueType._def,{...X,currentPath:[...X.currentPath,"items"]})};if($.minSize)J.minItems=$.minSize.value;if($.maxSize)J.maxItems=$.maxSize.value;return J}function Es($,X){if($.rest)return{type:"array",minItems:$.items.length,items:$.items.map((Q,J)=>u$(Q._def,{...X,currentPath:[...X.currentPath,"items",`${J}`]})).reduce((Q,J)=>J===void 0?Q:[...Q,J],[]),additionalItems:u$($.rest._def,{...X,currentPath:[...X.currentPath,"additionalItems"]})};else return{type:"array",minItems:$.items.length,maxItems:$.items.length,items:$.items.map((Q,J)=>u$(Q._def,{...X,currentPath:[...X.currentPath,"items",`${J}`]})).reduce((Q,J)=>J===void 0?Q:[...Q,J],[])}}function Ps(){return{not:e6()}}function Ts(){return e6()}var Ss=($,X)=>{return u$($.innerType._def,X)},Cs=($,X,Q)=>{switch(X){case b.ZodString:return Eb($,Q);case b.ZodNumber:return Zs($);case b.ZodObject:return qs($,Q);case b.ZodBigInt:return vs($);case b.ZodBoolean:return Js();case b.ZodDate:return Rb($,Q);case b.ZodUndefined:return Ps();case b.ZodNull:return Ns();case b.ZodArray:return Qs($,Q);case b.ZodUnion:case b.ZodDiscriminatedUnion:return Os($,Q);case b.ZodIntersection:return ws($,Q);case b.ZodTuple:return Es($,Q);case b.ZodRecord:return Pb($,Q);case b.ZodLiteral:return Hs($);case b.ZodEnum:return Us($);case b.ZodNativeEnum:return Ds($);case b.ZodNullable:return As($,Q);case b.ZodOptional:return xs($,Q);case b.ZodMap:return Bs($,Q);case b.ZodSet:return Rs($,Q);case b.ZodLazy:return()=>$.getter()._def;case b.ZodPromise:return is($,Q);case b.ZodNaN:case b.ZodNever:return Fs();case b.ZodEffects:return Gs($,Q);case b.ZodAny:return e6();case b.ZodUnknown:return Ts();case b.ZodDefault:return Ws($,Q);case b.ZodBranded:return ib($,Q);case b.ZodReadonly:return Ss($,Q);case b.ZodCatch:return Ys($,Q);case b.ZodPipeline:return bs($,Q);case b.ZodFunction:case b.ZodVoid:case b.ZodSymbol:return;default:return((J)=>{return})(X)}},us=($,X)=>{let Q=0;for(;Q<$.length&&Q{switch(X.$refStrategy){case"root":return{$ref:$.path.join("/")};case"relative":return{$ref:us(X.currentPath,$.path)};case"none":case"seen":{if($.path.lengthX.currentPath[J]===Q))return console.warn(`Recursive reference detected at ${X.currentPath.join("/")}! Defaulting to any`),e6();return X.$refStrategy==="seen"?e6():void 0}}},ys=($,X,Q)=>{if($.description)Q.description=$.description;return Q},fs=($)=>{let X=Xs($),Q=X.name!==void 0?[...X.basePath,X.definitionPath,X.name]:X.basePath;return{...X,currentPath:Q,propertyPath:void 0,seen:new Map(Object.entries(X.definitions).map(([J,v])=>[v._def,{def:v._def,path:[...X.basePath,X.definitionPath,J],jsonSchema:void 0}]))}},ks=($,X)=>{var Q;let J=fs(X),v=typeof X==="object"&&X.definitions?Object.entries(X.definitions).reduce((U,[w,H])=>{var j;return{...U,[w]:(j=u$(H._def,{...J,currentPath:[...J.basePath,J.definitionPath,w]},!0))!=null?j:e6()}},{}):void 0,Y=typeof X==="string"?X:(X==null?void 0:X.nameStrategy)==="title"?void 0:X==null?void 0:X.name,z=(Q=u$($._def,Y===void 0?J:{...J,currentPath:[...J.basePath,J.definitionPath,Y]},!1))!=null?Q:e6(),W=typeof X==="object"&&X.name!==void 0&&X.nameStrategy==="title"?X.name:void 0;if(W!==void 0)z.title=W;let G=Y===void 0?v?{...z,[J.definitionPath]:v}:z:{$ref:[...J.$refStrategy==="relative"?[]:J.basePath,J.definitionPath,Y].join("/"),[J.definitionPath]:{...v,[Y]:z}};return G.$schema="http://json-schema.org/draft-07/schema#",G},s3=Symbol.for("vercel.ai.schema");function w4($){let X;return()=>{if(X==null)X=$();return X}}function s9($,{validate:X}={}){return{[s3]:!0,_type:void 0,get jsonSchema(){if(typeof $==="function")$=$();return $},validate:X}}function ns($){return typeof $==="object"&&$!==null&&s3 in $&&$[s3]===!0&&"jsonSchema"in $&&"validate"in $}function x0($){return $==null?s9({properties:{},additionalProperties:!1}):ns($)?$:("~standard"in $)?$["~standard"].vendor==="zod"?v4($):gs($):$()}function gs($){return s9(()=>QN($["~standard"].jsonSchema.input({target:"draft-07"})),{validate:async(X)=>{let Q=await $["~standard"].validate(X);return"value"in Q?{success:!0,value:Q.value}:{success:!1,error:new y1({value:X,cause:Q.issues})}}})}function cs($,X){var Q;let J=(Q=X==null?void 0:X.useReferences)!=null?Q:!1;return s9(()=>ks($,{$refStrategy:J?"root":"none"}),{validate:async(v)=>{let Y=await $.safeParseAsync(v);return Y.success?{success:!0,value:Y.data}:{success:!1,error:Y.error}}})}function ms($,X){var Q;let J=(Q=X==null?void 0:X.useReferences)!=null?Q:!1;return s9(()=>QN(s0($,{target:"draft-7",io:"input",reused:J?"ref":"inline"})),{validate:async(v)=>{let Y=await OY($,v);return Y.success?{success:!0,value:Y.data}:{success:!1,error:Y.error}}})}function hs($){return"_zod"in $}function v4($,X){if(hs($))return ms($,X);else return cs($,X)}async function gY({value:$,schema:X,context:Q}){let J=await v6({value:$,schema:X,context:Q});if(!J.success)throw y1.wrap({value:$,cause:J.error,context:Q});return J.value}async function v6({value:$,schema:X,context:Q}){let J=x0(X);try{if(J.validate==null)return{success:!0,value:$,rawValue:$};let v=await J.validate($);if(v.success)return{success:!0,value:v.value,rawValue:$};return{success:!1,error:y1.wrap({value:$,cause:v.error,context:Q}),rawValue:$}}catch(v){return{success:!1,error:y1.wrap({value:$,cause:v,context:Q}),rawValue:$}}}async function ls({text:$,schema:X}){try{let Q=XN($);if(X==null)return Q;return gY({value:Q,schema:X})}catch(Q){if(EJ.isInstance(Q)||y1.isInstance(Q))throw Q;throw new EJ({text:$,cause:Q})}}async function _6({text:$,schema:X}){try{let Q=XN($);if(X==null)return{success:!0,value:Q,rawValue:Q};return await v6({value:Q,schema:X})}catch(Q){return{success:!1,error:EJ.isInstance(Q)?Q:new EJ({text:$,cause:Q}),rawValue:void 0}}}function vN($){try{return XN($),!0}catch(X){return!1}}function YU({stream:$,schema:X}){return $.pipeThrough(new TextDecoderStream).pipeThrough(new o3).pipeThrough(new TransformStream({async transform({data:Q},J){if(Q==="[DONE]")return;J.enqueue(await _6({text:Q,schema:X}))}}))}async function b0({provider:$,providerOptions:X,schema:Q}){if((X==null?void 0:X[$])==null)return;let J=await v6({value:X[$],schema:Q});if(!J.success)throw new V5({argument:"providerOptions",message:`invalid ${$} provider options`,cause:J.error});return J.value}var ps=()=>globalThis.fetch,M4=async({url:$,headers:X,body:Q,failedResponseHandler:J,successfulResponseHandler:v,abortSignal:Y,fetch:z})=>Sb({url:$,headers:{"Content-Type":"application/json",...X},body:{content:JSON.stringify(Q),values:Q},failedResponseHandler:J,successfulResponseHandler:v,abortSignal:Y,fetch:z}),Tb=async({url:$,headers:X,formData:Q,failedResponseHandler:J,successfulResponseHandler:v,abortSignal:Y,fetch:z})=>Sb({url:$,headers:X,body:{content:Q,values:Object.fromEntries(Q.entries())},failedResponseHandler:J,successfulResponseHandler:v,abortSignal:Y,fetch:z}),Sb=async({url:$,headers:X={},body:Q,successfulResponseHandler:J,failedResponseHandler:v,abortSignal:Y,fetch:z=ps()})=>{try{let W=await z($,{method:"POST",headers:M0(X,`ai-sdk/provider-utils/${Mb}`,JU()),body:Q.content,signal:Y}),G=fY(W);if(!W.ok){let U;try{U=await v({response:W,url:$,requestBodyValues:Q.values})}catch(w){if(v8(w)||h$.isInstance(w))throw w;throw new h$({message:"Failed to process error response",cause:w,statusCode:W.status,url:$,responseHeaders:G,requestBodyValues:Q.values})}throw U.value}try{return await J({response:W,url:$,requestBodyValues:Q.values})}catch(U){if(U instanceof Error){if(v8(U)||h$.isInstance(U))throw U}throw new h$({message:"Failed to process successful response",cause:U,statusCode:W.status,url:$,responseHeaders:G,requestBodyValues:Q.values})}}catch(W){throw Ib({error:W,url:$,requestBodyValues:Q.values})}};function Cb($){return $}function zU({id:$,inputSchema:X,outputSchema:Q,supportsDeferredResults:J}){return({execute:v,needsApproval:Y,toModelOutput:z,onInputStart:W,onInputDelta:G,onInputAvailable:U,...w})=>Cb({type:"provider",id:$,args:w,inputSchema:X,outputSchema:Q,execute:v,needsApproval:Y,toModelOutput:z,onInputStart:W,onInputDelta:G,onInputAvailable:U,supportsDeferredResults:J})}async function Z4($){if(typeof $==="function")$=$();return Promise.resolve($)}var H4=({errorSchema:$,errorToMessage:X,isRetryable:Q})=>async({response:J,url:v,requestBodyValues:Y})=>{let z=await J.text(),W=fY(J);if(z.trim()==="")return{responseHeaders:W,value:new h$({message:J.statusText,url:v,requestBodyValues:Y,statusCode:J.status,responseHeaders:W,responseBody:z,isRetryable:Q==null?void 0:Q(J)})};try{let G=await ls({text:z,schema:$});return{responseHeaders:W,value:new h$({message:X(G),url:v,requestBodyValues:Y,statusCode:J.status,responseHeaders:W,responseBody:z,data:G,isRetryable:Q==null?void 0:Q(J,G)})}}catch(G){return{responseHeaders:W,value:new h$({message:J.statusText,url:v,requestBodyValues:Y,statusCode:J.status,responseHeaders:W,responseBody:z,isRetryable:Q==null?void 0:Q(J)})}}},nQ=($)=>async({response:X})=>{let Q=fY(X);if(X.body==null)throw new xM({});return{responseHeaders:Q,value:YU({stream:X.body,schema:$})}},i4=($)=>async({response:X,url:Q,requestBodyValues:J})=>{let v=await X.text(),Y=await _6({text:v,schema:$}),z=fY(X);if(!Y.success)throw new h$({message:"Invalid JSON response",cause:Y.error,statusCode:X.status,responseHeaders:z,responseBody:v,url:Q,requestBodyValues:J});return{responseHeaders:z,value:Y.value,rawValue:Y.rawValue}};function WU($){return $==null?void 0:$.replace(/\/$/,"")}function ds($){return $!=null&&typeof $[Symbol.asyncIterator]==="function"}async function*ub({execute:$,input:X,options:Q}){let J=$(X,Q);if(ds(J)){let v;for await(let Y of J)v=Y,yield{type:"preliminary",output:Y};yield{type:"final",output:v}}else yield{type:"final",output:await J}}var $R=x$(NN(),1),XR=x$(NN(),1);var I$$="vercel.ai.gateway.error",ON=Symbol.for(I$$),Fi,Ni,O6=class $ extends(Ni=Error,Fi=ON,Ni){constructor({message:X,statusCode:Q=500,cause:J,generationId:v}){super(v?`${X} [${v}]`:X);this[Fi]=!0,this.statusCode=Q,this.cause=J,this.generationId=v}static isInstance(X){return $.hasMarker(X)}static hasMarker(X){return typeof X==="object"&&X!==null&&ON in X&&X[ON]===!0}},pi="GatewayAuthenticationError",M$$=`vercel.ai.gateway.error.${pi}`,Oi=Symbol.for(M$$),Li,Ai,jU=class $ extends(Ai=O6,Li=Oi,Ai){constructor({message:X="Authentication failed",statusCode:Q=401,cause:J,generationId:v}={}){super({message:X,statusCode:Q,cause:J,generationId:v});this[Li]=!0,this.name=pi,this.type="authentication_error"}static isInstance(X){return O6.hasMarker(X)&&Oi in X}static createContextualError({apiKeyProvided:X,oidcTokenProvided:Q,message:J="Authentication failed",statusCode:v=401,cause:Y,generationId:z}){let W;if(X)W=`AI Gateway authentication failed: Invalid API key. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/literal.js -function parseLiteralDef2(def, refs) { - const parsedType2 = typeof def.value; - if (parsedType2 !== "bigint" && parsedType2 !== "number" && parsedType2 !== "boolean" && parsedType2 !== "string") { - return { - type: Array.isArray(def.value) ? "array" : "object" - }; - } - if (refs.target === "openApi3") { - return { - type: parsedType2 === "bigint" ? "integer" : parsedType2, - enum: [def.value] - }; - } - return { - type: parsedType2 === "bigint" ? "integer" : parsedType2, - const: def.value - }; -} +Create a new API key: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/string.js -var emojiRegex3 = undefined; -var zodPatterns2 = { - cuid: /^[cC][^\s-]{8,}$/, - cuid2: /^[0-9a-z]+$/, - ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/, - email: /^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/, - emoji: () => { - if (emojiRegex3 === undefined) { - emojiRegex3 = RegExp("^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$", "u"); - } - return emojiRegex3; - }, - uuid: /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/, - ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, - ipv4Cidr: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/, - ipv6: /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/, - ipv6Cidr: /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/, - base64: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, - base64url: /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/, - nanoid: /^[a-zA-Z0-9_-]{21}$/, - jwt: /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/ -}; -function parseStringDef2(def, refs) { - const res = { - type: "string" - }; - if (def.checks) { - for (const check2 of def.checks) { - switch (check2.kind) { - case "min": - setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number" ? Math.max(res.minLength, check2.value) : check2.value, check2.message, refs); - break; - case "max": - setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number" ? Math.min(res.maxLength, check2.value) : check2.value, check2.message, refs); - break; - case "email": - switch (refs.emailStrategy) { - case "format:email": - addFormat2(res, "email", check2.message, refs); - break; - case "format:idn-email": - addFormat2(res, "idn-email", check2.message, refs); - break; - case "pattern:zod": - addPattern2(res, zodPatterns2.email, check2.message, refs); - break; - } - break; - case "url": - addFormat2(res, "uri", check2.message, refs); - break; - case "uuid": - addFormat2(res, "uuid", check2.message, refs); - break; - case "regex": - addPattern2(res, check2.regex, check2.message, refs); - break; - case "cuid": - addPattern2(res, zodPatterns2.cuid, check2.message, refs); - break; - case "cuid2": - addPattern2(res, zodPatterns2.cuid2, check2.message, refs); - break; - case "startsWith": - addPattern2(res, RegExp(`^${escapeLiteralCheckValue2(check2.value, refs)}`), check2.message, refs); - break; - case "endsWith": - addPattern2(res, RegExp(`${escapeLiteralCheckValue2(check2.value, refs)}$`), check2.message, refs); - break; - case "datetime": - addFormat2(res, "date-time", check2.message, refs); - break; - case "date": - addFormat2(res, "date", check2.message, refs); - break; - case "time": - addFormat2(res, "time", check2.message, refs); - break; - case "duration": - addFormat2(res, "duration", check2.message, refs); - break; - case "length": - setResponseValueAndErrors(res, "minLength", typeof res.minLength === "number" ? Math.max(res.minLength, check2.value) : check2.value, check2.message, refs); - setResponseValueAndErrors(res, "maxLength", typeof res.maxLength === "number" ? Math.min(res.maxLength, check2.value) : check2.value, check2.message, refs); - break; - case "includes": { - addPattern2(res, RegExp(escapeLiteralCheckValue2(check2.value, refs)), check2.message, refs); - break; - } - case "ip": { - if (check2.version !== "v6") { - addFormat2(res, "ipv4", check2.message, refs); - } - if (check2.version !== "v4") { - addFormat2(res, "ipv6", check2.message, refs); - } - break; - } - case "base64url": - addPattern2(res, zodPatterns2.base64url, check2.message, refs); - break; - case "jwt": - addPattern2(res, zodPatterns2.jwt, check2.message, refs); - break; - case "cidr": { - if (check2.version !== "v6") { - addPattern2(res, zodPatterns2.ipv4Cidr, check2.message, refs); - } - if (check2.version !== "v4") { - addPattern2(res, zodPatterns2.ipv6Cidr, check2.message, refs); - } - break; - } - case "emoji": - addPattern2(res, zodPatterns2.emoji(), check2.message, refs); - break; - case "ulid": { - addPattern2(res, zodPatterns2.ulid, check2.message, refs); - break; - } - case "base64": { - switch (refs.base64Strategy) { - case "format:binary": { - addFormat2(res, "binary", check2.message, refs); - break; - } - case "contentEncoding:base64": { - setResponseValueAndErrors(res, "contentEncoding", "base64", check2.message, refs); - break; - } - case "pattern:zod": { - addPattern2(res, zodPatterns2.base64, check2.message, refs); - break; - } - } - break; - } - case "nanoid": { - addPattern2(res, zodPatterns2.nanoid, check2.message, refs); - } - case "toLowerCase": - case "toUpperCase": - case "trim": - break; - default: - ((_) => {})(check2); - } - } - } - return res; -} -function escapeLiteralCheckValue2(literal2, refs) { - return refs.patternStrategy === "escape" ? escapeNonAlphaNumeric2(literal2) : literal2; -} -var ALPHA_NUMERIC2 = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789"); -function escapeNonAlphaNumeric2(source) { - let result = ""; - for (let i = 0;i < source.length; i++) { - if (!ALPHA_NUMERIC2.has(source[i])) { - result += "\\"; - } - result += source[i]; - } - return result; -} -function addFormat2(schema, value, message, refs) { - if (schema.format || schema.anyOf?.some((x) => x.format)) { - if (!schema.anyOf) { - schema.anyOf = []; - } - if (schema.format) { - schema.anyOf.push({ - format: schema.format, - ...schema.errorMessage && refs.errorMessages && { - errorMessage: { format: schema.errorMessage.format } - } - }); - delete schema.format; - if (schema.errorMessage) { - delete schema.errorMessage.format; - if (Object.keys(schema.errorMessage).length === 0) { - delete schema.errorMessage; - } - } - } - schema.anyOf.push({ - format: value, - ...message && refs.errorMessages && { errorMessage: { format: message } } - }); - } else { - setResponseValueAndErrors(schema, "format", value, message, refs); - } -} -function addPattern2(schema, regex, message, refs) { - if (schema.pattern || schema.allOf?.some((x) => x.pattern)) { - if (!schema.allOf) { - schema.allOf = []; - } - if (schema.pattern) { - schema.allOf.push({ - pattern: schema.pattern, - ...schema.errorMessage && refs.errorMessages && { - errorMessage: { pattern: schema.errorMessage.pattern } - } - }); - delete schema.pattern; - if (schema.errorMessage) { - delete schema.errorMessage.pattern; - if (Object.keys(schema.errorMessage).length === 0) { - delete schema.errorMessage; - } - } - } - schema.allOf.push({ - pattern: stringifyRegExpWithFlags2(regex, refs), - ...message && refs.errorMessages && { errorMessage: { pattern: message } } - }); - } else { - setResponseValueAndErrors(schema, "pattern", stringifyRegExpWithFlags2(regex, refs), message, refs); - } -} -function stringifyRegExpWithFlags2(regex, refs) { - if (!refs.applyRegexFlags || !regex.flags) { - return regex.source; - } - const flags = { - i: regex.flags.includes("i"), - m: regex.flags.includes("m"), - s: regex.flags.includes("s") - }; - const source = flags.i ? regex.source.toLowerCase() : regex.source; - let pattern = ""; - let isEscaped = false; - let inCharGroup = false; - let inCharRange = false; - for (let i = 0;i < source.length; i++) { - if (isEscaped) { - pattern += source[i]; - isEscaped = false; - continue; - } - if (flags.i) { - if (inCharGroup) { - if (source[i].match(/[a-z]/)) { - if (inCharRange) { - pattern += source[i]; - pattern += `${source[i - 2]}-${source[i]}`.toUpperCase(); - inCharRange = false; - } else if (source[i + 1] === "-" && source[i + 2]?.match(/[a-z]/)) { - pattern += source[i]; - inCharRange = true; - } else { - pattern += `${source[i]}${source[i].toUpperCase()}`; - } - continue; - } - } else if (source[i].match(/[a-z]/)) { - pattern += `[${source[i]}${source[i].toUpperCase()}]`; - continue; - } - } - if (flags.m) { - if (source[i] === "^") { - pattern += `(^|(?<=[\r -]))`; - continue; - } else if (source[i] === "$") { - pattern += `($|(?=[\r -]))`; - continue; - } - } - if (flags.s && source[i] === ".") { - pattern += inCharGroup ? `${source[i]}\r -` : `[${source[i]}\r -]`; - continue; - } - pattern += source[i]; - if (source[i] === "\\") { - isEscaped = true; - } else if (inCharGroup && source[i] === "]") { - inCharGroup = false; - } else if (!inCharGroup && source[i] === "[") { - inCharGroup = true; - } - } - try { - new RegExp(pattern); - } catch { - console.warn(`Could not convert regex pattern at ${refs.currentPath.join("/")} to a flag-independent form! Falling back to the flag-ignorant source`); - return regex.source; - } - return pattern; -} +Provide via 'apiKey' option or 'AI_GATEWAY_API_KEY' environment variable.`;else if(Q)W=`AI Gateway authentication failed: Invalid OIDC token. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/record.js -function parseRecordDef2(def, refs) { - if (refs.target === "openAi") { - console.warn("Warning: OpenAI may not support records in schemas! Try an array of key-value pairs instead."); - } - if (refs.target === "openApi3" && def.keyType?._def.typeName === ZodFirstPartyTypeKind2.ZodEnum) { - return { - type: "object", - required: def.keyType._def.values, - properties: def.keyType._def.values.reduce((acc, key) => ({ - ...acc, - [key]: parseDef2(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "properties", key] - }) ?? parseAnyDef2(refs) - }), {}), - additionalProperties: refs.rejectedAdditionalProperties - }; - } - const schema = { - type: "object", - additionalProperties: parseDef2(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "additionalProperties"] - }) ?? refs.allowedAdditionalProperties - }; - if (refs.target === "openApi3") { - return schema; - } - if (def.keyType?._def.typeName === ZodFirstPartyTypeKind2.ZodString && def.keyType._def.checks?.length) { - const { type, ...keyType } = parseStringDef2(def.keyType._def, refs); - return { - ...schema, - propertyNames: keyType - }; - } else if (def.keyType?._def.typeName === ZodFirstPartyTypeKind2.ZodEnum) { - return { - ...schema, - propertyNames: { - enum: def.keyType._def.values - } - }; - } else if (def.keyType?._def.typeName === ZodFirstPartyTypeKind2.ZodBranded && def.keyType._def.type._def.typeName === ZodFirstPartyTypeKind2.ZodString && def.keyType._def.type._def.checks?.length) { - const { type, ...keyType } = parseBrandedDef2(def.keyType._def, refs); - return { - ...schema, - propertyNames: keyType - }; - } - return schema; -} +Run 'npx vercel link' to link your project, then 'vc env pull' to fetch the token. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/map.js -function parseMapDef2(def, refs) { - if (refs.mapStrategy === "record") { - return parseRecordDef2(def, refs); - } - const keys = parseDef2(def.keyType._def, { - ...refs, - currentPath: [...refs.currentPath, "items", "items", "0"] - }) || parseAnyDef2(refs); - const values = parseDef2(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "items", "items", "1"] - }) || parseAnyDef2(refs); - return { - type: "array", - maxItems: 125, - items: { - type: "array", - items: [keys, values], - minItems: 2, - maxItems: 2 - } - }; -} +Alternatively, use an API key: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys`;else W=`AI Gateway authentication failed: No authentication provided. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/nativeEnum.js -function parseNativeEnumDef2(def) { - const object4 = def.values; - const actualKeys = Object.keys(def.values).filter((key) => { - return typeof object4[object4[key]] !== "number"; - }); - const actualValues = actualKeys.map((key) => object4[key]); - const parsedTypes = Array.from(new Set(actualValues.map((values) => typeof values))); - return { - type: parsedTypes.length === 1 ? parsedTypes[0] === "string" ? "string" : "number" : ["string", "number"], - enum: actualValues - }; -} +Option 1 - API key: +Create an API key: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys +Provide via 'apiKey' option or 'AI_GATEWAY_API_KEY' environment variable. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/never.js -function parseNeverDef2(refs) { - return refs.target === "openAi" ? undefined : { - not: parseAnyDef2({ - ...refs, - currentPath: [...refs.currentPath, "not"] - }) - }; -} +Option 2 - OIDC token: +Run 'npx vercel link' to link your project, then 'vc env pull' to fetch the token.`;return new $({message:W,statusCode:v,cause:Y,generationId:z})}},di="GatewayInvalidRequestError",x$$=`vercel.ai.gateway.error.${di}`,Zi=Symbol.for(x$$),qi,Ii,b$$=class extends(Ii=O6,qi=Zi,Ii){constructor({message:$="Invalid request",statusCode:X=400,cause:Q,generationId:J}={}){super({message:$,statusCode:X,cause:Q,generationId:J});this[qi]=!0,this.name=di,this.type="invalid_request_error"}static isInstance($){return O6.hasMarker($)&&Zi in $}},oi="GatewayRateLimitError",i$$=`vercel.ai.gateway.error.${oi}`,Mi=Symbol.for(i$$),xi,bi,R$$=class extends(bi=O6,xi=Mi,bi){constructor({message:$="Rate limit exceeded",statusCode:X=429,cause:Q,generationId:J}={}){super({message:$,statusCode:X,cause:Q,generationId:J});this[xi]=!0,this.name=oi,this.type="rate_limit_exceeded"}static isInstance($){return O6.hasMarker($)&&Mi in $}},ai="GatewayModelNotFoundError",E$$=`vercel.ai.gateway.error.${ai}`,ii=Symbol.for(E$$),P$$=w4(()=>v4(K.object({modelId:K.string()}))),Ri,Ei,T$$=class extends(Ei=O6,Ri=ii,Ei){constructor({message:$="Model not found",statusCode:X=404,modelId:Q,cause:J,generationId:v}={}){super({message:$,statusCode:X,cause:J,generationId:v});this[Ri]=!0,this.name=ai,this.type="model_not_found",this.modelId=Q}static isInstance($){return O6.hasMarker($)&&ii in $}},ri="GatewayInternalServerError",S$$=`vercel.ai.gateway.error.${ri}`,Pi=Symbol.for(S$$),Ti,Si,Ci=class extends(Si=O6,Ti=Pi,Si){constructor({message:$="Internal server error",statusCode:X=500,cause:Q,generationId:J}={}){super({message:$,statusCode:X,cause:Q,generationId:J});this[Ti]=!0,this.name=ri,this.type="internal_server_error"}static isInstance($){return O6.hasMarker($)&&Pi in $}},ti="GatewayResponseError",C$$=`vercel.ai.gateway.error.${ti}`,ui=Symbol.for(C$$),_i,yi,u$$=class extends(yi=O6,_i=ui,yi){constructor({message:$="Invalid response from Gateway",statusCode:X=502,response:Q,validationError:J,cause:v,generationId:Y}={}){super({message:$,statusCode:X,cause:v,generationId:Y});this[_i]=!0,this.name=ti,this.type="response_error",this.response=Q,this.validationError=J}static isInstance($){return O6.hasMarker($)&&ui in $}};async function fi({response:$,statusCode:X,defaultMessage:Q="Gateway request failed",cause:J,authMethod:v}){var Y;let z=await v6({value:$,schema:_$$});if(!z.success){let H=typeof $==="object"&&$!==null&&"generationId"in $?$.generationId:void 0;return new u$$({message:`Invalid error response format: ${Q}`,statusCode:X,response:$,validationError:z.error,cause:J,generationId:H})}let W=z.value,G=W.error.type,U=W.error.message,w=(Y=W.generationId)!=null?Y:void 0;switch(G){case"authentication_error":return jU.createContextualError({apiKeyProvided:v==="api-key",oidcTokenProvided:v==="oidc",statusCode:X,cause:J,generationId:w});case"invalid_request_error":return new b$$({message:U,statusCode:X,cause:J,generationId:w});case"rate_limit_exceeded":return new R$$({message:U,statusCode:X,cause:J,generationId:w});case"model_not_found":{let H=await v6({value:W.error.param,schema:P$$});return new T$$({message:U,statusCode:X,modelId:H.success?H.value.modelId:void 0,cause:J,generationId:w})}case"internal_server_error":return new Ci({message:U,statusCode:X,cause:J,generationId:w});default:return new Ci({message:U,statusCode:X,cause:J,generationId:w})}}var _$$=w4(()=>v4(K.object({error:K.object({message:K.string(),type:K.string().nullish(),param:K.unknown().nullish(),code:K.union([K.string(),K.number()]).nullish()}),generationId:K.string().nullish()}))),si="GatewayTimeoutError",y$$=`vercel.ai.gateway.error.${si}`,ki=Symbol.for(y$$),ni,gi,ci=class $ extends(gi=O6,ni=ki,gi){constructor({message:X="Request timed out",statusCode:Q=408,cause:J,generationId:v}={}){super({message:X,statusCode:Q,cause:J,generationId:v});this[ni]=!0,this.name=si,this.type="timeout_error"}static isInstance(X){return O6.hasMarker(X)&&ki in X}static createTimeoutError({originalMessage:X,statusCode:Q=408,cause:J,generationId:v}){let Y=`Gateway request timed out: ${X} -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/null.js -function parseNullDef2(refs) { - return refs.target === "openApi3" ? { - enum: ["null"], - nullable: true - } : { - type: "null" - }; -} + This is a client-side timeout. To resolve this, increase your timeout configuration: https://vercel.com/docs/ai-gateway/capabilities/video-generation#extending-timeouts-for-node.js`;return new $({message:Y,statusCode:Q,cause:J,generationId:v})}};function mi($){if(!($ instanceof Error))return!1;let X=$.code;if(typeof X==="string")return["UND_ERR_HEADERS_TIMEOUT","UND_ERR_BODY_TIMEOUT","UND_ERR_CONNECT_TIMEOUT"].includes(X);return!1}async function J8($,X){var Q;if(O6.isInstance($))return $;if(mi($))return ci.createTimeoutError({originalMessage:$ instanceof Error?$.message:"Unknown error",cause:$});if(h$.isInstance($)){if($.cause&&mi($.cause))return ci.createTimeoutError({originalMessage:$.message,cause:$});return await fi({response:f$$($),statusCode:(Q=$.statusCode)!=null?Q:500,defaultMessage:"Gateway request failed",cause:$,authMethod:X})}return await fi({response:{},statusCode:500,defaultMessage:$ instanceof Error?`Gateway request failed: ${$.message}`:"Unknown Gateway error",cause:$,authMethod:X})}function f$$($){if($.data!==void 0)return $.data;if($.responseBody!=null)try{return JSON.parse($.responseBody)}catch(X){return $.responseBody}return{}}var ei="ai-gateway-auth-method";async function XX($){let X=await v6({value:$[ei],schema:k$$});return X.success?X.value:void 0}var k$$=w4(()=>v4(K.union([K.literal("api-key"),K.literal("oidc")]))),hi=class{constructor($){this.config=$}async getAvailableModels(){try{let{value:$}=await $N({url:`${this.config.baseURL}/config`,headers:await Z4(this.config.headers()),successfulResponseHandler:i4(n$$),failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(X)=>X}),fetch:this.config.fetch});return $}catch($){throw await J8($)}}async getCredits(){try{let $=new URL(this.config.baseURL),{value:X}=await $N({url:`${$.origin}/v1/credits`,headers:await Z4(this.config.headers()),successfulResponseHandler:i4(g$$),failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(Q)=>Q}),fetch:this.config.fetch});return X}catch($){throw await J8($)}}},n$$=w4(()=>v4(K.object({models:K.array(K.object({id:K.string(),name:K.string(),description:K.string().nullish(),pricing:K.object({input:K.string(),output:K.string(),input_cache_read:K.string().nullish(),input_cache_write:K.string().nullish()}).transform(({input:$,output:X,input_cache_read:Q,input_cache_write:J})=>({input:$,output:X,...Q?{cachedInputTokens:Q}:{},...J?{cacheCreationInputTokens:J}:{}})).nullish(),specification:K.object({specificationVersion:K.literal("v3"),provider:K.string(),modelId:K.string()}),modelType:K.enum(["embedding","image","language","video"]).nullish()}))}))),g$$=w4(()=>v4(K.object({balance:K.string(),total_used:K.string()}).transform(({balance:$,total_used:X})=>({balance:$,totalUsed:X})))),c$$=class{constructor($,X){this.modelId=$,this.config=X,this.specificationVersion="v3",this.supportedUrls={"*/*":[/.*/]}}get provider(){return this.config.provider}async getArgs($){let{abortSignal:X,...Q}=$;return{args:this.maybeEncodeFileParts(Q),warnings:[]}}async doGenerate($){let{args:X,warnings:Q}=await this.getArgs($),{abortSignal:J}=$,v=await Z4(this.config.headers());try{let{responseHeaders:Y,value:z,rawValue:W}=await M4({url:this.getUrl(),headers:A4(v,$.headers,this.getModelConfigHeaders(this.modelId,!1),await Z4(this.config.o11yHeaders)),body:X,successfulResponseHandler:i4(K.any()),failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(G)=>G}),...J&&{abortSignal:J},fetch:this.config.fetch});return{...z,request:{body:X},response:{headers:Y,body:W},warnings:Q}}catch(Y){throw await J8(Y,await XX(v))}}async doStream($){let{args:X,warnings:Q}=await this.getArgs($),{abortSignal:J}=$,v=await Z4(this.config.headers());try{let{value:Y,responseHeaders:z}=await M4({url:this.getUrl(),headers:A4(v,$.headers,this.getModelConfigHeaders(this.modelId,!0),await Z4(this.config.o11yHeaders)),body:X,successfulResponseHandler:nQ(K.any()),failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(W)=>W}),...J&&{abortSignal:J},fetch:this.config.fetch});return{stream:Y.pipeThrough(new TransformStream({start(W){if(Q.length>0)W.enqueue({type:"stream-start",warnings:Q})},transform(W,G){if(W.success){let U=W.value;if(U.type==="raw"&&!$.includeRawChunks)return;if(U.type==="response-metadata"&&U.timestamp&&typeof U.timestamp==="string")U.timestamp=new Date(U.timestamp);G.enqueue(U)}else G.error(W.error)}})),request:{body:X},response:{headers:z}}}catch(Y){throw await J8(Y,await XX(v))}}isFilePart($){return $&&typeof $==="object"&&"type"in $&&$.type==="file"}maybeEncodeFileParts($){for(let X of $.prompt)for(let Q of X.content)if(this.isFilePart(Q)){let J=Q;if(J.data instanceof Uint8Array){let v=Uint8Array.from(J.data),Y=Buffer.from(v).toString("base64");J.data=new URL(`data:${J.mediaType||"application/octet-stream"};base64,${Y}`)}}return $}getUrl(){return`${this.config.baseURL}/language-model`}getModelConfigHeaders($,X){return{"ai-language-model-specification-version":"3","ai-language-model-id":$,"ai-language-model-streaming":String(X)}}},m$$=class{constructor($,X){this.modelId=$,this.config=X,this.specificationVersion="v3",this.maxEmbeddingsPerCall=2048,this.supportsParallelCalls=!0}get provider(){return this.config.provider}async doEmbed({values:$,headers:X,abortSignal:Q,providerOptions:J}){var v;let Y=await Z4(this.config.headers());try{let{responseHeaders:z,value:W,rawValue:G}=await M4({url:this.getUrl(),headers:A4(Y,X!=null?X:{},this.getModelConfigHeaders(),await Z4(this.config.o11yHeaders)),body:{values:$,...J?{providerOptions:J}:{}},successfulResponseHandler:i4(h$$),failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(U)=>U}),...Q&&{abortSignal:Q},fetch:this.config.fetch});return{embeddings:W.embeddings,usage:(v=W.usage)!=null?v:void 0,providerMetadata:W.providerMetadata,response:{headers:z,body:G},warnings:[]}}catch(z){throw await J8(z,await XX(Y))}}getUrl(){return`${this.config.baseURL}/embedding-model`}getModelConfigHeaders(){return{"ai-embedding-model-specification-version":"3","ai-model-id":this.modelId}}},h$$=w4(()=>v4(K.object({embeddings:K.array(K.array(K.number())),usage:K.object({tokens:K.number()}).nullish(),providerMetadata:K.record(K.string(),K.record(K.string(),K.unknown())).optional()}))),l$$=class{constructor($,X){this.modelId=$,this.config=X,this.specificationVersion="v3",this.maxImagesPerCall=Number.MAX_SAFE_INTEGER}get provider(){return this.config.provider}async doGenerate({prompt:$,n:X,size:Q,aspectRatio:J,seed:v,files:Y,mask:z,providerOptions:W,headers:G,abortSignal:U}){var w,H,j,V;let B=await Z4(this.config.headers());try{let{responseHeaders:D,value:F,rawValue:L}=await M4({url:this.getUrl(),headers:A4(B,G!=null?G:{},this.getModelConfigHeaders(),await Z4(this.config.o11yHeaders)),body:{prompt:$,n:X,...Q&&{size:Q},...J&&{aspectRatio:J},...v&&{seed:v},...W&&{providerOptions:W},...Y&&{files:Y.map((A)=>li(A))},...z&&{mask:li(z)}},successfulResponseHandler:i4(a$$),failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(A)=>A}),...U&&{abortSignal:U},fetch:this.config.fetch});return{images:F.images,warnings:(w=F.warnings)!=null?w:[],providerMetadata:F.providerMetadata,response:{timestamp:new Date,modelId:this.modelId,headers:D},...F.usage!=null&&{usage:{inputTokens:(H=F.usage.inputTokens)!=null?H:void 0,outputTokens:(j=F.usage.outputTokens)!=null?j:void 0,totalTokens:(V=F.usage.totalTokens)!=null?V:void 0}}}}catch(D){throw await J8(D,await XX(B))}}getUrl(){return`${this.config.baseURL}/image-model`}getModelConfigHeaders(){return{"ai-image-model-specification-version":"3","ai-model-id":this.modelId}}};function li($){if($.type==="file"&&$.data instanceof Uint8Array)return{...$,data:q0($.data)};return $}var p$$=K.object({images:K.array(K.unknown()).optional()}).catchall(K.unknown()),d$$=K.discriminatedUnion("type",[K.object({type:K.literal("unsupported"),feature:K.string(),details:K.string().optional()}),K.object({type:K.literal("compatibility"),feature:K.string(),details:K.string().optional()}),K.object({type:K.literal("other"),message:K.string()})]),o$$=K.object({inputTokens:K.number().nullish(),outputTokens:K.number().nullish(),totalTokens:K.number().nullish()}),a$$=K.object({images:K.array(K.string()),warnings:K.array(d$$).optional(),providerMetadata:K.record(K.string(),p$$).optional(),usage:o$$.optional()}),r$$=class{constructor($,X){this.modelId=$,this.config=X,this.specificationVersion="v3",this.maxVideosPerCall=Number.MAX_SAFE_INTEGER}get provider(){return this.config.provider}async doGenerate({prompt:$,n:X,aspectRatio:Q,resolution:J,duration:v,fps:Y,seed:z,image:W,providerOptions:G,headers:U,abortSignal:w}){var H;let j=await Z4(this.config.headers());try{let{responseHeaders:V,value:B}=await M4({url:this.getUrl(),headers:A4(j,U!=null?U:{},this.getModelConfigHeaders(),await Z4(this.config.o11yHeaders),{accept:"text/event-stream"}),body:{prompt:$,n:X,...Q&&{aspectRatio:Q},...J&&{resolution:J},...v&&{duration:v},...Y&&{fps:Y},...z&&{seed:z},...G&&{providerOptions:G},...W&&{image:t$$(W)}},successfulResponseHandler:async({response:D,url:F,requestBodyValues:L})=>{if(D.body==null)throw new h$({message:"SSE response body is empty",url:F,requestBodyValues:L,statusCode:D.status});let O=YU({stream:D.body,schema:X4$}).getReader(),{done:i,value:M}=await O.read();if(O.releaseLock(),i||!M)throw new h$({message:"SSE stream ended without a data event",url:F,requestBodyValues:L,statusCode:D.status});if(!M.success)throw new h$({message:"Failed to parse video SSE event",cause:M.error,url:F,requestBodyValues:L,statusCode:D.status});let R=M.value;if(R.type==="error")throw new h$({message:R.message,statusCode:R.statusCode,url:F,requestBodyValues:L,responseHeaders:Object.fromEntries([...D.headers]),responseBody:JSON.stringify(R),data:{error:{message:R.message,type:R.errorType,param:R.param}}});return{value:{videos:R.videos,warnings:R.warnings,providerMetadata:R.providerMetadata},responseHeaders:Object.fromEntries([...D.headers])}},failedResponseHandler:H4({errorSchema:K.any(),errorToMessage:(D)=>D}),...w&&{abortSignal:w},fetch:this.config.fetch});return{videos:B.videos,warnings:(H=B.warnings)!=null?H:[],providerMetadata:B.providerMetadata,response:{timestamp:new Date,modelId:this.modelId,headers:V}}}catch(V){throw await J8(V,await XX(j))}}getUrl(){return`${this.config.baseURL}/video-model`}getModelConfigHeaders(){return{"ai-video-model-specification-version":"3","ai-model-id":this.modelId}}};function t$$($){if($.type==="file"&&$.data instanceof Uint8Array)return{...$,data:q0($.data)};return $}var s$$=K.object({videos:K.array(K.unknown()).optional()}).catchall(K.unknown()),e$$=K.union([K.object({type:K.literal("url"),url:K.string(),mediaType:K.string()}),K.object({type:K.literal("base64"),data:K.string(),mediaType:K.string()})]),$4$=K.discriminatedUnion("type",[K.object({type:K.literal("unsupported"),feature:K.string(),details:K.string().optional()}),K.object({type:K.literal("compatibility"),feature:K.string(),details:K.string().optional()}),K.object({type:K.literal("other"),message:K.string()})]),X4$=K.discriminatedUnion("type",[K.object({type:K.literal("result"),videos:K.array(e$$),warnings:K.array($4$).optional(),providerMetadata:K.record(K.string(),s$$).optional()}),K.object({type:K.literal("error"),message:K.string(),errorType:K.string(),statusCode:K.number(),param:K.unknown().nullable()})]),Q4$=w4(()=>v4(K.object({objective:K.string().describe("Natural-language description of the web research goal, including source or freshness guidance and broader context from the task. Maximum 5000 characters."),search_queries:K.array(K.string()).optional().describe("Optional search queries to supplement the objective. Maximum 200 characters per query."),mode:K.enum(["one-shot","agentic"]).optional().describe('Mode preset: "one-shot" for comprehensive results with longer excerpts (default), "agentic" for concise, token-efficient results for multi-step workflows.'),max_results:K.number().optional().describe("Maximum number of results to return (1-20). Defaults to 10 if not specified."),source_policy:K.object({include_domains:K.array(K.string()).optional().describe("List of domains to include in search results."),exclude_domains:K.array(K.string()).optional().describe("List of domains to exclude from search results."),after_date:K.string().optional().describe("Only include results published after this date (ISO 8601 format).")}).optional().describe("Source policy for controlling which domains to include/exclude and freshness."),excerpts:K.object({max_chars_per_result:K.number().optional().describe("Maximum characters per result."),max_chars_total:K.number().optional().describe("Maximum total characters across all results.")}).optional().describe("Excerpt configuration for controlling result length."),fetch_policy:K.object({max_age_seconds:K.number().optional().describe("Maximum age in seconds for cached content. Set to 0 to always fetch fresh content.")}).optional().describe("Fetch policy for controlling content freshness.")}))),v4$=w4(()=>v4(K.union([K.object({searchId:K.string(),results:K.array(K.object({url:K.string(),title:K.string(),excerpt:K.string(),publishDate:K.string().nullable().optional(),relevanceScore:K.number().optional()}))}),K.object({error:K.enum(["api_error","rate_limit","timeout","invalid_input","configuration_error","unknown"]),statusCode:K.number().optional(),message:K.string()})]))),J4$=zU({id:"gateway.parallel_search",inputSchema:Q4$,outputSchema:v4$}),Y4$=($={})=>J4$($),z4$=w4(()=>v4(K.object({query:K.union([K.string(),K.array(K.string())]).describe("Search query (string) or multiple queries (array of up to 5 strings). Multi-query searches return combined results from all queries."),max_results:K.number().optional().describe("Maximum number of search results to return (1-20, default: 10)"),max_tokens_per_page:K.number().optional().describe("Maximum number of tokens to extract per search result page (256-2048, default: 2048)"),max_tokens:K.number().optional().describe("Maximum total tokens across all search results (default: 25000, max: 1000000)"),country:K.string().optional().describe("Two-letter ISO 3166-1 alpha-2 country code for regional search results (e.g., 'US', 'GB', 'FR')"),search_domain_filter:K.array(K.string()).optional().describe("List of domains to include or exclude from search results (max 20). To include: ['nature.com', 'science.org']. To exclude: ['-example.com', '-spam.net']"),search_language_filter:K.array(K.string()).optional().describe("List of ISO 639-1 language codes to filter results (max 10, lowercase). Examples: ['en', 'fr', 'de']"),search_after_date:K.string().optional().describe("Include only results published after this date. Format: 'MM/DD/YYYY' (e.g., '3/1/2025'). Cannot be used with search_recency_filter."),search_before_date:K.string().optional().describe("Include only results published before this date. Format: 'MM/DD/YYYY' (e.g., '3/15/2025'). Cannot be used with search_recency_filter."),last_updated_after_filter:K.string().optional().describe("Include only results last updated after this date. Format: 'MM/DD/YYYY' (e.g., '3/1/2025'). Cannot be used with search_recency_filter."),last_updated_before_filter:K.string().optional().describe("Include only results last updated before this date. Format: 'MM/DD/YYYY' (e.g., '3/15/2025'). Cannot be used with search_recency_filter."),search_recency_filter:K.enum(["day","week","month","year"]).optional().describe("Filter results by relative time period. Cannot be used with search_after_date or search_before_date.")}))),W4$=w4(()=>v4(K.union([K.object({results:K.array(K.object({title:K.string(),url:K.string(),snippet:K.string(),date:K.string().optional(),lastUpdated:K.string().optional()})),id:K.string()}),K.object({error:K.enum(["api_error","rate_limit","timeout","invalid_input","unknown"]),statusCode:K.number().optional(),message:K.string()})]))),G4$=zU({id:"gateway.perplexity_search",inputSchema:z4$,outputSchema:W4$}),U4$=($={})=>G4$($),K4$={parallelSearch:Y4$,perplexitySearch:U4$};async function w4$(){var $;return($=$R.getContext().headers)==null?void 0:$["x-vercel-id"]}var H4$="3.0.54",j4$="0.0.1";function V4$($={}){var X,Q;let J=null,v=null,Y=(X=$.metadataCacheRefreshMillis)!=null?X:300000,z=0,W=(Q=WU($.baseURL))!=null?Q:"https://ai-gateway.vercel.sh/v3/ai",G=async()=>{try{let D=await B4$($);return M0({Authorization:`Bearer ${D.token}`,"ai-gateway-protocol-version":j4$,[ei]:D.authMethod,...$.headers},`ai-sdk/gateway/${H4$}`)}catch(D){throw jU.createContextualError({apiKeyProvided:!1,oidcTokenProvided:!1,statusCode:401,cause:D})}},U=()=>{let D=nY({settingValue:void 0,environmentVariableName:"VERCEL_DEPLOYMENT_ID"}),F=nY({settingValue:void 0,environmentVariableName:"VERCEL_ENV"}),L=nY({settingValue:void 0,environmentVariableName:"VERCEL_REGION"});return async()=>{let A=await w4$();return{...D&&{"ai-o11y-deployment-id":D},...F&&{"ai-o11y-environment":F},...L&&{"ai-o11y-region":L},...A&&{"ai-o11y-request-id":A}}}},w=(D)=>{return new c$$(D,{provider:"gateway",baseURL:W,headers:G,fetch:$.fetch,o11yHeaders:U()})},H=async()=>{var D,F,L;let A=(L=(F=(D=$._internal)==null?void 0:D.currentDate)==null?void 0:F.call(D).getTime())!=null?L:Date.now();if(!J||A-z>Y)z=A,J=new hi({baseURL:W,headers:G,fetch:$.fetch}).getAvailableModels().then((O)=>{return v=O,O}).catch(async(O)=>{throw await J8(O,await XX(await G()))});return v?Promise.resolve(v):J},j=async()=>{return new hi({baseURL:W,headers:G,fetch:$.fetch}).getCredits().catch(async(D)=>{throw await J8(D,await XX(await G()))})},V=function(D){if(new.target)throw Error("The Gateway Provider model function cannot be called with the new keyword.");return w(D)};V.specificationVersion="v3",V.getAvailableModels=H,V.getCredits=j,V.imageModel=(D)=>{return new l$$(D,{provider:"gateway",baseURL:W,headers:G,fetch:$.fetch,o11yHeaders:U()})},V.languageModel=w;let B=(D)=>{return new m$$(D,{provider:"gateway",baseURL:W,headers:G,fetch:$.fetch,o11yHeaders:U()})};return V.embeddingModel=B,V.textEmbeddingModel=B,V.videoModel=(D)=>{return new r$$(D,{provider:"gateway",baseURL:W,headers:G,fetch:$.fetch,o11yHeaders:U()})},V.chat=V.languageModel,V.embedding=V.embeddingModel,V.image=V.imageModel,V.video=V.videoModel,V.tools=K4$,V}var QR=V4$();async function B4$($){let X=nY({settingValue:$.apiKey,environmentVariableName:"AI_GATEWAY_API_KEY"});if(X)return{token:X,authMethod:"api-key"};return{token:await XR.getVercelOidcToken(),authMethod:"oidc"}}var LP=x$(WO(),1),Qv=x$(WO(),1);var X1$=Object.defineProperty,Q1$=($,X)=>{for(var Q in X)X1$($,Q,{get:X[Q],enumerable:!0})},qE="AI_InvalidArgumentError",IE=`vercel.ai.error.${qE}`,v1$=Symbol.for(IE),ME,P0=class extends m{constructor({parameter:$,value:X,message:Q}){super({name:qE,message:`Invalid argument for parameter ${$}: ${Q}`});this[ME]=!0,this.parameter=$,this.value=X}static isInstance($){return m.hasMarker($,IE)}};ME=v1$;var J1$="AI_InvalidStreamPartError",Y1$=`vercel.ai.error.${J1$}`,z1$=Symbol.for(Y1$),W1$;W1$=z1$;var xE="AI_InvalidToolApprovalError",bE=`vercel.ai.error.${xE}`,G1$=Symbol.for(bE),iE,U1$=class extends m{constructor({approvalId:$}){super({name:xE,message:`Tool approval response references unknown approvalId: "${$}". No matching tool-approval-request found in message history.`});this[iE]=!0,this.approvalId=$}static isInstance($){return m.hasMarker($,bE)}};iE=G1$;var RE="AI_InvalidToolInputError",EE=`vercel.ai.error.${RE}`,K1$=Symbol.for(EE),PE,OO=class extends m{constructor({toolInput:$,toolName:X,cause:Q,message:J=`Invalid input for tool ${X}: ${_1(Q)}`}){super({name:RE,message:J,cause:Q});this[PE]=!0,this.toolInput=$,this.toolName=X}static isInstance($){return m.hasMarker($,EE)}};PE=K1$;var TE="AI_ToolCallNotFoundForApprovalError",SE=`vercel.ai.error.${TE}`,w1$=Symbol.for(SE),CE,uE=class extends m{constructor({toolCallId:$,approvalId:X}){super({name:TE,message:`Tool call "${$}" not found for approval request "${X}".`});this[CE]=!0,this.toolCallId=$,this.approvalId=X}static isInstance($){return m.hasMarker($,SE)}};CE=w1$;var _E="AI_MissingToolResultsError",yE=`vercel.ai.error.${_E}`,H1$=Symbol.for(yE),fE,VE=class extends m{constructor({toolCallIds:$}){super({name:_E,message:`Tool result${$.length>1?"s are":" is"} missing for tool call${$.length>1?"s":""} ${$.join(", ")}.`});this[fE]=!0,this.toolCallIds=$}static isInstance($){return m.hasMarker($,yE)}};fE=H1$;var j1$="AI_NoImageGeneratedError",V1$=`vercel.ai.error.${j1$}`,B1$=Symbol.for(V1$),D1$;D1$=B1$;var kE="AI_NoObjectGeneratedError",nE=`vercel.ai.error.${kE}`,F1$=Symbol.for(nE),gE,d8=class extends m{constructor({message:$="No object generated.",cause:X,text:Q,response:J,usage:v,finishReason:Y}){super({name:kE,message:$,cause:X});this[gE]=!0,this.text=Q,this.response=J,this.usage=v,this.finishReason=Y}static isInstance($){return m.hasMarker($,nE)}};gE=F1$;var cE="AI_NoOutputGeneratedError",mE=`vercel.ai.error.${cE}`,N1$=Symbol.for(mE),hE,O1$=class extends m{constructor({message:$="No output generated.",cause:X}={}){super({name:cE,message:$,cause:X});this[hE]=!0}static isInstance($){return m.hasMarker($,mE)}};hE=N1$;var L1$="AI_NoSpeechGeneratedError",A1$=`vercel.ai.error.${L1$}`,Z1$=Symbol.for(A1$),q1$;q1$=Z1$;var I1$="AI_NoTranscriptGeneratedError",M1$=`vercel.ai.error.${I1$}`,x1$=Symbol.for(M1$),b1$;b1$=x1$;var i1$="AI_NoVideoGeneratedError",R1$=`vercel.ai.error.${i1$}`,E1$=Symbol.for(R1$),P1$;P1$=E1$;var lE="AI_NoSuchToolError",pE=`vercel.ai.error.${lE}`,T1$=Symbol.for(pE),dE,jO=class extends m{constructor({toolName:$,availableTools:X=void 0,message:Q=`Model tried to call unavailable tool '${$}'. ${X===void 0?"No tools are available.":`Available tools: ${X.join(", ")}.`}`}){super({name:lE,message:Q});this[dE]=!0,this.toolName=$,this.availableTools=X}static isInstance($){return m.hasMarker($,pE)}};dE=T1$;var oE="AI_ToolCallRepairError",aE=`vercel.ai.error.${oE}`,S1$=Symbol.for(aE),rE,C1$=class extends m{constructor({cause:$,originalError:X,message:Q=`Error repairing tool call: ${_1($)}`}){super({name:oE,message:Q,cause:$});this[rE]=!0,this.originalError=X}static isInstance($){return m.hasMarker($,aE)}};rE=S1$;var u1$=class extends m{constructor($){super({name:"AI_UnsupportedModelVersionError",message:`Unsupported model version ${$.version} for provider "${$.provider}" and model "${$.modelId}". AI SDK 5 only supports models that implement specification version "v2".`});this.version=$.version,this.provider=$.provider,this.modelId=$.modelId}},tE="AI_UIMessageStreamError",sE=`vercel.ai.error.${tE}`,_1$=Symbol.for(sE),eE,eQ=class extends m{constructor({chunkType:$,chunkId:X,message:Q}){super({name:tE,message:Q});this[eE]=!0,this.chunkType=$,this.chunkId=X}static isInstance($){return m.hasMarker($,sE)}};eE=_1$;var y1$="AI_InvalidDataContentError",f1$=`vercel.ai.error.${y1$}`,k1$=Symbol.for(f1$),n1$;n1$=k1$;var $P="AI_InvalidMessageRoleError",XP=`vercel.ai.error.${$P}`,g1$=Symbol.for(XP),QP,c1$=class extends m{constructor({role:$,message:X=`Invalid message role: '${$}'. Must be one of: "system", "user", "assistant", "tool".`}){super({name:$P,message:X});this[QP]=!0,this.role=$}static isInstance($){return m.hasMarker($,XP)}};QP=g1$;var vP="AI_MessageConversionError",JP=`vercel.ai.error.${vP}`,m1$=Symbol.for(JP),YP,h1$=class extends m{constructor({originalMessage:$,message:X}){super({name:vP,message:X});this[YP]=!0,this.originalMessage=$}static isInstance($){return m.hasMarker($,JP)}};YP=m1$;var zP="AI_RetryError",WP=`vercel.ai.error.${zP}`,l1$=Symbol.for(WP),GP,BE=class extends m{constructor({message:$,reason:X,errors:Q}){super({name:zP,message:$});this[GP]=!0,this.reason=X,this.errors=Q,this.lastError=Q[Q.length-1]}static isInstance($){return m.hasMarker($,WP)}};GP=l1$;function p1$({warning:$,provider:X,model:Q}){let J=`AI SDK Warning (${X} / ${Q}):`;switch($.type){case"unsupported":{let v=`${J} The feature "${$.feature}" is not supported.`;if($.details)v+=` ${$.details}`;return v}case"compatibility":{let v=`${J} The feature "${$.feature}" is used in a compatibility mode.`;if($.details)v+=` ${$.details}`;return v}case"other":return`${J} ${$.message}`;default:return`${J} ${JSON.stringify($,null,2)}`}}var d1$="AI SDK Warning System: To turn off warning logging, set the AI_SDK_LOG_WARNINGS global to false.",DE=!1,UP=($)=>{if($.warnings.length===0)return;let X=globalThis.AI_SDK_LOG_WARNINGS;if(X===!1)return;if(typeof X==="function"){X($);return}if(!DE)DE=!0,console.info(d1$);for(let Q of $.warnings)console.warn(p1$({warning:Q,provider:$.provider,model:$.model}))};function o1$({provider:$,modelId:X}){UP({warnings:[{type:"compatibility",feature:"specificationVersion",details:"Using v2 specification compatibility mode. Some features may not be available."}],provider:$,model:X})}function a1$($){if($.specificationVersion==="v3")return $;return o1$({provider:$.provider,modelId:$.modelId}),new Proxy($,{get(X,Q){switch(Q){case"specificationVersion":return"v3";case"doGenerate":return async(...J)=>{let v=await X.doGenerate(...J);return{...v,finishReason:KP(v.finishReason),usage:wP(v.usage)}};case"doStream":return async(...J)=>{let v=await X.doStream(...J);return{...v,stream:r1$(v.stream)}};default:return X[Q]}}})}function r1$($){return $.pipeThrough(new TransformStream({transform(X,Q){switch(X.type){case"finish":Q.enqueue({...X,finishReason:KP(X.finishReason),usage:wP(X.usage)});break;default:Q.enqueue(X);break}}}))}function KP($){return{unified:$==="unknown"?"other":$,raw:void 0}}function wP($){return{inputTokens:{total:$.inputTokens,noCache:void 0,cacheRead:$.cachedInputTokens,cacheWrite:void 0},outputTokens:{total:$.outputTokens,text:void 0,reasoning:$.reasoningTokens}}}function HP($){if(typeof $!=="string"){if($.specificationVersion!=="v3"&&$.specificationVersion!=="v2"){let X=$;throw new u1$({version:X.specificationVersion,provider:X.provider,modelId:X.modelId})}return a1$($)}return t1$().languageModel($)}function t1$(){var $;return($=globalThis.AI_SDK_DEFAULT_PROVIDER)!=null?$:QR}function jP($){if($==null)return;if(typeof $==="number")return $;return $.totalMs}function s1$($){if($==null||typeof $==="number")return;return $.stepMs}function e1$($){if($==null||typeof $==="number")return;return $.chunkMs}var $0$=[{mediaType:"image/gif",bytesPrefix:[71,73,70]},{mediaType:"image/png",bytesPrefix:[137,80,78,71]},{mediaType:"image/jpeg",bytesPrefix:[255,216]},{mediaType:"image/webp",bytesPrefix:[82,73,70,70,null,null,null,null,87,69,66,80]},{mediaType:"image/bmp",bytesPrefix:[66,77]},{mediaType:"image/tiff",bytesPrefix:[73,73,42,0]},{mediaType:"image/tiff",bytesPrefix:[77,77,0,42]},{mediaType:"image/avif",bytesPrefix:[0,0,0,32,102,116,121,112,97,118,105,102]},{mediaType:"image/heic",bytesPrefix:[0,0,0,32,102,116,121,112,104,101,105,99]}];var X0$=($)=>{let X=typeof $==="string"?l8($):$,Q=(X[6]&127)<<21|(X[7]&127)<<14|(X[8]&127)<<7|X[9]&127;return X.slice(Q+10)};function Q0$($){return typeof $==="string"&&$.startsWith("SUQz")||typeof $!=="string"&&$.length>10&&$[0]===73&&$[1]===68&&$[2]===51?X0$($):$}function v0$({data:$,signatures:X}){let Q=Q0$($),J=typeof Q==="string"?l8(Q.substring(0,Math.min(Q.length,24))):Q;for(let v of X)if(J.length>=v.bytesPrefix.length&&v.bytesPrefix.every((Y,z)=>Y===null||J[z]===Y))return v.mediaType;return}var J0$="6.0.98",VP=async({url:$,maxBytes:X,abortSignal:Q})=>{var J;let v=$.toString();try{let Y=await fetch(v,{headers:M0({},`ai-sdk/${J0$}`,JU()),signal:Q});if(!Y.ok)throw new Q8({url:v,statusCode:Y.status,statusText:Y.statusText});return{data:await e3({response:Y,url:v,maxBytes:X!=null?X:vU}),mediaType:(J=Y.headers.get("content-type"))!=null?J:void 0}}catch(Y){if(Q8.isInstance(Y))throw Y;throw new Q8({url:v,cause:Y})}},Y0$=($=VP)=>(X)=>Promise.all(X.map(async(Q)=>Q.isUrlSupportedByModel?null:$(Q)));function z0$($){try{let[X,Q]=$.split(",");return{mediaType:X.split(";")[0].split(":")[1],base64Content:Q}}catch(X){return{mediaType:void 0,base64Content:void 0}}}var BP=K.union([K.string(),K.instanceof(Uint8Array),K.instanceof(ArrayBuffer),K.custom(($)=>{var X,Q;return(Q=(X=globalThis.Buffer)==null?void 0:X.isBuffer($))!=null?Q:!1},{message:"Must be a Buffer"})]);function DP($){if($ instanceof Uint8Array)return{data:$,mediaType:void 0};if($ instanceof ArrayBuffer)return{data:new Uint8Array($),mediaType:void 0};if(typeof $==="string")try{$=new URL($)}catch(X){}if($ instanceof URL&&$.protocol==="data:"){let{mediaType:X,base64Content:Q}=z0$($.toString());if(X==null||Q==null)throw new m({name:"InvalidDataContentError",message:`Invalid data URL format in content ${$.toString()}`});return{data:Q,mediaType:X}}return{data:$,mediaType:void 0}}function W0$($){if(typeof $==="string")return $;if($ instanceof ArrayBuffer)return q0(new Uint8Array($));return q0($)}function aU($){return $===void 0?[]:Array.isArray($)?$:[$]}async function G0$({prompt:$,supportedUrls:X,download:Q=Y0$()}){let J=await K0$($.messages,Q,X),v=new Map;for(let U of $.messages)if(U.role==="assistant"&&Array.isArray(U.content)){for(let w of U.content)if(w.type==="tool-approval-request"&&"approvalId"in w&&"toolCallId"in w)v.set(w.approvalId,w.toolCallId)}let Y=new Set;for(let U of $.messages)if(U.role==="tool"){for(let w of U.content)if(w.type==="tool-approval-response"){let H=v.get(w.approvalId);if(H)Y.add(H)}}let z=[...$.system!=null?typeof $.system==="string"?[{role:"system",content:$.system}]:aU($.system).map((U)=>({role:"system",content:U.content,providerOptions:U.providerOptions})):[],...$.messages.map((U)=>U0$({message:U,downloadedAssets:J}))],W=[];for(let U of z){if(U.role!=="tool"){W.push(U);continue}let w=W.at(-1);if((w==null?void 0:w.role)==="tool")w.content.push(...U.content);else W.push(U)}let G=new Set;for(let U of W)switch(U.role){case"assistant":{for(let w of U.content)if(w.type==="tool-call"&&!w.providerExecuted)G.add(w.toolCallId);break}case"tool":{for(let w of U.content)if(w.type==="tool-result")G.delete(w.toolCallId);break}case"user":case"system":for(let w of Y)G.delete(w);if(G.size>0)throw new VE({toolCallIds:Array.from(G)});break}for(let U of Y)G.delete(U);if(G.size>0)throw new VE({toolCallIds:Array.from(G)});return W.filter((U)=>U.role!=="tool"||U.content.length>0)}function U0$({message:$,downloadedAssets:X}){let Q=$.role;switch(Q){case"system":return{role:"system",content:$.content,providerOptions:$.providerOptions};case"user":{if(typeof $.content==="string")return{role:"user",content:[{type:"text",text:$.content}],providerOptions:$.providerOptions};return{role:"user",content:$.content.map((J)=>w0$(J,X)).filter((J)=>J.type!=="text"||J.text!==""),providerOptions:$.providerOptions}}case"assistant":{if(typeof $.content==="string")return{role:"assistant",content:[{type:"text",text:$.content}],providerOptions:$.providerOptions};return{role:"assistant",content:$.content.filter((J)=>J.type!=="text"||J.text!==""||J.providerOptions!=null).filter((J)=>J.type!=="tool-approval-request").map((J)=>{let v=J.providerOptions;switch(J.type){case"file":{let{data:Y,mediaType:z}=DP(J.data);return{type:"file",data:Y,filename:J.filename,mediaType:z!=null?z:J.mediaType,providerOptions:v}}case"reasoning":return{type:"reasoning",text:J.text,providerOptions:v};case"text":return{type:"text",text:J.text,providerOptions:v};case"tool-call":return{type:"tool-call",toolCallId:J.toolCallId,toolName:J.toolName,input:J.input,providerExecuted:J.providerExecuted,providerOptions:v};case"tool-result":return{type:"tool-result",toolCallId:J.toolCallId,toolName:J.toolName,output:FE(J.output),providerOptions:v}}}),providerOptions:$.providerOptions}}case"tool":return{role:"tool",content:$.content.filter((J)=>J.type!=="tool-approval-response"||J.providerExecuted).map((J)=>{switch(J.type){case"tool-result":return{type:"tool-result",toolCallId:J.toolCallId,toolName:J.toolName,output:FE(J.output),providerOptions:J.providerOptions};case"tool-approval-response":return{type:"tool-approval-response",approvalId:J.approvalId,approved:J.approved,reason:J.reason}}}),providerOptions:$.providerOptions};default:throw new c1$({role:Q})}}async function K0$($,X,Q){let J=$.filter((Y)=>Y.role==="user").map((Y)=>Y.content).filter((Y)=>Array.isArray(Y)).flat().filter((Y)=>Y.type==="image"||Y.type==="file").map((Y)=>{var z;let W=(z=Y.mediaType)!=null?z:Y.type==="image"?"image/*":void 0,G=Y.type==="image"?Y.image:Y.data;if(typeof G==="string")try{G=new URL(G)}catch(U){}return{mediaType:W,data:G}}).filter((Y)=>Y.data instanceof URL).map((Y)=>({url:Y.data,isUrlSupportedByModel:Y.mediaType!=null&&bb({url:Y.data.toString(),mediaType:Y.mediaType,supportedUrls:Q})})),v=await X(J);return Object.fromEntries(v.map((Y,z)=>Y==null?null:[J[z].url.toString(),{data:Y.data,mediaType:Y.mediaType}]).filter((Y)=>Y!=null))}function w0$($,X){var Q;if($.type==="text")return{type:"text",text:$.text,providerOptions:$.providerOptions};let J,v=$.type;switch(v){case"image":J=$.image;break;case"file":J=$.data;break;default:throw Error(`Unsupported part type: ${v}`)}let{data:Y,mediaType:z}=DP(J),W=z!=null?z:$.mediaType,G=Y;if(G instanceof URL){let U=X[G.toString()];if(U)G=U.data,W!=null||(W=U.mediaType)}switch(v){case"image":{if(G instanceof Uint8Array||typeof G==="string")W=(Q=v0$({data:G,signatures:$0$}))!=null?Q:W;return{type:"file",mediaType:W!=null?W:"image/*",filename:void 0,data:G,providerOptions:$.providerOptions}}case"file":{if(W==null)throw Error("Media type is missing for file part");return{type:"file",mediaType:W,filename:$.filename,data:G,providerOptions:$.providerOptions}}}}function FE($){if($.type!=="content")return $;return{type:"content",value:$.value.map((X)=>{if(X.type!=="media")return X;if(X.mediaType.startsWith("image/"))return{type:"image-data",data:X.data,mediaType:X.mediaType};return{type:"file-data",data:X.data,mediaType:X.mediaType}})}}async function Xv({toolCallId:$,input:X,output:Q,tool:J,errorMode:v}){if(v==="text")return{type:"error-text",value:_1(Q)};else if(v==="json")return{type:"error-json",value:NE(Q)};if(J==null?void 0:J.toModelOutput)return await J.toModelOutput({toolCallId:$,input:X,output:Q});return typeof Q==="string"?{type:"text",value:Q}:{type:"json",value:NE(Q)}}function NE($){return $===void 0?null:$}function H0$({maxOutputTokens:$,temperature:X,topP:Q,topK:J,presencePenalty:v,frequencyPenalty:Y,seed:z,stopSequences:W}){if($!=null){if(!Number.isInteger($))throw new P0({parameter:"maxOutputTokens",value:$,message:"maxOutputTokens must be an integer"});if($<1)throw new P0({parameter:"maxOutputTokens",value:$,message:"maxOutputTokens must be >= 1"})}if(X!=null){if(typeof X!=="number")throw new P0({parameter:"temperature",value:X,message:"temperature must be a number"})}if(Q!=null){if(typeof Q!=="number")throw new P0({parameter:"topP",value:Q,message:"topP must be a number"})}if(J!=null){if(typeof J!=="number")throw new P0({parameter:"topK",value:J,message:"topK must be a number"})}if(v!=null){if(typeof v!=="number")throw new P0({parameter:"presencePenalty",value:v,message:"presencePenalty must be a number"})}if(Y!=null){if(typeof Y!=="number")throw new P0({parameter:"frequencyPenalty",value:Y,message:"frequencyPenalty must be a number"})}if(z!=null){if(!Number.isInteger(z))throw new P0({parameter:"seed",value:z,message:"seed must be an integer"})}return{maxOutputTokens:$,temperature:X,topP:Q,topK:J,presencePenalty:v,frequencyPenalty:Y,stopSequences:W,seed:z}}function j0$($){return $!=null&&Object.keys($).length>0}async function V0$({tools:$,toolChoice:X,activeTools:Q}){if(!j0$($))return{tools:void 0,toolChoice:void 0};let J=Q!=null?Object.entries($).filter(([Y])=>Q.includes(Y)):Object.entries($),v=[];for(let[Y,z]of J){let W=z.type;switch(W){case void 0:case"dynamic":case"function":v.push({type:"function",name:Y,description:z.description,inputSchema:await x0(z.inputSchema).jsonSchema,...z.inputExamples!=null?{inputExamples:z.inputExamples}:{},providerOptions:z.providerOptions,...z.strict!=null?{strict:z.strict}:{}});break;case"provider":v.push({type:"provider",name:Y,id:z.id,args:z.args});break;default:throw Error(`Unsupported tool type: ${W}`)}}return{tools:v,toolChoice:X==null?{type:"auto"}:typeof X==="string"?{type:X}:{type:"tool",toolName:X.toolName}}}var zz=K.lazy(()=>K.union([K.null(),K.string(),K.number(),K.boolean(),K.record(K.string(),zz.optional()),K.array(zz)])),e=K.record(K.string(),K.record(K.string(),zz.optional())),FP=K.object({type:K.literal("text"),text:K.string(),providerOptions:e.optional()}),B0$=K.object({type:K.literal("image"),image:K.union([BP,K.instanceof(URL)]),mediaType:K.string().optional(),providerOptions:e.optional()}),NP=K.object({type:K.literal("file"),data:K.union([BP,K.instanceof(URL)]),filename:K.string().optional(),mediaType:K.string(),providerOptions:e.optional()}),D0$=K.object({type:K.literal("reasoning"),text:K.string(),providerOptions:e.optional()}),F0$=K.object({type:K.literal("tool-call"),toolCallId:K.string(),toolName:K.string(),input:K.unknown(),providerOptions:e.optional(),providerExecuted:K.boolean().optional()}),N0$=K.discriminatedUnion("type",[K.object({type:K.literal("text"),value:K.string(),providerOptions:e.optional()}),K.object({type:K.literal("json"),value:zz,providerOptions:e.optional()}),K.object({type:K.literal("execution-denied"),reason:K.string().optional(),providerOptions:e.optional()}),K.object({type:K.literal("error-text"),value:K.string(),providerOptions:e.optional()}),K.object({type:K.literal("error-json"),value:zz,providerOptions:e.optional()}),K.object({type:K.literal("content"),value:K.array(K.union([K.object({type:K.literal("text"),text:K.string(),providerOptions:e.optional()}),K.object({type:K.literal("media"),data:K.string(),mediaType:K.string()}),K.object({type:K.literal("file-data"),data:K.string(),mediaType:K.string(),filename:K.string().optional(),providerOptions:e.optional()}),K.object({type:K.literal("file-url"),url:K.string(),providerOptions:e.optional()}),K.object({type:K.literal("file-id"),fileId:K.union([K.string(),K.record(K.string(),K.string())]),providerOptions:e.optional()}),K.object({type:K.literal("image-data"),data:K.string(),mediaType:K.string(),providerOptions:e.optional()}),K.object({type:K.literal("image-url"),url:K.string(),providerOptions:e.optional()}),K.object({type:K.literal("image-file-id"),fileId:K.union([K.string(),K.record(K.string(),K.string())]),providerOptions:e.optional()}),K.object({type:K.literal("custom"),providerOptions:e.optional()})]))})]),OP=K.object({type:K.literal("tool-result"),toolCallId:K.string(),toolName:K.string(),output:N0$,providerOptions:e.optional()}),O0$=K.object({type:K.literal("tool-approval-request"),approvalId:K.string(),toolCallId:K.string()}),L0$=K.object({type:K.literal("tool-approval-response"),approvalId:K.string(),approved:K.boolean(),reason:K.string().optional()}),A0$=K.object({role:K.literal("system"),content:K.string(),providerOptions:e.optional()}),Z0$=K.object({role:K.literal("user"),content:K.union([K.string(),K.array(K.union([FP,B0$,NP]))]),providerOptions:e.optional()}),q0$=K.object({role:K.literal("assistant"),content:K.union([K.string(),K.array(K.union([FP,NP,D0$,F0$,OP,O0$]))]),providerOptions:e.optional()}),I0$=K.object({role:K.literal("tool"),content:K.array(K.union([OP,L0$])),providerOptions:e.optional()}),M0$=K.union([A0$,Z0$,q0$,I0$]);async function x0$($){if($.prompt==null&&$.messages==null)throw new a0({prompt:$,message:"prompt or messages must be defined"});if($.prompt!=null&&$.messages!=null)throw new a0({prompt:$,message:"prompt and messages cannot be defined at the same time"});if($.system!=null&&typeof $.system!=="string"&&!aU($.system).every((J)=>typeof J==="object"&&J!==null&&("role"in J)&&J.role==="system"))throw new a0({prompt:$,message:"system must be a string, SystemModelMessage, or array of SystemModelMessage"});let X;if($.prompt!=null&&typeof $.prompt==="string")X=[{role:"user",content:$.prompt}];else if($.prompt!=null&&Array.isArray($.prompt))X=$.prompt;else if($.messages!=null)X=$.messages;else throw new a0({prompt:$,message:"prompt or messages must be defined"});if(X.length===0)throw new a0({prompt:$,message:"messages must not be empty"});let Q=await v6({value:X,schema:K.array(M0$)});if(!Q.success)throw new a0({prompt:$,message:"The messages do not match the ModelMessage[] schema.",cause:Q.error});return{messages:X,system:$.system}}function b0$($){if(!jU.isInstance($))return $;let X=(process==null?void 0:"development")==="production",Q="https://ai-sdk.dev/unauthenticated-ai-gateway";if(X)return new m({name:"GatewayError",message:`Unauthenticated. Configure AI_GATEWAY_API_KEY or use a provider module. Learn more: ${Q}`});return Object.assign(Error(`\x1B[1m\x1B[31mUnauthenticated request to AI Gateway.\x1B[0m -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/union.js -var primitiveMappings2 = { - ZodString: "string", - ZodNumber: "number", - ZodBigInt: "integer", - ZodBoolean: "boolean", - ZodNull: "null" -}; -function parseUnionDef2(def, refs) { - if (refs.target === "openApi3") - return asAnyOf2(def, refs); - const options = def.options instanceof Map ? Array.from(def.options.values()) : def.options; - if (options.every((x) => (x._def.typeName in primitiveMappings2) && (!x._def.checks || !x._def.checks.length))) { - const types2 = options.reduce((types3, x) => { - const type = primitiveMappings2[x._def.typeName]; - return type && !types3.includes(type) ? [...types3, type] : types3; - }, []); - return { - type: types2.length > 1 ? types2 : types2[0] - }; - } else if (options.every((x) => x._def.typeName === "ZodLiteral" && !x.description)) { - const types2 = options.reduce((acc, x) => { - const type = typeof x._def.value; - switch (type) { - case "string": - case "number": - case "boolean": - return [...acc, type]; - case "bigint": - return [...acc, "integer"]; - case "object": - if (x._def.value === null) - return [...acc, "null"]; - case "symbol": - case "undefined": - case "function": - default: - return acc; - } - }, []); - if (types2.length === options.length) { - const uniqueTypes = types2.filter((x, i, a) => a.indexOf(x) === i); - return { - type: uniqueTypes.length > 1 ? uniqueTypes : uniqueTypes[0], - enum: options.reduce((acc, x) => { - return acc.includes(x._def.value) ? acc : [...acc, x._def.value]; - }, []) - }; - } - } else if (options.every((x) => x._def.typeName === "ZodEnum")) { - return { - type: "string", - enum: options.reduce((acc, x) => [ - ...acc, - ...x._def.values.filter((x2) => !acc.includes(x2)) - ], []) - }; - } - return asAnyOf2(def, refs); -} -var asAnyOf2 = (def, refs) => { - const anyOf = (def.options instanceof Map ? Array.from(def.options.values()) : def.options).map((x, i) => parseDef2(x._def, { - ...refs, - currentPath: [...refs.currentPath, "anyOf", `${i}`] - })).filter((x) => !!x && (!refs.strictUnions || typeof x === "object" && Object.keys(x).length > 0)); - return anyOf.length ? { anyOf } : undefined; -}; +To authenticate, set the \x1B[33mAI_GATEWAY_API_KEY\x1B[0m environment variable with your API key. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/nullable.js -function parseNullableDef2(def, refs) { - if (["ZodString", "ZodNumber", "ZodBigInt", "ZodBoolean", "ZodNull"].includes(def.innerType._def.typeName) && (!def.innerType._def.checks || !def.innerType._def.checks.length)) { - if (refs.target === "openApi3") { - return { - type: primitiveMappings2[def.innerType._def.typeName], - nullable: true - }; - } - return { - type: [ - primitiveMappings2[def.innerType._def.typeName], - "null" - ] - }; - } - if (refs.target === "openApi3") { - const base2 = parseDef2(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath] - }); - if (base2 && "$ref" in base2) - return { allOf: [base2], nullable: true }; - return base2 && { ...base2, nullable: true }; - } - const base = parseDef2(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath, "anyOf", "0"] - }); - return base && { anyOf: [base, { type: "null" }] }; -} +Alternatively, you can use a provider module instead of the AI Gateway. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/number.js -function parseNumberDef2(def, refs) { - const res = { - type: "number" - }; - if (!def.checks) - return res; - for (const check2 of def.checks) { - switch (check2.kind) { - case "int": - res.type = "integer"; - addErrorMessage(res, "type", check2.message, refs); - break; - case "min": - if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); - } else { - setResponseValueAndErrors(res, "exclusiveMinimum", check2.value, check2.message, refs); - } - } else { - if (!check2.inclusive) { - res.exclusiveMinimum = true; - } - setResponseValueAndErrors(res, "minimum", check2.value, check2.message, refs); - } - break; - case "max": - if (refs.target === "jsonSchema7") { - if (check2.inclusive) { - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); - } else { - setResponseValueAndErrors(res, "exclusiveMaximum", check2.value, check2.message, refs); - } - } else { - if (!check2.inclusive) { - res.exclusiveMaximum = true; - } - setResponseValueAndErrors(res, "maximum", check2.value, check2.message, refs); - } - break; - case "multipleOf": - setResponseValueAndErrors(res, "multipleOf", check2.value, check2.message, refs); - break; - } - } - return res; -} +Learn more: \x1B[34m${Q}\x1B[0m -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/object.js -function parseObjectDef2(def, refs) { - const forceOptionalIntoNullable = refs.target === "openAi"; - const result = { - type: "object", - properties: {} - }; - const required2 = []; - const shape = def.shape(); - for (const propName in shape) { - let propDef = shape[propName]; - if (propDef === undefined || propDef._def === undefined) { - continue; - } - let propOptional = safeIsOptional2(propDef); - if (propOptional && forceOptionalIntoNullable) { - if (propDef._def.typeName === "ZodOptional") { - propDef = propDef._def.innerType; - } - if (!propDef.isNullable()) { - propDef = propDef.nullable(); - } - propOptional = false; - } - const parsedDef = parseDef2(propDef._def, { - ...refs, - currentPath: [...refs.currentPath, "properties", propName], - propertyPath: [...refs.currentPath, "properties", propName] - }); - if (parsedDef === undefined) { - continue; - } - result.properties[propName] = parsedDef; - if (!propOptional) { - required2.push(propName); - } - } - if (required2.length) { - result.required = required2; - } - const additionalProperties = decideAdditionalProperties2(def, refs); - if (additionalProperties !== undefined) { - result.additionalProperties = additionalProperties; - } - return result; -} -function decideAdditionalProperties2(def, refs) { - if (def.catchall._def.typeName !== "ZodNever") { - return parseDef2(def.catchall._def, { - ...refs, - currentPath: [...refs.currentPath, "additionalProperties"] - }); - } - switch (def.unknownKeys) { - case "passthrough": - return refs.allowedAdditionalProperties; - case "strict": - return refs.rejectedAdditionalProperties; - case "strip": - return refs.removeAdditionalStrategy === "strict" ? refs.allowedAdditionalProperties : refs.rejectedAdditionalProperties; - } -} -function safeIsOptional2(schema) { - try { - return schema.isOptional(); - } catch { - return true; - } -} +`),{name:"GatewayAuthenticationError"})}function VO({operationId:$,telemetry:X}){return{"operation.name":`${$}${(X==null?void 0:X.functionId)!=null?` ${X.functionId}`:""}`,"resource.name":X==null?void 0:X.functionId,"ai.operationId":$,"ai.telemetry.functionId":X==null?void 0:X.functionId}}function i0$({model:$,settings:X,telemetry:Q,headers:J}){var v;return{"ai.model.provider":$.provider,"ai.model.id":$.modelId,...Object.entries(X).reduce((Y,[z,W])=>{if(z==="timeout"){let G=jP(W);if(G!=null)Y[`ai.settings.${z}`]=G}else Y[`ai.settings.${z}`]=W;return Y},{}),...Object.entries((v=Q==null?void 0:Q.metadata)!=null?v:{}).reduce((Y,[z,W])=>{return Y[`ai.telemetry.metadata.${z}`]=W,Y},{}),...Object.entries(J!=null?J:{}).reduce((Y,[z,W])=>{if(W!==void 0)Y[`ai.request.headers.${z}`]=W;return Y},{})}}var R0$={startSpan(){return dU},startActiveSpan($,X,Q,J){if(typeof X==="function")return X(dU);if(typeof Q==="function")return Q(dU);if(typeof J==="function")return J(dU)}},dU={spanContext(){return E0$},setAttribute(){return this},setAttributes(){return this},addEvent(){return this},addLink(){return this},addLinks(){return this},setStatus(){return this},updateName(){return this},end(){return this},isRecording(){return!1},recordException(){return this}},E0$={traceId:"",spanId:"",traceFlags:0};function P0$({isEnabled:$=!1,tracer:X}={}){if(!$)return R0$;if(X)return X;return LP.trace.getTracer("ai")}async function BO({name:$,tracer:X,attributes:Q,fn:J,endWhenDone:v=!0}){return X.startActiveSpan($,{attributes:await Q},async(Y)=>{let z=Qv.context.active();try{let W=await Qv.context.with(z,()=>J(Y));if(v)Y.end();return W}catch(W){try{AP(Y,W)}finally{Y.end()}throw W}})}function AP($,X){if(X instanceof Error)$.recordException({name:X.name,message:X.message,stack:X.stack}),$.setStatus({code:Qv.SpanStatusCode.ERROR,message:X.message});else $.setStatus({code:Qv.SpanStatusCode.ERROR})}async function $v({telemetry:$,attributes:X}){if(($==null?void 0:$.isEnabled)!==!0)return{};let Q={};for(let[J,v]of Object.entries(X)){if(v==null)continue;if(typeof v==="object"&&"input"in v&&typeof v.input==="function"){if(($==null?void 0:$.recordInputs)===!1)continue;let Y=await v.input();if(Y!=null)Q[J]=Y;continue}if(typeof v==="object"&&"output"in v&&typeof v.output==="function"){if(($==null?void 0:$.recordOutputs)===!1)continue;let Y=await v.output();if(Y!=null)Q[J]=Y;continue}Q[J]=v}return Q}function T0$($){return JSON.stringify($.map((X)=>({...X,content:typeof X.content==="string"?X.content:X.content.map((Q)=>Q.type==="file"?{...Q,data:Q.data instanceof Uint8Array?W0$(Q.data):Q.data}:Q)})))}function S0$($){return{inputTokens:$.inputTokens.total,inputTokenDetails:{noCacheTokens:$.inputTokens.noCache,cacheReadTokens:$.inputTokens.cacheRead,cacheWriteTokens:$.inputTokens.cacheWrite},outputTokens:$.outputTokens.total,outputTokenDetails:{textTokens:$.outputTokens.text,reasoningTokens:$.outputTokens.reasoning},totalTokens:d1($.inputTokens.total,$.outputTokens.total),raw:$.raw,reasoningTokens:$.outputTokens.reasoning,cachedInputTokens:$.inputTokens.cacheRead}}function GO(){return{inputTokens:void 0,inputTokenDetails:{noCacheTokens:void 0,cacheReadTokens:void 0,cacheWriteTokens:void 0},outputTokens:void 0,outputTokenDetails:{textTokens:void 0,reasoningTokens:void 0},totalTokens:void 0,raw:void 0}}function C0$($,X){var Q,J,v,Y,z,W,G,U,w,H;return{inputTokens:d1($.inputTokens,X.inputTokens),inputTokenDetails:{noCacheTokens:d1((Q=$.inputTokenDetails)==null?void 0:Q.noCacheTokens,(J=X.inputTokenDetails)==null?void 0:J.noCacheTokens),cacheReadTokens:d1((v=$.inputTokenDetails)==null?void 0:v.cacheReadTokens,(Y=X.inputTokenDetails)==null?void 0:Y.cacheReadTokens),cacheWriteTokens:d1((z=$.inputTokenDetails)==null?void 0:z.cacheWriteTokens,(W=X.inputTokenDetails)==null?void 0:W.cacheWriteTokens)},outputTokens:d1($.outputTokens,X.outputTokens),outputTokenDetails:{textTokens:d1((G=$.outputTokenDetails)==null?void 0:G.textTokens,(U=X.outputTokenDetails)==null?void 0:U.textTokens),reasoningTokens:d1((w=$.outputTokenDetails)==null?void 0:w.reasoningTokens,(H=X.outputTokenDetails)==null?void 0:H.reasoningTokens)},totalTokens:d1($.totalTokens,X.totalTokens),reasoningTokens:d1($.reasoningTokens,X.reasoningTokens),cachedInputTokens:d1($.cachedInputTokens,X.cachedInputTokens)}}function d1($,X){return $==null&&X==null?void 0:($!=null?$:0)+(X!=null?X:0)}function LO($,X){if($===void 0&&X===void 0)return;if($===void 0)return X;if(X===void 0)return $;let Q={...$};for(let J in X)if(Object.prototype.hasOwnProperty.call(X,J)){let v=X[J];if(v===void 0)continue;let Y=J in $?$[J]:void 0,z=v!==null&&typeof v==="object"&&!Array.isArray(v)&&!(v instanceof Date)&&!(v instanceof RegExp),W=Y!==null&&Y!==void 0&&typeof Y==="object"&&!Array.isArray(Y)&&!(Y instanceof Date)&&!(Y instanceof RegExp);if(z&&W)Q[J]=LO(Y,v);else Q[J]=v}return Q}function u0$({error:$,exponentialBackoffDelay:X}){let Q=$.responseHeaders;if(!Q)return X;let J,v=Q["retry-after-ms"];if(v){let z=parseFloat(v);if(!Number.isNaN(z))J=z}let Y=Q["retry-after"];if(Y&&J===void 0){let z=parseFloat(Y);if(!Number.isNaN(z))J=z*1000;else J=Date.parse(Y)-Date.now()}if(J!=null&&!Number.isNaN(J)&&0<=J&&(J<60000||Jasync(v)=>ZP(v,{maxRetries:$,delayInMs:X,backoffFactor:Q,abortSignal:J});async function ZP($,{maxRetries:X,delayInMs:Q,backoffFactor:J,abortSignal:v},Y=[]){try{return await $()}catch(z){if(v8(z))throw z;if(X===0)throw z;let W=kY(z),G=[...Y,z],U=G.length;if(U>X)throw new BE({message:`Failed after ${U} attempts. Last error: ${W}`,reason:"maxRetriesExceeded",errors:G});if(z instanceof Error&&h$.isInstance(z)&&z.isRetryable===!0&&U<=X)return await Ob(u0$({error:z,exponentialBackoffDelay:Q}),{abortSignal:v}),ZP($,{maxRetries:X,delayInMs:J*Q,backoffFactor:J,abortSignal:v},G);if(U===1)throw z;throw new BE({message:`Failed after ${U} attempts with non-retryable error: '${W}'`,reason:"errorNotRetryable",errors:G})}}function y0$({maxRetries:$,abortSignal:X}){if($!=null){if(!Number.isInteger($))throw new P0({parameter:"maxRetries",value:$,message:"maxRetries must be an integer"});if($<0)throw new P0({parameter:"maxRetries",value:$,message:"maxRetries must be >= 0"})}let Q=$!=null?$:2;return{maxRetries:Q,retry:_0$({maxRetries:Q,abortSignal:X})}}function f0$({messages:$}){let X=$.at(-1);if((X==null?void 0:X.role)!="tool")return{approvedToolApprovals:[],deniedToolApprovals:[]};let Q={};for(let G of $)if(G.role==="assistant"&&typeof G.content!=="string"){let U=G.content;for(let w of U)if(w.type==="tool-call")Q[w.toolCallId]=w}let J={};for(let G of $)if(G.role==="assistant"&&typeof G.content!=="string"){let U=G.content;for(let w of U)if(w.type==="tool-approval-request")J[w.approvalId]=w}let v={};for(let G of X.content)if(G.type==="tool-result")v[G.toolCallId]=G;let Y=[],z=[],W=X.content.filter((G)=>G.type==="tool-approval-response");for(let G of W){let U=J[G.approvalId];if(U==null)throw new U1$({approvalId:G.approvalId});if(v[U.toolCallId]!=null)continue;let w=Q[U.toolCallId];if(w==null)throw new uE({toolCallId:U.toolCallId,approvalId:U.approvalId});let H={approvalRequest:U,approvalResponse:G,toolCall:w};if(G.approved)Y.push(H);else z.push(H)}return{approvedToolApprovals:Y,deniedToolApprovals:z}}function oU(){var $,X;return(X=($=globalThis==null?void 0:globalThis.performance)==null?void 0:$.now())!=null?X:Date.now()}async function qP({toolCall:$,tools:X,tracer:Q,telemetry:J,messages:v,abortSignal:Y,experimental_context:z,stepNumber:W,model:G,onPreliminaryToolResult:U,onToolCallStart:w,onToolCallFinish:H}){let{toolName:j,toolCallId:V,input:B}=$,D=X==null?void 0:X[j];if((D==null?void 0:D.execute)==null)return;let F={stepNumber:W,model:G,toolCall:$,messages:v,abortSignal:Y,functionId:J==null?void 0:J.functionId,metadata:J==null?void 0:J.metadata,experimental_context:z};return BO({name:"ai.toolCall",attributes:$v({telemetry:J,attributes:{...VO({operationId:"ai.toolCall",telemetry:J}),"ai.toolCall.name":j,"ai.toolCall.id":V,"ai.toolCall.args":{output:()=>JSON.stringify(B)}}}),tracer:Q,fn:async(L)=>{let A;try{await(w==null?void 0:w(F))}catch(M){}let O=oU();try{let M=ub({execute:D.execute.bind(D),input:B,options:{toolCallId:V,messages:v,abortSignal:Y,experimental_context:z}});for await(let R of M)if(R.type==="preliminary")U==null||U({...$,type:"tool-result",output:R.output,preliminary:!0});else A=R.output}catch(M){let R=oU()-O;try{await(H==null?void 0:H({...F,success:!1,error:M,durationMs:R}))}catch(E){}return AP(L,M),{type:"tool-error",toolCallId:V,toolName:j,input:B,error:M,dynamic:D.type==="dynamic",...$.providerMetadata!=null?{providerMetadata:$.providerMetadata}:{}}}let i=oU()-O;try{await(H==null?void 0:H({...F,success:!0,output:A,durationMs:i}))}catch(M){}try{L.setAttributes(await $v({telemetry:J,attributes:{"ai.toolCall.result":{output:()=>JSON.stringify(A)}}}))}catch(M){}return{type:"tool-result",toolCallId:V,toolName:j,input:B,output:A,dynamic:D.type==="dynamic",...$.providerMetadata!=null?{providerMetadata:$.providerMetadata}:{}}}})}var k0$=class{constructor({data:$,mediaType:X}){let Q=$ instanceof Uint8Array;this.base64Data=Q?void 0:$,this.uint8ArrayData=Q?$:void 0,this.mediaType=X}get base64(){if(this.base64Data==null)this.base64Data=q0(this.uint8ArrayData);return this.base64Data}get uint8Array(){if(this.uint8ArrayData==null)this.uint8ArrayData=l8(this.base64Data);return this.uint8ArrayData}},n0$=class extends k0${constructor($){super($);this.type="file"}};async function g0$({tool:$,toolCall:X,messages:Q,experimental_context:J}){if($.needsApproval==null)return!1;if(typeof $.needsApproval==="boolean")return $.needsApproval;return await $.needsApproval(X.input,{toolCallId:X.toolCallId,messages:Q,experimental_context:J})}var c0$={};Q1$(c0$,{array:()=>l0$,choice:()=>p0$,json:()=>d0$,object:()=>h0$,text:()=>DO});function m0$($){let X=["ROOT"],Q=-1,J=null;function v(G,U,w){switch(G){case'"':{Q=U,X.pop(),X.push(w),X.push("INSIDE_STRING");break}case"f":case"t":case"n":{Q=U,J=U,X.pop(),X.push(w),X.push("INSIDE_LITERAL");break}case"-":{X.pop(),X.push(w),X.push("INSIDE_NUMBER");break}case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":{Q=U,X.pop(),X.push(w),X.push("INSIDE_NUMBER");break}case"{":{Q=U,X.pop(),X.push(w),X.push("INSIDE_OBJECT_START");break}case"[":{Q=U,X.pop(),X.push(w),X.push("INSIDE_ARRAY_START");break}}}function Y(G,U){switch(G){case",":{X.pop(),X.push("INSIDE_OBJECT_AFTER_COMMA");break}case"}":{Q=U,X.pop();break}}}function z(G,U){switch(G){case",":{X.pop(),X.push("INSIDE_ARRAY_AFTER_COMMA");break}case"]":{Q=U,X.pop();break}}}for(let G=0;G<$.length;G++){let U=$[G];switch(X[X.length-1]){case"ROOT":v(U,G,"FINISH");break;case"INSIDE_OBJECT_START":{switch(U){case'"':{X.pop(),X.push("INSIDE_OBJECT_KEY");break}case"}":{Q=G,X.pop();break}}break}case"INSIDE_OBJECT_AFTER_COMMA":{switch(U){case'"':{X.pop(),X.push("INSIDE_OBJECT_KEY");break}}break}case"INSIDE_OBJECT_KEY":{switch(U){case'"':{X.pop(),X.push("INSIDE_OBJECT_AFTER_KEY");break}}break}case"INSIDE_OBJECT_AFTER_KEY":{switch(U){case":":{X.pop(),X.push("INSIDE_OBJECT_BEFORE_VALUE");break}}break}case"INSIDE_OBJECT_BEFORE_VALUE":{v(U,G,"INSIDE_OBJECT_AFTER_VALUE");break}case"INSIDE_OBJECT_AFTER_VALUE":{Y(U,G);break}case"INSIDE_STRING":{switch(U){case'"':{X.pop(),Q=G;break}case"\\":{X.push("INSIDE_STRING_ESCAPE");break}default:Q=G}break}case"INSIDE_ARRAY_START":{switch(U){case"]":{Q=G,X.pop();break}default:{Q=G,v(U,G,"INSIDE_ARRAY_AFTER_VALUE");break}}break}case"INSIDE_ARRAY_AFTER_VALUE":{switch(U){case",":{X.pop(),X.push("INSIDE_ARRAY_AFTER_COMMA");break}case"]":{Q=G,X.pop();break}default:{Q=G;break}}break}case"INSIDE_ARRAY_AFTER_COMMA":{v(U,G,"INSIDE_ARRAY_AFTER_VALUE");break}case"INSIDE_STRING_ESCAPE":{X.pop(),Q=G;break}case"INSIDE_NUMBER":{switch(U){case"0":case"1":case"2":case"3":case"4":case"5":case"6":case"7":case"8":case"9":{Q=G;break}case"e":case"E":case"-":case".":break;case",":{if(X.pop(),X[X.length-1]==="INSIDE_ARRAY_AFTER_VALUE")z(U,G);if(X[X.length-1]==="INSIDE_OBJECT_AFTER_VALUE")Y(U,G);break}case"}":{if(X.pop(),X[X.length-1]==="INSIDE_OBJECT_AFTER_VALUE")Y(U,G);break}case"]":{if(X.pop(),X[X.length-1]==="INSIDE_ARRAY_AFTER_VALUE")z(U,G);break}default:{X.pop();break}}break}case"INSIDE_LITERAL":{let H=$.substring(J,G+1);if(!"false".startsWith(H)&&!"true".startsWith(H)&&!"null".startsWith(H)){if(X.pop(),X[X.length-1]==="INSIDE_OBJECT_AFTER_VALUE")Y(U,G);else if(X[X.length-1]==="INSIDE_ARRAY_AFTER_VALUE")z(U,G)}else Q=G;break}}}let W=$.slice(0,Q+1);for(let G=X.length-1;G>=0;G--)switch(X[G]){case"INSIDE_STRING":{W+='"';break}case"INSIDE_OBJECT_KEY":case"INSIDE_OBJECT_AFTER_KEY":case"INSIDE_OBJECT_AFTER_COMMA":case"INSIDE_OBJECT_START":case"INSIDE_OBJECT_BEFORE_VALUE":case"INSIDE_OBJECT_AFTER_VALUE":{W+="}";break}case"INSIDE_ARRAY_START":case"INSIDE_ARRAY_AFTER_COMMA":case"INSIDE_ARRAY_AFTER_VALUE":{W+="]";break}case"INSIDE_LITERAL":{let w=$.substring(J,$.length);if("true".startsWith(w))W+="true".slice(w.length);else if("false".startsWith(w))W+="false".slice(w.length);else if("null".startsWith(w))W+="null".slice(w.length)}}return W}async function Wz($){if($===void 0)return{value:void 0,state:"undefined-input"};let X=await _6({text:$});if(X.success)return{value:X.value,state:"successful-parse"};if(X=await _6({text:m0$($)}),X.success)return{value:X.value,state:"repaired-parse"};return{value:void 0,state:"failed-parse"}}var DO=()=>({name:"text",responseFormat:Promise.resolve({type:"text"}),async parseCompleteOutput({text:$}){return $},async parsePartialOutput({text:$}){return{partial:$}},createElementStreamTransform(){return}}),h0$=({schema:$,name:X,description:Q})=>{let J=x0($);return{name:"object",responseFormat:Z4(J.jsonSchema).then((v)=>({type:"json",schema:v,...X!=null&&{name:X},...Q!=null&&{description:Q}})),async parseCompleteOutput({text:v},Y){let z=await _6({text:v});if(!z.success)throw new d8({message:"No object generated: could not parse the response.",cause:z.error,text:v,response:Y.response,usage:Y.usage,finishReason:Y.finishReason});let W=await v6({value:z.value,schema:J});if(!W.success)throw new d8({message:"No object generated: response did not match schema.",cause:W.error,text:v,response:Y.response,usage:Y.usage,finishReason:Y.finishReason});return W.value},async parsePartialOutput({text:v}){let Y=await Wz(v);switch(Y.state){case"failed-parse":case"undefined-input":return;case"repaired-parse":case"successful-parse":return{partial:Y.value}}},createElementStreamTransform(){return}}},l0$=({element:$,name:X,description:Q})=>{let J=x0($);return{name:"array",responseFormat:Z4(J.jsonSchema).then((v)=>{let{$schema:Y,...z}=v;return{type:"json",schema:{$schema:"http://json-schema.org/draft-07/schema#",type:"object",properties:{elements:{type:"array",items:z}},required:["elements"],additionalProperties:!1},...X!=null&&{name:X},...Q!=null&&{description:Q}}}),async parseCompleteOutput({text:v},Y){let z=await _6({text:v});if(!z.success)throw new d8({message:"No object generated: could not parse the response.",cause:z.error,text:v,response:Y.response,usage:Y.usage,finishReason:Y.finishReason});let W=z.value;if(W==null||typeof W!=="object"||!("elements"in W)||!Array.isArray(W.elements))throw new d8({message:"No object generated: response did not match schema.",cause:new y1({value:W,cause:"response must be an object with an elements array"}),text:v,response:Y.response,usage:Y.usage,finishReason:Y.finishReason});for(let G of W.elements){let U=await v6({value:G,schema:J});if(!U.success)throw new d8({message:"No object generated: response did not match schema.",cause:U.error,text:v,response:Y.response,usage:Y.usage,finishReason:Y.finishReason})}return W.elements},async parsePartialOutput({text:v}){let Y=await Wz(v);switch(Y.state){case"failed-parse":case"undefined-input":return;case"repaired-parse":case"successful-parse":{let z=Y.value;if(z==null||typeof z!=="object"||!("elements"in z)||!Array.isArray(z.elements))return;let W=Y.state==="repaired-parse"&&z.elements.length>0?z.elements.slice(0,-1):z.elements,G=[];for(let U of W){let w=await v6({value:U,schema:J});if(w.success)G.push(w.value)}return{partial:G}}}},createElementStreamTransform(){let v=0;return new TransformStream({transform({partialOutput:Y},z){if(Y!=null)for(;v{return{name:"choice",responseFormat:Promise.resolve({type:"json",schema:{$schema:"http://json-schema.org/draft-07/schema#",type:"object",properties:{result:{type:"string",enum:$}},required:["result"],additionalProperties:!1},...X!=null&&{name:X},...Q!=null&&{description:Q}}),async parseCompleteOutput({text:J},v){let Y=await _6({text:J});if(!Y.success)throw new d8({message:"No object generated: could not parse the response.",cause:Y.error,text:J,response:v.response,usage:v.usage,finishReason:v.finishReason});let z=Y.value;if(z==null||typeof z!=="object"||!("result"in z)||typeof z.result!=="string"||!$.includes(z.result))throw new d8({message:"No object generated: response did not match schema.",cause:new y1({value:z,cause:"response must be an object that contains a choice value."}),text:J,response:v.response,usage:v.usage,finishReason:v.finishReason});return z.result},async parsePartialOutput({text:J}){let v=await Wz(J);switch(v.state){case"failed-parse":case"undefined-input":return;case"repaired-parse":case"successful-parse":{let Y=v.value;if(Y==null||typeof Y!=="object"||!("result"in Y)||typeof Y.result!=="string")return;let z=$.filter((W)=>W.startsWith(Y.result));if(v.state==="successful-parse")return z.includes(Y.result)?{partial:Y.result}:void 0;else return z.length===1?{partial:z[0]}:void 0}}},createElementStreamTransform(){return}}},d0$=({name:$,description:X}={})=>{return{name:"json",responseFormat:Promise.resolve({type:"json",...$!=null&&{name:$},...X!=null&&{description:X}}),async parseCompleteOutput({text:Q},J){let v=await _6({text:Q});if(!v.success)throw new d8({message:"No object generated: could not parse the response.",cause:v.error,text:Q,response:J.response,usage:J.usage,finishReason:J.finishReason});return v.value},async parsePartialOutput({text:Q}){let J=await Wz(Q);switch(J.state){case"failed-parse":case"undefined-input":return;case"repaired-parse":case"successful-parse":return J.value===void 0?void 0:{partial:J.value}}},createElementStreamTransform(){return}}};async function o0$({toolCall:$,tools:X,repairToolCall:Q,system:J,messages:v}){var Y;try{if(X==null){if($.providerExecuted&&$.dynamic)return await IP($);throw new jO({toolName:$.toolName})}try{return await OE({toolCall:$,tools:X})}catch(z){if(Q==null||!(jO.isInstance(z)||OO.isInstance(z)))throw z;let W=null;try{W=await Q({toolCall:$,tools:X,inputSchema:async({toolName:G})=>{let{inputSchema:U}=X[G];return await x0(U).jsonSchema},system:J,messages:v,error:z})}catch(G){throw new C1$({cause:G,originalError:z})}if(W==null)throw z;return await OE({toolCall:W,tools:X})}}catch(z){let W=await _6({text:$.input}),G=W.success?W.value:$.input;return{type:"tool-call",toolCallId:$.toolCallId,toolName:$.toolName,input:G,dynamic:!0,invalid:!0,error:z,title:(Y=X==null?void 0:X[$.toolName])==null?void 0:Y.title,providerExecuted:$.providerExecuted,providerMetadata:$.providerMetadata}}}async function IP($){let X=$.input.trim()===""?{success:!0,value:{}}:await _6({text:$.input});if(X.success===!1)throw new OO({toolName:$.toolName,toolInput:$.input,cause:X.error});return{type:"tool-call",toolCallId:$.toolCallId,toolName:$.toolName,input:X.value,providerExecuted:!0,dynamic:!0,providerMetadata:$.providerMetadata}}async function OE({toolCall:$,tools:X}){let Q=$.toolName,J=X[Q];if(J==null){if($.providerExecuted&&$.dynamic)return await IP($);throw new jO({toolName:$.toolName,availableTools:Object.keys(X)})}let v=x0(J.inputSchema),Y=$.input.trim()===""?await v6({value:{},schema:v}):await _6({text:$.input,schema:v});if(Y.success===!1)throw new OO({toolName:Q,toolInput:$.input,cause:Y.error});return J.type==="dynamic"?{type:"tool-call",toolCallId:$.toolCallId,toolName:$.toolName,input:Y.value,providerExecuted:$.providerExecuted,providerMetadata:$.providerMetadata,dynamic:!0,title:J.title}:{type:"tool-call",toolCallId:$.toolCallId,toolName:Q,input:Y.value,providerExecuted:$.providerExecuted,providerMetadata:$.providerMetadata,title:J.title}}var a0$=class{constructor({stepNumber:$,model:X,functionId:Q,metadata:J,experimental_context:v,content:Y,finishReason:z,rawFinishReason:W,usage:G,warnings:U,request:w,response:H,providerMetadata:j}){this.stepNumber=$,this.model=X,this.functionId=Q,this.metadata=J,this.experimental_context=v,this.content=Y,this.finishReason=z,this.rawFinishReason=W,this.usage=G,this.warnings=U,this.request=w,this.response=H,this.providerMetadata=j}get text(){return this.content.filter(($)=>$.type==="text").map(($)=>$.text).join("")}get reasoning(){return this.content.filter(($)=>$.type==="reasoning")}get reasoningText(){return this.reasoning.length===0?void 0:this.reasoning.map(($)=>$.text).join("")}get files(){return this.content.filter(($)=>$.type==="file").map(($)=>$.file)}get sources(){return this.content.filter(($)=>$.type==="source")}get toolCalls(){return this.content.filter(($)=>$.type==="tool-call")}get staticToolCalls(){return this.toolCalls.filter(($)=>$.dynamic!==!0)}get dynamicToolCalls(){return this.toolCalls.filter(($)=>$.dynamic===!0)}get toolResults(){return this.content.filter(($)=>$.type==="tool-result")}get staticToolResults(){return this.toolResults.filter(($)=>$.dynamic!==!0)}get dynamicToolResults(){return this.toolResults.filter(($)=>$.dynamic===!0)}};function AO($){return({steps:X})=>X.length===$}async function r0$({stopConditions:$,steps:X}){return(await Promise.all($.map((Q)=>Q({steps:X})))).some((Q)=>Q)}async function LE({content:$,tools:X}){let Q=[],J=[];for(let Y of $){if(Y.type==="source")continue;if((Y.type==="tool-result"||Y.type==="tool-error")&&!Y.providerExecuted)continue;if(Y.type==="text"&&Y.text.length===0)continue;switch(Y.type){case"text":J.push({type:"text",text:Y.text,providerOptions:Y.providerMetadata});break;case"reasoning":J.push({type:"reasoning",text:Y.text,providerOptions:Y.providerMetadata});break;case"file":J.push({type:"file",data:Y.file.base64,mediaType:Y.file.mediaType,providerOptions:Y.providerMetadata});break;case"tool-call":J.push({type:"tool-call",toolCallId:Y.toolCallId,toolName:Y.toolName,input:Y.input,providerExecuted:Y.providerExecuted,providerOptions:Y.providerMetadata});break;case"tool-result":{let z=await Xv({toolCallId:Y.toolCallId,input:Y.input,tool:X==null?void 0:X[Y.toolName],output:Y.output,errorMode:"none"});J.push({type:"tool-result",toolCallId:Y.toolCallId,toolName:Y.toolName,output:z,providerOptions:Y.providerMetadata});break}case"tool-error":{let z=await Xv({toolCallId:Y.toolCallId,input:Y.input,tool:X==null?void 0:X[Y.toolName],output:Y.error,errorMode:"json"});J.push({type:"tool-result",toolCallId:Y.toolCallId,toolName:Y.toolName,output:z,providerOptions:Y.providerMetadata});break}case"tool-approval-request":J.push({type:"tool-approval-request",approvalId:Y.approvalId,toolCallId:Y.toolCall.toolCallId});break}}if(J.length>0)Q.push({role:"assistant",content:J});let v=[];for(let Y of $){if(!(Y.type==="tool-result"||Y.type==="tool-error")||Y.providerExecuted)continue;let z=await Xv({toolCallId:Y.toolCallId,input:Y.input,tool:X==null?void 0:X[Y.toolName],output:Y.type==="tool-result"?Y.output:Y.error,errorMode:Y.type==="tool-error"?"text":"none"});v.push({type:"tool-result",toolCallId:Y.toolCallId,toolName:Y.toolName,output:z,...Y.providerMetadata!=null?{providerOptions:Y.providerMetadata}:{}})}if(v.length>0)Q.push({role:"tool",content:v});return Q}function t0$(...$){let X=$.filter((J)=>J!=null);if(X.length===0)return;if(X.length===1)return X[0];let Q=new AbortController;for(let J of X){if(J.aborted)return Q.abort(J.reason),Q.signal;J.addEventListener("abort",()=>{Q.abort(J.reason)},{once:!0})}return Q.signal}var kL$=I0({prefix:"aitxt",size:24});function rU($,X){let Q=new Headers($!=null?$:{});for(let[J,v]of Object.entries(X))if(!Q.has(J))Q.set(J,v);return Q}function s0$({status:$,statusText:X,headers:Q,textStream:J}){return new Response(J.pipeThrough(new TextEncoderStream),{status:$!=null?$:200,statusText:X,headers:rU(Q,{"content-type":"text/plain; charset=utf-8"})})}function MP({response:$,status:X,statusText:Q,headers:J,stream:v}){let Y=X!=null?X:200;if(Q!==void 0)$.writeHead(Y,Q,J);else $.writeHead(Y,J);let z=v.getReader();(async()=>{try{while(!0){let{done:G,value:U}=await z.read();if(G)break;if(!$.write(U))await new Promise((H)=>{$.once("drain",H)})}}catch(G){throw G}finally{$.end()}})()}function e0$({response:$,status:X,statusText:Q,headers:J,textStream:v}){MP({response:$,status:X,statusText:Q,headers:Object.fromEntries(rU(J,{"content-type":"text/plain; charset=utf-8"}).entries()),stream:v.pipeThrough(new TextEncoderStream)})}var xP=class extends TransformStream{constructor(){super({transform($,X){X.enqueue(`data: ${JSON.stringify($)} -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/optional.js -var parseOptionalDef2 = (def, refs) => { - if (refs.currentPath.toString() === refs.propertyPath?.toString()) { - return parseDef2(def.innerType._def, refs); - } - const innerSchema = parseDef2(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath, "anyOf", "1"] - }); - return innerSchema ? { - anyOf: [ - { - not: parseAnyDef2(refs) - }, - innerSchema - ] - } : parseAnyDef2(refs); -}; +`)},flush($){$.enqueue(`data: [DONE] -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/pipeline.js -var parsePipelineDef2 = (def, refs) => { - if (refs.pipeStrategy === "input") { - return parseDef2(def.in._def, refs); - } else if (refs.pipeStrategy === "output") { - return parseDef2(def.out._def, refs); - } - const a = parseDef2(def.in._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", "0"] - }); - const b = parseDef2(def.out._def, { - ...refs, - currentPath: [...refs.currentPath, "allOf", a ? "1" : "0"] - }); - return { - allOf: [a, b].filter((x) => x !== undefined) - }; -}; +`)}})}},bP={"content-type":"text/event-stream","cache-control":"no-cache",connection:"keep-alive","x-vercel-ai-ui-message-stream":"v1","x-accel-buffering":"no"};function $8$({status:$,statusText:X,headers:Q,stream:J,consumeSseStream:v}){let Y=J.pipeThrough(new xP);if(v){let[z,W]=Y.tee();Y=z,v({stream:W})}return new Response(Y.pipeThrough(new TextEncoderStream),{status:$,statusText:X,headers:rU(Q,bP)})}function X8$({originalMessages:$,responseMessageId:X}){if($==null)return;let Q=$[$.length-1];return(Q==null?void 0:Q.role)==="assistant"?Q.id:typeof X==="function"?X():X}var lL$=w4(()=>v4(K.union([K.strictObject({type:K.literal("text-start"),id:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("text-delta"),id:K.string(),delta:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("text-end"),id:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("error"),errorText:K.string()}),K.strictObject({type:K.literal("tool-input-start"),toolCallId:K.string(),toolName:K.string(),providerExecuted:K.boolean().optional(),providerMetadata:e.optional(),dynamic:K.boolean().optional(),title:K.string().optional()}),K.strictObject({type:K.literal("tool-input-delta"),toolCallId:K.string(),inputTextDelta:K.string()}),K.strictObject({type:K.literal("tool-input-available"),toolCallId:K.string(),toolName:K.string(),input:K.unknown(),providerExecuted:K.boolean().optional(),providerMetadata:e.optional(),dynamic:K.boolean().optional(),title:K.string().optional()}),K.strictObject({type:K.literal("tool-input-error"),toolCallId:K.string(),toolName:K.string(),input:K.unknown(),providerExecuted:K.boolean().optional(),providerMetadata:e.optional(),dynamic:K.boolean().optional(),errorText:K.string(),title:K.string().optional()}),K.strictObject({type:K.literal("tool-approval-request"),approvalId:K.string(),toolCallId:K.string()}),K.strictObject({type:K.literal("tool-output-available"),toolCallId:K.string(),output:K.unknown(),providerExecuted:K.boolean().optional(),dynamic:K.boolean().optional(),preliminary:K.boolean().optional()}),K.strictObject({type:K.literal("tool-output-error"),toolCallId:K.string(),errorText:K.string(),providerExecuted:K.boolean().optional(),dynamic:K.boolean().optional()}),K.strictObject({type:K.literal("tool-output-denied"),toolCallId:K.string()}),K.strictObject({type:K.literal("reasoning-start"),id:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("reasoning-delta"),id:K.string(),delta:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("reasoning-end"),id:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("source-url"),sourceId:K.string(),url:K.string(),title:K.string().optional(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("source-document"),sourceId:K.string(),mediaType:K.string(),title:K.string(),filename:K.string().optional(),providerMetadata:e.optional()}),K.strictObject({type:K.literal("file"),url:K.string(),mediaType:K.string(),providerMetadata:e.optional()}),K.strictObject({type:K.custom(($)=>typeof $==="string"&&$.startsWith("data-"),{message:'Type must start with "data-"'}),id:K.string().optional(),data:K.unknown(),transient:K.boolean().optional()}),K.strictObject({type:K.literal("start-step")}),K.strictObject({type:K.literal("finish-step")}),K.strictObject({type:K.literal("start"),messageId:K.string().optional(),messageMetadata:K.unknown().optional()}),K.strictObject({type:K.literal("finish"),finishReason:K.enum(["stop","length","content-filter","tool-calls","error","other"]).optional(),messageMetadata:K.unknown().optional()}),K.strictObject({type:K.literal("abort"),reason:K.string().optional()}),K.strictObject({type:K.literal("message-metadata"),messageMetadata:K.unknown()})])));function Q8$($){return $.type.startsWith("data-")}function UO($){return $.type.startsWith("data-")}function KO($){return $.type==="text"}function wO($){return $.type==="file"}function AE($){return $.type==="reasoning"}function FO($){return $.type.startsWith("tool-")}function iP($){return $.type==="dynamic-tool"}function Yz($){return FO($)||iP($)}function NO($){return $.type.split("-").slice(1).join("-")}function HO($){return iP($)?$.toolName:NO($)}function v8$({lastMessage:$,messageId:X}){return{message:($==null?void 0:$.role)==="assistant"?$:{id:X,metadata:void 0,role:"assistant",parts:[]},activeTextParts:{},activeReasoningParts:{},partialToolCalls:{}}}function J8$({stream:$,messageMetadataSchema:X,dataPartSchemas:Q,runUpdateMessageJob:J,onError:v,onToolCall:Y,onData:z}){return $.pipeThrough(new TransformStream({async transform(W,G){await J(async({state:U,write:w})=>{var H,j,V,B;function D(O){let M=U.message.parts.filter(Yz).find((R)=>R.toolCallId===O);if(M==null)throw new eQ({chunkType:"tool-invocation",chunkId:O,message:`No tool invocation found for tool call ID "${O}".`});return M}function F(O){var i;let M=U.message.parts.find((v$)=>FO(v$)&&v$.toolCallId===O.toolCallId),R=O,E=M;if(M!=null){if(M.state=O.state,E.input=R.input,E.output=R.output,E.errorText=R.errorText,E.rawInput=R.rawInput,E.preliminary=R.preliminary,O.title!==void 0)E.title=O.title;if(E.providerExecuted=(i=R.providerExecuted)!=null?i:M.providerExecuted,R.providerMetadata!=null)M.callProviderMetadata=R.providerMetadata}else U.message.parts.push({type:`tool-${O.toolName}`,toolCallId:O.toolCallId,state:O.state,title:O.title,input:R.input,output:R.output,rawInput:R.rawInput,errorText:R.errorText,providerExecuted:R.providerExecuted,preliminary:R.preliminary,...R.providerMetadata!=null?{callProviderMetadata:R.providerMetadata}:{}})}function L(O){var i,M;let R=U.message.parts.find((q$)=>q$.type==="dynamic-tool"&&q$.toolCallId===O.toolCallId),E=O,v$=R;if(R!=null){if(R.state=O.state,v$.toolName=O.toolName,v$.input=E.input,v$.output=E.output,v$.errorText=E.errorText,v$.rawInput=(i=E.rawInput)!=null?i:v$.rawInput,v$.preliminary=E.preliminary,O.title!==void 0)v$.title=O.title;if(v$.providerExecuted=(M=E.providerExecuted)!=null?M:R.providerExecuted,E.providerMetadata!=null)R.callProviderMetadata=E.providerMetadata}else U.message.parts.push({type:"dynamic-tool",toolName:O.toolName,toolCallId:O.toolCallId,state:O.state,input:E.input,output:E.output,errorText:E.errorText,preliminary:E.preliminary,providerExecuted:E.providerExecuted,title:O.title,...E.providerMetadata!=null?{callProviderMetadata:E.providerMetadata}:{}})}async function A(O){if(O!=null){let i=U.message.metadata!=null?LO(U.message.metadata,O):O;if(X!=null)await gY({value:i,schema:X,context:{field:"message.metadata",entityId:U.message.id}});U.message.metadata=i}}switch(W.type){case"text-start":{let O={type:"text",text:"",providerMetadata:W.providerMetadata,state:"streaming"};U.activeTextParts[W.id]=O,U.message.parts.push(O),w();break}case"text-delta":{let O=U.activeTextParts[W.id];if(O==null)throw new eQ({chunkType:"text-delta",chunkId:W.id,message:`Received text-delta for missing text part with ID "${W.id}". Ensure a "text-start" chunk is sent before any "text-delta" chunks.`});O.text+=W.delta,O.providerMetadata=(H=W.providerMetadata)!=null?H:O.providerMetadata,w();break}case"text-end":{let O=U.activeTextParts[W.id];if(O==null)throw new eQ({chunkType:"text-end",chunkId:W.id,message:`Received text-end for missing text part with ID "${W.id}". Ensure a "text-start" chunk is sent before any "text-end" chunks.`});O.state="done",O.providerMetadata=(j=W.providerMetadata)!=null?j:O.providerMetadata,delete U.activeTextParts[W.id],w();break}case"reasoning-start":{let O={type:"reasoning",text:"",providerMetadata:W.providerMetadata,state:"streaming"};U.activeReasoningParts[W.id]=O,U.message.parts.push(O),w();break}case"reasoning-delta":{let O=U.activeReasoningParts[W.id];if(O==null)throw new eQ({chunkType:"reasoning-delta",chunkId:W.id,message:`Received reasoning-delta for missing reasoning part with ID "${W.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-delta" chunks.`});O.text+=W.delta,O.providerMetadata=(V=W.providerMetadata)!=null?V:O.providerMetadata,w();break}case"reasoning-end":{let O=U.activeReasoningParts[W.id];if(O==null)throw new eQ({chunkType:"reasoning-end",chunkId:W.id,message:`Received reasoning-end for missing reasoning part with ID "${W.id}". Ensure a "reasoning-start" chunk is sent before any "reasoning-end" chunks.`});O.providerMetadata=(B=W.providerMetadata)!=null?B:O.providerMetadata,O.state="done",delete U.activeReasoningParts[W.id],w();break}case"file":{U.message.parts.push({type:"file",mediaType:W.mediaType,url:W.url}),w();break}case"source-url":{U.message.parts.push({type:"source-url",sourceId:W.sourceId,url:W.url,title:W.title,providerMetadata:W.providerMetadata}),w();break}case"source-document":{U.message.parts.push({type:"source-document",sourceId:W.sourceId,mediaType:W.mediaType,title:W.title,filename:W.filename,providerMetadata:W.providerMetadata}),w();break}case"tool-input-start":{let O=U.message.parts.filter(FO);if(U.partialToolCalls[W.toolCallId]={text:"",toolName:W.toolName,index:O.length,dynamic:W.dynamic,title:W.title},W.dynamic)L({toolCallId:W.toolCallId,toolName:W.toolName,state:"input-streaming",input:void 0,providerExecuted:W.providerExecuted,title:W.title,providerMetadata:W.providerMetadata});else F({toolCallId:W.toolCallId,toolName:W.toolName,state:"input-streaming",input:void 0,providerExecuted:W.providerExecuted,title:W.title,providerMetadata:W.providerMetadata});w();break}case"tool-input-delta":{let O=U.partialToolCalls[W.toolCallId];if(O==null)throw new eQ({chunkType:"tool-input-delta",chunkId:W.toolCallId,message:`Received tool-input-delta for missing tool call with ID "${W.toolCallId}". Ensure a "tool-input-start" chunk is sent before any "tool-input-delta" chunks.`});O.text+=W.inputTextDelta;let{value:i}=await Wz(O.text);if(O.dynamic)L({toolCallId:W.toolCallId,toolName:O.toolName,state:"input-streaming",input:i,title:O.title});else F({toolCallId:W.toolCallId,toolName:O.toolName,state:"input-streaming",input:i,title:O.title});w();break}case"tool-input-available":{if(W.dynamic)L({toolCallId:W.toolCallId,toolName:W.toolName,state:"input-available",input:W.input,providerExecuted:W.providerExecuted,providerMetadata:W.providerMetadata,title:W.title});else F({toolCallId:W.toolCallId,toolName:W.toolName,state:"input-available",input:W.input,providerExecuted:W.providerExecuted,providerMetadata:W.providerMetadata,title:W.title});if(w(),Y&&!W.providerExecuted)await Y({toolCall:W});break}case"tool-input-error":{if(W.dynamic)L({toolCallId:W.toolCallId,toolName:W.toolName,state:"output-error",input:W.input,errorText:W.errorText,providerExecuted:W.providerExecuted,providerMetadata:W.providerMetadata});else F({toolCallId:W.toolCallId,toolName:W.toolName,state:"output-error",input:void 0,rawInput:W.input,errorText:W.errorText,providerExecuted:W.providerExecuted,providerMetadata:W.providerMetadata});w();break}case"tool-approval-request":{let O=D(W.toolCallId);O.state="approval-requested",O.approval={id:W.approvalId},w();break}case"tool-output-denied":{let O=D(W.toolCallId);O.state="output-denied",w();break}case"tool-output-available":{let O=D(W.toolCallId);if(O.type==="dynamic-tool")L({toolCallId:W.toolCallId,toolName:O.toolName,state:"output-available",input:O.input,output:W.output,preliminary:W.preliminary,providerExecuted:W.providerExecuted,title:O.title});else F({toolCallId:W.toolCallId,toolName:NO(O),state:"output-available",input:O.input,output:W.output,providerExecuted:W.providerExecuted,preliminary:W.preliminary,title:O.title});w();break}case"tool-output-error":{let O=D(W.toolCallId);if(O.type==="dynamic-tool")L({toolCallId:W.toolCallId,toolName:O.toolName,state:"output-error",input:O.input,errorText:W.errorText,providerExecuted:W.providerExecuted,title:O.title});else F({toolCallId:W.toolCallId,toolName:NO(O),state:"output-error",input:O.input,rawInput:O.rawInput,errorText:W.errorText,providerExecuted:W.providerExecuted,title:O.title});w();break}case"start-step":{U.message.parts.push({type:"step-start"});break}case"finish-step":{U.activeTextParts={},U.activeReasoningParts={};break}case"start":{if(W.messageId!=null)U.message.id=W.messageId;if(await A(W.messageMetadata),W.messageId!=null||W.messageMetadata!=null)w();break}case"finish":{if(W.finishReason!=null)U.finishReason=W.finishReason;if(await A(W.messageMetadata),W.messageMetadata!=null)w();break}case"message-metadata":{if(await A(W.messageMetadata),W.messageMetadata!=null)w();break}case"error":{v==null||v(Error(W.errorText));break}default:if(Q8$(W)){if((Q==null?void 0:Q[W.type])!=null){let M=U.message.parts.findIndex((E)=>("id"in E)&&("data"in E)&&E.id===W.id&&E.type===W.type),R=M>=0?M:U.message.parts.length;await gY({value:W.data,schema:Q[W.type],context:{field:`message.parts[${R}].data`,entityName:W.type,entityId:W.id}})}let O=W;if(O.transient){z==null||z(O);break}let i=O.id!=null?U.message.parts.find((M)=>O.type===M.type&&O.id===M.id):void 0;if(i!=null)i.data=O.data;else U.message.parts.push(O);z==null||z(O),w()}}G.enqueue(W)})}}))}function Y8$({messageId:$,originalMessages:X=[],onStepFinish:Q,onFinish:J,onError:v,stream:Y}){let z=X==null?void 0:X[X.length-1];if((z==null?void 0:z.role)!=="assistant")z=void 0;else $=z.id;let W=!1,G=Y.pipeThrough(new TransformStream({transform(B,D){if(B.type==="start"){let F=B;if(F.messageId==null&&$!=null)F.messageId=$}if(B.type==="abort")W=!0;D.enqueue(B)}}));if(J==null&&Q==null)return G;let U=v8$({lastMessage:z?structuredClone(z):void 0,messageId:$!=null?$:""}),w=async(B)=>{await B({state:U,write:()=>{}})},H=!1,j=async()=>{if(H||!J)return;H=!0;let B=U.message.id===(z==null?void 0:z.id);await J({isAborted:W,isContinuation:B,responseMessage:U.message,messages:[...B?X.slice(0,-1):X,U.message],finishReason:U.finishReason})},V=async()=>{if(!Q)return;let B=U.message.id===(z==null?void 0:z.id);try{await Q({isContinuation:B,responseMessage:structuredClone(U.message),messages:[...B?X.slice(0,-1):X,structuredClone(U.message)]})}catch(D){v(D)}};return J8$({stream:G,runUpdateMessageJob:w,onError:v}).pipeThrough(new TransformStream({async transform(B,D){if(B.type==="finish-step")await V();D.enqueue(B)},async cancel(){await j()},async flush(){await j()}}))}function z8$({response:$,status:X,statusText:Q,headers:J,stream:v,consumeSseStream:Y}){let z=v.pipeThrough(new xP);if(Y){let[W,G]=z.tee();z=W,Y({stream:G})}MP({response:$,status:X,statusText:Q,headers:Object.fromEntries(rU(J,bP).entries()),stream:z.pipeThrough(new TextEncoderStream)})}function Jz($){let X=$.pipeThrough(new TransformStream);return X[Symbol.asyncIterator]=function(){let Q=this.getReader(),J=!1;async function v(Y){var z;if(J)return;J=!0;try{if(Y)await((z=Q.cancel)==null?void 0:z.call(Q))}finally{try{Q.releaseLock()}catch(W){}}}return{async next(){if(J)return{done:!0,value:void 0};let{done:Y,value:z}=await Q.read();if(Y)return await v(!0),{done:!0,value:void 0};return{done:!1,value:z}},async return(){return await v(!0),{done:!0,value:void 0}},async throw(Y){throw await v(!0),Y}}},X}async function W8$({stream:$,onError:X}){let Q=$.getReader();try{while(!0){let{done:J}=await Q.read();if(J)break}}catch(J){X==null||X(J)}finally{Q.releaseLock()}}function ZE(){let $,X;return{promise:new Promise((J,v)=>{$=J,X=v}),resolve:$,reject:X}}function G8$(){let $=[],X=null,Q=!1,J=ZE(),v=()=>{Q=!0,J.resolve(),$.forEach((z)=>z.cancel()),$=[],X==null||X.close()},Y=async()=>{if(Q&&$.length===0){X==null||X.close();return}if($.length===0)return J=ZE(),await J.promise,Y();try{let{value:z,done:W}=await $[0].read();if(W)if($.shift(),$.length===0&&Q)X==null||X.close();else await Y();else X==null||X.enqueue(z)}catch(z){X==null||X.error(z),$.shift(),v()}};return{stream:new ReadableStream({start(z){X=z},pull:Y,async cancel(){for(let z of $)await z.cancel();$=[],Q=!0}}),addStream:(z)=>{if(Q)throw Error("Cannot add inner stream: outer stream is closed");$.push(z.getReader()),J.resolve()},close:()=>{if(Q=!0,J.resolve(),$.length===0)X==null||X.close()},terminate:v}}function U8$({tools:$,generatorStream:X,tracer:Q,telemetry:J,system:v,messages:Y,abortSignal:z,repairToolCall:W,experimental_context:G,generateId:U,stepNumber:w,model:H,onToolCallStart:j,onToolCallFinish:V}){let B=null,D=new ReadableStream({start(E){B=E}}),F=new Set,L=new Map,A=new Map,O=!1,i=void 0;function M(){if(O&&F.size===0){if(i!=null)B.enqueue(i);B.close()}}let R=new TransformStream({async transform(E,v$){let q$=E.type;switch(q$){case"stream-start":case"text-start":case"text-delta":case"text-end":case"reasoning-start":case"reasoning-delta":case"reasoning-end":case"tool-input-start":case"tool-input-delta":case"tool-input-end":case"source":case"response-metadata":case"error":case"raw":{v$.enqueue(E);break}case"file":{v$.enqueue({type:"file",file:new n0$({data:E.data,mediaType:E.mediaType})});break}case"finish":{i={type:"finish",finishReason:E.finishReason.unified,rawFinishReason:E.finishReason.raw,usage:S0$(E.usage),providerMetadata:E.providerMetadata};break}case"tool-approval-request":{let U$=A.get(E.toolCallId);if(U$==null){B.enqueue({type:"error",error:new uE({toolCallId:E.toolCallId,approvalId:E.approvalId})});break}v$.enqueue({type:"tool-approval-request",approvalId:E.approvalId,toolCall:U$});break}case"tool-call":{try{let U$=await o0$({toolCall:E,tools:$,repairToolCall:W,system:v,messages:Y});if(A.set(U$.toolCallId,U$),v$.enqueue(U$),U$.invalid){B.enqueue({type:"tool-error",toolCallId:U$.toolCallId,toolName:U$.toolName,input:U$.input,error:kY(U$.error),dynamic:!0,title:U$.title});break}let f4=$==null?void 0:$[U$.toolName];if(f4==null)break;if(f4.onInputAvailable!=null)await f4.onInputAvailable({input:U$.input,toolCallId:U$.toolCallId,messages:Y,abortSignal:z,experimental_context:G});if(await g0$({tool:f4,toolCall:U$,messages:Y,experimental_context:G})){B.enqueue({type:"tool-approval-request",approvalId:U(),toolCall:U$});break}if(L.set(U$.toolCallId,U$.input),f4.execute!=null&&U$.providerExecuted!==!0){let a6=U();F.add(a6),qP({toolCall:U$,tools:$,tracer:Q,telemetry:J,messages:Y,abortSignal:z,experimental_context:G,stepNumber:w,model:H,onToolCallStart:j,onToolCallFinish:V,onPreliminaryToolResult:(H6)=>{B.enqueue(H6)}}).then((H6)=>{B.enqueue(H6)}).catch((H6)=>{B.enqueue({type:"error",error:H6})}).finally(()=>{F.delete(a6),M()})}}catch(U$){B.enqueue({type:"error",error:U$})}break}case"tool-result":{let U$=E.toolName;if(E.isError)B.enqueue({type:"tool-error",toolCallId:E.toolCallId,toolName:U$,input:L.get(E.toolCallId),providerExecuted:!0,error:E.result,dynamic:E.dynamic});else v$.enqueue({type:"tool-result",toolCallId:E.toolCallId,toolName:U$,input:L.get(E.toolCallId),output:E.result,providerExecuted:!0,dynamic:E.dynamic});break}default:throw Error(`Unhandled chunk type: ${q$}`)}},flush(){O=!0,M()}});return new ReadableStream({async start(E){return Promise.all([X.pipeThrough(R).pipeTo(new WritableStream({write(v$){E.enqueue(v$)},close(){}})),D.pipeTo(new WritableStream({write(v$){E.enqueue(v$)},close(){E.close()}}))])}})}var K8$=I0({prefix:"aitxt",size:24});function RP({model:$,tools:X,toolChoice:Q,system:J,prompt:v,messages:Y,maxRetries:z,abortSignal:W,timeout:G,headers:U,stopWhen:w=AO(1),experimental_output:H,output:j=H,experimental_telemetry:V,prepareStep:B,providerOptions:D,experimental_activeTools:F,activeTools:L=F,experimental_repairToolCall:A,experimental_transform:O,experimental_download:i,includeRawChunks:M=!1,onChunk:R,onError:E=({error:j6})=>{console.error(j6)},onFinish:v$,onAbort:q$,onStepFinish:U$,experimental_onStart:f4,experimental_onStepStart:a6,experimental_onToolCallStart:H6,experimental_onToolCallFinish:d0,experimental_context:R9,experimental_include:E9,_internal:{now:P9=oU,generateId:T9=K8$}={},...o0}){let j6=jP(G),D4=s1$(G),S9=e1$(G),X4=D4!=null?new AbortController:void 0,V1=S9!=null?new AbortController:void 0;return new H8$({model:HP($),telemetry:V,headers:U,settings:o0,maxRetries:z,abortSignal:t0$(W,j6!=null?AbortSignal.timeout(j6):void 0,X4==null?void 0:X4.signal,V1==null?void 0:V1.signal),stepTimeoutMs:D4,stepAbortController:X4,chunkTimeoutMs:S9,chunkAbortController:V1,system:J,prompt:v,messages:Y,tools:X,toolChoice:Q,transforms:aU(O),activeTools:L,repairToolCall:A,stopConditions:aU(w),output:j,providerOptions:D,prepareStep:B,includeRawChunks:M,timeout:G,stopWhen:w,originalAbortSignal:W,onChunk:R,onError:E,onFinish:v$,onAbort:q$,onStepFinish:U$,onStart:f4,onStepStart:a6,onToolCallStart:H6,onToolCallFinish:d0,now:P9,generateId:T9,experimental_context:R9,download:i,include:E9})}function w8$($){let X=void 0,Q="",J="",v=void 0,Y="";function z({controller:W,partialOutput:G=void 0}){W.enqueue({part:{type:"text-delta",id:X,text:J,providerMetadata:v},partialOutput:G}),J=""}return new TransformStream({async transform(W,G){var U;if(W.type==="finish-step"&&J.length>0)z({controller:G});if(W.type!=="text-delta"&&W.type!=="text-start"&&W.type!=="text-end"){G.enqueue({part:W,partialOutput:void 0});return}if(X==null)X=W.id;else if(W.id!==X){G.enqueue({part:W,partialOutput:void 0});return}if(W.type==="text-start"){G.enqueue({part:W,partialOutput:void 0});return}if(W.type==="text-end"){if(J.length>0)z({controller:G});G.enqueue({part:W,partialOutput:void 0});return}Q+=W.text,J+=W.text,v=(U=W.providerMetadata)!=null?U:v;let w=await $.parsePartialOutput({text:Q});if(w!==void 0){let H=JSON.stringify(w.partial);if(H!==Y)z({controller:G,partialOutput:w.partial}),Y=H}}})}var H8$=class{constructor({model:$,telemetry:X,headers:Q,settings:J,maxRetries:v,abortSignal:Y,stepTimeoutMs:z,stepAbortController:W,chunkTimeoutMs:G,chunkAbortController:U,system:w,prompt:H,messages:j,tools:V,toolChoice:B,transforms:D,activeTools:F,repairToolCall:L,stopConditions:A,output:O,providerOptions:i,prepareStep:M,includeRawChunks:R,now:E,generateId:v$,timeout:q$,stopWhen:U$,originalAbortSignal:f4,onChunk:a6,onError:H6,onFinish:d0,onAbort:R9,onStepFinish:E9,onStart:P9,onStepStart:T9,onToolCallStart:o0,onToolCallFinish:j6,experimental_context:D4,download:S9,include:X4}){this._totalUsage=new t9,this._finishReason=new t9,this._rawFinishReason=new t9,this._steps=new t9,this.outputSpecification=O,this.includeRawChunks=R,this.tools=V;let V1,d$=[],k4=[],VV=void 0,HI=void 0,BV=void 0,jI={},DV=[],n4=[],FV=new Map,NV,SX={},CX={},Qh=new TransformStream({async transform(F4,N4){var q4,F$,P6,C9;N4.enqueue(F4);let{part:l}=F4;if(l.type==="text-delta"||l.type==="reasoning-delta"||l.type==="source"||l.type==="tool-call"||l.type==="tool-result"||l.type==="tool-input-start"||l.type==="tool-input-delta"||l.type==="raw")await(a6==null?void 0:a6({chunk:l}));if(l.type==="error")await H6({error:b0$(l.error)});if(l.type==="text-start")SX[l.id]={type:"text",text:"",providerMetadata:l.providerMetadata},d$.push(SX[l.id]);if(l.type==="text-delta"){let o$=SX[l.id];if(o$==null){N4.enqueue({part:{type:"error",error:`text part ${l.id} not found`},partialOutput:void 0});return}o$.text+=l.text,o$.providerMetadata=(q4=l.providerMetadata)!=null?q4:o$.providerMetadata}if(l.type==="text-end"){let o$=SX[l.id];if(o$==null){N4.enqueue({part:{type:"error",error:`text part ${l.id} not found`},partialOutput:void 0});return}o$.providerMetadata=(F$=l.providerMetadata)!=null?F$:o$.providerMetadata,delete SX[l.id]}if(l.type==="reasoning-start")CX[l.id]={type:"reasoning",text:"",providerMetadata:l.providerMetadata},d$.push(CX[l.id]);if(l.type==="reasoning-delta"){let o$=CX[l.id];if(o$==null){N4.enqueue({part:{type:"error",error:`reasoning part ${l.id} not found`},partialOutput:void 0});return}o$.text+=l.text,o$.providerMetadata=(P6=l.providerMetadata)!=null?P6:o$.providerMetadata}if(l.type==="reasoning-end"){let o$=CX[l.id];if(o$==null){N4.enqueue({part:{type:"error",error:`reasoning part ${l.id} not found`},partialOutput:void 0});return}o$.providerMetadata=(C9=l.providerMetadata)!=null?C9:o$.providerMetadata,delete CX[l.id]}if(l.type==="file")d$.push({type:"file",file:l.file});if(l.type==="source")d$.push(l);if(l.type==="tool-call")d$.push(l);if(l.type==="tool-result"&&!l.preliminary)d$.push(l);if(l.type==="tool-approval-request")d$.push(l);if(l.type==="tool-error")d$.push(l);if(l.type==="start-step")d$=[],CX={},SX={},jI=l.request,DV=l.warnings;if(l.type==="finish-step"){let o$=await LE({content:d$,tools:V}),M8=new a0$({stepNumber:n4.length,model:uX,...LV,experimental_context:D4,content:d$,finishReason:l.finishReason,rawFinishReason:l.rawFinishReason,usage:l.usage,warnings:DV,request:jI,response:{...l.response,messages:[...k4,...o$]},providerMetadata:l.providerMetadata});await(E9==null?void 0:E9(M8)),UP({warnings:DV,provider:uX.provider,model:uX.modelId}),n4.push(M8),k4.push(...o$),V1.resolve()}if(l.type==="finish")BV=l.totalUsage,VV=l.finishReason,HI=l.rawFinishReason},async flush(F4){try{if(n4.length===0){let P6=(Y==null?void 0:Y.aborted)?Y.reason:new O1$({message:"No output generated. Check the stream for errors."});V6._finishReason.reject(P6),V6._rawFinishReason.reject(P6),V6._totalUsage.reject(P6),V6._steps.reject(P6);return}let N4=VV!=null?VV:"other",q4=BV!=null?BV:GO();V6._finishReason.resolve(N4),V6._rawFinishReason.resolve(HI),V6._totalUsage.resolve(q4),V6._steps.resolve(n4);let F$=n4[n4.length-1];await(d0==null?void 0:d0({stepNumber:F$.stepNumber,model:F$.model,functionId:F$.functionId,metadata:F$.metadata,experimental_context:F$.experimental_context,finishReason:F$.finishReason,rawFinishReason:F$.rawFinishReason,totalUsage:q4,usage:F$.usage,content:F$.content,text:F$.text,reasoningText:F$.reasoningText,reasoning:F$.reasoning,files:F$.files,sources:F$.sources,toolCalls:F$.toolCalls,staticToolCalls:F$.staticToolCalls,dynamicToolCalls:F$.dynamicToolCalls,toolResults:F$.toolResults,staticToolResults:F$.staticToolResults,dynamicToolResults:F$.dynamicToolResults,request:F$.request,response:F$.response,warnings:F$.warnings,providerMetadata:F$.providerMetadata,steps:n4})),NV.setAttributes(await $v({telemetry:X,attributes:{"ai.response.finishReason":N4,"ai.response.text":{output:()=>F$.text},"ai.response.reasoning":{output:()=>F$.reasoningText},"ai.response.toolCalls":{output:()=>{var P6;return((P6=F$.toolCalls)==null?void 0:P6.length)?JSON.stringify(F$.toolCalls):void 0}},"ai.response.providerMetadata":JSON.stringify(F$.providerMetadata),"ai.usage.inputTokens":q4.inputTokens,"ai.usage.outputTokens":q4.outputTokens,"ai.usage.totalTokens":q4.totalTokens,"ai.usage.reasoningTokens":q4.reasoningTokens,"ai.usage.cachedInputTokens":q4.cachedInputTokens}}))}catch(N4){F4.error(N4)}finally{NV.end()}}}),LJ=G8$();this.addStream=LJ.addStream,this.closeStream=LJ.close;let vh=LJ.stream.getReader(),OV=new ReadableStream({async start(F4){F4.enqueue({type:"start"})},async pull(F4){function N4(){R9==null||R9({steps:n4}),F4.enqueue({type:"abort",...(Y==null?void 0:Y.reason)!==void 0?{reason:_1(Y.reason)}:{}}),F4.close()}try{let{done:q4,value:F$}=await vh.read();if(q4){F4.close();return}if(Y==null?void 0:Y.aborted){N4();return}F4.enqueue(F$)}catch(q4){if(v8(q4)&&(Y==null?void 0:Y.aborted))N4();else F4.error(q4)}},cancel(F4){return LJ.stream.cancel(F4)}});for(let F4 of D)OV=OV.pipeThrough(F4({tools:V,stopStream(){LJ.terminate()}}));this.baseStream=OV.pipeThrough(w8$(O!=null?O:DO())).pipeThrough(Qh);let{maxRetries:VI,retry:Jh}=y0$({maxRetries:v,abortSignal:Y}),$5=P0$(X),g4=H0$(J),BI=i0$({model:$,telemetry:X,headers:Q,settings:{...g4,maxRetries:VI}}),V6=this,uX={provider:$.provider,modelId:$.modelId},LV={functionId:X==null?void 0:X.functionId,metadata:X==null?void 0:X.metadata};BO({name:"ai.streamText",attributes:$v({telemetry:X,attributes:{...VO({operationId:"ai.streamText",telemetry:X}),...BI,"ai.prompt":{input:()=>JSON.stringify({system:w,prompt:H,messages:j})}}}),tracer:$5,endWhenDone:!1,fn:async(F4)=>{NV=F4;let N4=await x0$({system:w,prompt:H,messages:j});try{await(P9==null?void 0:P9({model:uX,system:w,prompt:H,messages:j,tools:V,toolChoice:B,activeTools:F,maxOutputTokens:g4.maxOutputTokens,temperature:g4.temperature,topP:g4.topP,topK:g4.topK,presencePenalty:g4.presencePenalty,frequencyPenalty:g4.frequencyPenalty,stopSequences:g4.stopSequences,seed:g4.seed,maxRetries:VI,timeout:q$,headers:Q,providerOptions:i,stopWhen:U$,output:O,abortSignal:f4,include:X4,...LV,experimental_context:D4}))}catch(o$){}let q4=N4.messages,F$=[],{approvedToolApprovals:P6,deniedToolApprovals:C9}=f0$({messages:q4});if(C9.length>0||P6.length>0){let o$=[...P6,...C9].filter(($6)=>$6.toolCall.providerExecuted),M8=P6.filter(($6)=>!$6.toolCall.providerExecuted),AJ=C9.filter(($6)=>!$6.toolCall.providerExecuted),X5=C9.filter(($6)=>$6.toolCall.providerExecuted),B1,Q5=new ReadableStream({start($6){B1=$6}});V6.addStream(Q5);try{for(let B6 of[...AJ,...X5])B1==null||B1.enqueue({type:"tool-output-denied",toolCallId:B6.toolCall.toolCallId,toolName:B6.toolCall.toolName});let $6=[];if(await Promise.all(M8.map(async(B6)=>{let O4=await qP({toolCall:B6.toolCall,tools:V,tracer:$5,telemetry:X,messages:q4,abortSignal:Y,experimental_context:D4,stepNumber:n4.length,model:uX,onToolCallStart:o0,onToolCallFinish:j6,onPreliminaryToolResult:(v5)=>{B1==null||B1.enqueue(v5)}});if(O4!=null)B1==null||B1.enqueue(O4),$6.push(O4)})),o$.length>0)F$.push({role:"tool",content:o$.map((B6)=>({type:"tool-approval-response",approvalId:B6.approvalResponse.approvalId,approved:B6.approvalResponse.approved,reason:B6.approvalResponse.reason,providerExecuted:!0}))});if($6.length>0||AJ.length>0){let B6=[];for(let O4 of $6)B6.push({type:"tool-result",toolCallId:O4.toolCallId,toolName:O4.toolName,output:await Xv({toolCallId:O4.toolCallId,input:O4.input,tool:V==null?void 0:V[O4.toolName],output:O4.type==="tool-result"?O4.output:O4.error,errorMode:O4.type==="tool-error"?"json":"none"})});for(let O4 of AJ)B6.push({type:"tool-result",toolCallId:O4.toolCall.toolCallId,toolName:O4.toolCall.toolName,output:{type:"execution-denied",reason:O4.approvalResponse.reason}});F$.push({role:"tool",content:B6})}}finally{B1==null||B1.close()}}k4.push(...F$);async function l({currentStep:o$,responseMessages:M8,usage:AJ}){var X5,B1,Q5,$6,B6,O4,v5,DI,FI;let NI=V6.includeRawChunks,OI=z!=null?setTimeout(()=>W.abort(),z):void 0,_X=void 0;function Yh(){if(G!=null){if(_X!=null)clearTimeout(_X);_X=setTimeout(()=>U.abort(),G)}}function LI(){if(_X!=null)clearTimeout(_X),_X=void 0}function AI(){if(OI!=null)clearTimeout(OI)}try{V1=new t9;let yX=[...q4,...M8],x4=await(M==null?void 0:M({model:$,steps:n4,stepNumber:n4.length,messages:yX,experimental_context:D4})),x8=HP((X5=x4==null?void 0:x4.model)!=null?X5:$),ZI={provider:x8.provider,modelId:x8.modelId},qI=await G0$({prompt:{system:(B1=x4==null?void 0:x4.system)!=null?B1:N4.system,messages:(Q5=x4==null?void 0:x4.messages)!=null?Q5:yX},supportedUrls:await x8.supportedUrls,download:S9}),II=($6=x4==null?void 0:x4.activeTools)!=null?$6:F,{toolChoice:J5,tools:AV}=await V0$({tools:V,toolChoice:(B6=x4==null?void 0:x4.toolChoice)!=null?B6:B,activeTools:II});D4=(O4=x4==null?void 0:x4.experimental_context)!=null?O4:D4;let zh=(v5=x4==null?void 0:x4.messages)!=null?v5:yX,Wh=(DI=x4==null?void 0:x4.system)!=null?DI:N4.system,MI=LO(i,x4==null?void 0:x4.providerOptions);try{await(T9==null?void 0:T9({stepNumber:n4.length,model:ZI,system:Wh,messages:zh,tools:V,toolChoice:J5,activeTools:II,steps:[...n4],providerOptions:MI,timeout:q$,headers:Q,stopWhen:U$,output:O,abortSignal:f4,include:X4,...LV,experimental_context:D4}))}catch($$){}let{result:{stream:Gh,response:xI,request:ZV},doStreamSpan:fX,startTimestampMs:bI}=await Jh(()=>BO({name:"ai.streamText.doStream",attributes:$v({telemetry:X,attributes:{...VO({operationId:"ai.streamText.doStream",telemetry:X}),...BI,"ai.model.provider":x8.provider,"ai.model.id":x8.modelId,"ai.prompt.messages":{input:()=>T0$(qI)},"ai.prompt.tools":{input:()=>AV==null?void 0:AV.map(($$)=>JSON.stringify($$))},"ai.prompt.toolChoice":{input:()=>J5!=null?JSON.stringify(J5):void 0},"gen_ai.system":x8.provider,"gen_ai.request.model":x8.modelId,"gen_ai.request.frequency_penalty":g4.frequencyPenalty,"gen_ai.request.max_tokens":g4.maxOutputTokens,"gen_ai.request.presence_penalty":g4.presencePenalty,"gen_ai.request.stop_sequences":g4.stopSequences,"gen_ai.request.temperature":g4.temperature,"gen_ai.request.top_k":g4.topK,"gen_ai.request.top_p":g4.topP}}),tracer:$5,endWhenDone:!1,fn:async($$)=>({startTimestampMs:E(),doStreamSpan:$$,result:await x8.doStream({...g4,tools:AV,toolChoice:J5,responseFormat:await(O==null?void 0:O.responseFormat),prompt:qI,providerOptions:MI,abortSignal:Y,headers:Q,includeRawChunks:NI})})})),Uh=U8$({tools:V,generatorStream:Gh,tracer:$5,telemetry:X,system:w,messages:yX,repairToolCall:L,abortSignal:Y,experimental_context:D4,generateId:v$,stepNumber:n4.length,model:ZI,onToolCallStart:o0,onToolCallFinish:j6}),Kh=((FI=X4==null?void 0:X4.requestBody)!=null?FI:!0)?ZV!=null?ZV:{}:{...ZV,body:void 0},ZJ=[],qJ=[],qV,IV={},kX="other",MV=void 0,C1=GO(),xV,iI=!0,H0={id:v$(),timestamp:new Date,modelId:uX.modelId},RI="";V6.addStream(Uh.pipeThrough(new TransformStream({async transform($$,c4){var IJ,MJ,Y5,z4,j0;if(Yh(),$$.type==="stream-start"){qV=$$.warnings;return}if(iI){let L4=E()-bI;iI=!1,fX.addEvent("ai.stream.firstChunk",{"ai.response.msToFirstChunk":L4}),fX.setAttributes({"ai.response.msToFirstChunk":L4}),c4.enqueue({type:"start-step",request:Kh,warnings:qV!=null?qV:[]})}let bV=$$.type;switch(bV){case"tool-approval-request":case"text-start":case"text-end":{c4.enqueue($$);break}case"text-delta":{if($$.delta.length>0)c4.enqueue({type:"text-delta",id:$$.id,text:$$.delta,providerMetadata:$$.providerMetadata}),RI+=$$.delta;break}case"reasoning-start":case"reasoning-end":{c4.enqueue($$);break}case"reasoning-delta":{c4.enqueue({type:"reasoning-delta",id:$$.id,text:$$.delta,providerMetadata:$$.providerMetadata});break}case"tool-call":{c4.enqueue($$),ZJ.push($$);break}case"tool-result":{if(c4.enqueue($$),!$$.preliminary)qJ.push($$);break}case"tool-error":{c4.enqueue($$),qJ.push($$);break}case"response-metadata":{H0={id:(IJ=$$.id)!=null?IJ:H0.id,timestamp:(MJ=$$.timestamp)!=null?MJ:H0.timestamp,modelId:(Y5=$$.modelId)!=null?Y5:H0.modelId};break}case"finish":{C1=$$.usage,kX=$$.finishReason,MV=$$.rawFinishReason,xV=$$.providerMetadata;let L4=E()-bI;fX.addEvent("ai.stream.finish"),fX.setAttributes({"ai.response.msToFinish":L4,"ai.response.avgOutputTokensPerSecond":1000*((z4=C1.outputTokens)!=null?z4:0)/L4});break}case"file":{c4.enqueue($$);break}case"source":{c4.enqueue($$);break}case"tool-input-start":{IV[$$.id]=$$.toolName;let L4=V==null?void 0:V[$$.toolName];if((L4==null?void 0:L4.onInputStart)!=null)await L4.onInputStart({toolCallId:$$.id,messages:yX,abortSignal:Y,experimental_context:D4});c4.enqueue({...$$,dynamic:(j0=$$.dynamic)!=null?j0:(L4==null?void 0:L4.type)==="dynamic",title:L4==null?void 0:L4.title});break}case"tool-input-end":{delete IV[$$.id],c4.enqueue($$);break}case"tool-input-delta":{let L4=IV[$$.id],iV=V==null?void 0:V[L4];if((iV==null?void 0:iV.onInputDelta)!=null)await iV.onInputDelta({inputTextDelta:$$.delta,toolCallId:$$.id,messages:yX,abortSignal:Y,experimental_context:D4});c4.enqueue($$);break}case"error":{c4.enqueue($$),kX="error";break}case"raw":{if(NI)c4.enqueue($$);break}default:throw Error(`Unknown chunk type: ${bV}`)}},async flush($$){let c4=ZJ.length>0?JSON.stringify(ZJ):void 0;try{fX.setAttributes(await $v({telemetry:X,attributes:{"ai.response.finishReason":kX,"ai.response.text":{output:()=>RI},"ai.response.reasoning":{output:()=>{let z4=d$.filter((j0)=>j0.type==="reasoning");return z4.length>0?z4.map((j0)=>j0.text).join(` +`):void 0}},"ai.response.toolCalls":{output:()=>c4},"ai.response.id":H0.id,"ai.response.model":H0.modelId,"ai.response.timestamp":H0.timestamp.toISOString(),"ai.response.providerMetadata":JSON.stringify(xV),"ai.usage.inputTokens":C1.inputTokens,"ai.usage.outputTokens":C1.outputTokens,"ai.usage.totalTokens":C1.totalTokens,"ai.usage.reasoningTokens":C1.reasoningTokens,"ai.usage.cachedInputTokens":C1.cachedInputTokens,"gen_ai.response.finish_reasons":[kX],"gen_ai.response.id":H0.id,"gen_ai.response.model":H0.modelId,"gen_ai.usage.input_tokens":C1.inputTokens,"gen_ai.usage.output_tokens":C1.outputTokens}}))}catch(z4){}finally{fX.end()}$$.enqueue({type:"finish-step",finishReason:kX,rawFinishReason:MV,usage:C1,providerMetadata:xV,response:{...H0,headers:xI==null?void 0:xI.headers}});let IJ=C0$(AJ,C1);await V1.promise;let MJ=ZJ.filter((z4)=>z4.providerExecuted!==!0),Y5=qJ.filter((z4)=>z4.providerExecuted!==!0);for(let z4 of ZJ){if(z4.providerExecuted!==!0)continue;let j0=V==null?void 0:V[z4.toolName];if((j0==null?void 0:j0.type)==="provider"&&j0.supportsDeferredResults){if(!qJ.some((L4)=>(L4.type==="tool-result"||L4.type==="tool-error")&&L4.toolCallId===z4.toolCallId))FV.set(z4.toolCallId,{toolName:z4.toolName})}}for(let z4 of qJ)if(z4.type==="tool-result"||z4.type==="tool-error")FV.delete(z4.toolCallId);if(AI(),LI(),(MJ.length>0&&Y5.length===MJ.length||FV.size>0)&&!await r0$({stopConditions:A,steps:n4})){M8.push(...await LE({content:n4[n4.length-1].content,tools:V}));try{await l({currentStep:o$+1,responseMessages:M8,usage:IJ})}catch(z4){$$.enqueue({type:"error",error:z4}),V6.closeStream()}}else $$.enqueue({type:"finish",finishReason:kX,rawFinishReason:MV,totalUsage:IJ}),V6.closeStream()}})))}finally{AI(),LI()}}await l({currentStep:0,responseMessages:F$,usage:GO()})}}).catch((F4)=>{V6.addStream(new ReadableStream({start(N4){N4.enqueue({type:"error",error:F4}),N4.close()}})),V6.closeStream()})}get steps(){return this.consumeStream(),this._steps.promise}get finalStep(){return this.steps.then(($)=>$[$.length-1])}get content(){return this.finalStep.then(($)=>$.content)}get warnings(){return this.finalStep.then(($)=>$.warnings)}get providerMetadata(){return this.finalStep.then(($)=>$.providerMetadata)}get text(){return this.finalStep.then(($)=>$.text)}get reasoningText(){return this.finalStep.then(($)=>$.reasoningText)}get reasoning(){return this.finalStep.then(($)=>$.reasoning)}get sources(){return this.finalStep.then(($)=>$.sources)}get files(){return this.finalStep.then(($)=>$.files)}get toolCalls(){return this.finalStep.then(($)=>$.toolCalls)}get staticToolCalls(){return this.finalStep.then(($)=>$.staticToolCalls)}get dynamicToolCalls(){return this.finalStep.then(($)=>$.dynamicToolCalls)}get toolResults(){return this.finalStep.then(($)=>$.toolResults)}get staticToolResults(){return this.finalStep.then(($)=>$.staticToolResults)}get dynamicToolResults(){return this.finalStep.then(($)=>$.dynamicToolResults)}get usage(){return this.finalStep.then(($)=>$.usage)}get request(){return this.finalStep.then(($)=>$.request)}get response(){return this.finalStep.then(($)=>$.response)}get totalUsage(){return this.consumeStream(),this._totalUsage.promise}get finishReason(){return this.consumeStream(),this._finishReason.promise}get rawFinishReason(){return this.consumeStream(),this._rawFinishReason.promise}teeStream(){let[$,X]=this.baseStream.tee();return this.baseStream=X,$}get textStream(){return Jz(this.teeStream().pipeThrough(new TransformStream({transform({part:$},X){if($.type==="text-delta")X.enqueue($.text)}})))}get fullStream(){return Jz(this.teeStream().pipeThrough(new TransformStream({transform({part:$},X){X.enqueue($)}})))}async consumeStream($){var X;try{await W8$({stream:this.fullStream,onError:$==null?void 0:$.onError})}catch(Q){(X=$==null?void 0:$.onError)==null||X.call($,Q)}}get experimental_partialOutputStream(){return this.partialOutputStream}get partialOutputStream(){return Jz(this.teeStream().pipeThrough(new TransformStream({transform({partialOutput:$},X){if($!=null)X.enqueue($)}})))}get elementStream(){var $,X,Q;let J=($=this.outputSpecification)==null?void 0:$.createElementStreamTransform();if(J==null)throw new D1({functionality:`element streams in ${(Q=(X=this.outputSpecification)==null?void 0:X.name)!=null?Q:"text"} mode`});return Jz(this.teeStream().pipeThrough(J))}get output(){return this.finalStep.then(($)=>{var X;return((X=this.outputSpecification)!=null?X:DO()).parseCompleteOutput({text:$.text},{response:$.response,usage:$.usage,finishReason:$.finishReason})})}toUIMessageStream({originalMessages:$,generateMessageId:X,onFinish:Q,messageMetadata:J,sendReasoning:v=!0,sendSources:Y=!1,sendStart:z=!0,sendFinish:W=!0,onError:G=_1}={}){let U=X!=null?X8$({originalMessages:$,responseMessageId:X}):void 0,w=(j)=>{var V;let B=(V=this.tools)==null?void 0:V[j.toolName];if(B==null)return j.dynamic;return(B==null?void 0:B.type)==="dynamic"?!0:void 0},H=this.fullStream.pipeThrough(new TransformStream({transform:async(j,V)=>{let B=J==null?void 0:J({part:j}),D=j.type;switch(D){case"text-start":{V.enqueue({type:"text-start",id:j.id,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"text-delta":{V.enqueue({type:"text-delta",id:j.id,delta:j.text,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"text-end":{V.enqueue({type:"text-end",id:j.id,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"reasoning-start":{V.enqueue({type:"reasoning-start",id:j.id,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"reasoning-delta":{if(v)V.enqueue({type:"reasoning-delta",id:j.id,delta:j.text,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"reasoning-end":{V.enqueue({type:"reasoning-end",id:j.id,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"file":{V.enqueue({type:"file",mediaType:j.file.mediaType,url:`data:${j.file.mediaType};base64,${j.file.base64}`});break}case"source":{if(Y&&j.sourceType==="url")V.enqueue({type:"source-url",sourceId:j.id,url:j.url,title:j.title,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});if(Y&&j.sourceType==="document")V.enqueue({type:"source-document",sourceId:j.id,mediaType:j.mediaType,title:j.title,filename:j.filename,...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{}});break}case"tool-input-start":{let F=w(j);V.enqueue({type:"tool-input-start",toolCallId:j.id,toolName:j.toolName,...j.providerExecuted!=null?{providerExecuted:j.providerExecuted}:{},...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{},...F!=null?{dynamic:F}:{},...j.title!=null?{title:j.title}:{}});break}case"tool-input-delta":{V.enqueue({type:"tool-input-delta",toolCallId:j.id,inputTextDelta:j.delta});break}case"tool-call":{let F=w(j);if(j.invalid)V.enqueue({type:"tool-input-error",toolCallId:j.toolCallId,toolName:j.toolName,input:j.input,...j.providerExecuted!=null?{providerExecuted:j.providerExecuted}:{},...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{},...F!=null?{dynamic:F}:{},errorText:G(j.error),...j.title!=null?{title:j.title}:{}});else V.enqueue({type:"tool-input-available",toolCallId:j.toolCallId,toolName:j.toolName,input:j.input,...j.providerExecuted!=null?{providerExecuted:j.providerExecuted}:{},...j.providerMetadata!=null?{providerMetadata:j.providerMetadata}:{},...F!=null?{dynamic:F}:{},...j.title!=null?{title:j.title}:{}});break}case"tool-approval-request":{V.enqueue({type:"tool-approval-request",approvalId:j.approvalId,toolCallId:j.toolCall.toolCallId});break}case"tool-result":{let F=w(j);V.enqueue({type:"tool-output-available",toolCallId:j.toolCallId,output:j.output,...j.providerExecuted!=null?{providerExecuted:j.providerExecuted}:{},...j.preliminary!=null?{preliminary:j.preliminary}:{},...F!=null?{dynamic:F}:{}});break}case"tool-error":{let F=w(j);V.enqueue({type:"tool-output-error",toolCallId:j.toolCallId,errorText:G(j.error),...j.providerExecuted!=null?{providerExecuted:j.providerExecuted}:{},...F!=null?{dynamic:F}:{}});break}case"tool-output-denied":{V.enqueue({type:"tool-output-denied",toolCallId:j.toolCallId});break}case"error":{V.enqueue({type:"error",errorText:G(j.error)});break}case"start-step":{V.enqueue({type:"start-step"});break}case"finish-step":{V.enqueue({type:"finish-step"});break}case"start":{if(z)V.enqueue({type:"start",...B!=null?{messageMetadata:B}:{},...U!=null?{messageId:U}:{}});break}case"finish":{if(W)V.enqueue({type:"finish",finishReason:j.finishReason,...B!=null?{messageMetadata:B}:{}});break}case"abort":{V.enqueue(j);break}case"tool-input-end":break;case"raw":break;default:throw Error(`Unknown chunk type: ${D}`)}if(B!=null&&D!=="start"&&D!=="finish")V.enqueue({type:"message-metadata",messageMetadata:B})}}));return Jz(Y8$({stream:H,messageId:U!=null?U:X==null?void 0:X(),originalMessages:$,onFinish:Q,onError:G}))}pipeUIMessageStreamToResponse($,{originalMessages:X,generateMessageId:Q,onFinish:J,messageMetadata:v,sendReasoning:Y,sendSources:z,sendFinish:W,sendStart:G,onError:U,...w}={}){z8$({response:$,stream:this.toUIMessageStream({originalMessages:X,generateMessageId:Q,onFinish:J,messageMetadata:v,sendReasoning:Y,sendSources:z,sendFinish:W,sendStart:G,onError:U}),...w})}pipeTextStreamToResponse($,X){e0$({response:$,textStream:this.textStream,...X})}toUIMessageStreamResponse({originalMessages:$,generateMessageId:X,onFinish:Q,messageMetadata:J,sendReasoning:v,sendSources:Y,sendFinish:z,sendStart:W,onError:G,...U}={}){return $8$({stream:this.toUIMessageStream({originalMessages:$,generateMessageId:X,onFinish:Q,messageMetadata:J,sendReasoning:v,sendSources:Y,sendFinish:z,sendStart:W,onError:G}),...U})}toTextStreamResponse($){return s0$({textStream:this.textStream,...$})}};async function EP($,X){let Q=[];if(X==null?void 0:X.ignoreIncompleteToolCalls)$=$.map((J)=>({...J,parts:J.parts.filter((v)=>!Yz(v)||v.state!=="input-streaming"&&v.state!=="input-available")}));for(let J of $)switch(J.role){case"system":{let v=J.parts.filter((z)=>z.type==="text"),Y=v.reduce((z,W)=>{if(W.providerMetadata!=null)return{...z,...W.providerMetadata};return z},{});Q.push({role:"system",content:v.map((z)=>z.text).join(""),...Object.keys(Y).length>0?{providerOptions:Y}:{}});break}case"user":{Q.push({role:"user",content:J.parts.map((v)=>{var Y;if(KO(v))return{type:"text",text:v.text,...v.providerMetadata!=null?{providerOptions:v.providerMetadata}:{}};if(wO(v))return{type:"file",mediaType:v.mediaType,filename:v.filename,data:v.url,...v.providerMetadata!=null?{providerOptions:v.providerMetadata}:{}};if(UO(v))return(Y=X==null?void 0:X.convertDataPart)==null?void 0:Y.call(X,v)}).filter(xb)});break}case"assistant":{if(J.parts!=null){let v=[];async function Y(){var z,W,G,U,w,H;if(v.length===0)return;let j=[];for(let B of v)if(KO(B))j.push({type:"text",text:B.text,...B.providerMetadata!=null?{providerOptions:B.providerMetadata}:{}});else if(wO(B))j.push({type:"file",mediaType:B.mediaType,filename:B.filename,data:B.url});else if(AE(B))j.push({type:"reasoning",text:B.text,providerOptions:B.providerMetadata});else if(Yz(B)){let D=HO(B);if(B.state!=="input-streaming"){if(j.push({type:"tool-call",toolCallId:B.toolCallId,toolName:D,input:B.state==="output-error"?(z=B.input)!=null?z:("rawInput"in B)?B.rawInput:void 0:B.input,providerExecuted:B.providerExecuted,...B.callProviderMetadata!=null?{providerOptions:B.callProviderMetadata}:{}}),B.approval!=null)j.push({type:"tool-approval-request",approvalId:B.approval.id,toolCallId:B.toolCallId});if(B.providerExecuted===!0&&B.state!=="approval-responded"&&(B.state==="output-available"||B.state==="output-error"))j.push({type:"tool-result",toolCallId:B.toolCallId,toolName:D,output:await Xv({toolCallId:B.toolCallId,input:B.input,output:B.state==="output-error"?B.errorText:B.output,tool:(W=X==null?void 0:X.tools)==null?void 0:W[D],errorMode:B.state==="output-error"?"json":"none"}),...B.callProviderMetadata!=null?{providerOptions:B.callProviderMetadata}:{}})}}else if(UO(B)){let D=(G=X==null?void 0:X.convertDataPart)==null?void 0:G.call(X,B);if(D!=null)j.push(D)}else throw Error(`Unsupported part: ${B}`);Q.push({role:"assistant",content:j});let V=v.filter((B)=>{var D;return Yz(B)&&(B.providerExecuted!==!0||((D=B.approval)==null?void 0:D.approved)!=null)});if(V.length>0){let B=[];for(let D of V){if(((U=D.approval)==null?void 0:U.approved)!=null)B.push({type:"tool-approval-response",approvalId:D.approval.id,approved:D.approval.approved,reason:D.approval.reason,providerExecuted:D.providerExecuted});if(D.providerExecuted===!0)continue;switch(D.state){case"output-denied":{B.push({type:"tool-result",toolCallId:D.toolCallId,toolName:HO(D),output:{type:"error-text",value:(w=D.approval.reason)!=null?w:"Tool execution denied."},...D.callProviderMetadata!=null?{providerOptions:D.callProviderMetadata}:{}});break}case"output-error":case"output-available":{let F=HO(D);B.push({type:"tool-result",toolCallId:D.toolCallId,toolName:F,output:await Xv({toolCallId:D.toolCallId,input:D.input,output:D.state==="output-error"?D.errorText:D.output,tool:(H=X==null?void 0:X.tools)==null?void 0:H[F],errorMode:D.state==="output-error"?"text":"none"}),...D.callProviderMetadata!=null?{providerOptions:D.callProviderMetadata}:{}});break}}}if(B.length>0)Q.push({role:"tool",content:B})}v=[]}for(let z of J.parts)if(KO(z)||AE(z)||wO(z)||Yz(z)||UO(z))v.push(z);else if(z.type==="step-start")await Y();await Y();break}break}default:{let v=J.role;throw new h1$({originalMessage:J,message:`Unsupported role: ${v}`})}}return Q}var rL$=w4(()=>v4(K.array(K.object({id:K.string(),role:K.enum(["system","user","assistant"]),metadata:K.unknown().optional(),parts:K.array(K.union([K.object({type:K.literal("text"),text:K.string(),state:K.enum(["streaming","done"]).optional(),providerMetadata:e.optional()}),K.object({type:K.literal("reasoning"),text:K.string(),state:K.enum(["streaming","done"]).optional(),providerMetadata:e.optional()}),K.object({type:K.literal("source-url"),sourceId:K.string(),url:K.string(),title:K.string().optional(),providerMetadata:e.optional()}),K.object({type:K.literal("source-document"),sourceId:K.string(),mediaType:K.string(),title:K.string(),filename:K.string().optional(),providerMetadata:e.optional()}),K.object({type:K.literal("file"),mediaType:K.string(),filename:K.string().optional(),url:K.string(),providerMetadata:e.optional()}),K.object({type:K.literal("step-start")}),K.object({type:K.string().startsWith("data-"),id:K.string().optional(),data:K.unknown()}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("input-streaming"),input:K.unknown().optional(),providerExecuted:K.boolean().optional(),callProviderMetadata:e.optional(),output:K.never().optional(),errorText:K.never().optional(),approval:K.never().optional()}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("input-available"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.never().optional()}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("approval-requested"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.never().optional(),reason:K.never().optional()})}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("approval-responded"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.boolean(),reason:K.string().optional()})}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("output-available"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.unknown(),errorText:K.never().optional(),callProviderMetadata:e.optional(),preliminary:K.boolean().optional(),approval:K.object({id:K.string(),approved:K.literal(!0),reason:K.string().optional()}).optional()}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("output-error"),input:K.unknown(),rawInput:K.unknown().optional(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.string(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.literal(!0),reason:K.string().optional()}).optional()}),K.object({type:K.literal("dynamic-tool"),toolName:K.string(),toolCallId:K.string(),state:K.literal("output-denied"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.literal(!1),reason:K.string().optional()})}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("input-streaming"),providerExecuted:K.boolean().optional(),callProviderMetadata:e.optional(),input:K.unknown().optional(),output:K.never().optional(),errorText:K.never().optional(),approval:K.never().optional()}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("input-available"),providerExecuted:K.boolean().optional(),input:K.unknown(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.never().optional()}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("approval-requested"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.never().optional(),reason:K.never().optional()})}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("approval-responded"),input:K.unknown(),providerExecuted:K.boolean().optional(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.boolean(),reason:K.string().optional()})}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("output-available"),providerExecuted:K.boolean().optional(),input:K.unknown(),output:K.unknown(),errorText:K.never().optional(),callProviderMetadata:e.optional(),preliminary:K.boolean().optional(),approval:K.object({id:K.string(),approved:K.literal(!0),reason:K.string().optional()}).optional()}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("output-error"),providerExecuted:K.boolean().optional(),input:K.unknown(),rawInput:K.unknown().optional(),output:K.never().optional(),errorText:K.string(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.literal(!0),reason:K.string().optional()}).optional()}),K.object({type:K.string().startsWith("tool-"),toolCallId:K.string(),state:K.literal("output-denied"),providerExecuted:K.boolean().optional(),input:K.unknown(),output:K.never().optional(),errorText:K.never().optional(),callProviderMetadata:e.optional(),approval:K.object({id:K.string(),approved:K.literal(!1),reason:K.string().optional()})})])).nonempty("Message must contain at least one part")})).nonempty("Messages array must not be empty")));var sL$=I0({prefix:"aiobj",size:24});function PP($){return({url:X,abortSignal:Q})=>VP({url:X,maxBytes:$==null?void 0:$.maxBytes,abortSignal:Q})}var $A$=I0({prefix:"aiobj",size:24});var XA$=PP();var j8$="AI_NoSuchProviderError",V8$=`vercel.ai.error.${j8$}`,B8$=Symbol.for(V8$),D8$;D8$=B8$;var QA$=PP();var ZO=($)=>Object.fromEntries(Object.entries($).map(([X,Q])=>[X,{...Q.description?{description:Q.description}:void 0,inputSchema:s9(Q.parameters)}]));var j4="begin-rename-files-task",L6="add-rename-file-to-task",R4="end-rename-files-task",TP=`Begin a rename task V2 for batch renaming media files. This tool creates a task that can be used to add multiple files for renaming. Use ${L6} to add files, then ${R4} to execute.`,SP=`Add a file to a rename task. This tool adds a single file to an existing task created by ${j4}. Provide the task ID, current file path, and new file path.`,CP=`End a rename task and execute the batch rename operation. This tool finalizes the task created by ${j4} and executes all pending file renames.`,uP=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format")}),_P=K.object({taskId:K.string().describe(`The task ID from ${j4}`),from:K.string().describe("Current absolute path of the video file to rename (POSIX or Windows format)"),to:K.string().describe("New absolute path for the file (POSIX or Windows format)")}),yP=K.object({taskId:K.string().describe(`The task ID from ${j4}`)});var E4="begin-recognize-task",A6="add-recognized-media-file",o4="end-recognize-task",fP=`Begin a recognition task for identifying media files. This tool creates a task that can be used to add media files for recognition. Use ${A6} to add files, then ${o4} to execute.`,kP=`Add a recognized media file to a recognition task. This tool adds a single video file to an existing task created by ${E4}. Provide the task ID, season number, episode number, and file path.`,nP=`End a recognition task and execute the recognition. This tool finalizes the task created by ${E4} and processes all added media files.`,gP=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder, it can be POSIX format or Windows format")}),cP=K.object({taskId:K.string().describe(`The task ID returned from ${E4}`),season:K.number().describe("The season number of the episode."),episode:K.number().describe("The episode number."),path:K.string().describe("The absolute path of the media file (POSIX or Windows format).")}),mP=K.object({taskId:K.string().describe(`The task ID returned from ${E4}`)});var $1="get-app-context",tU=`Get SMM context: + * The media folder user selected/focused on SMM UI + * The language in user preferences`,sU=K.object({}),eU=K.object({selectedMediaFolder:K.string().describe("The path of the media folder that user selected in UI."),language:K.string().describe("The language in user preferences."),error:K.string().optional().describe("Error message if the operation failed")});var o1="get-media-metadata",$K="Get cached media metadata for a media folder. Returns normalized TV show season/episode data and TMDB/TVDB movie information when available. Use list-files-in-media-folder for raw file paths; episode-to-file mappings are not included in this response.",hP="Media folder not found. The folder path may not be correct or the folder is not managed by SMM",vv="SMM未识别本文件夹, 请提示用户从SMM界面中搜索并匹配媒体",lP="Path is not a directory",pP="Folder not found",dP="No metadata cached for this folder",XK=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder (POSIX or Windows format)")}),F8$=K.object({seasonNumber:K.number(),seasonName:K.string(),episodes:K.array(K.object({seasonNumber:K.number(),episodeNumber:K.number(),episodeName:K.string()}))}),N8$=K.object({mediaFolderPath:K.string().describe("The path of the media folder"),type:K.enum(["tvshow-folder","movie-folder","music-folder"]).describe("The type of the media folder"),tvShow:K.union([K.object({source:K.enum(["TMDB","TVDB"]),id:K.number(),name:K.string(),seasons:K.array(F8$)}),K.string()]).optional().describe("Normalized TV show data or message if not recognized"),tmdbMovie:K.union([K.object({tmdbId:K.number(),title:K.string(),originalTitle:K.string(),overview:K.string(),releaseDate:K.string(),posterPath:K.string().nullable()}),K.string()]).optional().describe("TMDB movie data or message if not recognized"),tvdbMovie:K.union([K.object({tvdbId:K.number(),name:K.string(),database:K.enum(["TMDB","TVDB"])}),K.string()]).optional().describe("TVDB movie data or message if not recognized")}),QK=N8$.extend({error:K.string().optional().describe("Error message when lookup failed")});var a1="get-episodes",vK="Get all episodes for a TV show with their video file paths. Combines TMDB or TVDB episode data (from cached metadata) with local media file paths. For each episode, returns season, episode number, and video file path. The video file path may be undefined if the episode has not been recognized yet.",oP="Invalid path: 'mediaFolderPath' must be a non-empty string",aP="Media folder not found. The folder path may not be correct or the folder is not managed by SMM",qO="TV show not found. Please ensure the media folder is opened in SMM.",rP="Not a TV show folder. This tool only works with TV show media folders that have TMDB or TVDB metadata cached.",JK=K.object({mediaFolderPath:K.string().describe("The absolute path of the TV show media folder (POSIX or Windows format)")}),O8$=K.object({season:K.number().describe("The season number"),episode:K.number().describe("The episode number"),videoFilePath:K.string().optional().describe("The absolute path of the video file, undefined if not recognized yet")}),L8$=K.object({episodes:K.array(O8$).describe("Array of all episodes with their video file paths"),totalCount:K.number().describe("Total number of episodes"),showName:K.string().describe("The name of the TV show"),numberOfSeasons:K.number().describe("Number of seasons in the show")}),YK=L8$.extend({error:K.string().optional()});var o8="list-files-in-media-folder",tP="List files in a media folder by scanning the file system recursively. Returns file paths in OS-native format. Use videoFileOnly to restrict to video files.",zK="Invalid path: 'mediaFolderPath' must be a non-empty string",WK="Media folder not found. The folder path may not be correct or the folder is not managed by SMM",sP=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder (POSIX or Windows format)"),recursively:K.boolean().optional().default(!0).describe("Whether to list files recursively (default: true)"),videoFileOnly:K.boolean().optional().default(!1).describe("Whether to return only video files (default: false)")}),A8$=K.object({files:K.array(K.string()).describe("Array of file paths"),count:K.number().describe("Number of files listed")}),eP=A8$.extend({error:K.string().optional()});var Z6="scrape",GK=`Start a scrape job for a managed TV show or movie folder (poster, fanart, thumbnails, nfo). Returns a job id immediately; the scrape runs in the background. Call get-job with the returned id to check progress and per-task status. Supports TMDB and TVDB. Movie folders skip thumbnails. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/promise.js -function parsePromiseDef2(def, refs) { - return parseDef2(def.type._def, refs); -} +Example: Scrape media folder "/path/to/Show".`,$T="scrape job created, use get-job tool to check job status by id.",UK=K.object({path:K.string().describe("Absolute path of the managed media folder to scrape (POSIX or Windows format)"),language:K.string().optional().describe("Optional language code for metadata/assets (defaults to user preferMediaLanguage)")}),KK=K.object({id:K.string().describe("Scrape job id; pass to get-job to poll status"),message:K.string().describe("Guidance for checking job status with get-job"),error:K.string().optional().describe("Error message when the scrape job could not be started")});var q6="get-job",HK=`Get the status of a background job by id. Supports scrape jobs (kind: "scrape" with poster/fanart/thumbnails/nfo tasks) and import jobs (kind: "import"). Poll until status is succeeded, failed, or aborted. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/set.js -function parseSetDef2(def, refs) { - const items = parseDef2(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, "items"] - }); - const schema = { - type: "array", - uniqueItems: true, - items - }; - if (def.minSize) { - setResponseValueAndErrors(schema, "minItems", def.minSize.value, def.minSize.message, refs); - } - if (def.maxSize) { - setResponseValueAndErrors(schema, "maxItems", def.maxSize.value, def.maxSize.message, refs); - } - return schema; -} +Example: Check job status for id "550e8400-e29b-41d4-a716-446655440000".`,XT=K.enum(["pending","running","succeeded","failed","aborted"]),Z8$=K.enum(["pending","running","skipped","completed","failed"]),wK=K.object({status:Z8$,error:K.string().optional()}),q8$=K.object({kind:K.literal("scrape"),id:K.string(),folderPath:K.string(),status:XT,tasks:K.object({poster:wK,fanart:wK,thumbnails:wK,nfo:wK}),error:K.string().optional(),createdAt:K.number(),updatedAt:K.number()}),I8$=K.object({kind:K.literal("import"),id:K.string(),folderPath:K.string(),type:K.string(),status:XT,stage:K.string().nullable(),progress:K.number(),recognizedTitle:K.string().optional(),error:K.string().optional(),createdAt:K.number(),updatedAt:K.number()}),M8$=K.discriminatedUnion("kind",[q8$,I8$]),jK=K.object({id:K.string().describe("Job id returned by scrape or import-folder")}),VK=K.object({job:M8$.optional().describe("Job payload when found"),error:K.string().optional().describe("Error message when the job could not be loaded")});var Jv=K.string().optional().describe("TMDB primary translation IETF tag (e.g. zh-CN, en-US). Defaults from userConfig.preferMediaLanguage."),Yv=K.string().optional().describe("Optional TMDB API base URL override (defaults from userConfig.tmdb.host)");function BK($){let X=$.baseURL?.trim();return{language:$.language,host:X||void 0}}function DK($){let X=$ instanceof Error?$.message:String($);return X.startsWith("Error Reason:")?X:`Error Reason: ${X}`}var X1="tmdb-search",FK=`Search TMDB (The Movie Database) for movies or TV shows by keyword. Returns matching results with title, release date, overview, and TMDB ID. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/tuple.js -function parseTupleDef2(def, refs) { - if (def.rest) { - return { - type: "array", - minItems: def.items.length, - items: def.items.map((x, i) => parseDef2(x._def, { - ...refs, - currentPath: [...refs.currentPath, "items", `${i}`] - })).reduce((acc, x) => x === undefined ? acc : [...acc, x], []), - additionalItems: parseDef2(def.rest._def, { - ...refs, - currentPath: [...refs.currentPath, "additionalItems"] - }) - }; - } else { - return { - type: "array", - minItems: def.items.length, - maxItems: def.items.length, - items: def.items.map((x, i) => parseDef2(x._def, { - ...refs, - currentPath: [...refs.currentPath, "items", `${i}`] - })).reduce((acc, x) => x === undefined ? acc : [...acc, x], []) - }; - } -} +Example: Search TV shows matching "naruto".`,NK=K.object({keyword:K.string().describe("Search keyword"),type:K.enum(["tv","movie"]).describe("Media type to search"),language:Jv,baseURL:Yv}),OK=K.object({results:K.array(K.record(K.string(),K.unknown())).optional(),page:K.number().optional(),total_pages:K.number().optional(),total_results:K.number().optional(),error:K.string().optional()});var Q1="tmdb-get-movie",LK=`Retrieve detailed movie information from TMDB by TMDB ID. Includes title, overview, release date, runtime, genres, poster images, and more. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/undefined.js -function parseUndefinedDef2(refs) { - return { - not: parseAnyDef2(refs) - }; -} +Example: Get movie details for TMDB id 550.`,AK=K.object({id:K.number().int().positive().describe("TMDB movie id"),language:Jv,baseURL:Yv}),ZK=K.object({error:K.string().optional()}).passthrough();var v1="tmdb-get-tv-show",qK=`Retrieve detailed TV show information from TMDB by TMDB ID, including seasons and episodes with titles, overviews, and air dates. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/unknown.js -function parseUnknownDef2(refs) { - return parseAnyDef2(refs); -} +Example: Get TV show details for TMDB id 31917.`,IK=K.object({id:K.number().int().positive().describe("TMDB TV series id"),language:Jv,baseURL:Yv}),MK=K.object({error:K.string().optional()}).passthrough();var QT=`You're a helpful assistant for Simple Media Manager(SMM) software. +SMM is a media manager that helps user to manage their TV Show, anime, movie or music. +SMM holds multiple media folders and user can switch between them. -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parsers/readonly.js -var parseReadonlyDef2 = (def, refs) => { - return parseDef2(def.innerType._def, refs); -}; +SMM maintains below date for a media folder: +1. path in local file system +2. TV Show or Movie media metadata from TMDB +3. Media File Metadata - The linkage between local video file to Season and Episode of TV show -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/selectParser.js -var selectParser2 = (def, typeName, refs) => { - switch (typeName) { - case ZodFirstPartyTypeKind2.ZodString: - return parseStringDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodNumber: - return parseNumberDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodObject: - return parseObjectDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodBigInt: - return parseBigintDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodBoolean: - return parseBooleanDef2(); - case ZodFirstPartyTypeKind2.ZodDate: - return parseDateDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodUndefined: - return parseUndefinedDef2(refs); - case ZodFirstPartyTypeKind2.ZodNull: - return parseNullDef2(refs); - case ZodFirstPartyTypeKind2.ZodArray: - return parseArrayDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodUnion: - case ZodFirstPartyTypeKind2.ZodDiscriminatedUnion: - return parseUnionDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodIntersection: - return parseIntersectionDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodTuple: - return parseTupleDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodRecord: - return parseRecordDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodLiteral: - return parseLiteralDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodEnum: - return parseEnumDef2(def); - case ZodFirstPartyTypeKind2.ZodNativeEnum: - return parseNativeEnumDef2(def); - case ZodFirstPartyTypeKind2.ZodNullable: - return parseNullableDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodOptional: - return parseOptionalDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodMap: - return parseMapDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodSet: - return parseSetDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodLazy: - return () => def.getter()._def; - case ZodFirstPartyTypeKind2.ZodPromise: - return parsePromiseDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodNaN: - case ZodFirstPartyTypeKind2.ZodNever: - return parseNeverDef2(refs); - case ZodFirstPartyTypeKind2.ZodEffects: - return parseEffectsDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodAny: - return parseAnyDef2(refs); - case ZodFirstPartyTypeKind2.ZodUnknown: - return parseUnknownDef2(refs); - case ZodFirstPartyTypeKind2.ZodDefault: - return parseDefaultDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodBranded: - return parseBrandedDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodReadonly: - return parseReadonlyDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodCatch: - return parseCatchDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodPipeline: - return parsePipelineDef2(def, refs); - case ZodFirstPartyTypeKind2.ZodFunction: - case ZodFirstPartyTypeKind2.ZodVoid: - case ZodFirstPartyTypeKind2.ZodSymbol: - return; - default: - return ((_) => { - return; - })(typeName); - } -}; -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/parseDef.js -function parseDef2(def, refs, forceResolution = false) { - const seenItem = refs.seen.get(def); - if (refs.override) { - const overrideResult = refs.override?.(def, refs, seenItem, forceResolution); - if (overrideResult !== ignoreOverride2) { - return overrideResult; - } - } - if (seenItem && !forceResolution) { - const seenSchema = get$ref2(seenItem, refs); - if (seenSchema !== undefined) { - return seenSchema; - } - } - const newItem = { def, path: refs.currentPath, jsonSchema: undefined }; - refs.seen.set(def, newItem); - const jsonSchemaOrGetter = selectParser2(def, def.typeName, refs); - const jsonSchema2 = typeof jsonSchemaOrGetter === "function" ? parseDef2(jsonSchemaOrGetter(), refs) : jsonSchemaOrGetter; - if (jsonSchema2) { - addMeta2(def, refs, jsonSchema2); - } - if (refs.postProcess) { - const postProcessResult = refs.postProcess(jsonSchema2, def, refs); - newItem.jsonSchema = jsonSchema2; - return postProcessResult; - } - newItem.jsonSchema = jsonSchema2; - return jsonSchema2; -} -var get$ref2 = (item, refs) => { - switch (refs.$refStrategy) { - case "root": - return { $ref: item.path.join("/") }; - case "relative": - return { $ref: getRelativePath2(refs.currentPath, item.path) }; - case "none": - case "seen": { - if (item.path.length < refs.currentPath.length && item.path.every((value, index) => refs.currentPath[index] === value)) { - console.warn(`Recursive reference detected at ${refs.currentPath.join("/")}! Defaulting to any`); - return parseAnyDef2(refs); - } - return refs.$refStrategy === "seen" ? parseAnyDef2(refs) : undefined; - } - } -}; -var addMeta2 = (def, refs, jsonSchema2) => { - if (def.description) { - jsonSchema2.description = def.description; - if (refs.markdownDescription) { - jsonSchema2.markdownDescription = def.description; - } - } - return jsonSchema2; -}; -// ../../node_modules/.pnpm/zod-to-json-schema@3.25.1_zod@4.3.6/node_modules/zod-to-json-schema/dist/esm/zodToJsonSchema.js -var zodToJsonSchema = (schema, options) => { - const refs = getRefs2(options); - let definitions = typeof options === "object" && options.definitions ? Object.entries(options.definitions).reduce((acc, [name24, schema2]) => ({ - ...acc, - [name24]: parseDef2(schema2._def, { - ...refs, - currentPath: [...refs.basePath, refs.definitionPath, name24] - }, true) ?? parseAnyDef2(refs) - }), {}) : undefined; - const name21 = typeof options === "string" ? options : options?.nameStrategy === "title" ? undefined : options?.name; - const main = parseDef2(schema._def, name21 === undefined ? refs : { - ...refs, - currentPath: [...refs.basePath, refs.definitionPath, name21] - }, false) ?? parseAnyDef2(refs); - const title = typeof options === "object" && options.name !== undefined && options.nameStrategy === "title" ? options.name : undefined; - if (title !== undefined) { - main.title = title; - } - if (refs.flags.hasReferencedOpenAiAnyType) { - if (!definitions) { - definitions = {}; - } - if (!definitions[refs.openAiAnyTypeName]) { - definitions[refs.openAiAnyTypeName] = { - type: ["string", "number", "integer", "boolean", "array", "null"], - items: { - $ref: refs.$refStrategy === "relative" ? "1" : [ - ...refs.basePath, - refs.definitionPath, - refs.openAiAnyTypeName - ].join("/") - } - }; - } - } - const combined = name21 === undefined ? definitions ? { - ...main, - [refs.definitionPath]: definitions - } : main : { - $ref: [ - ...refs.$refStrategy === "relative" ? [] : refs.basePath, - refs.definitionPath, - name21 - ].join("/"), - [refs.definitionPath]: { - ...definitions, - [name21]: main - } - }; - if (refs.target === "jsonSchema7") { - combined.$schema = "http://json-schema.org/draft-07/schema#"; - } else if (refs.target === "jsonSchema2019-09" || refs.target === "openAi") { - combined.$schema = "https://json-schema.org/draft/2019-09/schema#"; - } - if (refs.target === "openAi" && (("anyOf" in combined) || ("oneOf" in combined) || ("allOf" in combined) || ("type" in combined) && Array.isArray(combined.type))) { - console.warn("Warning: OpenAI may not support schemas with unions as roots! Try wrapping it in an object property."); - } - return combined; -}; -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js -function mapMiniTarget(t) { - if (!t) - return "draft-7"; - if (t === "jsonSchema7" || t === "draft-7") - return "draft-7"; - if (t === "jsonSchema2019-09" || t === "draft-2020-12") - return "draft-2020-12"; - return "draft-7"; -} -function toJsonSchemaCompat(schema, opts) { - if (isZ4Schema(schema)) { - return toJSONSchema(schema, { - target: mapMiniTarget(opts?.target), - io: opts?.pipeStrategy ?? "input" - }); - } - return zodToJsonSchema(schema, { - strictUnions: opts?.strictUnions ?? true, - pipeStrategy: opts?.pipeStrategy ?? "input" - }); -} -function getMethodLiteral(schema) { - const shape = getObjectShape(schema); - const methodSchema = shape?.method; - if (!methodSchema) { - throw new Error("Schema is missing a method literal"); - } - const value = getLiteralValue(methodSchema); - if (typeof value !== "string") { - throw new Error("Schema method literal must be a string"); - } - return value; -} -function parseWithCompat(schema, data) { - const result = safeParse3(schema, data); - if (!result.success) { - throw result.error; - } - return result.data; -} +## Tasks -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js -var DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; +This section defines tasks for how to handle certain user requirements. -class Protocol { - constructor(_options) { - this._options = _options; - this._requestMessageId = 0; - this._requestHandlers = new Map; - this._requestHandlerAbortControllers = new Map; - this._notificationHandlers = new Map; - this._responseHandlers = new Map; - this._progressHandlers = new Map; - this._timeoutInfo = new Map; - this._pendingDebouncedNotifications = new Set; - this._taskProgressTokens = new Map; - this._requestResolvers = new Map; - this.setNotificationHandler(CancelledNotificationSchema, (notification) => { - this._oncancel(notification); - }); - this.setNotificationHandler(ProgressNotificationSchema, (notification) => { - this._onprogress(notification); - }); - this.setRequestHandler(PingRequestSchema, (_request) => ({})); - this._taskStore = _options?.taskStore; - this._taskMessageQueue = _options?.taskMessageQueue; - if (this._taskStore) { - this.setRequestHandler(GetTaskRequestSchema, async (request, extra) => { - const task = await this._taskStore.getTask(request.params.taskId, extra.sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, "Failed to retrieve task: Task not found"); - } - return { - ...task - }; - }); - this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => { - const handleTaskResult = async () => { - const taskId = request.params.taskId; - if (this._taskMessageQueue) { - let queuedMessage; - while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) { - if (queuedMessage.type === "response" || queuedMessage.type === "error") { - const message = queuedMessage.message; - const requestId = message.id; - const resolver = this._requestResolvers.get(requestId); - if (resolver) { - this._requestResolvers.delete(requestId); - if (queuedMessage.type === "response") { - resolver(message); - } else { - const errorMessage = message; - const error48 = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error48); - } - } else { - const messageType = queuedMessage.type === "response" ? "Response" : "Error"; - this._onerror(new Error(`${messageType} handler missing for request ${requestId}`)); - } - continue; - } - await this._transport?.send(queuedMessage.message, { relatedRequestId: extra.requestId }); - } - } - const task = await this._taskStore.getTask(taskId, extra.sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`); - } - if (!isTerminal(task.status)) { - await this._waitForTaskUpdate(taskId, extra.signal); - return await handleTaskResult(); - } - if (isTerminal(task.status)) { - const result = await this._taskStore.getTaskResult(taskId, extra.sessionId); - this._clearTaskQueue(taskId); - return { - ...result, - _meta: { - ...result._meta, - [RELATED_TASK_META_KEY]: { - taskId - } - } - }; - } - return await handleTaskResult(); - }; - return await handleTaskResult(); - }); - this.setRequestHandler(ListTasksRequestSchema, async (request, extra) => { - try { - const { tasks, nextCursor } = await this._taskStore.listTasks(request.params?.cursor, extra.sessionId); - return { - tasks, - nextCursor, - _meta: {} - }; - } catch (error48) { - throw new McpError(ErrorCode.InvalidParams, `Failed to list tasks: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); - this.setRequestHandler(CancelTaskRequestSchema, async (request, extra) => { - try { - const task = await this._taskStore.getTask(request.params.taskId, extra.sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, `Task not found: ${request.params.taskId}`); - } - if (isTerminal(task.status)) { - throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); - } - await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId); - this._clearTaskQueue(request.params.taskId); - const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId); - if (!cancelledTask) { - throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`); - } - return { - _meta: {}, - ...cancelledTask - }; - } catch (error48) { - if (error48 instanceof McpError) { - throw error48; - } - throw new McpError(ErrorCode.InvalidRequest, `Failed to cancel task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); - } - } - async _oncancel(notification) { - if (!notification.params.requestId) { - return; - } - const controller = this._requestHandlerAbortControllers.get(notification.params.requestId); - controller?.abort(notification.params.reason); - } - _setupTimeout(messageId, timeout2, maxTotalTimeout, onTimeout, resetTimeoutOnProgress = false) { - this._timeoutInfo.set(messageId, { - timeoutId: setTimeout(onTimeout, timeout2), - startTime: Date.now(), - timeout: timeout2, - maxTotalTimeout, - resetTimeoutOnProgress, - onTimeout - }); - } - _resetTimeout(messageId) { - const info = this._timeoutInfo.get(messageId); - if (!info) - return false; - const totalElapsed = Date.now() - info.startTime; - if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) { - this._timeoutInfo.delete(messageId); - throw McpError.fromError(ErrorCode.RequestTimeout, "Maximum total timeout exceeded", { - maxTotalTimeout: info.maxTotalTimeout, - totalElapsed - }); - } - clearTimeout(info.timeoutId); - info.timeoutId = setTimeout(info.onTimeout, info.timeout); - return true; - } - _cleanupTimeout(messageId) { - const info = this._timeoutInfo.get(messageId); - if (info) { - clearTimeout(info.timeoutId); - this._timeoutInfo.delete(messageId); - } - } - async connect(transport) { - if (this._transport) { - throw new Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection."); - } - this._transport = transport; - const _onclose = this.transport?.onclose; - this._transport.onclose = () => { - _onclose?.(); - this._onclose(); - }; - const _onerror = this.transport?.onerror; - this._transport.onerror = (error48) => { - _onerror?.(error48); - this._onerror(error48); - }; - const _onmessage = this._transport?.onmessage; - this._transport.onmessage = (message, extra) => { - _onmessage?.(message, extra); - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - this._onresponse(message); - } else if (isJSONRPCRequest(message)) { - this._onrequest(message, extra); - } else if (isJSONRPCNotification(message)) { - this._onnotification(message); - } else { - this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); - } - }; - await this._transport.start(); - } - _onclose() { - const responseHandlers = this._responseHandlers; - this._responseHandlers = new Map; - this._progressHandlers.clear(); - this._taskProgressTokens.clear(); - this._pendingDebouncedNotifications.clear(); - for (const controller of this._requestHandlerAbortControllers.values()) { - controller.abort(); - } - this._requestHandlerAbortControllers.clear(); - const error48 = McpError.fromError(ErrorCode.ConnectionClosed, "Connection closed"); - this._transport = undefined; - this.onclose?.(); - for (const handler of responseHandlers.values()) { - handler(error48); - } - } - _onerror(error48) { - this.onerror?.(error48); - } - _onnotification(notification) { - const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; - if (handler === undefined) { - return; - } - Promise.resolve().then(() => handler(notification)).catch((error48) => this._onerror(new Error(`Uncaught error in notification handler: ${error48}`))); - } - _onrequest(request, extra) { - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; - const capturedTransport = this._transport; - const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId; - if (handler === undefined) { - const errorResponse = { - jsonrpc: "2.0", - id: request.id, - error: { - code: ErrorCode.MethodNotFound, - message: "Method not found" - } - }; - if (relatedTaskId && this._taskMessageQueue) { - this._enqueueTaskMessage(relatedTaskId, { - type: "error", - message: errorResponse, - timestamp: Date.now() - }, capturedTransport?.sessionId).catch((error48) => this._onerror(new Error(`Failed to enqueue error response: ${error48}`))); - } else { - capturedTransport?.send(errorResponse).catch((error48) => this._onerror(new Error(`Failed to send an error response: ${error48}`))); - } - return; - } - const abortController = new AbortController; - this._requestHandlerAbortControllers.set(request.id, abortController); - const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined; - const taskStore = this._taskStore ? this.requestTaskStore(request, capturedTransport?.sessionId) : undefined; - const fullExtra = { - signal: abortController.signal, - sessionId: capturedTransport?.sessionId, - _meta: request.params?._meta, - sendNotification: async (notification) => { - if (abortController.signal.aborted) - return; - const notificationOptions = { relatedRequestId: request.id }; - if (relatedTaskId) { - notificationOptions.relatedTask = { taskId: relatedTaskId }; - } - await this.notification(notification, notificationOptions); - }, - sendRequest: async (r, resultSchema, options) => { - if (abortController.signal.aborted) { - throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled"); - } - const requestOptions = { ...options, relatedRequestId: request.id }; - if (relatedTaskId && !requestOptions.relatedTask) { - requestOptions.relatedTask = { taskId: relatedTaskId }; - } - const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; - if (effectiveTaskId && taskStore) { - await taskStore.updateTaskStatus(effectiveTaskId, "input_required"); - } - return await this.request(r, resultSchema, requestOptions); - }, - authInfo: extra?.authInfo, - requestId: request.id, - requestInfo: extra?.requestInfo, - taskId: relatedTaskId, - taskStore, - taskRequestedTtl: taskCreationParams?.ttl, - closeSSEStream: extra?.closeSSEStream, - closeStandaloneSSEStream: extra?.closeStandaloneSSEStream - }; - Promise.resolve().then(() => { - if (taskCreationParams) { - this.assertTaskHandlerCapability(request.method); - } - }).then(() => handler(request, fullExtra)).then(async (result) => { - if (abortController.signal.aborted) { - return; - } - const response = { - result, - jsonrpc: "2.0", - id: request.id - }; - if (relatedTaskId && this._taskMessageQueue) { - await this._enqueueTaskMessage(relatedTaskId, { - type: "response", - message: response, - timestamp: Date.now() - }, capturedTransport?.sessionId); - } else { - await capturedTransport?.send(response); - } - }, async (error48) => { - if (abortController.signal.aborted) { - return; - } - const errorResponse = { - jsonrpc: "2.0", - id: request.id, - error: { - code: Number.isSafeInteger(error48["code"]) ? error48["code"] : ErrorCode.InternalError, - message: error48.message ?? "Internal error", - ...error48["data"] !== undefined && { data: error48["data"] } - } - }; - if (relatedTaskId && this._taskMessageQueue) { - await this._enqueueTaskMessage(relatedTaskId, { - type: "error", - message: errorResponse, - timestamp: Date.now() - }, capturedTransport?.sessionId); - } else { - await capturedTransport?.send(errorResponse); - } - }).catch((error48) => this._onerror(new Error(`Failed to send response: ${error48}`))).finally(() => { - this._requestHandlerAbortControllers.delete(request.id); - }); - } - _onprogress(notification) { - const { progressToken, ...params } = notification.params; - const messageId = Number(progressToken); - const handler = this._progressHandlers.get(messageId); - if (!handler) { - this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); - return; - } - const responseHandler = this._responseHandlers.get(messageId); - const timeoutInfo = this._timeoutInfo.get(messageId); - if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) { - try { - this._resetTimeout(messageId); - } catch (error48) { - this._responseHandlers.delete(messageId); - this._progressHandlers.delete(messageId); - this._cleanupTimeout(messageId); - responseHandler(error48); - return; - } - } - handler(params); - } - _onresponse(response) { - const messageId = Number(response.id); - const resolver = this._requestResolvers.get(messageId); - if (resolver) { - this._requestResolvers.delete(messageId); - if (isJSONRPCResultResponse(response)) { - resolver(response); - } else { - const error48 = new McpError(response.error.code, response.error.message, response.error.data); - resolver(error48); - } - return; - } - const handler = this._responseHandlers.get(messageId); - if (handler === undefined) { - this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); - return; - } - this._responseHandlers.delete(messageId); - this._cleanupTimeout(messageId); - let isTaskResponse = false; - if (isJSONRPCResultResponse(response) && response.result && typeof response.result === "object") { - const result = response.result; - if (result.task && typeof result.task === "object") { - const task = result.task; - if (typeof task.taskId === "string") { - isTaskResponse = true; - this._taskProgressTokens.set(task.taskId, messageId); - } - } - } - if (!isTaskResponse) { - this._progressHandlers.delete(messageId); - } - if (isJSONRPCResultResponse(response)) { - handler(response); - } else { - const error48 = McpError.fromError(response.error.code, response.error.message, response.error.data); - handler(error48); - } - } - get transport() { - return this._transport; - } - async close() { - await this._transport?.close(); - } - async* requestStream(request, resultSchema, options) { - const { task } = options ?? {}; - if (!task) { - try { - const result = await this.request(request, resultSchema, options); - yield { type: "result", result }; - } catch (error48) { - yield { - type: "error", - error: error48 instanceof McpError ? error48 : new McpError(ErrorCode.InternalError, String(error48)) - }; - } - return; - } - let taskId; - try { - const createResult = await this.request(request, CreateTaskResultSchema, options); - if (createResult.task) { - taskId = createResult.task.taskId; - yield { type: "taskCreated", task: createResult.task }; - } else { - throw new McpError(ErrorCode.InternalError, "Task creation did not return a task"); - } - while (true) { - const task2 = await this.getTask({ taskId }, options); - yield { type: "taskStatus", task: task2 }; - if (isTerminal(task2.status)) { - if (task2.status === "completed") { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: "result", result }; - } else if (task2.status === "failed") { - yield { - type: "error", - error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`) - }; - } else if (task2.status === "cancelled") { - yield { - type: "error", - error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`) - }; - } - return; - } - if (task2.status === "input_required") { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: "result", result }; - return; - } - const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1000; - await new Promise((resolve2) => setTimeout(resolve2, pollInterval)); - options?.signal?.throwIfAborted(); - } - } catch (error48) { - yield { - type: "error", - error: error48 instanceof McpError ? error48 : new McpError(ErrorCode.InternalError, String(error48)) - }; - } - } - request(request, resultSchema, options) { - const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; - return new Promise((resolve2, reject) => { - const earlyReject = (error48) => { - reject(error48); - }; - if (!this._transport) { - earlyReject(new Error("Not connected")); - return; - } - if (this._options?.enforceStrictCapabilities === true) { - try { - this.assertCapabilityForMethod(request.method); - if (task) { - this.assertTaskCapability(request.method); - } - } catch (e) { - earlyReject(e); - return; - } - } - options?.signal?.throwIfAborted(); - const messageId = this._requestMessageId++; - const jsonrpcRequest = { - ...request, - jsonrpc: "2.0", - id: messageId - }; - if (options?.onprogress) { - this._progressHandlers.set(messageId, options.onprogress); - jsonrpcRequest.params = { - ...request.params, - _meta: { - ...request.params?._meta || {}, - progressToken: messageId - } - }; - } - if (task) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - task - }; - } - if (relatedTask) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - _meta: { - ...jsonrpcRequest.params?._meta || {}, - [RELATED_TASK_META_KEY]: relatedTask - } - }; - } - const cancel = (reason) => { - this._responseHandlers.delete(messageId); - this._progressHandlers.delete(messageId); - this._cleanupTimeout(messageId); - this._transport?.send({ - jsonrpc: "2.0", - method: "notifications/cancelled", - params: { - requestId: messageId, - reason: String(reason) - } - }, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error49) => this._onerror(new Error(`Failed to send cancellation: ${error49}`))); - const error48 = reason instanceof McpError ? reason : new McpError(ErrorCode.RequestTimeout, String(reason)); - reject(error48); - }; - this._responseHandlers.set(messageId, (response) => { - if (options?.signal?.aborted) { - return; - } - if (response instanceof Error) { - return reject(response); - } - try { - const parseResult = safeParse3(resultSchema, response.result); - if (!parseResult.success) { - reject(parseResult.error); - } else { - resolve2(parseResult.data); - } - } catch (error48) { - reject(error48); - } - }); - options?.signal?.addEventListener("abort", () => { - cancel(options?.signal?.reason); - }); - const timeout2 = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, "Request timed out", { timeout: timeout2 })); - this._setupTimeout(messageId, timeout2, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - const relatedTaskId = relatedTask?.taskId; - if (relatedTaskId) { - const responseResolver = (response) => { - const handler = this._responseHandlers.get(messageId); - if (handler) { - handler(response); - } else { - this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); - } - }; - this._requestResolvers.set(messageId, responseResolver); - this._enqueueTaskMessage(relatedTaskId, { - type: "request", - message: jsonrpcRequest, - timestamp: Date.now() - }).catch((error48) => { - this._cleanupTimeout(messageId); - reject(error48); - }); - } else { - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch((error48) => { - this._cleanupTimeout(messageId); - reject(error48); - }); - } - }); - } - async getTask(params, options) { - return this.request({ method: "tasks/get", params }, GetTaskResultSchema, options); - } - async getTaskResult(params, resultSchema, options) { - return this.request({ method: "tasks/result", params }, resultSchema, options); - } - async listTasks(params, options) { - return this.request({ method: "tasks/list", params }, ListTasksResultSchema, options); - } - async cancelTask(params, options) { - return this.request({ method: "tasks/cancel", params }, CancelTaskResultSchema, options); - } - async notification(notification, options) { - if (!this._transport) { - throw new Error("Not connected"); - } - this.assertNotificationCapability(notification.method); - const relatedTaskId = options?.relatedTask?.taskId; - if (relatedTaskId) { - const jsonrpcNotification2 = { - ...notification, - jsonrpc: "2.0", - params: { - ...notification.params, - _meta: { - ...notification.params?._meta || {}, - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - await this._enqueueTaskMessage(relatedTaskId, { - type: "notification", - message: jsonrpcNotification2, - timestamp: Date.now() - }); - return; - } - const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; - const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId && !options?.relatedTask; - if (canDebounce) { - if (this._pendingDebouncedNotifications.has(notification.method)) { - return; - } - this._pendingDebouncedNotifications.add(notification.method); - Promise.resolve().then(() => { - this._pendingDebouncedNotifications.delete(notification.method); - if (!this._transport) { - return; - } - let jsonrpcNotification2 = { - ...notification, - jsonrpc: "2.0" - }; - if (options?.relatedTask) { - jsonrpcNotification2 = { - ...jsonrpcNotification2, - params: { - ...jsonrpcNotification2.params, - _meta: { - ...jsonrpcNotification2.params?._meta || {}, - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - } - this._transport?.send(jsonrpcNotification2, options).catch((error48) => this._onerror(error48)); - }); - return; - } - let jsonrpcNotification = { - ...notification, - jsonrpc: "2.0" - }; - if (options?.relatedTask) { - jsonrpcNotification = { - ...jsonrpcNotification, - params: { - ...jsonrpcNotification.params, - _meta: { - ...jsonrpcNotification.params?._meta || {}, - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - } - await this._transport.send(jsonrpcNotification, options); - } - setRequestHandler(requestSchema2, handler) { - const method = getMethodLiteral(requestSchema2); - this.assertRequestHandlerCapability(method); - this._requestHandlers.set(method, (request, extra) => { - const parsed = parseWithCompat(requestSchema2, request); - return Promise.resolve(handler(parsed, extra)); - }); - } - removeRequestHandler(method) { - this._requestHandlers.delete(method); - } - assertCanSetRequestHandler(method) { - if (this._requestHandlers.has(method)) { - throw new Error(`A request handler for ${method} already exists, which would be overridden`); - } - } - setNotificationHandler(notificationSchema, handler) { - const method = getMethodLiteral(notificationSchema); - this._notificationHandlers.set(method, (notification) => { - const parsed = parseWithCompat(notificationSchema, notification); - return Promise.resolve(handler(parsed)); - }); - } - removeNotificationHandler(method) { - this._notificationHandlers.delete(method); - } - _cleanupTaskProgressHandler(taskId) { - const progressToken = this._taskProgressTokens.get(taskId); - if (progressToken !== undefined) { - this._progressHandlers.delete(progressToken); - this._taskProgressTokens.delete(taskId); - } - } - async _enqueueTaskMessage(taskId, message, sessionId) { - if (!this._taskStore || !this._taskMessageQueue) { - throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured"); - } - const maxQueueSize = this._options?.maxTaskQueueSize; - await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize); - } - async _clearTaskQueue(taskId, sessionId) { - if (this._taskMessageQueue) { - const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); - for (const message of messages) { - if (message.type === "request" && isJSONRPCRequest(message.message)) { - const requestId = message.message.id; - const resolver = this._requestResolvers.get(requestId); - if (resolver) { - resolver(new McpError(ErrorCode.InternalError, "Task cancelled or completed")); - this._requestResolvers.delete(requestId); - } else { - this._onerror(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); - } - } - } - } - } - async _waitForTaskUpdate(taskId, signal) { - let interval = this._options?.defaultTaskPollInterval ?? 1000; - try { - const task = await this._taskStore?.getTask(taskId); - if (task?.pollInterval) { - interval = task.pollInterval; - } - } catch {} - return new Promise((resolve2, reject) => { - if (signal.aborted) { - reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); - return; - } - const timeoutId = setTimeout(resolve2, interval); - signal.addEventListener("abort", () => { - clearTimeout(timeoutId); - reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); - }, { once: true }); - }); - } - requestTaskStore(request, sessionId) { - const taskStore = this._taskStore; - if (!taskStore) { - throw new Error("No task store configured"); - } - return { - createTask: async (taskParams) => { - if (!request) { - throw new Error("No request provided"); - } - return await taskStore.createTask(taskParams, request.id, { - method: request.method, - params: request.params - }, sessionId); - }, - getTask: async (taskId) => { - const task = await taskStore.getTask(taskId, sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, "Failed to retrieve task: Task not found"); - } - return task; - }, - storeTaskResult: async (taskId, status, result) => { - await taskStore.storeTaskResult(taskId, status, result, sessionId); - const task = await taskStore.getTask(taskId, sessionId); - if (task) { - const notification = TaskStatusNotificationSchema.parse({ - method: "notifications/tasks/status", - params: task - }); - await this.notification(notification); - if (isTerminal(task.status)) { - this._cleanupTaskProgressHandler(taskId); - } - } - }, - getTaskResult: (taskId) => { - return taskStore.getTaskResult(taskId, sessionId); - }, - updateTaskStatus: async (taskId, status, statusMessage) => { - const task = await taskStore.getTask(taskId, sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); - } - if (isTerminal(task.status)) { - throw new McpError(ErrorCode.InvalidParams, `Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.`); - } - await taskStore.updateTaskStatus(taskId, status, statusMessage, sessionId); - const updatedTask = await taskStore.getTask(taskId, sessionId); - if (updatedTask) { - const notification = TaskStatusNotificationSchema.parse({ - method: "notifications/tasks/status", - params: updatedTask - }); - await this.notification(notification); - if (isTerminal(updatedTask.status)) { - this._cleanupTaskProgressHandler(taskId); - } - } - }, - listTasks: (cursor) => { - return taskStore.listTasks(cursor, sessionId); - } - }; - } -} -function isPlainObject4(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} -function mergeCapabilities(base, additional) { - const result = { ...base }; - for (const key in additional) { - const k = key; - const addValue = additional[k]; - if (addValue === undefined) - continue; - const baseValue = result[k]; - if (isPlainObject4(baseValue) && isPlainObject4(addValue)) { - result[k] = { ...baseValue, ...addValue }; - } else { - result[k] = addValue; - } - } - return result; -} +### Recognize Media File -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js -var import_ajv = __toESM(require_ajv(), 1); -var import_ajv_formats = __toESM(require_dist8(), 1); -function createDefaultAjvInstance() { - const ajv = new import_ajv.default({ - strict: false, - validateFormats: true, - validateSchema: false, - allErrors: true - }); - const addFormats = import_ajv_formats.default; - addFormats(ajv); - return ajv; -} +When user asks to recognize media file, match media file, or find the video file for episodes, +user is asking for this use case. -class AjvJsonSchemaValidator { - constructor(ajv) { - this._ajv = ajv ?? createDefaultAjvInstance(); - } - getValidator(schema) { - const ajvValidator = "$id" in schema && typeof schema.$id === "string" ? this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema) : this._ajv.compile(schema); - return (input) => { - const valid = ajvValidator(input); - if (valid) { - return { - valid: true, - data: input, - errorMessage: undefined - }; - } else { - return { - valid: false, - data: undefined, - errorMessage: this._ajv.errorsText(ajvValidator.errors) - }; - } - }; - } -} +SMM maintains a linkage between local video file to season and episode. This task will update the linkages. -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js -class ExperimentalServerTasks { - constructor(_server) { - this._server = _server; - } - requestStream(request, resultSchema, options) { - return this._server.requestStream(request, resultSchema, options); - } - createMessageStream(params, options) { - const clientCapabilities = this._server.getClientCapabilities(); - if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { - throw new Error("Client does not support sampling tools capability."); - } - if (params.messages.length > 0) { - const lastMessage = params.messages[params.messages.length - 1]; - const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; - const hasToolResults = lastContent.some((c) => c.type === "tool_result"); - const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : undefined; - const previousContent = previousMessage ? Array.isArray(previousMessage.content) ? previousMessage.content : [previousMessage.content] : []; - const hasPreviousToolUse = previousContent.some((c) => c.type === "tool_use"); - if (hasToolResults) { - if (lastContent.some((c) => c.type !== "tool_result")) { - throw new Error("The last message must contain only tool_result content if any is present"); - } - if (!hasPreviousToolUse) { - throw new Error("tool_result blocks are not matching any tool_use from the previous message"); - } - } - if (hasPreviousToolUse) { - const toolUseIds = new Set(previousContent.filter((c) => c.type === "tool_use").map((c) => c.id)); - const toolResultIds = new Set(lastContent.filter((c) => c.type === "tool_result").map((c) => c.toolUseId)); - if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every((id) => toolResultIds.has(id))) { - throw new Error("ids of tool_result blocks and tool_use blocks from previous message do not match"); - } - } - } - return this.requestStream({ - method: "sampling/createMessage", - params - }, CreateMessageResultSchema, options); - } - elicitInputStream(params, options) { - const clientCapabilities = this._server.getClientCapabilities(); - const mode = params.mode ?? "form"; - switch (mode) { - case "url": { - if (!clientCapabilities?.elicitation?.url) { - throw new Error("Client does not support url elicitation."); - } - break; - } - case "form": { - if (!clientCapabilities?.elicitation?.form) { - throw new Error("Client does not support form elicitation."); - } - break; - } - } - const normalizedParams = mode === "form" && params.mode === undefined ? { ...params, mode: "form" } : params; - return this.requestStream({ - method: "elicitation/create", - params: normalizedParams - }, ElicitResultSchema, options); - } - async getTask(taskId, options) { - return this._server.getTask({ taskId }, options); - } - async getTaskResult(taskId, resultSchema, options) { - return this._server.getTaskResult({ taskId }, resultSchema, options); - } - async listTasks(cursor, options) { - return this._server.listTasks(cursor ? { cursor } : undefined, options); - } - async cancelTask(taskId, options) { - return this._server.cancelTask({ taskId }, options); - } -} +Below is the steps to recognize media file: -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/helpers.js -function assertToolsCallTaskCapability(requests, method, entityName) { - if (!requests) { - throw new Error(`${entityName} does not support task creation (required for ${method})`); - } - switch (method) { - case "tools/call": - if (!requests.tools?.call) { - throw new Error(`${entityName} does not support task creation for tools/call (required for ${method})`); - } - break; - default: - break; - } -} -function assertClientRequestTaskCapability(requests, method, entityName) { - if (!requests) { - throw new Error(`${entityName} does not support task creation (required for ${method})`); - } - switch (method) { - case "sampling/createMessage": - if (!requests.sampling?.createMessage) { - throw new Error(`${entityName} does not support task creation for sampling/createMessage (required for ${method})`); - } - break; - case "elicitation/create": - if (!requests.elicitation?.create) { - throw new Error(`${entityName} does not support task creation for elicitation/create (required for ${method})`); - } - break; - default: - break; - } -} +1. The media folder user is asking for. + If user don't tell which folder he is asking for, you should call "${$1}" to get the selected media folder in UI. +2. Get episodes using "${a1}" tool +3. Get local files using "${o8}" tool +4. Call "${E4}" tool to notify AI Agent to start a recognize task +5. iterate each episodes, find the local video file for the episode, and call "${A6}" tool to add the recognized media file to the task + IMPORTANT: It's OK to skip the episode if the local video file is not found. +6. Call "${o4}" tool to notify AI Agent to end the recognize task -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.js -class Server2 extends Protocol { - constructor(_serverInfo, options) { - super(options); - this._serverInfo = _serverInfo; - this._loggingLevels = new Map; - this.LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); - this.isMessageIgnored = (level, sessionId) => { - const currentLevel = this._loggingLevels.get(sessionId); - return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level) < this.LOG_LEVEL_SEVERITY.get(currentLevel) : false; - }; - this._capabilities = options?.capabilities ?? {}; - this._instructions = options?.instructions; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator; - this.setRequestHandler(InitializeRequestSchema, (request) => this._oninitialize(request)); - this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.()); - if (this._capabilities.logging) { - this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { - const transportSessionId = extra.sessionId || extra.requestInfo?.headers["mcp-session-id"] || undefined; - const { level } = request.params; - const parseResult = LoggingLevelSchema.safeParse(level); - if (parseResult.success) { - this._loggingLevels.set(transportSessionId, parseResult.data); - } - return {}; - }); - } - } - get experimental() { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalServerTasks(this) - }; - } - return this._experimental; - } - registerCapabilities(capabilities) { - if (this.transport) { - throw new Error("Cannot register capabilities after connecting to transport"); - } - this._capabilities = mergeCapabilities(this._capabilities, capabilities); - } - setRequestHandler(requestSchema2, handler) { - const shape = getObjectShape(requestSchema2); - const methodSchema = shape?.method; - if (!methodSchema) { - throw new Error("Schema is missing a method literal"); - } - let methodValue; - if (isZ4Schema(methodSchema)) { - const v4Schema = methodSchema; - const v4Def = v4Schema._zod?.def; - methodValue = v4Def?.value ?? v4Schema.value; - } else { - const v3Schema = methodSchema; - const legacyDef = v3Schema._def; - methodValue = legacyDef?.value ?? v3Schema.value; - } - if (typeof methodValue !== "string") { - throw new Error("Schema method literal must be a string"); - } - const method = methodValue; - if (method === "tools/call") { - const wrappedHandler = async (request, extra) => { - const validatedRequest = safeParse3(CallToolRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); - } - const { params } = validatedRequest.data; - const result = await Promise.resolve(handler(request, extra)); - if (params.task) { - const taskValidationResult = safeParse3(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = taskValidationResult.error instanceof Error ? taskValidationResult.error.message : String(taskValidationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - const validationResult = safeParse3(CallToolResultSchema, result); - if (!validationResult.success) { - const errorMessage = validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); - } - return validationResult.data; - }; - return super.setRequestHandler(requestSchema2, wrappedHandler); - } - return super.setRequestHandler(requestSchema2, handler); - } - assertCapabilityForMethod(method) { - switch (method) { - case "sampling/createMessage": - if (!this._clientCapabilities?.sampling) { - throw new Error(`Client does not support sampling (required for ${method})`); - } - break; - case "elicitation/create": - if (!this._clientCapabilities?.elicitation) { - throw new Error(`Client does not support elicitation (required for ${method})`); - } - break; - case "roots/list": - if (!this._clientCapabilities?.roots) { - throw new Error(`Client does not support listing roots (required for ${method})`); - } - break; - case "ping": - break; - } - } - assertNotificationCapability(method) { - switch (method) { - case "notifications/message": - if (!this._capabilities.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - case "notifications/resources/updated": - case "notifications/resources/list_changed": - if (!this._capabilities.resources) { - throw new Error(`Server does not support notifying about resources (required for ${method})`); - } - break; - case "notifications/tools/list_changed": - if (!this._capabilities.tools) { - throw new Error(`Server does not support notifying of tool list changes (required for ${method})`); - } - break; - case "notifications/prompts/list_changed": - if (!this._capabilities.prompts) { - throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`); - } - break; - case "notifications/elicitation/complete": - if (!this._clientCapabilities?.elicitation?.url) { - throw new Error(`Client does not support URL elicitation (required for ${method})`); - } - break; - case "notifications/cancelled": - break; - case "notifications/progress": - break; - } - } - assertRequestHandlerCapability(method) { - if (!this._capabilities) { - return; - } - switch (method) { - case "completion/complete": - if (!this._capabilities.completions) { - throw new Error(`Server does not support completions (required for ${method})`); - } - break; - case "logging/setLevel": - if (!this._capabilities.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - case "prompts/get": - case "prompts/list": - if (!this._capabilities.prompts) { - throw new Error(`Server does not support prompts (required for ${method})`); - } - break; - case "resources/list": - case "resources/templates/list": - case "resources/read": - if (!this._capabilities.resources) { - throw new Error(`Server does not support resources (required for ${method})`); - } - break; - case "tools/call": - case "tools/list": - if (!this._capabilities.tools) { - throw new Error(`Server does not support tools (required for ${method})`); - } - break; - case "tasks/get": - case "tasks/list": - case "tasks/result": - case "tasks/cancel": - if (!this._capabilities.tasks) { - throw new Error(`Server does not support tasks capability (required for ${method})`); - } - break; - case "ping": - case "initialize": - break; - } - } - assertTaskCapability(method) { - assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, "Client"); - } - assertTaskHandlerCapability(method) { - if (!this._capabilities) { - return; - } - assertToolsCallTaskCapability(this._capabilities.tasks?.requests, method, "Server"); - } - async _oninitialize(request) { - const requestedVersion = request.params.protocolVersion; - this._clientCapabilities = request.params.capabilities; - this._clientVersion = request.params.clientInfo; - const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; - return { - protocolVersion, - capabilities: this.getCapabilities(), - serverInfo: this._serverInfo, - ...this._instructions && { instructions: this._instructions } - }; - } - getClientCapabilities() { - return this._clientCapabilities; - } - getClientVersion() { - return this._clientVersion; - } - getCapabilities() { - return this._capabilities; - } - async ping() { - return this.request({ method: "ping" }, EmptyResultSchema); - } - async createMessage(params, options) { - if (params.tools || params.toolChoice) { - if (!this._clientCapabilities?.sampling?.tools) { - throw new Error("Client does not support sampling tools capability."); - } - } - if (params.messages.length > 0) { - const lastMessage = params.messages[params.messages.length - 1]; - const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; - const hasToolResults = lastContent.some((c) => c.type === "tool_result"); - const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : undefined; - const previousContent = previousMessage ? Array.isArray(previousMessage.content) ? previousMessage.content : [previousMessage.content] : []; - const hasPreviousToolUse = previousContent.some((c) => c.type === "tool_use"); - if (hasToolResults) { - if (lastContent.some((c) => c.type !== "tool_result")) { - throw new Error("The last message must contain only tool_result content if any is present"); - } - if (!hasPreviousToolUse) { - throw new Error("tool_result blocks are not matching any tool_use from the previous message"); - } - } - if (hasPreviousToolUse) { - const toolUseIds = new Set(previousContent.filter((c) => c.type === "tool_use").map((c) => c.id)); - const toolResultIds = new Set(lastContent.filter((c) => c.type === "tool_result").map((c) => c.toolUseId)); - if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every((id) => toolResultIds.has(id))) { - throw new Error("ids of tool_result blocks and tool_use blocks from previous message do not match"); - } - } - } - if (params.tools) { - return this.request({ method: "sampling/createMessage", params }, CreateMessageResultWithToolsSchema, options); - } - return this.request({ method: "sampling/createMessage", params }, CreateMessageResultSchema, options); - } - async elicitInput(params, options) { - const mode = params.mode ?? "form"; - switch (mode) { - case "url": { - if (!this._clientCapabilities?.elicitation?.url) { - throw new Error("Client does not support url elicitation."); - } - const urlParams = params; - return this.request({ method: "elicitation/create", params: urlParams }, ElicitResultSchema, options); - } - case "form": { - if (!this._clientCapabilities?.elicitation?.form) { - throw new Error("Client does not support form elicitation."); - } - const formParams = params.mode === "form" ? params : { ...params, mode: "form" }; - const result = await this.request({ method: "elicitation/create", params: formParams }, ElicitResultSchema, options); - if (result.action === "accept" && result.content && formParams.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema); - const validationResult = validator(result.content); - if (!validationResult.valid) { - throw new McpError(ErrorCode.InvalidParams, `Elicitation response content does not match requested schema: ${validationResult.errorMessage}`); - } - } catch (error48) { - if (error48 instanceof McpError) { - throw error48; - } - throw new McpError(ErrorCode.InternalError, `Error validating elicitation response: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - } - return result; - } - } - } - createElicitationCompletionNotifier(elicitationId, options) { - if (!this._clientCapabilities?.elicitation?.url) { - throw new Error("Client does not support URL elicitation (required for notifications/elicitation/complete)"); - } - return () => this.notification({ - method: "notifications/elicitation/complete", - params: { - elicitationId - } - }, options); - } - async listRoots(params, options) { - return this.request({ method: "roots/list", params }, ListRootsResultSchema, options); - } - async sendLoggingMessage(params, sessionId) { - if (this._capabilities.logging) { - if (!this.isMessageIgnored(params.level, sessionId)) { - return this.notification({ method: "notifications/message", params }); - } - } - } - async sendResourceUpdated(params) { - return this.notification({ - method: "notifications/resources/updated", - params - }); - } - async sendResourceListChanged() { - return this.notification({ - method: "notifications/resources/list_changed" - }); - } - async sendToolListChanged() { - return this.notification({ method: "notifications/tools/list_changed" }); - } - async sendPromptListChanged() { - return this.notification({ method: "notifications/prompts/list_changed" }); - } -} +### Rename Files -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js -var COMPLETABLE_SYMBOL = Symbol.for("mcp.completable"); -function isCompletable(schema) { - return !!schema && typeof schema === "object" && COMPLETABLE_SYMBOL in schema; -} -function getCompleter(schema) { - const meta3 = schema[COMPLETABLE_SYMBOL]; - return meta3?.complete; -} -var McpZodTypeKind; -(function(McpZodTypeKind2) { - McpZodTypeKind2["Completable"] = "McpCompletable"; -})(McpZodTypeKind || (McpZodTypeKind = {})); +When user ask to rename files, you need to understand below information: +1. Which media folder user is asking for. If user don't tell the media folder, you should call "${$1}" to get the selected media folder in UI. +2. The naming rules. If user don't tell the naming rules, you should ask user if he wants follow the Plex naming rules. -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/shared/toolNameValidation.js -var TOOL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; -function validateToolName(name21) { - const warnings = []; - if (name21.length === 0) { - return { - isValid: false, - warnings: ["Tool name cannot be empty"] - }; - } - if (name21.length > 128) { - return { - isValid: false, - warnings: [`Tool name exceeds maximum length of 128 characters (current: ${name21.length})`] - }; - } - if (name21.includes(" ")) { - warnings.push("Tool name contains spaces, which may cause parsing issues"); - } - if (name21.includes(",")) { - warnings.push("Tool name contains commas, which may cause parsing issues"); - } - if (name21.startsWith("-") || name21.endsWith("-")) { - warnings.push("Tool name starts or ends with a dash, which may cause parsing issues in some contexts"); - } - if (name21.startsWith(".") || name21.endsWith(".")) { - warnings.push("Tool name starts or ends with a dot, which may cause parsing issues in some contexts"); - } - if (!TOOL_NAME_REGEX.test(name21)) { - const invalidChars = name21.split("").filter((char) => !/[A-Za-z0-9._-]/.test(char)).filter((char, index, arr) => arr.indexOf(char) === index); - warnings.push(`Tool name contains invalid characters: ${invalidChars.map((c) => `"${c}"`).join(", ")}`, "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)"); - return { - isValid: false, - warnings - }; - } - return { - isValid: true, - warnings - }; -} -function issueToolNameWarning(name21, warnings) { - if (warnings.length > 0) { - console.warn(`Tool name validation warning for "${name21}":`); - for (const warning of warnings) { - console.warn(` - ${warning}`); - } - console.warn("Tool registration will proceed, but this may cause compatibility issues."); - console.warn("Consider updating the tool name to conform to the MCP tool naming standard."); - console.warn("See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details."); - } -} -function validateAndWarnToolName(name21) { - const result = validateToolName(name21); - issueToolNameWarning(name21, result.warnings); - return result.isValid; -} +You ONLY need to rename the video file. For image files, subtitle files, nfo files that link to video file, those files will be renamed implicitly when the video name got changed. -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/mcp-server.js -class ExperimentalMcpServerTasks { - constructor(_mcpServer) { - this._mcpServer = _mcpServer; - } - registerToolTask(name21, config2, handler) { - const execution = { taskSupport: "required", ...config2.execution }; - if (execution.taskSupport === "forbidden") { - throw new Error(`Cannot register task-based tool '${name21}' with taskSupport 'forbidden'. Use registerTool() instead.`); - } - const mcpServerInternal = this._mcpServer; - return mcpServerInternal._createRegisteredTool(name21, config2.title, config2.description, config2.inputSchema, config2.outputSchema, config2.annotations, execution, config2._meta, handler); - } -} +Steps +[ ] Call "${o1}" to get the video files needs to rename +[ ] Call "${j4}" to notify AI Agent to start a rename files task +[ ] Call "${L6}" to add a file to rename task, call multiple times to add multiple files +[ ] Call "${R4}" to notify AI Agent to end the rename files task -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js -class McpServer { - constructor(serverInfo, options) { - this._registeredResources = {}; - this._registeredResourceTemplates = {}; - this._registeredTools = {}; - this._registeredPrompts = {}; - this._toolHandlersInitialized = false; - this._completionHandlerInitialized = false; - this._resourceHandlersInitialized = false; - this._promptHandlersInitialized = false; - this.server = new Server2(serverInfo, options); - } - get experimental() { - if (!this._experimental) { - this._experimental = { - tasks: new ExperimentalMcpServerTasks(this) - }; - } - return this._experimental; - } - async connect(transport) { - return await this.server.connect(transport); - } - async close() { - await this.server.close(); - } - setToolRequestHandlers() { - if (this._toolHandlersInitialized) { - return; - } - this.server.assertCanSetRequestHandler(getMethodValue(ListToolsRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(CallToolRequestSchema)); - this.server.registerCapabilities({ - tools: { - listChanged: true - } - }); - this.server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: Object.entries(this._registeredTools).filter(([, tool2]) => tool2.enabled).map(([name21, tool2]) => { - const toolDefinition = { - name: name21, - title: tool2.title, - description: tool2.description, - inputSchema: (() => { - const obj = normalizeObjectSchema(tool2.inputSchema); - return obj ? toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: "input" - }) : EMPTY_OBJECT_JSON_SCHEMA; - })(), - annotations: tool2.annotations, - execution: tool2.execution, - _meta: tool2._meta - }; - if (tool2.outputSchema) { - const obj = normalizeObjectSchema(tool2.outputSchema); - if (obj) { - toolDefinition.outputSchema = toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: "output" - }); - } - } - return toolDefinition; - }) - })); - this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - try { - const tool2 = this._registeredTools[request.params.name]; - if (!tool2) { - throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`); - } - if (!tool2.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); - } - const isTaskRequest = !!request.params.task; - const taskSupport = tool2.execution?.taskSupport; - const isTaskHandler = "createTask" in tool2.handler; - if ((taskSupport === "required" || taskSupport === "optional") && !isTaskHandler) { - throw new McpError(ErrorCode.InternalError, `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask`); - } - if (taskSupport === "required" && !isTaskRequest) { - throw new McpError(ErrorCode.MethodNotFound, `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')`); - } - if (taskSupport === "optional" && !isTaskRequest && isTaskHandler) { - return await this.handleAutomaticTaskPolling(tool2, request, extra); - } - const args = await this.validateToolInput(tool2, request.params.arguments, request.params.name); - const result = await this.executeToolHandler(tool2, args, extra); - if (isTaskRequest) { - return result; - } - await this.validateToolOutput(tool2, result, request.params.name); - return result; - } catch (error48) { - if (error48 instanceof McpError) { - if (error48.code === ErrorCode.UrlElicitationRequired) { - throw error48; - } - } - return this.createToolError(error48 instanceof Error ? error48.message : String(error48)); - } - }); - this._toolHandlersInitialized = true; - } - createToolError(errorMessage) { - return { - content: [ - { - type: "text", - text: errorMessage - } - ], - isError: true - }; - } - async validateToolInput(tool2, args, toolName) { - if (!tool2.inputSchema) { - return; - } - const inputObj = normalizeObjectSchema(tool2.inputSchema); - const schemaToParse = inputObj ?? tool2.inputSchema; - const parseResult = await safeParseAsync3(schemaToParse, args); - if (!parseResult.success) { - const error48 = "error" in parseResult ? parseResult.error : "Unknown error"; - const errorMessage = getParseErrorMessage(error48); - throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`); - } - return parseResult.data; - } - async validateToolOutput(tool2, result, toolName) { - if (!tool2.outputSchema) { - return; - } - if (!("content" in result)) { - return; - } - if (result.isError) { - return; - } - if (!result.structuredContent) { - throw new McpError(ErrorCode.InvalidParams, `Output validation error: Tool ${toolName} has an output schema but no structured content was provided`); - } - const outputObj = normalizeObjectSchema(tool2.outputSchema); - const parseResult = await safeParseAsync3(outputObj, result.structuredContent); - if (!parseResult.success) { - const error48 = "error" in parseResult ? parseResult.error : "Unknown error"; - const errorMessage = getParseErrorMessage(error48); - throw new McpError(ErrorCode.InvalidParams, `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}`); - } - } - async executeToolHandler(tool2, args, extra) { - const handler = tool2.handler; - const isTaskHandler = "createTask" in handler; - if (isTaskHandler) { - if (!extra.taskStore) { - throw new Error("No task store provided."); - } - const taskExtra = { ...extra, taskStore: extra.taskStore }; - if (tool2.inputSchema) { - const typedHandler = handler; - return await Promise.resolve(typedHandler.createTask(args, taskExtra)); - } else { - const typedHandler = handler; - return await Promise.resolve(typedHandler.createTask(taskExtra)); - } - } - if (tool2.inputSchema) { - const typedHandler = handler; - return await Promise.resolve(typedHandler(args, extra)); - } else { - const typedHandler = handler; - return await Promise.resolve(typedHandler(extra)); - } - } - async handleAutomaticTaskPolling(tool2, request, extra) { - if (!extra.taskStore) { - throw new Error("No task store provided for task-capable tool."); - } - const args = await this.validateToolInput(tool2, request.params.arguments, request.params.name); - const handler = tool2.handler; - const taskExtra = { ...extra, taskStore: extra.taskStore }; - const createTaskResult = args ? await Promise.resolve(handler.createTask(args, taskExtra)) : await Promise.resolve(handler.createTask(taskExtra)); - const taskId = createTaskResult.task.taskId; - let task = createTaskResult.task; - const pollInterval = task.pollInterval ?? 5000; - while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") { - await new Promise((resolve2) => setTimeout(resolve2, pollInterval)); - const updatedTask = await extra.taskStore.getTask(taskId); - if (!updatedTask) { - throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`); - } - task = updatedTask; - } - return await extra.taskStore.getTaskResult(taskId); - } - setCompletionRequestHandler() { - if (this._completionHandlerInitialized) { - return; - } - this.server.assertCanSetRequestHandler(getMethodValue(CompleteRequestSchema)); - this.server.registerCapabilities({ - completions: {} - }); - this.server.setRequestHandler(CompleteRequestSchema, async (request) => { - switch (request.params.ref.type) { - case "ref/prompt": - assertCompleteRequestPrompt(request); - return this.handlePromptCompletion(request, request.params.ref); - case "ref/resource": - assertCompleteRequestResourceTemplate(request); - return this.handleResourceCompletion(request, request.params.ref); - default: - throw new McpError(ErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); - } - }); - this._completionHandlerInitialized = true; - } - async handlePromptCompletion(request, ref) { - const prompt = this._registeredPrompts[ref.name]; - if (!prompt) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} not found`); - } - if (!prompt.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); - } - if (!prompt.argsSchema) { - return EMPTY_COMPLETION_RESULT; - } - const promptShape = getObjectShape(prompt.argsSchema); - const field = promptShape?.[request.params.argument.name]; - if (!isCompletable(field)) { - return EMPTY_COMPLETION_RESULT; - } - const completer = getCompleter(field); - if (!completer) { - return EMPTY_COMPLETION_RESULT; - } - const suggestions = await completer(request.params.argument.value, request.params.context); - return createCompletionResult(suggestions); - } - async handleResourceCompletion(request, ref) { - const template = Object.values(this._registeredResourceTemplates).find((t) => t.resourceTemplate.uriTemplate.toString() === ref.uri); - if (!template) { - if (this._registeredResources[ref.uri]) { - return EMPTY_COMPLETION_RESULT; - } - throw new McpError(ErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); - } - const completer = template.resourceTemplate.completeCallback(request.params.argument.name); - if (!completer) { - return EMPTY_COMPLETION_RESULT; - } - const suggestions = await completer(request.params.argument.value, request.params.context); - return createCompletionResult(suggestions); - } - setResourceRequestHandlers() { - if (this._resourceHandlersInitialized) { - return; - } - this.server.assertCanSetRequestHandler(getMethodValue(ListResourcesRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(ListResourceTemplatesRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(ReadResourceRequestSchema)); - this.server.registerCapabilities({ - resources: { - listChanged: true - } - }); - this.server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { - const resources = Object.entries(this._registeredResources).filter(([_, resource]) => resource.enabled).map(([uri, resource]) => ({ - uri, - name: resource.name, - ...resource.metadata - })); - const templateResources = []; - for (const template of Object.values(this._registeredResourceTemplates)) { - if (!template.resourceTemplate.listCallback) { - continue; - } - const result = await template.resourceTemplate.listCallback(extra); - for (const resource of result.resources) { - templateResources.push({ - ...template.metadata, - ...resource - }); - } - } - return { resources: [...resources, ...templateResources] }; - }); - this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name21, template]) => ({ - name: name21, - uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata - })); - return { resourceTemplates }; - }); - this.server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => { - const uri = new URL(request.params.uri); - const resource = this._registeredResources[uri.toString()]; - if (resource) { - if (!resource.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} disabled`); - } - return resource.readCallback(uri, extra); - } - for (const template of Object.values(this._registeredResourceTemplates)) { - const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); - if (variables) { - return template.readCallback(uri, variables, extra); - } - } - throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`); - }); - this._resourceHandlersInitialized = true; - } - setPromptRequestHandlers() { - if (this._promptHandlersInitialized) { - return; - } - this.server.assertCanSetRequestHandler(getMethodValue(ListPromptsRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(GetPromptRequestSchema)); - this.server.registerCapabilities({ - prompts: { - listChanged: true - } - }); - this.server.setRequestHandler(ListPromptsRequestSchema, () => ({ - prompts: Object.entries(this._registeredPrompts).filter(([, prompt]) => prompt.enabled).map(([name21, prompt]) => { - return { - name: name21, - title: prompt.title, - description: prompt.description, - arguments: prompt.argsSchema ? promptArgumentsFromSchema(prompt.argsSchema) : undefined - }; - }) - })); - this.server.setRequestHandler(GetPromptRequestSchema, async (request, extra) => { - const prompt = this._registeredPrompts[request.params.name]; - if (!prompt) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); - } - if (!prompt.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); - } - if (prompt.argsSchema) { - const argsObj = normalizeObjectSchema(prompt.argsSchema); - const parseResult = await safeParseAsync3(argsObj, request.params.arguments); - if (!parseResult.success) { - const error48 = "error" in parseResult ? parseResult.error : "Unknown error"; - const errorMessage = getParseErrorMessage(error48); - throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage}`); - } - const args = parseResult.data; - const cb = prompt.callback; - return await Promise.resolve(cb(args, extra)); - } else { - const cb = prompt.callback; - return await Promise.resolve(cb(extra)); - } - }); - this._promptHandlersInitialized = true; - } - resource(name21, uriOrTemplate, ...rest2) { - let metadata; - if (typeof rest2[0] === "object") { - metadata = rest2.shift(); - } - const readCallback = rest2[0]; - if (typeof uriOrTemplate === "string") { - if (this._registeredResources[uriOrTemplate]) { - throw new Error(`Resource ${uriOrTemplate} is already registered`); - } - const registeredResource = this._createRegisteredResource(name21, undefined, uriOrTemplate, metadata, readCallback); - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResource; - } else { - if (this._registeredResourceTemplates[name21]) { - throw new Error(`Resource template ${name21} is already registered`); - } - const registeredResourceTemplate = this._createRegisteredResourceTemplate(name21, undefined, uriOrTemplate, metadata, readCallback); - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResourceTemplate; - } - } - registerResource(name21, uriOrTemplate, config2, readCallback) { - if (typeof uriOrTemplate === "string") { - if (this._registeredResources[uriOrTemplate]) { - throw new Error(`Resource ${uriOrTemplate} is already registered`); - } - const registeredResource = this._createRegisteredResource(name21, config2.title, uriOrTemplate, config2, readCallback); - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResource; - } else { - if (this._registeredResourceTemplates[name21]) { - throw new Error(`Resource template ${name21} is already registered`); - } - const registeredResourceTemplate = this._createRegisteredResourceTemplate(name21, config2.title, uriOrTemplate, config2, readCallback); - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResourceTemplate; - } - } - _createRegisteredResource(name21, title, uri, metadata, readCallback) { - const registeredResource = { - name: name21, - title, - metadata, - readCallback, - enabled: true, - disable: () => registeredResource.update({ enabled: false }), - enable: () => registeredResource.update({ enabled: true }), - remove: () => registeredResource.update({ uri: null }), - update: (updates) => { - if (typeof updates.uri !== "undefined" && updates.uri !== uri) { - delete this._registeredResources[uri]; - if (updates.uri) - this._registeredResources[updates.uri] = registeredResource; - } - if (typeof updates.name !== "undefined") - registeredResource.name = updates.name; - if (typeof updates.title !== "undefined") - registeredResource.title = updates.title; - if (typeof updates.metadata !== "undefined") - registeredResource.metadata = updates.metadata; - if (typeof updates.callback !== "undefined") - registeredResource.readCallback = updates.callback; - if (typeof updates.enabled !== "undefined") - registeredResource.enabled = updates.enabled; - this.sendResourceListChanged(); - } - }; - this._registeredResources[uri] = registeredResource; - return registeredResource; - } - _createRegisteredResourceTemplate(name21, title, template, metadata, readCallback) { - const registeredResourceTemplate = { - resourceTemplate: template, - title, - metadata, - readCallback, - enabled: true, - disable: () => registeredResourceTemplate.update({ enabled: false }), - enable: () => registeredResourceTemplate.update({ enabled: true }), - remove: () => registeredResourceTemplate.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name21) { - delete this._registeredResourceTemplates[name21]; - if (updates.name) - this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; - } - if (typeof updates.title !== "undefined") - registeredResourceTemplate.title = updates.title; - if (typeof updates.template !== "undefined") - registeredResourceTemplate.resourceTemplate = updates.template; - if (typeof updates.metadata !== "undefined") - registeredResourceTemplate.metadata = updates.metadata; - if (typeof updates.callback !== "undefined") - registeredResourceTemplate.readCallback = updates.callback; - if (typeof updates.enabled !== "undefined") - registeredResourceTemplate.enabled = updates.enabled; - this.sendResourceListChanged(); - } - }; - this._registeredResourceTemplates[name21] = registeredResourceTemplate; - const variableNames = template.uriTemplate.variableNames; - const hasCompleter = Array.isArray(variableNames) && variableNames.some((v) => !!template.completeCallback(v)); - if (hasCompleter) { - this.setCompletionRequestHandler(); - } - return registeredResourceTemplate; - } - _createRegisteredPrompt(name21, title, description, argsSchema, callback) { - const registeredPrompt = { - title, - description, - argsSchema: argsSchema === undefined ? undefined : objectFromShape(argsSchema), - callback, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name21) { - delete this._registeredPrompts[name21]; - if (updates.name) - this._registeredPrompts[updates.name] = registeredPrompt; - } - if (typeof updates.title !== "undefined") - registeredPrompt.title = updates.title; - if (typeof updates.description !== "undefined") - registeredPrompt.description = updates.description; - if (typeof updates.argsSchema !== "undefined") - registeredPrompt.argsSchema = objectFromShape(updates.argsSchema); - if (typeof updates.callback !== "undefined") - registeredPrompt.callback = updates.callback; - if (typeof updates.enabled !== "undefined") - registeredPrompt.enabled = updates.enabled; - this.sendPromptListChanged(); - } - }; - this._registeredPrompts[name21] = registeredPrompt; - if (argsSchema) { - const hasCompletable = Object.values(argsSchema).some((field) => { - const inner = field instanceof ZodOptional ? field._def?.innerType : field; - return isCompletable(inner); - }); - if (hasCompletable) { - this.setCompletionRequestHandler(); - } - } - return registeredPrompt; - } - _createRegisteredTool(name21, title, description, inputSchema, outputSchema2, annotations, execution, _meta, handler) { - validateAndWarnToolName(name21); - const registeredTool = { - title, - description, - inputSchema: getZodSchemaObject(inputSchema), - outputSchema: getZodSchemaObject(outputSchema2), - annotations, - execution, - _meta, - handler, - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: (updates) => { - if (typeof updates.name !== "undefined" && updates.name !== name21) { - if (typeof updates.name === "string") { - validateAndWarnToolName(updates.name); - } - delete this._registeredTools[name21]; - if (updates.name) - this._registeredTools[updates.name] = registeredTool; - } - if (typeof updates.title !== "undefined") - registeredTool.title = updates.title; - if (typeof updates.description !== "undefined") - registeredTool.description = updates.description; - if (typeof updates.paramsSchema !== "undefined") - registeredTool.inputSchema = objectFromShape(updates.paramsSchema); - if (typeof updates.outputSchema !== "undefined") - registeredTool.outputSchema = objectFromShape(updates.outputSchema); - if (typeof updates.callback !== "undefined") - registeredTool.handler = updates.callback; - if (typeof updates.annotations !== "undefined") - registeredTool.annotations = updates.annotations; - if (typeof updates._meta !== "undefined") - registeredTool._meta = updates._meta; - if (typeof updates.enabled !== "undefined") - registeredTool.enabled = updates.enabled; - this.sendToolListChanged(); - } - }; - this._registeredTools[name21] = registeredTool; - this.setToolRequestHandlers(); - this.sendToolListChanged(); - return registeredTool; - } - tool(name21, ...rest2) { - if (this._registeredTools[name21]) { - throw new Error(`Tool ${name21} is already registered`); - } - let description; - let inputSchema; - let outputSchema2; - let annotations; - if (typeof rest2[0] === "string") { - description = rest2.shift(); - } - if (rest2.length > 1) { - const firstArg = rest2[0]; - if (isZodRawShapeCompat(firstArg)) { - inputSchema = rest2.shift(); - if (rest2.length > 1 && typeof rest2[0] === "object" && rest2[0] !== null && !isZodRawShapeCompat(rest2[0])) { - annotations = rest2.shift(); - } - } else if (typeof firstArg === "object" && firstArg !== null) { - annotations = rest2.shift(); - } - } - const callback = rest2[0]; - return this._createRegisteredTool(name21, undefined, description, inputSchema, outputSchema2, annotations, { taskSupport: "forbidden" }, undefined, callback); - } - registerTool(name21, config2, cb) { - if (this._registeredTools[name21]) { - throw new Error(`Tool ${name21} is already registered`); - } - const { title, description, inputSchema, outputSchema: outputSchema2, annotations, _meta } = config2; - return this._createRegisteredTool(name21, title, description, inputSchema, outputSchema2, annotations, { taskSupport: "forbidden" }, _meta, cb); - } - prompt(name21, ...rest2) { - if (this._registeredPrompts[name21]) { - throw new Error(`Prompt ${name21} is already registered`); - } - let description; - if (typeof rest2[0] === "string") { - description = rest2.shift(); - } - let argsSchema; - if (rest2.length > 1) { - argsSchema = rest2.shift(); - } - const cb = rest2[0]; - const registeredPrompt = this._createRegisteredPrompt(name21, undefined, description, argsSchema, cb); - this.setPromptRequestHandlers(); - this.sendPromptListChanged(); - return registeredPrompt; - } - registerPrompt(name21, config2, cb) { - if (this._registeredPrompts[name21]) { - throw new Error(`Prompt ${name21} is already registered`); - } - const { title, description, argsSchema } = config2; - const registeredPrompt = this._createRegisteredPrompt(name21, title, description, argsSchema, cb); - this.setPromptRequestHandlers(); - this.sendPromptListChanged(); - return registeredPrompt; - } - isConnected() { - return this.server.transport !== undefined; - } - async sendLoggingMessage(params, sessionId) { - return this.server.sendLoggingMessage(params, sessionId); - } - sendResourceListChanged() { - if (this.isConnected()) { - this.server.sendResourceListChanged(); - } - } - sendToolListChanged() { - if (this.isConnected()) { - this.server.sendToolListChanged(); - } - } - sendPromptListChanged() { - if (this.isConnected()) { - this.server.sendPromptListChanged(); - } - } -} -var EMPTY_OBJECT_JSON_SCHEMA = { - type: "object", - properties: {} -}; -function isZodTypeLike(value) { - return value !== null && typeof value === "object" && "parse" in value && typeof value.parse === "function" && "safeParse" in value && typeof value.safeParse === "function"; -} -function isZodSchemaInstance(obj) { - return "_def" in obj || "_zod" in obj || isZodTypeLike(obj); -} -function isZodRawShapeCompat(obj) { - if (typeof obj !== "object" || obj === null) { - return false; - } - if (isZodSchemaInstance(obj)) { - return false; - } - if (Object.keys(obj).length === 0) { - return true; - } - return Object.values(obj).some(isZodTypeLike); -} -function getZodSchemaObject(schema) { - if (!schema) { - return; - } - if (isZodRawShapeCompat(schema)) { - return objectFromShape(schema); - } - return schema; -} -function promptArgumentsFromSchema(schema) { - const shape = getObjectShape(schema); - if (!shape) - return []; - return Object.entries(shape).map(([name21, field]) => { - const description = getSchemaDescription(field); - const isOptional = isSchemaOptional(field); - return { - name: name21, - description, - required: !isOptional - }; - }); -} -function getMethodValue(schema) { - const shape = getObjectShape(schema); - const methodSchema = shape?.method; - if (!methodSchema) { - throw new Error("Schema is missing a method literal"); - } - const value = getLiteralValue(methodSchema); - if (typeof value === "string") { - return value; - } - throw new Error("Schema method literal must be a string"); -} -function createCompletionResult(suggestions) { - return { - completion: { - values: suggestions.slice(0, 100), - total: suggestions.length, - hasMore: suggestions.length > 100 - } - }; -} -var EMPTY_COMPLETION_RESULT = { - completion: { - values: [], - hasMore: false - } -}; +### Scrape Media Artwork and NFO -// ../../node_modules/.pnpm/@modelcontextprotocol+sdk@1.27.0_zod@4.3.6/node_modules/@modelcontextprotocol/sdk/dist/esm/server/webStandardStreamableHttp.js -class WebStandardStreamableHTTPServerTransport { - constructor(options = {}) { - this._started = false; - this._hasHandledRequest = false; - this._streamMapping = new Map; - this._requestToStreamMapping = new Map; - this._requestResponseMap = new Map; - this._initialized = false; - this._enableJsonResponse = false; - this._standaloneSseStreamId = "_GET_stream"; - this.sessionIdGenerator = options.sessionIdGenerator; - this._enableJsonResponse = options.enableJsonResponse ?? false; - this._eventStore = options.eventStore; - this._onsessioninitialized = options.onsessioninitialized; - this._onsessionclosed = options.onsessionclosed; - this._allowedHosts = options.allowedHosts; - this._allowedOrigins = options.allowedOrigins; - this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; - this._retryInterval = options.retryInterval; - } - async start() { - if (this._started) { - throw new Error("Transport already started"); - } - this._started = true; - } - createJsonErrorResponse(status, code, message, options) { - const error48 = { code, message }; - if (options?.data !== undefined) { - error48.data = options.data; - } - return new Response(JSON.stringify({ - jsonrpc: "2.0", - error: error48, - id: null - }), { - status, - headers: { - "Content-Type": "application/json", - ...options?.headers - } - }); - } - validateRequestHeaders(req) { - if (!this._enableDnsRebindingProtection) { - return; - } - if (this._allowedHosts && this._allowedHosts.length > 0) { - const hostHeader = req.headers.get("host"); - if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { - const error48 = `Invalid Host header: ${hostHeader}`; - this.onerror?.(new Error(error48)); - return this.createJsonErrorResponse(403, -32000, error48); - } - } - if (this._allowedOrigins && this._allowedOrigins.length > 0) { - const originHeader = req.headers.get("origin"); - if (originHeader && !this._allowedOrigins.includes(originHeader)) { - const error48 = `Invalid Origin header: ${originHeader}`; - this.onerror?.(new Error(error48)); - return this.createJsonErrorResponse(403, -32000, error48); - } - } - return; - } - async handleRequest(req, options) { - if (!this.sessionIdGenerator && this._hasHandledRequest) { - throw new Error("Stateless transport cannot be reused across requests. Create a new transport per request."); - } - this._hasHandledRequest = true; - const validationError2 = this.validateRequestHeaders(req); - if (validationError2) { - return validationError2; - } - switch (req.method) { - case "POST": - return this.handlePostRequest(req, options); - case "GET": - return this.handleGetRequest(req); - case "DELETE": - return this.handleDeleteRequest(req); - default: - return this.handleUnsupportedRequest(); - } - } - async writePrimingEvent(controller, encoder, streamId, protocolVersion) { - if (!this._eventStore) { - return; - } - if (protocolVersion < "2025-11-25") { - return; - } - const primingEventId = await this._eventStore.storeEvent(streamId, {}); - let primingEvent = `id: ${primingEventId} -data: +When user asks to scrape, download poster/fanart/thumbnails, or write NFO files for a media folder, +use the scrape job tools: -`; - if (this._retryInterval !== undefined) { - primingEvent = `id: ${primingEventId} -retry: ${this._retryInterval} -data: +1. Resolve which media folder (ask user or call "${$1}"). +2. Call "${Z6}" with the folder path (optional language). It returns a job id immediately. +3. Call "${q6}" with that id to check progress. Poll until status is succeeded, failed, or aborted. +4. Report per-task results (poster, fanart, thumbnails, nfo) from the scrape job. -`; - } - controller.enqueue(encoder.encode(primingEvent)); - } - async handleGetRequest(req) { - const acceptHeader = req.headers.get("accept"); - if (!acceptHeader?.includes("text/event-stream")) { - return this.createJsonErrorResponse(406, -32000, "Not Acceptable: Client must accept text/event-stream"); - } - const sessionError = this.validateSession(req); - if (sessionError) { - return sessionError; - } - const protocolError = this.validateProtocolVersion(req); - if (protocolError) { - return protocolError; - } - if (this._eventStore) { - const lastEventId = req.headers.get("last-event-id"); - if (lastEventId) { - return this.replayEvents(lastEventId); - } - } - if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { - return this.createJsonErrorResponse(409, -32000, "Conflict: Only one SSE stream is allowed per session"); - } - const encoder = new TextEncoder; - let streamController; - const readable = new ReadableStream({ - start: (controller) => { - streamController = controller; - }, - cancel: () => { - this._streamMapping.delete(this._standaloneSseStreamId); - } - }); - const headers = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive" - }; - if (this.sessionId !== undefined) { - headers["mcp-session-id"] = this.sessionId; - } - this._streamMapping.set(this._standaloneSseStreamId, { - controller: streamController, - encoder, - cleanup: () => { - this._streamMapping.delete(this._standaloneSseStreamId); - try { - streamController.close(); - } catch {} - } - }); - return new Response(readable, { headers }); - } - async replayEvents(lastEventId) { - if (!this._eventStore) { - return this.createJsonErrorResponse(400, -32000, "Event store not configured"); - } - try { - let streamId; - if (this._eventStore.getStreamIdForEventId) { - streamId = await this._eventStore.getStreamIdForEventId(lastEventId); - if (!streamId) { - return this.createJsonErrorResponse(400, -32000, "Invalid event ID format"); - } - if (this._streamMapping.get(streamId) !== undefined) { - return this.createJsonErrorResponse(409, -32000, "Conflict: Stream already has an active connection"); - } - } - const headers = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive" - }; - if (this.sessionId !== undefined) { - headers["mcp-session-id"] = this.sessionId; - } - const encoder = new TextEncoder; - let streamController; - const readable = new ReadableStream({ - start: (controller) => { - streamController = controller; - }, - cancel: () => {} - }); - const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { - send: async (eventId, message) => { - const success2 = this.writeSSEEvent(streamController, encoder, message, eventId); - if (!success2) { - this.onerror?.(new Error("Failed replay events")); - try { - streamController.close(); - } catch {} - } - } - }); - this._streamMapping.set(replayedStreamId, { - controller: streamController, - encoder, - cleanup: () => { - this._streamMapping.delete(replayedStreamId); - try { - streamController.close(); - } catch {} - } - }); - return new Response(readable, { headers }); - } catch (error48) { - this.onerror?.(error48); - return this.createJsonErrorResponse(500, -32000, "Error replaying events"); - } - } - writeSSEEvent(controller, encoder, message, eventId) { - try { - let eventData = `event: message -`; - if (eventId) { - eventData += `id: ${eventId} -`; - } - eventData += `data: ${JSON.stringify(message)} +### TMDB Search and Details -`; - controller.enqueue(encoder.encode(eventData)); - return true; - } catch { - return false; - } - } - handleUnsupportedRequest() { - return new Response(JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Method not allowed." - }, - id: null - }), { - status: 405, - headers: { - Allow: "GET, POST, DELETE", - "Content-Type": "application/json" - } - }); - } - async handlePostRequest(req, options) { - try { - const acceptHeader = req.headers.get("accept"); - if (!acceptHeader?.includes("application/json") || !acceptHeader.includes("text/event-stream")) { - return this.createJsonErrorResponse(406, -32000, "Not Acceptable: Client must accept both application/json and text/event-stream"); - } - const ct = req.headers.get("content-type"); - if (!ct || !ct.includes("application/json")) { - return this.createJsonErrorResponse(415, -32000, "Unsupported Media Type: Content-Type must be application/json"); - } - const requestInfo = { - headers: Object.fromEntries(req.headers.entries()), - url: new URL(req.url) - }; - let rawMessage; - if (options?.parsedBody !== undefined) { - rawMessage = options.parsedBody; - } else { - try { - rawMessage = await req.json(); - } catch { - return this.createJsonErrorResponse(400, -32700, "Parse error: Invalid JSON"); - } - } - let messages; - try { - if (Array.isArray(rawMessage)) { - messages = rawMessage.map((msg) => JSONRPCMessageSchema.parse(msg)); - } else { - messages = [JSONRPCMessageSchema.parse(rawMessage)]; - } - } catch { - return this.createJsonErrorResponse(400, -32700, "Parse error: Invalid JSON-RPC message"); - } - const isInitializationRequest = messages.some(isInitializeRequest); - if (isInitializationRequest) { - if (this._initialized && this.sessionId !== undefined) { - return this.createJsonErrorResponse(400, -32600, "Invalid Request: Server already initialized"); - } - if (messages.length > 1) { - return this.createJsonErrorResponse(400, -32600, "Invalid Request: Only one initialization request is allowed"); - } - this.sessionId = this.sessionIdGenerator?.(); - this._initialized = true; - if (this.sessionId && this._onsessioninitialized) { - await Promise.resolve(this._onsessioninitialized(this.sessionId)); - } - } - if (!isInitializationRequest) { - const sessionError = this.validateSession(req); - if (sessionError) { - return sessionError; - } - const protocolError = this.validateProtocolVersion(req); - if (protocolError) { - return protocolError; - } - } - const hasRequests = messages.some(isJSONRPCRequest); - if (!hasRequests) { - for (const message of messages) { - this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo }); - } - return new Response(null, { status: 202 }); - } - const streamId = crypto.randomUUID(); - const initRequest = messages.find((m) => isInitializeRequest(m)); - const clientProtocolVersion = initRequest ? initRequest.params.protocolVersion : req.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; - if (this._enableJsonResponse) { - return new Promise((resolve2) => { - this._streamMapping.set(streamId, { - resolveJson: resolve2, - cleanup: () => { - this._streamMapping.delete(streamId); - } - }); - for (const message of messages) { - if (isJSONRPCRequest(message)) { - this._requestToStreamMapping.set(message.id, streamId); - } - } - for (const message of messages) { - this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo }); - } - }); - } - const encoder = new TextEncoder; - let streamController; - const readable = new ReadableStream({ - start: (controller) => { - streamController = controller; - }, - cancel: () => { - this._streamMapping.delete(streamId); - } - }); - const headers = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive" - }; - if (this.sessionId !== undefined) { - headers["mcp-session-id"] = this.sessionId; - } - for (const message of messages) { - if (isJSONRPCRequest(message)) { - this._streamMapping.set(streamId, { - controller: streamController, - encoder, - cleanup: () => { - this._streamMapping.delete(streamId); - try { - streamController.close(); - } catch {} - } - }); - this._requestToStreamMapping.set(message.id, streamId); - } - } - await this.writePrimingEvent(streamController, encoder, streamId, clientProtocolVersion); - for (const message of messages) { - let closeSSEStream; - let closeStandaloneSSEStream; - if (isJSONRPCRequest(message) && this._eventStore && clientProtocolVersion >= "2025-11-25") { - closeSSEStream = () => { - this.closeSSEStream(message.id); - }; - closeStandaloneSSEStream = () => { - this.closeStandaloneSSEStream(); - }; - } - this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo, closeSSEStream, closeStandaloneSSEStream }); - } - return new Response(readable, { status: 200, headers }); - } catch (error48) { - this.onerror?.(error48); - return this.createJsonErrorResponse(400, -32700, "Parse error", { data: String(error48) }); - } - } - async handleDeleteRequest(req) { - const sessionError = this.validateSession(req); - if (sessionError) { - return sessionError; - } - const protocolError = this.validateProtocolVersion(req); - if (protocolError) { - return protocolError; - } - await Promise.resolve(this._onsessionclosed?.(this.sessionId)); - await this.close(); - return new Response(null, { status: 200 }); - } - validateSession(req) { - if (this.sessionIdGenerator === undefined) { - return; - } - if (!this._initialized) { - return this.createJsonErrorResponse(400, -32000, "Bad Request: Server not initialized"); - } - const sessionId = req.headers.get("mcp-session-id"); - if (!sessionId) { - return this.createJsonErrorResponse(400, -32000, "Bad Request: Mcp-Session-Id header is required"); - } - if (sessionId !== this.sessionId) { - return this.createJsonErrorResponse(404, -32001, "Session not found"); - } - return; - } - validateProtocolVersion(req) { - const protocolVersion = req.headers.get("mcp-protocol-version"); - if (protocolVersion !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { - return this.createJsonErrorResponse(400, -32000, `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")})`); - } - return; - } - async close() { - this._streamMapping.forEach(({ cleanup }) => { - cleanup(); - }); - this._streamMapping.clear(); - this._requestResponseMap.clear(); - this.onclose?.(); - } - closeSSEStream(requestId) { - const streamId = this._requestToStreamMapping.get(requestId); - if (!streamId) - return; - const stream = this._streamMapping.get(streamId); - if (stream) { - stream.cleanup(); - } - } - closeStandaloneSSEStream() { - const stream = this._streamMapping.get(this._standaloneSseStreamId); - if (stream) { - stream.cleanup(); - } - } - async send(message, options) { - let requestId = options?.relatedRequestId; - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - requestId = message.id; - } - if (requestId === undefined) { - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request"); - } - let eventId; - if (this._eventStore) { - eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); - } - const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId); - if (standaloneSse === undefined) { - return; - } - if (standaloneSse.controller && standaloneSse.encoder) { - this.writeSSEEvent(standaloneSse.controller, standaloneSse.encoder, message, eventId); - } - return; - } - const streamId = this._requestToStreamMapping.get(requestId); - if (!streamId) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); - } - const stream = this._streamMapping.get(streamId); - if (!this._enableJsonResponse && stream?.controller && stream?.encoder) { - let eventId; - if (this._eventStore) { - eventId = await this._eventStore.storeEvent(streamId, message); - } - this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); - } - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - this._requestResponseMap.set(requestId, message); - const relatedIds = Array.from(this._requestToStreamMapping.entries()).filter(([_, sid]) => sid === streamId).map(([id]) => id); - const allResponsesReady = relatedIds.every((id) => this._requestResponseMap.has(id)); - if (allResponsesReady) { - if (!stream) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); - } - if (this._enableJsonResponse && stream.resolveJson) { - const headers = { - "Content-Type": "application/json" - }; - if (this.sessionId !== undefined) { - headers["mcp-session-id"] = this.sessionId; - } - const responses = relatedIds.map((id) => this._requestResponseMap.get(id)); - if (responses.length === 1) { - stream.resolveJson(new Response(JSON.stringify(responses[0]), { status: 200, headers })); - } else { - stream.resolveJson(new Response(JSON.stringify(responses), { status: 200, headers })); - } - } else { - stream.cleanup(); - } - for (const id of relatedIds) { - this._requestResponseMap.delete(id); - this._requestToStreamMapping.delete(id); - } - } - } - } -} +When user asks to search TMDB, find a TV show or movie on TMDB, or look up TMDB metadata by id: -// src/mcp/toolHandlers/addRecognizedFile.ts -function registerAddRecognizedFileTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const deps = defaultRecognizeFilesTaskDeps(fs); - const agentTool = buildAddRecognizedMediaFileTool("mcp", config2.appDataDir, fs, config2.logger, undefined, deps); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID from begin-recognize-task"), - season: exports_external.number().describe("The season number of the episode"), - episode: exports_external.number().describe("The episode number"), - path: exports_external.string().describe("The absolute path of the media file (POSIX or Windows format)") - }); - server.registerTool(ADD_RECOGNIZED_MEDIA_FILE, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId, season, episode, path: path13 } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - if (typeof season !== "number" || season < 0) { - return createErrorResponse("Invalid season: 'season' must be a non-negative number"); - } - if (typeof episode !== "number" || episode < 0) { - return createErrorResponse("Invalid episode: 'episode' must be a non-negative number"); - } - if (typeof path13 !== "string" || path13.trim() === "") { - return createErrorResponse("Invalid path: 'path' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ - taskId, - season, - episode, - path: Path.posix(path13) - }); - if (typeof result === "object" && result !== null && "error" in result && typeof result.error === "string") { - return createSuccessResponse({ - success: false, - error: result.error - }); - } - return createSuccessResponse({ success: true, taskId }); - } catch (error48) { - return createErrorResponse(`Error adding recognized file: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} +1. Call "${X1}" with keyword and type (\`tv\` or \`movie\`) to find candidates. +2. Call "${v1}" or "${Q1}" with the chosen TMDB id for full details (seasons/episodes for TV). -// src/mcp/toolHandlers/addRenameFile.ts -var import_node_path14 = require("node:path"); -function registerAddRenameFileTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildAddRenameFileToTaskTool("mcp", config2.appDataDir, fs, deps, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID returned from begin-rename-task"), - from: exports_external.string().describe("The current absolute path of the file to rename"), - to: exports_external.string().describe("The new absolute path for the file") - }); - server.registerTool(ADD_RENAME_FILE_TO_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId, from, to } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - if (typeof from !== "string" || from.trim() === "") { - return createErrorResponse("Invalid path: 'from' must be a non-empty string"); - } - if (typeof to !== "string" || to.trim() === "") { - return createErrorResponse("Invalid path: 'to' must be a non-empty string"); - } - if (!isVideoFile2(from)) { - return createErrorResponse("Invalid path: 'from' must be a video file"); - } - if (!isVideoFile2(to)) { - return createErrorResponse("Invalid path: 'to' must be a video file"); - } - try { - const result = await agentTool.execute({ - taskId, - from: Path.posix(from), - to: Path.posix(to) - }); - if (typeof result === "object" && result !== null && "error" in result && typeof result.error === "string") { - const message = result.error; - if (message.includes("Not Episode Video File")) { - return createSuccessResponse({ - success: false, - error: `"${from}" is not video file to any episode, you're not allowed to rename it. ` + `Call "get-episodes" tool to get the list of episode video files that needs to rename.` - }); - } - return createSuccessResponse({ success: false, error: message }); - } - return createSuccessResponse({ success: true, taskId }); - } catch (error48) { - const message = error48 instanceof Error ? error48.message : String(error48); - if (message.includes("Not Episode Video File")) { - return createErrorResponse(`"${from}" is not video file to any episode, you're not allowed to rename it. ` + `Call "get-episodes" tool to get the list of episode video files that needs to rename.`); - } - return createErrorResponse(message); - } - }); -} -function isVideoFile2(filePath) { - const extension = import_node_path14.extname(filePath).toLowerCase(); - return videoFileExtensions.includes(extension); -} +## User Preferences -// src/mcp/toolHandlers/beginRecognizeTask.ts -function registerBeginRecognizeTaskTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildBeginRecognizeTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder") - }); - server.registerTool(BEGIN_RECOGNIZE_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ mediaFolderPath }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: result.taskId, - mediaFolderPath: Path.posix(mediaFolderPath) - }); - } - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(`Error starting recognize task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} +Language: zh-CN (you should answer in user's language) +TimeZone: Asia/Shanghai (you should use user's time zone to answer user's question) -// src/mcp/toolHandlers/beginRenameTask.ts -function registerBeginRenameTaskTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildBeginRenameFilesTaskTool("mcp", config2.appDataDir, fs, deps, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, in POSIX or Windows format") - }); - server.registerTool(BEGIN_RENAME_FILES_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ mediaFolderPath }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: result.taskId, - mediaFolderPath: Path.posix(mediaFolderPath) - }); - } - config2.logger?.error?.({ - resultType: typeof result, - resultKeys: typeof result === "object" && result !== null ? Object.keys(result) : [] - }, `[tool][${BEGIN_RENAME_FILES_TASK}] Unexpected agent tool result shape`); - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(`Error starting rename task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} +## Background Knowledge -// src/mcp/toolHandlers/endRecognizeTask.ts -function registerEndRecognizeTaskTool(server, config2) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildEndRecognizeTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID from begin-recognize-task") - }); - server.registerTool(END_RECOGNIZE_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ taskId }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error - }); - } - } - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE - }); - } catch (error48) { - return createErrorResponse(`Error ending recognize task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} +### Naming Rules -// src/mcp/toolHandlers/endRenameTask.ts -function registerEndRenameTaskTool(server, config2, deps) { - const fs = config2.fs ?? defaultChatFs(); - const agentTool = buildEndRenameFilesTaskTool("mcp", config2.appDataDir, fs, config2.broadcast, config2.logger, undefined); - const inputSchema = exports_external.object({ - taskId: exports_external.string().describe("The task ID returned from begin-rename-task") - }); - server.registerTool(END_RENAME_FILES_TASK, { - description: agentTool.description, - inputSchema - }, async (args) => { - const { taskId } = args ?? {}; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse("Invalid taskId: 'taskId' must be a non-empty string"); - } - try { - const result = await agentTool.execute({ taskId }); - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error - }); - } - } - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE - }); - } catch (error48) { - return createErrorResponse(`Error ending rename task: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} +Naming rules are file naming conventions for media server (such as Plex, Jellyfin, etc.) to recognize the media files. +Below are naming rules for different media servers: -// src/mcp/toolHandlers/getApplicationContext.ts -function registerGetApplicationContextTool(server, config2) { - const description = config2.toolDescriptions?.[GET_APPLICATION_CONTEXT] ?? GET_APPLICATION_CONTEXT_DESCRIPTION; - server.registerTool(GET_APPLICATION_CONTEXT, { - description, - inputSchema: getApplicationContextInputSchema, - outputSchema: getApplicationContextOutputSchema - }, async () => { - try { - const userConfig = await config2.getUserConfig(); - const language = resolveAppLanguage({ - configured: userConfig.applicationLanguage, - osLocale: detectOsLocale() - }); - let selectedMediaFolder = ""; - if (config2.acknowledge) { - try { - const responseData = await config2.acknowledge({ - event: "getSelectedMediaMetadata" - }, 1000); - selectedMediaFolder = responseData?.selectedMediaMetadata?.mediaFolderPath ?? ""; - } catch {} - } - const result = await executeGetApplicationContext({ - selectedMediaFolder, - language - }); - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +**Plex** -// src/mcp/toolHandlers/getEpisode.ts -var getEpisodeInputSchema = exports_external.object({ - mediaFolderPath: exports_external.string().describe("The absolute path of the media folder, in POSIX or Windows format"), - season: exports_external.number().describe("The season number of the episode"), - episode: exports_external.number().describe("The episode number") -}); -var getEpisodeOutputSchema = exports_external.object({ - videoFilePath: exports_external.string().describe("The absolute path of the video file in platform-specific format"), - season: exports_external.number().describe("The season number"), - episode: exports_external.number().describe("The episode number"), - message: exports_external.string().describe("Status message") -}).strict(); -var TOOL_NAME = "get-episode"; -var TOOL_DESCRIPTION = "Get the absolute video file path for a single episode in a media folder. " + "Returns the resolved video file path plus a status message."; -function registerGetEpisodeTool(server, config2) { - const description = config2.toolDescriptions?.[TOOL_NAME] ?? TOOL_DESCRIPTION; - server.registerTool(TOOL_NAME, { - description, - inputSchema: getEpisodeInputSchema, - outputSchema: getEpisodeOutputSchema - }, async (args) => { - const { mediaFolderPath, season, episode } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - if (typeof season !== "number" || season < 0) { - return createErrorResponse("Invalid season: 'season' must be a non-negative number"); - } - if (typeof episode !== "number" || episode < 0) { - return createErrorResponse("Invalid episode: 'episode' must be a non-negative number"); - } - try { - const metadata = await readMediaMetadataCache(config2.appDataDir, mediaFolderPath); - if (!metadata) { - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: "Media metadata not found. Please ensure the media folder is opened in SMM." - }); - } - if (!metadata.mediaFiles || metadata.mediaFiles.length === 0) { - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: "No media files found in the media folder metadata." - }); - } - const matchingEpisode = metadata.mediaFiles.find((mediaFile) => mediaFile.seasonNumber === season && mediaFile.episodeNumber === episode); - if (!matchingEpisode) { - return createSuccessResponse({ - videoFilePath: "", - season, - episode, - message: `Episode S${season}E${episode} not found in the media folder.` - }); - } - const platformPath = Path.toPlatformPath(matchingEpisode.absolutePath); - return createSuccessResponse({ - videoFilePath: platformPath, - season, - episode, - message: "succeeded" - }); - } catch (error48) { - return createErrorResponse(`Error getting episode: ${error48 instanceof Error ? error48.message : String(error48)}`); - } - }); -} +{FolderName}/{TVShowName} - S{SeasonNumber}E{EpisodeNumber} - {EpisodeName}.{Extension} -// src/mcp/toolHandlers/getEpisodes.ts -function registerGetEpisodesTool(server, config2) { - const description = config2.toolDescriptions?.[GET_EPISODES] ?? GET_EPISODES_DESCRIPTION; - server.registerTool(GET_EPISODES, { - description, - inputSchema: getEpisodesInputSchema, - outputSchema: getEpisodesToolOutputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const userConfig = await config2.getUserConfig(); - const syntheticConfig = { - allowlist: [], - hello: { - version: "0.0.0", - userDataDir: config2.userDataDir, - appDataDir: config2.appDataDir, - logDir: "", - tmpDir: "", - reverseProxyUrl: null, - osLocale: "en-US", - coreRoutesPort: 0 - }, - appDataDir: config2.appDataDir, - logger: config2.logger - }; - const result = await doGetEpisodes({ mediaFolderPath }, syntheticConfig); - if (result.error) { - return createSuccessResponse(result); - } - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +FolderName: The season folder name, such as "Specials", "Season 1", "Season 2", "Season 3", ... +TVShowName: The TV show name +SeasonNumber: The season number padded to 2 digits, such as "01", "02", "03", ... +EpisodeNumber: The episode number padded to 2 digits, such as "01", "02", "03", ... +EpisodeName: The episode name +Extension: The file extension, such as "mp4", "mkv", "avi", ... +`;var z8="is-folder-exist",xK="Check if a folder exists in the file system. Returns `{ exists, path, reason? }` where `exists` is true when the path is an existing directory.",vT="Invalid path: path must be a non-empty string",JT="Path exists but is not a directory",YT="Path does not exist",bK=K.object({path:K.string().describe("The absolute path of the folder to check (POSIX or Windows format)")}),iK=K.object({exists:K.boolean().describe("Whether the folder exists"),path:K.string().describe("The normalized path that was checked"),reason:K.string().optional().describe("Reason for non-existence or non-directory")});var W8="get-media-folders",RK="Get the list of media folders managed by SMM.",EK=K.object({}),x8$=K.object({folders:K.array(K.string()).describe("Array of media folder paths managed by SMM")}),PK=x8$.extend({error:K.string().optional()});var J1="rename-folder",TK=`Rename a media folder in SMM. This tool accepts the source folder path and destination folder path. This tool should ONLY be used to rename FOLDER, NOT FILE. This tool will update media metadata accordingly. -// src/mcp/toolHandlers/getMediaFolders.ts -function registerGetMediaFoldersTool(server, config2) { - const description = config2.toolDescriptions?.[GET_MEDIA_FOLDERS] ?? GET_MEDIA_FOLDERS_DESCRIPTION; - server.registerTool(GET_MEDIA_FOLDERS, { - description, - inputSchema: getMediaFoldersInputSchema, - outputSchema: getMediaFoldersOutputSchema - }, async () => { - try { - const userConfig = await config2.getUserConfig(); - const result = await executeGetMediaFolders(userConfig); - if (result.error) { - return createErrorResponse(result.error); - } - const { error: _error, ...data } = result; - return createSuccessResponse(data); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +Example: Rename folder "/path/to/old-folder" to "/path/to/new-folder".`,SK=K.object({from:K.string().describe("The current absolute path of the folder to rename (POSIX or Windows format)"),to:K.string().describe("The new absolute path for the folder (POSIX or Windows format)")}),CK=K.object({renamed:K.boolean().describe("Whether the folder was successfully renamed"),from:K.string().describe("The source path after normalization"),to:K.string().describe("The destination path after normalization"),error:K.string().optional().describe("Error message if rename failed")}),zT="User cancelled the operation";var Y1="rename-episode-file",uK=`Rename a linked TV episode video file (and same-stem associates such as subtitles) in a managed TV show folder. Use ONLY for a single episode file that already has seasonNumber and episodeNumber in media metadata. Do NOT use for folders (use rename-folder), movies, orphan files, or bulk renames (use begin/add/end-rename-files-task for multi-file plans). -// src/mcp/toolHandlers/getMediaMetadata.ts -function registerGetMediaMetadataTool(server, config2) { - const description = config2.toolDescriptions?.[GET_MEDIA_METADATA] ?? GET_MEDIA_METADATA_DESCRIPTION; - server.registerTool(GET_MEDIA_METADATA, { - description, - inputSchema: getMediaMetadataInputSchema, - outputSchema: getMediaMetadataToolOutputSchema - }, async (args) => { - const { mediaFolderPath } = args ?? {}; - if (typeof mediaFolderPath !== "string" || mediaFolderPath.trim() === "") { - return createErrorResponse("Invalid path: 'mediaFolderPath' must be a non-empty string"); - } - try { - const userConfig = await config2.getUserConfig(); - const result = await executeGetMediaMetadata({ mediaFolderPath }, userConfig, config2.appDataDir); - if (result.error) { - return createSuccessResponse(result); - } - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +Example: Rename episode file in folder "/path/to/show" from ".../S01E01.mp4" to ".../S01E01_renamed.mp4".`,_K=K.object({mediaFolder:K.string().describe("Absolute path of the managed TV show media folder (POSIX or Windows format)"),from:K.string().describe("Absolute current path of the linked episode video file (POSIX or Windows format)"),to:K.string().describe("Absolute target path for the episode video file under the same media folder (POSIX or Windows format)")}),yK=K.object({renamed:K.boolean().describe("True when at least one file was renamed successfully"),mediaFolder:K.string().describe("The media folder path after normalization"),from:K.string().describe("The primary source episode path after normalization"),to:K.string().describe("The primary destination episode path after normalization"),succeeded:K.array(K.object({from:K.string(),to:K.string()})).describe("Successful rename pairs (episode + associates)"),failed:K.array(K.object({path:K.string(),error:K.string()})).describe("Per-path failures"),error:K.string().optional().describe("Error or cancellation message when rename did not fully succeed")}),WT="User cancelled the operation";var b8$="en";function GT($){let X=$.trim();if(!X)return null;let Q=X.toLowerCase();if(Q.startsWith("zh")){if(Q==="zh-hk"||Q.startsWith("zh-hk"))return"zh-HK";if(Q==="zh-tw"||Q.startsWith("zh-tw"))return"zh-TW";return"zh-CN"}if(Q.startsWith("en"))return"en";return null}function fK($){if($.configured)return $.configured;if($.browserLocale){let X=GT($.browserLocale);if(X)return X}if($.osLocale){let X=GT($.osLocale);if(X)return X}return b8$}function kK(){let $=Intl.DateTimeFormat().resolvedOptions().locale;if($)return $;let X=process.env.LC_ALL??process.env.LC_MESSAGES??process.env.LANG;if(X)return X.split(".")[0]?.replace("_","-")??X;return""}function J6($){return{...$,error:void 0}}function a8($){return{error:$.startsWith("Error Reason:")?$:`Error Reason: ${$}`}}function k$($,X){if(typeof $!=="string"||$.trim()==="")return{error:`Invalid ${X}: must be a non-empty string`};return $}function i8$($){if($ instanceof Error)return $.message;if(typeof $==="string")return $;if($===null||$===void 0)return"Unknown error (null/undefined thrown)";try{let Q=JSON.stringify($);if(Q&&Q!=="{}")return Q}catch{}return String($)||"Unknown error"}function Y6($){return a8(i8$($))}function IO($){return{error:$}}function MO($){if($?.aborted)throw Error("Request was aborted")}async function xO($,X,Q){MO(Q);let J=k$($.keyword,"keyword");if(typeof J!=="string")return{error:J.error};if(!X)return IO("tmdb-search is not available on this host");try{let v=await X(J,{type:$.type,...BK($)});if(v.error)return{error:v.error};return{results:v.results,page:v.page,total_pages:v.total_pages,total_results:v.total_results}}catch(v){return{error:DK(v)}}}async function bO($,X,Q){if(MO(Q),!Number.isInteger($.id)||$.id<=0)return{error:"Invalid id: 'id' must be a positive integer"};if(!X)return IO("tmdb-get-movie is not available on this host");try{return await X($.id,BK($))}catch(J){return{error:DK(J)}}}async function iO($,X,Q){if(MO(Q),!Number.isInteger($.id)||$.id<=0)return{error:"Invalid id: 'id' must be a positive integer"};if(!X)return IO("tmdb-get-tv-show is not available on this host");try{return await X($.id,BK($))}catch(J){return{error:DK(J)}}}function UT($,X){return{description:FK,inputSchema:NK,outputSchema:OK,execute:async(Q)=>{return xO(Q??{},$?.searchInTmdb,X)}}}function KT($,X){return{description:LK,inputSchema:AK,outputSchema:ZK,execute:async(Q)=>{return bO(Q??{},$?.getMovieInTmdb,X)}}}function wT($,X){return{description:qK,inputSchema:IK,outputSchema:MK,execute:async(Q)=>{return iO(Q??{},$?.getTvShowInTmdb,X)}}}var zv=require("node:fs/promises");function J4(){return{async readJson($){try{let X=await zv.readFile($,"utf-8");return JSON.parse(X)}catch(X){if(X.code==="ENOENT")return null;throw X}},async writeJson($,X){let Q=JSON.stringify(X,null,2);await zv.writeFile($,Q,"utf-8")},async exists($){try{return await zv.stat($),!0}catch{return!1}}}}async function r8($,X){return}function t8($){}async function RO($){return{selectedMediaFolder:$.selectedMediaFolder,language:$.language}}function HT($,X,Q,J){let v=J??r8;return{description:tU,inputSchema:sU,outputSchema:eU,execute:async()=>{try{let[Y,z]=await Promise.all([R8$($,v),Q(X)]);return RO({selectedMediaFolder:Y,language:z})}catch(Y){return{selectedMediaFolder:"",language:"en",error:Y instanceof Error?Y.message:"Unknown error"}}}}}async function R8$($,X){return(await X({event:"getSelectedMediaMetadata",clientId:$},1000))?.selectedMediaMetadata?.mediaFolderPath??""}function jT(){return{exists:!1,path:"",reason:vT}}function VT($){return{exists:!0,path:Z.toPlatformPath($)}}function BT($){return{exists:!1,path:Z.toPlatformPath($),reason:JT}}function DT($){return{exists:!1,path:Z.toPlatformPath($),reason:YT}}function nK($,X){return{exists:!1,path:Z.toPlatformPath($),reason:`Error checking folder existence: ${X}`}}var FT=require("node:fs/promises");var E8$=k.object({path:k.string().min(1,"path is required")});async function Wv($){if(!$||typeof $!=="string"||$.trim()==="")return jT();try{let X=Z.toPlatformPath($);if((await FT.stat(X)).isDirectory())return VT($);return BT($)}catch(X){let Q=X.code;if(Q==="ENOENT"||Q==="ENOTFOUND")return DT($);let J=X instanceof Error?X.message:String(X);throw Error(`Error checking folder existence: ${J}`)}}async function NT($){return(await Wv($)).exists}async function gK($,X={}){let Q=E8$.safeParse($);if(!Q.success)return{available:!1,reason:"path is required"};try{let J=await Wv(Q.data.path);if(J.exists)X.logger?.debug({path:Q.data.path},"doIsFolderAvailable: folder exists");else X.logger?.info({path:Q.data.path,reason:J.reason},"doIsFolderAvailable: folder not available");return{available:J.exists,reason:J.reason}}catch(J){let v=J instanceof Error?J.message:String(J);return X.logger?.error({path:Q.data.path,error:v},"doIsFolderAvailable: check failed"),{available:!1,reason:nK(Q.data.path,v).reason}}}async function EO($){let X=k$($,"path");if(typeof X!=="string")return{exists:!1,path:"",reason:X.error};try{return await Wv(X)}catch(Q){let J=Q instanceof Error?Q.message:String(Q);return nK(X,J)}}function OT(){return{description:xK,inputSchema:bK,outputSchema:iK,execute:async($)=>{let{path:X}=$??{};if(typeof X!=="string")return{exists:!1,path:"",reason:"path is required"};return EO(X)}}}var ZT=require("node:fs/promises");function PO($){let X=Number.parseInt($,10);return Number.isFinite(X)?X:0}function P8$($){return{source:$.database??"TVDB",id:PO($.id),name:$.name,seasons:$.seasons?.map((X)=>({seasonNumber:X.season,seasonName:X.name,episodes:X.episodes?.map((Q)=>({seasonNumber:Q.season,episodeNumber:Q.episode,episodeName:Q.name}))??[]}))??[]}}function T8$($){return{tvdbId:PO($.id),name:$.name,database:$.database}}function TO($){return{mediaFolderPath:Z.toPlatformPath($),type:"tvshow-folder"}}function LT($,X){let Q={mediaFolderPath:Z.toPlatformPath($.mediaFolderPath||X),type:$.type||"tvshow-folder"};if(Q.type==="tvshow-folder")if($.tvShow)Q.tvShow=P8$($.tvShow);else Q.tvShow=vv;else if(Q.type==="movie-folder")if($.movie)if($.movie.database==="TMDB"){let J=PO($.movie.id);Q.tmdbMovie={tmdbId:J,title:$.movie.name,originalTitle:$.movie.name,overview:"",releaseDate:$.movie.airDate??"",posterPath:null},Q.tvdbMovie=vv}else Q.tvdbMovie=T8$($.movie),Q.tmdbMovie=vv;else Q.tmdbMovie=vv,Q.tvdbMovie=vv;return Q}var s8=require("node:fs/promises"),SO=x$(require("node:path"));function Gz($,X){let Q=X.replace(/[\/\\:?*|<>"]/g,"_");return SO.default.join($,"metadata",`${Q}.json`)}async function N1($,X){let Q=Z.posix(X),J=Gz($,Q);try{let v=await s8.readFile(J,"utf-8");return JSON.parse(v)}catch{return null}}async function cK($,X){if(!X.mediaFolderPath)throw Error("Media folder path is required");let Q=SO.default.join($,"metadata");await s8.mkdir(Q,{recursive:!0});let J=Gz($,Z.posix(X.mediaFolderPath));await s8.writeFile(J,JSON.stringify(X,null,2),"utf-8")}async function AT($,X){let Q=Gz($,Z.posix(X));try{await s8.unlink(Q)}catch(J){if(J.code!=="ENOENT")throw J}}async function CO($,X,Q,J){if(J?.aborted)throw Error("Request was aborted");let v=k$($.mediaFolderPath,"mediaFolderPath");if(typeof v!=="string")return{...TO(""),error:v.error};let Y=TO(v);if(!S8$(X,v))return{...Y,error:hP};try{let z=Z.toPlatformPath(v);try{if(!(await ZT.stat(z)).isDirectory())return{...Y,error:lP}}catch(U){if(U.code==="ENOENT")return{...Y,error:pP};throw U}let W=Z.posix(v),G=await N1(Q,W);if(!G)return{...Y,error:dP};return LT(G,W)}catch(z){let W=z instanceof Error?z.message:String(z);throw Error(`Error reading media metadata: ${W}`)}}function S8$($,X){let Q=Z.toPlatformPath(X),J=Z.posix(X);return($.folders??[]).some((v)=>{return Z.toPlatformPath(v)===Q||Z.posix(v)===J})}function qT($,X,Q){return{description:$K,inputSchema:XK,outputSchema:QK,execute:async(J)=>{return CO({mediaFolderPath:(J??{}).mediaFolderPath??""},$,X,Q)}}}function zX(){return{episodes:[],totalCount:0,showName:"",numberOfSeasons:0}}function IT($){let X=$.tvShow;if(!X)return zX();let Q=new Map;if($.mediaFiles&&Array.isArray($.mediaFiles)){for(let v of $.mediaFiles)if(v.seasonNumber!==void 0&&v.episodeNumber!==void 0){let Y=`${v.seasonNumber}:${v.episodeNumber}`;Q.set(Y,v.absolutePath)}}let J=[];for(let v of X.seasons??[])if(v.episodes&&Array.isArray(v.episodes))for(let Y of v.episodes){let z=`${v.season}:${Y.episode}`,W=Q.get(z);J.push({season:v.season,episode:Y.episode,videoFilePath:W?Z.toPlatformPath(W):void 0})}return{episodes:J,totalCount:J.length,showName:X.name??"",numberOfSeasons:X.seasons?.length??0}}var e8={allowlist:[]};var Gv=require("node:fs/promises"),uO=x$(require("node:path"));var MT={folders:[],tmdb:{},tvdb:{},renameRules:[],dryRun:!1,selectedRenameRule:"plex"};function xT($){return $.hello?.userDataDir??$.appDataDir}function r1($){return $.appDataDir??$.hello?.appDataDir}async function G8($){let X=xT($);if(!X)return MT;let Q=uO.default.join(X,"smm.json");try{let J=await Gv.readFile(Q,"utf-8");return JSON.parse(J)}catch{return MT}}async function Uv($,X){let J=(await G8(X)).folders??[];if(J.length===0)return!1;let v=Z.toPlatformPath($),Y=Z.posix($);return J.some((z)=>Z.toPlatformPath(z)===v||Z.posix(z)===Y)}async function Kv($,X){let Q=xT($);if(!Q)throw Error("userDataDir is not configured");await Gv.mkdir(Q,{recursive:!0}),await Gv.writeFile(uO.default.join(Q,"smm.json"),JSON.stringify(X,null,2),"utf-8")}var C8$=k.object({mediaFolderPath:k.string().min(1,"The absolute path of the media folder is required")});async function U8($,X=e8){let Q=C8$.safeParse($);if(!Q.success){let W=Q.error.issues.map((G)=>G.message).join(", ");return{...zX(),error:`Validation Failed: ${W}`}}let J=k$(Q.data.mediaFolderPath,"mediaFolderPath");if(typeof J!=="string")return{...zX(),error:oP};let v=zX();if(!await Uv(J,X))return{...v,error:aP};let Y=r1(X);if(!Y)return{...v,error:qO};let z=await N1(Y,J);if(!z)return{...v,error:qO};if(!z.tvShow)return{...v,error:rP};return X.logger?.info({mediaFolderPath:J,seasonCount:z.tvShow.seasons?.length??0,mediaFileCount:z.mediaFiles?.length??0},"[getEpisodes] building episode list"),J6(IT(z))}function bT($,X){return{description:vK,inputSchema:JK,outputSchema:YK,execute:async(Q)=>{if(X?.aborted)throw Error("Request was aborted");return U8({mediaFolderPath:(Q??{}).mediaFolderPath??""},$)}}}function iT(){return{folders:[]}}function RT($){return{folders:$.folders??[]}}async function _O($,X){if(X?.aborted)throw Error("Request was aborted");try{return J6(RT($))}catch(Q){return{...iT(),...Y6(Q)}}}function ET($,X){return{description:RK,inputSchema:EK,outputSchema:PK,execute:async()=>_O($,X)}}var yO={audioTrackFileExtensions:[".mka"],videoFileExtensions:[".mp4",".mkv",".avi",".mov",".wmv",".flv",".webm",".m4v",".mpg",".mpeg",".m2v",".m1v",".qt",".3gp",".3g2",".rm",".rmvb",".ra",".asf",".wm",".ogv",".ogm",".vob",".divx",".f4v",".h264",".mxf",".svi",".tp",".trp",".wtv",".ts",".m2ts",".mts",".swf",".yuv",".m4p",".m4b",".m4r"],subtitleFileExtensions:[".srt",".ass",".ssa",".vtt",".sub",".idx",".smi",".sami",".lrc",".sbv",".ttml",".dfxp",".stl",".usf",".dks",".jss",".pjs",".psb",".rt",".s2k",".sbt",".scc",".cap",".cdg",".scr",".xas",".mpl",".mks",".sup",".aqt",".gsub",".vsf",".zeg",".cif",".cip",".ets",".itk",".slt",".ssf",".tds",".txt"],imageFileExtensions:[".jpg",".jpeg",".png",".gif",".webp",".bmp",".tiff",".tif",".svg"],musicFileExtensions:[".mp3",".m4a",".aac",".ogg",".wav",".flac",".ape",".mka",".wma",".aac",".m4a",".ogg",".wav",".flac",".ape"]},mK=yO.videoFileExtensions,iq$=yO.imageFileExtensions,Rq$=yO.subtitleFileExtensions;function t1(){return{files:[],count:0}}function u8$($){let X=$.lastIndexOf(".");if(X===-1)return!1;let Q=$.substring(X).toLowerCase();return mK.includes(Q)}function wv($,X=!1){let Q=$.map((J)=>Z.toPlatformPath(J));if(X)Q=Q.filter(u8$);return{files:Q,count:Q.length}}var uT=x$(require("node:os")),Uz=require("node:fs/promises"),kO=x$(require("node:path"));var fO=x$(require("node:path"));function PT($,X){if($.startsWith("file://")){let Q=$.endsWith("/")?"":"/";return`${$}${Q}${X}`}return fO.default.join($,X)}function TT($){if($.startsWith("file://"))return $;return fO.default.resolve($)}function ST($){if($.startsWith("file://"))return $;if($.startsWith("/")&&Z.isWindows()){let X=$.replace(/^\/([A-Za-z]):\//,"/$1/");return Z.win(X)}if(/^[A-Za-z]:/.test($)&&!Z.isWindows())return new Z($).abs("posix");return Z.toPlatformPath($)}function CT($){if($ instanceof Error)return{message:$.message,errorType:$.constructor.name,stack:$.stack,raw:String($)};if(typeof $==="string")return{message:$,errorType:"string",raw:$};if($===null||$===void 0)return{message:"null or undefined thrown",errorType:String($),raw:String($)};try{let X=JSON.stringify($);return{message:X&&X!=="{}"?X:String($),errorType:typeof $,raw:X??String($)}}catch{return{message:String($),errorType:typeof $,raw:String($)}}}var _8$=k.object({path:k.string().min(1,"Path is required"),onlyFiles:k.boolean().optional(),onlyFolders:k.boolean().optional(),includeHiddenFiles:k.boolean().optional(),recursively:k.boolean().optional()}),y8$={path:"",items:[],size:0};async function s1($,X={}){let{logger:Q,activatePersistedFileAccess:J}=X,v=`listFiles-${Date.now()}-${Math.random().toString(36).slice(2,9)}`;Q?.info({requestId:v,body:$},"[ListFiles] request received");try{let Y=_8$.safeParse($);if(!Y.success)return Q?.info({requestId:v,issues:Y.error.issues},"[ListFiles] validation failed"),{data:{path:"",items:[],size:0},error:`Validation Failed: ${Y.error.issues.map((j)=>j.message).join(", ")}`};let{path:z,onlyFiles:W,onlyFolders:G,includeHiddenFiles:U=!1,recursively:w=!1}=Y.data;if(Q?.debug({requestId:v,folderPath:z,onlyFiles:W,onlyFolders:G,includeHiddenFiles:U,recursively:w},"[ListFiles] validated params"),z==="~"||z.startsWith("~/")){let j=uT.default.homedir();z=z==="~"?j:kO.default.join(j,z.slice(2))}try{z=ST(z)}catch(j){Q?.info({requestId:v,folderPath:z,error:j},"[ListFiles] Path normalization threw, using raw path")}let H=TT(z);if(Q?.info({requestId:v,validatedPath:H},"[ListFiles] resolved absolute path"),H.startsWith("file://"))if(J)try{await J([H])}catch(j){let V=CT(j);return Q?.warn({requestId:v,validatedPath:H,...V},"[ListFiles] file access activation failed"),{data:{path:H,items:[],size:0},error:`File Access Activation Failed: ${V.message}`}}else Q?.warn({requestId:v,validatedPath:H},"[ListFiles] file:// path without activatePersistedFileAccess hook");try{let j=await Uz.stat(H);if(Q?.info({requestId:v,validatedPath:H,isDirectory:j.isDirectory(),isFile:j.isFile()},"[ListFiles] stat result"),!j.isDirectory())return Q?.info({requestId:v,validatedPath:H},"[ListFiles] path is not a directory"),{data:{path:H,items:[],size:0},error:`Path Not Directory: ${z} is not a directory`}}catch(j){return Q?.info({requestId:v,validatedPath:H,folderPath:z,errMessage:j instanceof Error?j.message:String(j),errCode:j instanceof Error?j.code:void 0},"[ListFiles] stat failed (path not found or not accessible)"),{data:{path:H,items:[],size:0},error:`Directory Not Found: ${z} was not found`}}try{Q?.debug({requestId:v,validatedPath:H},"[ListFiles] starting readdir");let j=[],V=0;async function B(D,F=!1){Q?.debug({requestId:v,dirPath:D,isTopLevel:F},"[ListFiles] scanDirectory readdir");let L=await Uz.readdir(D);for(let A of L){let O=PT(D,A);try{let i=await Uz.stat(O),M=i.isFile(),R=i.isDirectory(),E=kO.default.basename(A),v$=E.startsWith(".")||E==="Thumbs.db"||E==="desktop.ini";if(!U&&v$)continue;if(F)V++;let q$=!0;if(W&&G)q$=M;else{if(W===!0&&!M)q$=!1;if(G===!0&&!R)q$=!1}if(q$)j.push({path:O,size:i.size,mtime:i.mtimeMs,isDirectory:R});if(R&&w)await B(O,!1)}catch{continue}}}return await B(H,!0),{data:{path:H,items:j,size:V}}}catch(j){let V=j instanceof Error?j:Error(String(j)),B=j?.code;return Q?.info({requestId:v,validatedPath:H,errMessage:V.message,errCode:B,errStack:V.stack},"[ListFiles] readdir/list failed"),{data:{path:H,items:[],size:0},error:`List Directory Failed: ${V.message}`}}}catch(Y){let z=Y instanceof Error?Y:Error(String(Y));return Q?.info({requestId:v,errMessage:z.message,errStack:z.stack},"[ListFiles] unexpected error in doListFiles"),{data:y8$,error:`Unexpected Error: ${z.message}`}}}async function f8$($,X,Q){if(Q?.aborted)throw Error("Request was aborted");let J=k$($.mediaFolderPath,"mediaFolderPath");if(typeof J!=="string")return{...t1(),error:zK};let v=t1();if(!k8$(X,J))return{...v,error:WK};try{let Y=await s1({path:J,recursively:$.recursively??!0,onlyFiles:!0});if(Y.error)return{...v,error:Y.error};let z=Y.data?.items.filter((W)=>!W.isDirectory).map((W)=>W.path)??[];return J6(wv(z,$.videoFileOnly??!1))}catch(Y){return{...v,...Y6(Y)}}}function k8$($,X){let Q=Z.toPlatformPath(X),J=Z.posix(X);return($.folders??[]).some((v)=>{return Z.toPlatformPath(v)===Q||Z.posix(v)===J})}function _T($,X){return{description:tP,inputSchema:sP,outputSchema:eP,execute:async(Q)=>{let J=Q??{};return f8$({mediaFolderPath:J.mediaFolderPath??"",recursively:J.recursively,videoFileOnly:J.videoFileOnly},$,X)}}}function yT($){let X=Z.posix($).split("/").filter(Boolean);return X[X.length-1]??Z.posix($)}function fT($,X){return`Rename folder "${yT($)}" to "${yT(X)}"? -// src/mcp/toolHandlers/isFolderExist.ts -function registerIsFolderExistTool(server, config2) { - const description = config2.toolDescriptions?.[IS_FOLDER_EXIST] ?? IS_FOLDER_EXIST_DESCRIPTION; - server.registerTool(IS_FOLDER_EXIST, { - description, - inputSchema: isFolderExistInputSchema, - outputSchema: isFolderExistOutputSchema - }, async (args) => { - const { path: path13 } = args ?? {}; - if (typeof path13 !== "string" || path13.trim() === "") { - return createErrorResponse("Invalid path: 'path' must be a non-empty string"); - } - try { - const result = await executeIsFolderExist(path13); - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +`+`This will: + • Rename the folder on disk + • Update media metadata`}function kT($,X){return{renamed:!1,from:Z.toPlatformPath($),to:Z.toPlatformPath(X),error:zT}}function $9($,X,Q){return{renamed:!1,from:Z.toPlatformPath($),to:Z.toPlatformPath(X),error:Q}}function nT($,X){return{renamed:!0,from:Z.toPlatformPath($),to:Z.toPlatformPath(X)}}var hT=require("node:fs/promises");function gT($,X,Q){let J=X.endsWith("/")?X:X+"/",v=Q.endsWith("/")?Q:Q+"/",Y=structuredClone($);if(Y.mediaFolderPath){let z=Y.mediaFolderPath.endsWith("/")?Y.mediaFolderPath:Y.mediaFolderPath+"/";if(Y.mediaFolderPath===J.slice(0,-1))Y.mediaFolderPath=v.slice(0,-1);else if(z.startsWith(J))Y.mediaFolderPath=v+Y.mediaFolderPath.slice(J.length)}if(Y.files)Y.files=Y.files.map((z)=>{if(z.startsWith(J))return v+z.slice(J.length);return z});if(Y.mediaFiles)Y.mediaFiles=Y.mediaFiles.map((z)=>{if(z.absolutePath.startsWith(J))return{...z,absolutePath:v+z.absolutePath.slice(J.length)};return z});return Y}function cT($,X){let Q=new Map;for(let{from:z,to:W}of X)Q.set(Z.posix(z),Z.posix(W));let J=$.files?.map((z)=>{let W=Z.posix(z);return Q.get(W)??z}),Y=$.mediaFiles?.map((z)=>{let W=Z.posix(z.absolutePath),G=Q.get(W);if(G)return{...z,absolutePath:G};return z})?.map((z)=>{if(z.subtitleFilePaths||z.audioFilePaths){let W=z.subtitleFilePaths?.map((U)=>{let w=Z.posix(U);return Q.get(w)??U}),G=z.audioFilePaths?.map((U)=>{let w=Z.posix(U);return Q.get(w)??U});return{...z,subtitleFilePaths:W,audioFilePaths:G}}return z});return{...$,files:J,mediaFiles:Y}}function mT($,X,Q){let J=Z.posix(X),v=Z.win(X),Y=Z.toPlatformPath(Q),z=$.folders.map((W)=>W===J?Y:W).map((W)=>W===v?Y:W);return{...$,folders:z}}var n8$=k.object({from:k.string().min(1,"Source folder path is required, in POSIX format"),to:k.string().min(1,"Destination folder path is required, in POSIX format")});async function WX($,X=e8){try{let Q=n8$.safeParse($);if(!Q.success)return{error:`Validation Failed: ${Q.error.issues.map((j)=>j.message).join(", ")}`};let{from:J,to:v}=Q.data,Y=Z.posix(J),z=Z.posix(v);if(!await Uv(J,X))return X.logger?.error({from:Y,to:z},"[renameFolder] source folder is not managed by SMM"),{error:`${Y} is not managed by SMM`};let W=r1(X);if(!W)return{error:"appDataDir is not configured"};let G=await N1(W,Y);if(!G)return X.logger?.error({from:Y,to:z},"[renameFolder] media metadata not found"),{error:`Media metadata not found: ${J}`};let U=gT(G,Y,z);await cK(W,U),await AT(W,Y);let w=await G8(X),H=mT(w,Y,z);return await Kv(X,H),await hT.rename(Z.toPlatformPath(Y),Z.toPlatformPath(z)),X.logger?.info({from:Y,to:z},"[renameFolder] renamed media folder"),{}}catch(Q){let J=Q instanceof Error?Q.message:"Unknown error";return X.logger?.error({error:J},"[renameFolder] unexpected error"),{error:`Unexpected Error: ${J}`}}}async function nO($,X,Q){if(Q?.aborted)throw Error("Request was aborted");let J=k$($.from,"from");if(typeof J!=="string")return $9("","",J.error);let v=k$($.to,"to");if(typeof v!=="string")return $9(J,"",v.error);try{let Y=await WX({from:J,to:v},X);if(Y.error)return $9(J,v,Y.error);return nT(J,v)}catch(Y){let z=Y instanceof Error?Y.message:String(Y);return $9(J,v,`Error renaming folder: ${z}`)}}async function g8$($,X,Q,J){let v=fT(X,Q);try{let Y=await J({event:"askForConfirmation",data:{message:v},clientId:$},30000);if(!(Y?.confirmed??Y?.response==="yes"))return kT(X,Q);return null}catch(Y){return $9(X,Q,`Failed to get user confirmation: ${Y instanceof Error?Y.message:"Unknown error"}`)}}function lT($,X,Q,J){let v=J??r8;return{description:TK,inputSchema:SK,outputSchema:CK,execute:async(Y)=>{if(Q?.aborted)throw Error("Request was aborted");let z=Y??{},W=k$(z.from,"from");if(typeof W!=="string")return $9("","",W.error);let G=k$(z.to,"to");if(typeof G!=="string")return $9(W,"",G.error);let U=await g8$($,W,G,v);if(U)return U;if(Q?.aborted)throw Error("Request was aborted");return nO({from:W,to:G},X,Q)}}}function pT($){let X=Z.posix($).split("/").filter(Boolean);return X[X.length-1]??Z.posix($)}function hK($,X){return`Rename episode file "${pT($)}" to "${pT(X)}"? + +This will: +`+` • Rename the episode video on disk +`+` • Rename same-stem associated files (e.g. subtitles) in the same directory +`+" • Update media metadata"}function lK($,X,Q){return{renamed:!1,mediaFolder:Z.toPlatformPath($),from:Z.toPlatformPath(X),to:Z.toPlatformPath(Q),succeeded:[],failed:[],error:WT}}function T0($,X,Q,J){return{renamed:!1,mediaFolder:Z.toPlatformPath($),from:Z.toPlatformPath(X),to:Z.toPlatformPath(Q),succeeded:[],failed:[],error:J}}function dT($,X,Q,J,v=[]){return{renamed:J.length>0,mediaFolder:Z.toPlatformPath($),from:Z.toPlatformPath(X),to:Z.toPlatformPath(Q),succeeded:J.map((Y)=>({from:Z.toPlatformPath(Y.from),to:Z.toPlatformPath(Y.to)})),failed:v,...v.length>0?{error:v.map((Y)=>Y.error).join("; ")}:{}}}async function gO($,X,Q){if(Q?.aborted)throw Error("Request was aborted");let J=k$($.mediaFolder,"mediaFolder");if(typeof J!=="string")return T0("","","",J.error);let v=k$($.from,"from");if(typeof v!=="string")return T0(J,"","",v.error);let Y=k$($.to,"to");if(typeof Y!=="string")return T0(J,v,"",Y.error);if(!X)return T0(J,v,Y,"rename-episode-file is not available on this host");try{let z=await X({mediaFolderPath:J,from:v,to:Y});return dT(J,v,Y,z.succeeded,z.failed)}catch(z){let W=z instanceof Error?z.message:String(z);return T0(J,v,Y,`Error renaming episode file: ${W}`)}}async function c8$($,X,Q,J){let v=hK(X,Q);try{let Y=await J({event:"askForConfirmation",data:{message:v},clientId:$},30000);if(!(Y?.confirmed??Y?.response==="yes"))return lK("",X,Q);return null}catch(Y){return T0("",X,Q,`Failed to get user confirmation: ${Y instanceof Error?Y.message:"Unknown error"}`)}}function oT($,X,Q,J){let v=J??r8;return{description:uK,inputSchema:_K,outputSchema:yK,execute:async(Y)=>{if(Q?.aborted)throw Error("Request was aborted");let z=Y??{},W=k$(z.mediaFolder,"mediaFolder");if(typeof W!=="string")return T0("","","",W.error);let G=k$(z.from,"from");if(typeof G!=="string")return T0(W,"","",G.error);let U=k$(z.to,"to");if(typeof U!=="string")return T0(W,G,"",U.error);let w=await c8$($,G,U,v);if(w)return{...w,mediaFolder:Z.toPlatformPath(W)};if(Q?.aborted)throw Error("Request was aborted");return gO({mediaFolder:W,from:G,to:U},X,Q)}}}function aT($){return{id:$,message:$T}}function pK($,X){return{id:"",message:"",error:$.trim()?`${X} (path: ${Z.toPlatformPath($)})`:X}}async function Kz($,X,Q){if(Q?.aborted)throw Error("Request was aborted");let J=k$($.path,"path");if(typeof J!=="string")return pK("",J.error);if(!X)return pK(J,"scrape is not available on this host");try{let v=typeof $.language==="string"&&$.language.trim()!==""?$.language:void 0,{id:Y}=await X(J,v!==void 0?{language:v}:void 0);return aT(Y)}catch(v){let Y=v instanceof Error?v.message:String(v),z=Y.startsWith("Error Reason:")?Y:`Error Reason: ${Y}`;return pK(J,z)}}function dK($,X){return{description:GK,inputSchema:UK,outputSchema:KK,execute:async(Q)=>{let J=Q??{};return Kz({path:J.path,language:J.language},$,X)}}}function rT($){return{job:$}}function wz($){return{error:$}}async function Hz($,X,Q){if(Q?.aborted)throw Error("Request was aborted");let J=k$($,"id");if(typeof J!=="string")return wz(J.error);if(!X)return wz("get-job is not available on this host");try{let v=X(J);if(v==null)return wz("Error Reason: Job not found");return rT(v)}catch(v){let Y=v instanceof Error?v.message:String(v),z=Y.startsWith("Error Reason:")?Y:`Error Reason: ${Y}`;return wz(z)}}function oK($,X){return{description:HK,inputSchema:jK,outputSchema:VK,execute:async(Q)=>{return Hz((Q??{}).id??"",$,X)}}}function tT($,X,Q){return{id:X??(typeof crypto<"u"&&"randomUUID"in crypto?crypto.randomUUID():`${Date.now()}-${Math.random().toString(16).slice(2)}`),task:"rename-files",status:Q?.status??"pending",creator:Q?.creator??"app",mediaFolderPath:Z.posix($),files:[]}}function sT($,X){if(!$)return`Error Reason: folderPath "${Z.posix(X)}" is not opened in SMM`;return}function m8$($,X){let Q=Z.posix(X);if(!($.mediaFiles??[]).find((v)=>v.absolutePath===Q))return"Error Reason: Not Episode Video File";return}async function eT($,X,Q){let J=Z.posix(X.from),v=Z.posix(X.to),Y=[...$.files,{from:J,to:v}],z=await Q.validateOperations(Y,$.mediaFolderPath);if(!z.isValid)return{error:`Error Reason: ${z.errors.join(` +`)}`};let W=await Q.getMediaMetadata($.mediaFolderPath);if(!W)return{error:`Error Reason: Media metadata not found for media folder: ${$.mediaFolderPath}`};let G=m8$(W,J);if(G)return{error:G};return{...$,files:[...$.files,{from:J,to:v}]}}var X9="Task is created successfuly. User need to go to SMM, review and approve the task.",GX="该任务已被用户取消, 请停止后续操作";var cO={event:"recognizeMediaFilePlanReady"},mO={event:"renameFilesPlanReady"};var $S="userConfigUpdated",XS="userConfig.folderRenamed";var S0=require("node:fs/promises"),aK=x$(require("node:path")),hO=require("node:crypto");function QS($){return $==="preparing"||$==="pending"}function rK($){return aK.default.join($,"plans")}function f6($,X){return aK.default.join(rK($),`${X}.plan.json`)}async function lO($,X){let Q=rK($);try{if(!(await S0.stat(Q)).isDirectory())throw Error("Plans path exists but is not a directory")}catch(J){if(J.code==="ENOENT"){await S0.mkdir(Q,{recursive:!0});return}throw J}}async function vS($,X,Q){await lO($,Q);let J=tT(Z.posix(X),void 0,{creator:"ai",status:"preparing"});return await Q.writeJson(f6($,J.id),J),J.id}async function JS($,X,Q,J,v,Y){let z=f6($,X),W=await v.readJson(z)??null;if(!W)throw Error(`Task with id ${X} not found`);if(W.status==="rejected")throw Error(GX);let G=await eT(W,{from:Q,to:J},Y);if("error"in G)throw Error(G.error.replace(/^Error Reason: /,""));await v.writeJson(z,G)}async function YS($,X,Q){let J=await tK($,X,Q);if(!J||J.task!=="rename-files")return null;return J}async function tK($,X,Q){let J=await Q.readJson(f6($,X));if(!J)return null;return oO(dO(J))}async function zS($,X,Q){await lO($,Q);let J=hO.randomUUID(),v={id:J,task:"recognize-media-file",status:"preparing",creator:"ai",mediaFolderPath:Z.posix(X),files:[]};return await Q.writeJson(f6($,J),v),J}async function pO($,X){for(let Q of $){if(!Q.path)throw Error(`File path is empty for S${Q.season}E${Q.episode}`);let J=Z.toPlatformPath(Z.posix(Q.path));if(!await X.exists(J))throw Error(`File "${Z.posix(Q.path)}" (S${Q.season}E${Q.episode}) does not exist in the media folder`)}}async function WS($,X,Q,J,v={}){let Y=f6($,X),z=await J.readJson(Y)??null;if(!z)throw Error(`Task with id ${X} not found`);if(z.status==="rejected")throw Error(GX);let W=Z.posix(Q.path);await(v.validateFiles??((U)=>pO(U,J)))([{...Q,path:W}]),z.files.push({season:Q.season,episode:Q.episode,path:W}),await J.writeJson(Y,z)}async function GS($,X,Q){let J=await tK($,X,Q);if(!J||J.task!=="recognize-media-file")return null;return J}async function US($){let X=rK($);try{if(!(await S0.stat(X)).isDirectory())return[]}catch{return[]}return(await S0.readdir(X)).filter((J)=>J.endsWith(".plan.json")).map((J)=>aK.default.join(X,J))}function dO($){if($.creator)return $;return{...$,creator:"app"}}function oO($){let X=Z.posix($.mediaFolderPath);if($.task==="recognize-media-file")return{...$,mediaFolderPath:X,files:$.files.map((Q)=>({...Q,path:Z.posix(Q.path)}))};return{...$,mediaFolderPath:X,files:$.files.map((Q)=>({from:Z.posix(Q.from),to:Z.posix(Q.to)}))}}async function KS($,X,Q){await lO($,Q);let J=X.id??hO.randomUUID(),v=Z.posix(X.mediaFolderPath),Y=X.task==="recognize-media-file"?{id:J,task:"recognize-media-file",status:"preparing",creator:X.creator,mediaFolderPath:v,files:[]}:{id:J,task:"rename-files",status:"preparing",creator:X.creator,mediaFolderPath:v,files:[]};return await Q.writeJson(f6($,J),Y),Y}async function Hv($,X,Q,J){let v=f6($,X),Y=await J.readJson(v);if(!Y)return null;let z=dO({...Y,...Q.status!==void 0?{status:Q.status}:{},...Q.files!==void 0?{files:Q.files}:{}}),W=oO(z);if(Q.status==="completed")return await h8$($,X),W;return await J.writeJson(v,W),W}async function h8$($,X){try{await S0.unlink(f6($,X))}catch(Q){if(Q.code!=="ENOENT")throw Q}}async function sK($,X,Q){let J=Date.now(),v=rK($);Q?.info({appDataDir:$,plansDir:v},"[cleanup] plan cleanup: scanning for stale preparing plans");let Y=await US($);Q?.info({plansDir:v,scanned:Y.length},"[cleanup] plan cleanup: enumerated plan files");let z=0,W=0;for(let G of Y)try{let U=await X.readJson(G);if(!U){Q?.debug({filePath:G},"[cleanup] plan cleanup: skipping unreadable plan file");continue}if(U.status==="preparing")await S0.unlink(G),z++,Q?.debug({filePath:G,planId:U.id,task:U.task},"[cleanup] plan cleanup: removed stale preparing plan");else Q?.debug({filePath:G,planId:U.id,status:U.status},"[cleanup] plan cleanup: keeping plan (not preparing)")}catch(U){W++,Q?.warn({filePath:G,error:U.message},"[cleanup] plan cleanup: failed to process plan file, skipping")}return Q?.info({plansDir:v,scanned:Y.length,removed:z,failed:W,durationMs:Date.now()-J},"[cleanup] plan cleanup: complete"),z}async function wS($,X,Q){let J=Z.posix(X),v=await US($),Y=[];for(let z of v){let W=await Q.readJson(z);if(!W)continue;let G=oO(dO(W));if(G.mediaFolderPath===J&&QS(G.status))Y.push(G)}return Y}function aO($){return{info:(X,Q)=>$?.info(X,Q),warn:(X,Q)=>$?.warn(X,Q),error:(X,Q)=>$?.error(X,Q)}}function eK($,X,Q,J,v,Y,z){let W=aO(Y),G=v??t8;return{description:TP,toolName:j4,inputSchema:uP,execute:async(U)=>{if(z?.aborted)throw Error("Request was aborted");let{mediaFolderPath:w}=U??{};W.info({mediaFolderPath:w,clientId:$},`[tool][${j4}] Starting new rename task`);let H=Z.posix(w??""),j=Gz(X,H),V=await Q.exists(j),B=sT(V,H);if(B)return W.warn({folderPath:H},`[tool][${j4}] Media metadata not found`),a8(B.replace(/^Error Reason: /,""));try{let D=await vS(X,H,Q);W.info({taskId:D,mediaFolderPath:H,clientId:$},`[tool][${j4}] Task created successfully`);let F=f6(X,D),L=Z.posix(F),A={taskId:D,planFilePath:L};return G({event:mO.event,data:A}),W.info({taskId:D,mediaFolderPath:H,clientId:$,broadcast:!0},`[tool][${j4}] RenameFilesPlanReady broadcast sent`),J6({taskId:D})}catch(D){return W.error({mediaFolderPath:H,error:D instanceof Error?D.message:String(D),clientId:$},`[tool][${j4}] Failed to create task`),Y6(D)}}}}function $w($,X,Q,J,v,Y){let z=aO(v);return{description:SP,toolName:L6,inputSchema:_P,execute:async(W)=>{if(Y?.aborted)throw Error("Request was aborted");let{taskId:G,from:U,to:w}=W??{},H=(G??"").trim();z.info({taskId:H,from:U,to:w,clientId:$},`[tool][${L6}] Adding file to task`);try{return await JS(X,H,U??"",w??"",Q,J),z.info({taskId:H,from:U,to:w,clientId:$},`[tool][${L6}] File added successfully`),J6({})}catch(j){return z.error({taskId:H,from:U,to:w,error:j instanceof Error?j.message:String(j),clientId:$},`[tool][${L6}] Failed to add file`),Y6(j)}}}}function Xw($,X,Q,J,v,Y){let z=aO(v),W=J??t8;return{description:CP,toolName:R4,inputSchema:yP,execute:async(G)=>{if(Y?.aborted)throw Error("Request was aborted");let{taskId:U}=G??{},w=(U??"").trim();z.info({taskId:w,clientId:$},`[tool][${R4}] Ending rename task`);try{let H=await YS(X,w,Q);if(!H)return z.error({taskId:w,clientId:$},`[tool][${R4}] Task not found`),a8(`Task with id "${w}" not found`);if(H.status==="rejected")return z.warn({taskId:w,clientId:$},`[tool][${R4}] Task cancelled by user`),a8(GX);if(H.files.length===0)return z.warn({taskId:w,clientId:$},`[tool][${R4}] No files in task`),a8("No rename entries in task");await Hv(X,H.id,{status:"pending"},Q);let j=f6(X,H.id),V=Z.posix(j),B={taskId:H.id,planFilePath:V};return W({event:mO.event,data:B}),z.info({taskId:w,fileCount:H.files.length,clientId:$},`[tool][${R4}] Plan ready, UI notified`),J6({message:X9})}catch(H){return z.error({taskId:w,error:H instanceof Error?H.message:String(H),clientId:$},`[tool][${R4}] End task error`),Y6(H)}}}}async function HS($,X){let Q=[];for(let J of $)try{if(!await X.isFile(J.from))Q.push(J.from)}catch{Q.push(J.from)}return{isValid:Q.length===0,missingFiles:Q}}async function jS($,X){let Q=[];for(let J of $)try{if(await X.isFile(J.to))Q.push(J.to)}catch{continue}return{isValid:Q.length===0,existingFiles:Q}}function VS($){let X=new Set;for(let Q of $)X.add(Q.from);for(let Q of $)if(X.has(Q.to))return!1;return!0}function BS($){if($.startsWith("../"))return!0;if($.includes("/..")||$.includes("/./")||$.endsWith("/."))return!1;let X=Z.toPlatformPath($),Q=Z.posix(X),J=Q.split("/").filter((z)=>z.length>0),v=[];for(let z of J)if(z===".."){if(v.length===0)return!1;v.pop()}else if(z!==".")v.push(z);let Y=Q.startsWith("/")||/^\/[A-Za-z]:/.test(Q)?(Q.startsWith("/")?"/":"")+v.join("/"):v.join("/");return Y===Q||Y===X}function DS($){let X=[];for(let Q of $){if(!Q)continue;if(!BS(Q.from))X.push(`Source path "${Q.from}" is abnormal`);if(!BS(Q.to))X.push(`Destination path "${Q.to}" is abnormal`)}return X}function FS($){let X=new Map;for(let J=0;J<$.length;J++){let v=$[J];if(!v)continue;let Y=X.get(v.to)??[];Y.push(J),X.set(v.to,Y)}let Q=[];for(let[J,v]of X)if(v.length>1)Q.push(J);return{isValid:Q.length===0,duplicates:Q}}function NS($){let X=new Map;for(let J=0;J<$.length;J++){let v=$[J];if(!v)continue;let Y=X.get(v.from)??[];Y.push(J),X.set(v.from,Y)}let Q=[];for(let[J,v]of X)if(v.length>1)Q.push(J);return{isValid:Q.length===0,duplicates:Q}}function OS($){let X=[];for(let Q of $)if(Q&&Q.from===Q.to)X.push(Q.from);return{isValid:X.length===0,identicals:X}}function LS($,X){let Q=[],Y=new Z($).abs("posix").replace(/^\/[A-Za-z](?::|\/)/,"");for(let z of X){if(!z)continue;if(!new Z(z.from).abs("posix").replace(/^\/[A-Za-z](?::|\/)/,"").startsWith(Y))Q.push({path:z.from,type:"source"});if(!new Z(z.to).abs("posix").replace(/^\/[A-Za-z](?::|\/)/,"").startsWith(Y))Q.push({path:z.to,type:"destination"})}return{isValid:Q.length===0,invalidPaths:Q}}function AS($,X){let Q=[],J=[],v=new Map;for(let D=0;D<$.length;D++){let F=$[D];if(!F)continue;v.set(J.length,D),J.push({from:Z.posix(F.from),to:Z.posix(F.to)})}if(J.length===0)return{isValid:!0,errors:[],validatedRenames:[]};let Y=DS(J);if(Y.length>0)Q.push(...Y);let z=NS(J);if(!z.isValid)for(let D of z.duplicates){let F=[];J.forEach((L,A)=>{if(L.from===D)F.push(v.get(A)??A)}),Q.push(`Source file "${D}" appears multiple times in the batch (at indices ${F.join(", ")})`)}let W=FS(J);if(!W.isValid)for(let D of W.duplicates){let F=[];J.forEach((L,A)=>{if(L.to===D)F.push(v.get(A)??A)}),Q.push(`Target file "${D}" appears multiple times in the batch (at indices ${F.join(", ")})`)}let G=OS(J),U=new Set;J.forEach((D)=>U.add(D.from));let w=!VS(J),H=new Set;if(w)J.forEach((D,F)=>{if(U.has(D.to))H.add(F),Q.push(`Target file "${D.to}" conflicts with a source path in the same batch (cannot chain renames)`)});let j=LS(X,J);if(!j.isValid)for(let D of j.invalidPaths)Q.push(`${D.type==="source"?"Source":"Target"} path "${D.path}" is outside the media folder "${X}"`);let V=new Set;J.forEach((D,F)=>{if(Y.some((A)=>A.includes(`"${D.from}"`)||A.includes(`"${D.to}"`))||z.duplicates.includes(D.from)||W.duplicates.includes(D.to)||G.identicals.includes(D.from)||j.invalidPaths.some((A)=>A.path===D.from||A.path===D.to)||H.has(F))V.add(F)});let B=[];return J.forEach((D,F)=>{if(!V.has(F)&&D.from!==D.to)B.push({from:D.from,to:D.to})}),{isValid:Q.length===0,errors:Q,validatedRenames:B}}async function Qw($,X,Q){let J=[];for(let G of $){if(!G)continue;J.push({from:Z.posix(G.from),to:Z.posix(G.to)})}if(J.length===0)return{isValid:!0,errors:[],validatedRenames:[]};let v=AS(J,X),Y=[...v.errors],z=await HS(J,Q);if(!z.isValid)for(let G of z.missingFiles)Y.push(`Source file "${G}" does not exist in the media folder`);let W=await jS(J,Q);if(!W.isValid)for(let G of W.existingFiles)Y.push(`Target file "${G}" already exists in the filesystem`);if(Y.length>0)return{isValid:!1,errors:Y,validatedRenames:[]};return v}async function ZS($,X){return Qw($,X,nX())}function vw($){return{validateOperations:async(X,Q)=>{return ZS(X,Q)},getMediaMetadata:async(X)=>{return await N1($,X)??null}}}function qS($){return{validateFiles:(X)=>pO(X,$)}}function rO($){return{info:(X,Q)=>$?.info(X,Q),warn:(X,Q)=>$?.warn(X,Q),error:(X,Q)=>$?.error(X,Q)}}function Jw($,X,Q,J,v,Y){let z=rO(v),W=J??t8;return{description:fP,toolName:E4,inputSchema:gP,execute:async(G)=>{if(Y?.aborted)throw Error("Request was aborted");let{mediaFolderPath:U}=G??{};z.info({mediaFolderPath:U,clientId:$},`[tool][${E4}] Starting new recognition task`);let w=Z.posix(U??"");try{let H=await zS(X,w,Q);z.info({taskId:H,mediaFolderPath:w,clientId:$},`[tool][${E4}] Task created successfully`);let j=f6(X,H),V=Z.posix(j),B={taskId:H,planFilePath:V};return W({event:cO.event,data:B}),z.info({taskId:H,mediaFolderPath:w,clientId:$,broadcast:!0},"[DIAG] begin-recognize-task: plan created, RecognizeMediaFilePlanReady broadcast sent"),J6({taskId:H})}catch(H){return z.error({mediaFolderPath:w,error:H instanceof Error?H.message:String(H),clientId:$},`[tool][${E4}] Failed to create task`),Y6(H)}}}}function Yw($,X,Q,J,v,Y){let z=rO(J);return{description:kP,toolName:A6,inputSchema:cP,execute:async(W)=>{if(v?.aborted)throw Error("Request was aborted");let{taskId:G,season:U,episode:w,path:H}=W??{},j=(G??"").trim();z.info({taskId:j,season:U,episode:w,path:H,clientId:$},`[tool][${A6}] Adding file to task`);try{return await WS(X,j,{season:U??0,episode:w??0,path:H??""},Q,{validateFiles:Y?.validateFiles}),z.info({taskId:j,season:U,episode:w,path:H,clientId:$},`[tool][${A6}] File added to task successfully`),J6({})}catch(V){return z.error({taskId:j,season:U,episode:w,path:H,error:V instanceof Error?V.message:String(V),clientId:$},`[tool][${A6}] Failed to add file to task`),Y6(V)}}}}function zw($,X,Q,J,v,Y){let z=rO(v),W=J??t8;return{description:nP,toolName:o4,inputSchema:mP,execute:async(G)=>{if(Y?.aborted)throw Error("Request was aborted");let{taskId:U}=G??{},w=(U??"").trim();z.info({taskId:w,clientId:$},`[tool][${o4}] Ending recognition task`);try{let H=await GS(X,w,Q);if(!H)return z.error({taskId:w,clientId:$},`[tool][${o4}] Task not found`),Y6(`Task with id "${w}" not found`);if(H.status==="rejected")return z.warn({taskId:w,clientId:$},`[tool][${o4}] Task cancelled by user`),a8(GX);if(H.files.length===0)return z.warn({taskId:w,clientId:$},`[tool][${o4}] No files in task`),Y6("No recognized files in task");await Hv(X,H.id,{status:"pending"},Q);let j=f6(X,H.id),V=Z.posix(j),B={taskId:H.id,planFilePath:V};return W({event:cO.event,data:B}),z.info({taskId:w,folderPath:H.mediaFolderPath,fileCount:H.files.length,clientId:$},`[tool][${o4}] Task completed successfully`),J6({message:X9})}catch(H){return Y6(H)}}}}function Ww($){let{config:X,coreRoutesConfig:Q,userConfig:J,clientId:v,abortSignal:Y,fs:z,extra:W}=$,G=X.logger??Q?.logger,U=X.acknowledge??r8,w=Q?.broadcast??X.broadcast??t8,H=Q??{allowlist:[],hello:{version:"0.0.0",userDataDir:X.appDataDir,appDataDir:X.appDataDir,logDir:"",tmpDir:"",reverseProxyUrl:null,osLocale:"en-US",coreRoutesPort:0},appDataDir:X.appDataDir,logger:G},j=W?.renameFilesTask??vw(X.appDataDir),V=W?.tmdb;return{[$1]:HT(v,J,(B)=>fK({configured:B.applicationLanguage,osLocale:kK()}),U),[z8]:OT(),[o1]:qT(J,X.appDataDir,Y),[a1]:bT(H,Y),[W8]:ET(J,Y),[o8]:_T(J,Y),[J1]:lT(v,H,Y,U),[Y1]:oT(v,W?.renameEpisodeFile,Y,U),[Z6]:dK(W?.scrapeFolder,Y),[q6]:oK(W?.getJob,Y),[X1]:UT(V,Y),[Q1]:KT(V,Y),[v1]:wT(V,Y),[j4]:eK(v,X.appDataDir,z,j,w,G,Y),[L6]:$w(v,X.appDataDir,z,j,G,Y),[R4]:Xw(v,X.appDataDir,z,w,G,Y),[E4]:Jw(v,X.appDataDir,z,w,G,Y),[A6]:Yw(v,X.appDataDir,z,G,Y),[o4]:zw(v,X.appDataDir,z,w,G,Y)}}var l8$=100;async function tO($,X,Q={}){let J=$.fs??J4(),v=$.logger,Y=await p8$($,X,J);if(Y.kind==="respond")return Y.response;let{userConfig:z,body:W,providerResult:G}=Y,{messages:U,model:w,tools:H,system:j,clientId:V}=W;v?.debug({clientId:V,hasMessages:Array.isArray(U),messageCount:U?.length??0},"doChat: received chat request"),v?.debug({selectedAIProvider:z.selectedAIProvider,model:w||G.model},"doChat: using AI model configuration");let B=await EP(U??[]),D=G.provider,F=G.model,L=D,A=Ww({config:$,userConfig:z,clientId:V,abortSignal:X.signal,fs:J,extra:Q}),O=RP({model:L.chatModel(w||F),messages:B,system:j||$.systemPrompt||QT,abortSignal:X.signal,tools:{...ZO(H),[$1]:A[$1],[z8]:A[z8],[o1]:A[o1],[a1]:A[a1],[W8]:A[W8],[o8]:A[o8],[J1]:A[J1],[Y1]:A[Y1],[Z6]:A[Z6],[q6]:A[q6],[X1]:A[X1],[Q1]:A[Q1],[v1]:A[v1],[j4]:A[j4],[L6]:A[L6],[R4]:A[R4],[E4]:A[E4],[A6]:A[A6],[o4]:A[o4]},stopWhen:AO(l8$)});if(X.signal?.aborted)throw v?.info({},"doChat: request aborted before response creation"),Error("Request aborted");return v?.debug({},"doChat: streaming response created"),O.toUIMessageStreamResponse()}async function p8$($,X,Q){let J=$.logger;if(X.signal?.aborted)return J?.info({},"doChat: request was already aborted"),{kind:"respond",response:jz(499,{error:"Request was aborted"})};let v;try{v=await $.getUserConfig()}catch(W){return J?.error({error:Gw(W)},"doChat: getUserConfig failed"),{kind:"respond",response:jz(500,{error:"Failed to read user config",details:W instanceof Error?W.message:String(W)})}}if(!v.selectedAIProvider)return J?.debug({selectedAIProvider:v.selectedAIProvider},"doChat: no AI provider selected"),{kind:"respond",response:jz(400,{error:"No AI selected"})};let Y;try{Y=await $.createAIProvider(v)}catch(W){return J?.error({error:Gw(W)},"doChat: failed to create AI provider"),{kind:"respond",response:jz(400,{error:W instanceof Error?W.message:"Failed to create AI provider"})}}let z;try{z=await X.json()}catch(W){return J?.error({error:Gw(W)},"doChat: invalid JSON body"),{kind:"respond",response:jz(400,{error:"Invalid JSON body",details:W instanceof Error?W.message:String(W)})}}return{kind:"stream",userConfig:v,body:z,providerResult:Y}}function Uw($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/chat")return Promise.resolve(!1);if(!Q.config.chat)return f(X,503,{error:"chat not configured"}),Promise.resolve(!0);return d8$(Q.config.chat,$,X)}async function d8$($,X,Q){let J=await o8$(X),v;try{v=await tO($,J)}catch(Y){if(Y instanceof Error&&(Y.name==="AbortError"||Y.message==="Request aborted"))return f(Q,499,{error:"Request was aborted"}),!0;return $.logger?.error({error:Gw(Y)},"doChat: handler caught error"),f(Q,500,{error:"Failed to process chat request",details:Y instanceof Error?Y.message:String(Y)}),!0}return await a8$(v,Q),!0}function jz($,X){return new Response(JSON.stringify(X),{status:$,headers:{"Content-Type":"application/json"}})}function Gw($){if($ instanceof Error)return{message:$.message,stack:$.stack,name:$.name};return{value:String($)}}async function o8$($){let J=`http://${$.headers.host??"127.0.0.1:0"}${$.url??"/"}`,v=new Headers;for(let[z,W]of Object.entries($.headers)){if(W===void 0)continue;if(Array.isArray(W))for(let G of W)v.append(z,G);else v.set(z,String(W))}let Y={method:$.method,headers:v};if($.method&&!["GET","HEAD"].includes($.method.toUpperCase()))Y.body=$,Y.duplex="half";return new Request(J,Y)}async function a8$($,X){if(X.statusCode=$.status,$.headers.forEach((Q,J)=>{X.setHeader(J,Q)}),X.setHeader("Cache-Control","no-store"),$.body){let Q=$.body.getReader();X.flushHeaders?.();while(!0){let{done:J,value:v}=await Q.read();if(J)break;if(!X.write(v))await new Promise((z)=>X.once("drain",z))}}X.end()}var r8$=K.object({error:K.object({message:K.string(),type:K.string().nullish(),param:K.any().nullish(),code:K.union([K.string(),K.number()]).nullish()})}),Bz={errorSchema:r8$,errorToMessage:($)=>$.error.message};function IS($){var X,Q,J,v,Y,z;if($==null)return{inputTokens:{total:void 0,noCache:void 0,cacheRead:void 0,cacheWrite:void 0},outputTokens:{total:void 0,text:void 0,reasoning:void 0},raw:void 0};let W=(X=$.prompt_tokens)!=null?X:0,G=(Q=$.completion_tokens)!=null?Q:0,U=(v=(J=$.prompt_tokens_details)==null?void 0:J.cached_tokens)!=null?v:0,w=(z=(Y=$.completion_tokens_details)==null?void 0:Y.reasoning_tokens)!=null?z:0;return{inputTokens:{total:W,noCache:W-U,cacheRead:U,cacheWrite:void 0},outputTokens:{total:G,text:G-w,reasoning:w},raw:$}}function Vz($){var X,Q;return(Q=(X=$==null?void 0:$.providerOptions)==null?void 0:X.openaiCompatible)!=null?Q:{}}function t8$($){switch($){case"audio/wav":return"wav";case"audio/mp3":case"audio/mpeg":return"mp3";default:return null}}function s8$($){var X,Q,J;let v=[];for(let{role:Y,content:z,...W}of $){let G=Vz({...W});switch(Y){case"system":{v.push({role:"system",content:z,...G});break}case"user":{if(z.length===1&&z[0].type==="text"){v.push({role:"user",content:z[0].text,...Vz(z[0])});break}v.push({role:"user",content:z.map((U)=>{var w;let H=Vz(U);switch(U.type){case"text":return{type:"text",text:U.text,...H};case"file":{if(U.mediaType.startsWith("image/")){let j=U.mediaType==="image/*"?"image/jpeg":U.mediaType;return{type:"image_url",image_url:{url:U.data instanceof URL?U.data.toString():`data:${j};base64,${QU(U.data)}`},...H}}if(U.mediaType.startsWith("audio/")){if(U.data instanceof URL)throw new D1({functionality:"audio file parts with URLs"});let j=t8$(U.mediaType);if(j===null)throw new D1({functionality:`audio media type ${U.mediaType}`});return{type:"input_audio",input_audio:{data:QU(U.data),format:j},...H}}if(U.mediaType==="application/pdf"){if(U.data instanceof URL)throw new D1({functionality:"PDF file parts with URLs"});return{type:"file",file:{filename:(w=U.filename)!=null?w:"document.pdf",file_data:`data:application/pdf;base64,${QU(U.data)}`},...H}}if(U.mediaType.startsWith("text/"))return{type:"text",text:U.data instanceof URL?U.data.toString():typeof U.data==="string"?U.data:new TextDecoder().decode(U.data),...H};throw new D1({functionality:`file part media type ${U.mediaType}`})}}}),...G});break}case"assistant":{let U="",w="",H=[];for(let j of z){let V=Vz(j);switch(j.type){case"text":{U+=j.text;break}case"reasoning":{w+=j.text;break}case"tool-call":{let B=(Q=(X=j.providerOptions)==null?void 0:X.google)==null?void 0:Q.thoughtSignature;H.push({id:j.toolCallId,type:"function",function:{name:j.toolName,arguments:JSON.stringify(j.input)},...V,...B?{extra_content:{google:{thought_signature:String(B)}}}:{}});break}}}v.push({role:"assistant",content:U,...w.length>0?{reasoning_content:w}:{},tool_calls:H.length>0?H:void 0,...G});break}case"tool":{for(let U of z){if(U.type==="tool-approval-response")continue;let w=U.output,H;switch(w.type){case"text":case"error-text":H=w.value;break;case"execution-denied":H=(J=w.reason)!=null?J:"Tool execution denied.";break;case"content":case"json":case"error-json":H=JSON.stringify(w.value);break}let j=Vz(U);v.push({role:"tool",tool_call_id:U.toolCallId,content:H,...j})}break}default:throw Error(`Unsupported role: ${Y}`)}}return v}function MS({id:$,model:X,created:Q}){return{id:$!=null?$:void 0,modelId:X!=null?X:void 0,timestamp:Q!=null?new Date(Q*1000):void 0}}function xS($){switch($){case"stop":return"stop";case"length":return"length";case"content_filter":return"content-filter";case"function_call":case"tool_calls":return"tool-calls";default:return"other"}}var Kw=K.object({user:K.string().optional(),reasoningEffort:K.string().optional(),textVerbosity:K.string().optional(),strictJsonSchema:K.boolean().optional()});function e8$({tools:$,toolChoice:X}){$=($==null?void 0:$.length)?$:void 0;let Q=[];if($==null)return{tools:void 0,toolChoice:void 0,toolWarnings:Q};let J=[];for(let Y of $)if(Y.type==="provider")Q.push({type:"unsupported",feature:`provider-defined tool ${Y.id}`});else J.push({type:"function",function:{name:Y.name,description:Y.description,parameters:Y.inputSchema,...Y.strict!=null?{strict:Y.strict}:{}}});if(X==null)return{tools:J,toolChoice:void 0,toolWarnings:Q};let v=X.type;switch(v){case"auto":case"none":case"required":return{tools:J,toolChoice:v,toolWarnings:Q};case"tool":return{tools:J,toolChoice:{type:"function",function:{name:X.toolName}},toolWarnings:Q};default:throw new D1({functionality:`tool choice type: ${v}`})}}var $9$=class{constructor($,X){this.specificationVersion="v3";var Q,J;this.modelId=$,this.config=X;let v=(Q=X.errorStructure)!=null?Q:Bz;this.chunkSchema=v9$(v.errorSchema),this.failedResponseHandler=H4(v),this.supportsStructuredOutputs=(J=X.supportsStructuredOutputs)!=null?J:!1}get provider(){return this.config.provider}get providerOptionsName(){return this.config.provider.split(".")[0].trim()}get supportedUrls(){var $,X,Q;return(Q=(X=($=this.config).supportedUrls)==null?void 0:X.call($))!=null?Q:{}}transformRequestBody($){var X,Q,J;return(J=(Q=(X=this.config).transformRequestBody)==null?void 0:Q.call(X,$))!=null?J:$}async getArgs({prompt:$,maxOutputTokens:X,temperature:Q,topP:J,topK:v,frequencyPenalty:Y,presencePenalty:z,providerOptions:W,stopSequences:G,responseFormat:U,seed:w,toolChoice:H,tools:j}){var V,B,D,F,L;let A=[],O=await b0({provider:"openai-compatible",providerOptions:W,schema:Kw});if(O!=null)A.push({type:"other",message:"The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead."});let i=Object.assign(O!=null?O:{},(V=await b0({provider:"openaiCompatible",providerOptions:W,schema:Kw}))!=null?V:{},(B=await b0({provider:this.providerOptionsName,providerOptions:W,schema:Kw}))!=null?B:{}),M=(D=i==null?void 0:i.strictJsonSchema)!=null?D:!0;if(v!=null)A.push({type:"unsupported",feature:"topK"});if((U==null?void 0:U.type)==="json"&&U.schema!=null&&!this.supportsStructuredOutputs)A.push({type:"unsupported",feature:"responseFormat",details:"JSON response format schema is only supported with structuredOutputs"});let{tools:R,toolChoice:E,toolWarnings:v$}=e8$({tools:j,toolChoice:H});return{args:{model:this.modelId,user:i.user,max_tokens:X,temperature:Q,top_p:J,frequency_penalty:Y,presence_penalty:z,response_format:(U==null?void 0:U.type)==="json"?this.supportsStructuredOutputs===!0&&U.schema!=null?{type:"json_schema",json_schema:{schema:U.schema,strict:M,name:(F=U.name)!=null?F:"response",description:U.description}}:{type:"json_object"}:void 0,stop:G,seed:w,...Object.fromEntries(Object.entries((L=W==null?void 0:W[this.providerOptionsName])!=null?L:{}).filter(([q$])=>!Object.keys(Kw.shape).includes(q$))),reasoning_effort:i.reasoningEffort,verbosity:i.textVerbosity,messages:s8$($),tools:R,tool_choice:E},warnings:[...A,...v$]}}async doGenerate($){var X,Q,J,v,Y,z,W,G;let{args:U,warnings:w}=await this.getArgs({...$}),H=this.transformRequestBody(U),j=JSON.stringify(H),{responseHeaders:V,value:B,rawValue:D}=await M4({url:this.config.url({path:"/chat/completions",modelId:this.modelId}),headers:A4(this.config.headers(),$.headers),body:H,failedResponseHandler:this.failedResponseHandler,successfulResponseHandler:i4(X9$),abortSignal:$.abortSignal,fetch:this.config.fetch}),F=B.choices[0],L=[],A=F.message.content;if(A!=null&&A.length>0)L.push({type:"text",text:A});let O=(X=F.message.reasoning_content)!=null?X:F.message.reasoning;if(O!=null&&O.length>0)L.push({type:"reasoning",text:O});if(F.message.tool_calls!=null)for(let R of F.message.tool_calls){let E=(J=(Q=R.extra_content)==null?void 0:Q.google)==null?void 0:J.thought_signature;L.push({type:"tool-call",toolCallId:(v=R.id)!=null?v:kQ(),toolName:R.function.name,input:R.function.arguments,...E?{providerMetadata:{[this.providerOptionsName]:{thoughtSignature:E}}}:{}})}let i={[this.providerOptionsName]:{},...await((z=(Y=this.config.metadataExtractor)==null?void 0:Y.extractMetadata)==null?void 0:z.call(Y,{parsedBody:D}))},M=(W=B.usage)==null?void 0:W.completion_tokens_details;if((M==null?void 0:M.accepted_prediction_tokens)!=null)i[this.providerOptionsName].acceptedPredictionTokens=M==null?void 0:M.accepted_prediction_tokens;if((M==null?void 0:M.rejected_prediction_tokens)!=null)i[this.providerOptionsName].rejectedPredictionTokens=M==null?void 0:M.rejected_prediction_tokens;return{content:L,finishReason:{unified:xS(F.finish_reason),raw:(G=F.finish_reason)!=null?G:void 0},usage:IS(B.usage),providerMetadata:i,request:{body:j},response:{...MS(B),headers:V,body:D},warnings:w}}async doStream($){var X;let{args:Q,warnings:J}=await this.getArgs({...$}),v=this.transformRequestBody({...Q,stream:!0,stream_options:this.config.includeUsage?{include_usage:!0}:void 0}),Y=(X=this.config.metadataExtractor)==null?void 0:X.createStreamExtractor(),{responseHeaders:z,value:W}=await M4({url:this.config.url({path:"/chat/completions",modelId:this.modelId}),headers:A4(this.config.headers(),$.headers),body:v,failedResponseHandler:this.failedResponseHandler,successfulResponseHandler:nQ(this.chunkSchema),abortSignal:$.abortSignal,fetch:this.config.fetch}),G=[],U={unified:"other",raw:void 0},w=void 0,H=!0,j=this.providerOptionsName,V=!1,B=!1;return{stream:W.pipeThrough(new TransformStream({start(D){D.enqueue({type:"stream-start",warnings:J})},transform(D,F){var L,A,O,i,M,R,E,v$,q$,U$,f4,a6,H6,d0,R9,E9,P9,T9;if($.includeRawChunks)F.enqueue({type:"raw",rawValue:D.rawValue});if(!D.success){U={unified:"error",raw:void 0},F.enqueue({type:"error",error:D.error});return}if(Y==null||Y.processChunk(D.rawValue),"error"in D.value){U={unified:"error",raw:void 0},F.enqueue({type:"error",error:D.value.error.message});return}let o0=D.value;if(H)H=!1,F.enqueue({type:"response-metadata",...MS(o0)});if(o0.usage!=null)w=o0.usage;let j6=o0.choices[0];if((j6==null?void 0:j6.finish_reason)!=null)U={unified:xS(j6.finish_reason),raw:(L=j6.finish_reason)!=null?L:void 0};if((j6==null?void 0:j6.delta)==null)return;let D4=j6.delta,S9=(A=D4.reasoning_content)!=null?A:D4.reasoning;if(S9){if(!V)F.enqueue({type:"reasoning-start",id:"reasoning-0"}),V=!0;F.enqueue({type:"reasoning-delta",id:"reasoning-0",delta:S9})}if(D4.content){if(V)F.enqueue({type:"reasoning-end",id:"reasoning-0"}),V=!1;if(!B)F.enqueue({type:"text-start",id:"txt-0"}),B=!0;F.enqueue({type:"text-delta",id:"txt-0",delta:D4.content})}if(D4.tool_calls!=null){if(V)F.enqueue({type:"reasoning-end",id:"reasoning-0"}),V=!1;for(let X4 of D4.tool_calls){let V1=(O=X4.index)!=null?O:G.length;if(G[V1]==null){if(X4.id==null)throw new uV({data:X4,message:"Expected 'id' to be a string."});if(((i=X4.function)==null?void 0:i.name)==null)throw new uV({data:X4,message:"Expected 'function.name' to be a string."});F.enqueue({type:"tool-input-start",id:X4.id,toolName:X4.function.name}),G[V1]={id:X4.id,type:"function",function:{name:X4.function.name,arguments:(M=X4.function.arguments)!=null?M:""},hasFinished:!1,thoughtSignature:(v$=(E=(R=X4.extra_content)==null?void 0:R.google)==null?void 0:E.thought_signature)!=null?v$:void 0};let k4=G[V1];if(((q$=k4.function)==null?void 0:q$.name)!=null&&((U$=k4.function)==null?void 0:U$.arguments)!=null){if(k4.function.arguments.length>0)F.enqueue({type:"tool-input-delta",id:k4.id,delta:k4.function.arguments});if(vN(k4.function.arguments))F.enqueue({type:"tool-input-end",id:k4.id}),F.enqueue({type:"tool-call",toolCallId:(f4=k4.id)!=null?f4:kQ(),toolName:k4.function.name,input:k4.function.arguments,...k4.thoughtSignature?{providerMetadata:{[j]:{thoughtSignature:k4.thoughtSignature}}}:{}}),k4.hasFinished=!0}continue}let d$=G[V1];if(d$.hasFinished)continue;if(((a6=X4.function)==null?void 0:a6.arguments)!=null)d$.function.arguments+=(d0=(H6=X4.function)==null?void 0:H6.arguments)!=null?d0:"";if(F.enqueue({type:"tool-input-delta",id:d$.id,delta:(R9=X4.function.arguments)!=null?R9:""}),((E9=d$.function)==null?void 0:E9.name)!=null&&((P9=d$.function)==null?void 0:P9.arguments)!=null&&vN(d$.function.arguments))F.enqueue({type:"tool-input-end",id:d$.id}),F.enqueue({type:"tool-call",toolCallId:(T9=d$.id)!=null?T9:kQ(),toolName:d$.function.name,input:d$.function.arguments,...d$.thoughtSignature?{providerMetadata:{[j]:{thoughtSignature:d$.thoughtSignature}}}:{}}),d$.hasFinished=!0}}},flush(D){var F,L,A,O,i;if(V)D.enqueue({type:"reasoning-end",id:"reasoning-0"});if(B)D.enqueue({type:"text-end",id:"txt-0"});for(let R of G.filter((E)=>!E.hasFinished))D.enqueue({type:"tool-input-end",id:R.id}),D.enqueue({type:"tool-call",toolCallId:(F=R.id)!=null?F:kQ(),toolName:R.function.name,input:R.function.arguments,...R.thoughtSignature?{providerMetadata:{[j]:{thoughtSignature:R.thoughtSignature}}}:{}});let M={[j]:{},...Y==null?void 0:Y.buildMetadata()};if(((L=w==null?void 0:w.completion_tokens_details)==null?void 0:L.accepted_prediction_tokens)!=null)M[j].acceptedPredictionTokens=(A=w==null?void 0:w.completion_tokens_details)==null?void 0:A.accepted_prediction_tokens;if(((O=w==null?void 0:w.completion_tokens_details)==null?void 0:O.rejected_prediction_tokens)!=null)M[j].rejectedPredictionTokens=(i=w==null?void 0:w.completion_tokens_details)==null?void 0:i.rejected_prediction_tokens;D.enqueue({type:"finish",finishReason:U,usage:IS(w),providerMetadata:M})}})),request:{body:v},response:{headers:z}}}},TS=K.looseObject({prompt_tokens:K.number().nullish(),completion_tokens:K.number().nullish(),total_tokens:K.number().nullish(),prompt_tokens_details:K.object({cached_tokens:K.number().nullish()}).nullish(),completion_tokens_details:K.object({reasoning_tokens:K.number().nullish(),accepted_prediction_tokens:K.number().nullish(),rejected_prediction_tokens:K.number().nullish()}).nullish()}).nullish(),X9$=K.looseObject({id:K.string().nullish(),created:K.number().nullish(),model:K.string().nullish(),choices:K.array(K.object({message:K.object({role:K.literal("assistant").nullish(),content:K.string().nullish(),reasoning_content:K.string().nullish(),reasoning:K.string().nullish(),tool_calls:K.array(K.object({id:K.string().nullish(),function:K.object({name:K.string(),arguments:K.string()}),extra_content:K.object({google:K.object({thought_signature:K.string().nullish()}).nullish()}).nullish()})).nullish()}),finish_reason:K.string().nullish()})),usage:TS}),Q9$=K.looseObject({id:K.string().nullish(),created:K.number().nullish(),model:K.string().nullish(),choices:K.array(K.object({delta:K.object({role:K.enum(["assistant"]).nullish(),content:K.string().nullish(),reasoning_content:K.string().nullish(),reasoning:K.string().nullish(),tool_calls:K.array(K.object({index:K.number().nullish(),id:K.string().nullish(),function:K.object({name:K.string().nullish(),arguments:K.string().nullish()}),extra_content:K.object({google:K.object({thought_signature:K.string().nullish()}).nullish()}).nullish()})).nullish()}).nullish(),finish_reason:K.string().nullish()})),usage:TS}),v9$=($)=>K.union([Q9$,$]);function bS($){var X,Q;if($==null)return{inputTokens:{total:void 0,noCache:void 0,cacheRead:void 0,cacheWrite:void 0},outputTokens:{total:void 0,text:void 0,reasoning:void 0},raw:void 0};let J=(X=$.prompt_tokens)!=null?X:0,v=(Q=$.completion_tokens)!=null?Q:0;return{inputTokens:{total:J,noCache:J,cacheRead:void 0,cacheWrite:void 0},outputTokens:{total:v,text:v,reasoning:void 0},raw:$}}function J9$({prompt:$,user:X="user",assistant:Q="assistant"}){let J="";if($[0].role==="system")J+=`${$[0].content} + +`,$=$.slice(1);for(let{role:v,content:Y}of $)switch(v){case"system":throw new a0({message:"Unexpected system message in prompt: ${content}",prompt:$});case"user":{let z=Y.map((W)=>{switch(W.type){case"text":return W.text}}).filter(Boolean).join("");J+=`${X}: +${z} + +`;break}case"assistant":{let z=Y.map((W)=>{switch(W.type){case"text":return W.text;case"tool-call":throw new D1({functionality:"tool-call messages"})}}).join("");J+=`${Q}: +${z} + +`;break}case"tool":throw new D1({functionality:"tool messages"});default:throw Error(`Unsupported role: ${v}`)}return J+=`${Q}: +`,{prompt:J,stopSequences:[` +${X}:`]}}function iS({id:$,model:X,created:Q}){return{id:$!=null?$:void 0,modelId:X!=null?X:void 0,timestamp:Q!=null?new Date(Q*1000):void 0}}function RS($){switch($){case"stop":return"stop";case"length":return"length";case"content_filter":return"content-filter";case"function_call":case"tool_calls":return"tool-calls";default:return"other"}}var Y9$=K.object({echo:K.boolean().optional(),logitBias:K.record(K.string(),K.number()).optional(),suffix:K.string().optional(),user:K.string().optional()}),z9$=class{constructor($,X){this.specificationVersion="v3";var Q;this.modelId=$,this.config=X;let J=(Q=X.errorStructure)!=null?Q:Bz;this.chunkSchema=G9$(J.errorSchema),this.failedResponseHandler=H4(J)}get provider(){return this.config.provider}get providerOptionsName(){return this.config.provider.split(".")[0].trim()}get supportedUrls(){var $,X,Q;return(Q=(X=($=this.config).supportedUrls)==null?void 0:X.call($))!=null?Q:{}}async getArgs({prompt:$,maxOutputTokens:X,temperature:Q,topP:J,topK:v,frequencyPenalty:Y,presencePenalty:z,stopSequences:W,responseFormat:G,seed:U,providerOptions:w,tools:H,toolChoice:j}){var V;let B=[],D=(V=await b0({provider:this.providerOptionsName,providerOptions:w,schema:Y9$}))!=null?V:{};if(v!=null)B.push({type:"unsupported",feature:"topK"});if(H==null?void 0:H.length)B.push({type:"unsupported",feature:"tools"});if(j!=null)B.push({type:"unsupported",feature:"toolChoice"});if(G!=null&&G.type!=="text")B.push({type:"unsupported",feature:"responseFormat",details:"JSON response format is not supported."});let{prompt:F,stopSequences:L}=J9$({prompt:$}),A=[...L!=null?L:[],...W!=null?W:[]];return{args:{model:this.modelId,echo:D.echo,logit_bias:D.logitBias,suffix:D.suffix,user:D.user,max_tokens:X,temperature:Q,top_p:J,frequency_penalty:Y,presence_penalty:z,seed:U,...w==null?void 0:w[this.providerOptionsName],prompt:F,stop:A.length>0?A:void 0},warnings:B}}async doGenerate($){let{args:X,warnings:Q}=await this.getArgs($),{responseHeaders:J,value:v,rawValue:Y}=await M4({url:this.config.url({path:"/completions",modelId:this.modelId}),headers:A4(this.config.headers(),$.headers),body:X,failedResponseHandler:this.failedResponseHandler,successfulResponseHandler:i4(W9$),abortSignal:$.abortSignal,fetch:this.config.fetch}),z=v.choices[0],W=[];if(z.text!=null&&z.text.length>0)W.push({type:"text",text:z.text});return{content:W,usage:bS(v.usage),finishReason:{unified:RS(z.finish_reason),raw:z.finish_reason},request:{body:X},response:{...iS(v),headers:J,body:Y},warnings:Q}}async doStream($){let{args:X,warnings:Q}=await this.getArgs($),J={...X,stream:!0,stream_options:this.config.includeUsage?{include_usage:!0}:void 0},{responseHeaders:v,value:Y}=await M4({url:this.config.url({path:"/completions",modelId:this.modelId}),headers:A4(this.config.headers(),$.headers),body:J,failedResponseHandler:this.failedResponseHandler,successfulResponseHandler:nQ(this.chunkSchema),abortSignal:$.abortSignal,fetch:this.config.fetch}),z={unified:"other",raw:void 0},W=void 0,G=!0;return{stream:Y.pipeThrough(new TransformStream({start(U){U.enqueue({type:"stream-start",warnings:Q})},transform(U,w){var H;if($.includeRawChunks)w.enqueue({type:"raw",rawValue:U.rawValue});if(!U.success){z={unified:"error",raw:void 0},w.enqueue({type:"error",error:U.error});return}let j=U.value;if("error"in j){z={unified:"error",raw:void 0},w.enqueue({type:"error",error:j.error});return}if(G)G=!1,w.enqueue({type:"response-metadata",...iS(j)}),w.enqueue({type:"text-start",id:"0"});if(j.usage!=null)W=j.usage;let V=j.choices[0];if((V==null?void 0:V.finish_reason)!=null)z={unified:RS(V.finish_reason),raw:(H=V.finish_reason)!=null?H:void 0};if((V==null?void 0:V.text)!=null)w.enqueue({type:"text-delta",id:"0",delta:V.text})},flush(U){if(!G)U.enqueue({type:"text-end",id:"0"});U.enqueue({type:"finish",finishReason:z,usage:bS(W)})}})),request:{body:J},response:{headers:v}}}},SS=K.object({prompt_tokens:K.number(),completion_tokens:K.number(),total_tokens:K.number()}),W9$=K.object({id:K.string().nullish(),created:K.number().nullish(),model:K.string().nullish(),choices:K.array(K.object({text:K.string(),finish_reason:K.string()})),usage:SS.nullish()}),G9$=($)=>K.union([K.object({id:K.string().nullish(),created:K.number().nullish(),model:K.string().nullish(),choices:K.array(K.object({text:K.string(),finish_reason:K.string().nullish(),index:K.number()})),usage:SS.nullish()}),$]),sO=K.object({dimensions:K.number().optional(),user:K.string().optional()}),U9$=class{constructor($,X){this.specificationVersion="v3",this.modelId=$,this.config=X}get provider(){return this.config.provider}get maxEmbeddingsPerCall(){var $;return($=this.config.maxEmbeddingsPerCall)!=null?$:2048}get supportsParallelCalls(){var $;return($=this.config.supportsParallelCalls)!=null?$:!0}get providerOptionsName(){return this.config.provider.split(".")[0].trim()}async doEmbed({values:$,headers:X,abortSignal:Q,providerOptions:J}){var v,Y,z;let W=[],G=await b0({provider:"openai-compatible",providerOptions:J,schema:sO});if(G!=null)W.push({type:"other",message:"The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead."});let U=Object.assign(G!=null?G:{},(v=await b0({provider:"openaiCompatible",providerOptions:J,schema:sO}))!=null?v:{},(Y=await b0({provider:this.providerOptionsName,providerOptions:J,schema:sO}))!=null?Y:{});if($.length>this.maxEmbeddingsPerCall)throw new lM({provider:this.provider,modelId:this.modelId,maxEmbeddingsPerCall:this.maxEmbeddingsPerCall,values:$});let{responseHeaders:w,value:H,rawValue:j}=await M4({url:this.config.url({path:"/embeddings",modelId:this.modelId}),headers:A4(this.config.headers(),X),body:{model:this.modelId,input:$,encoding_format:"float",dimensions:U.dimensions,user:U.user},failedResponseHandler:H4((z=this.config.errorStructure)!=null?z:Bz),successfulResponseHandler:i4(K9$),abortSignal:Q,fetch:this.config.fetch});return{warnings:W,embeddings:H.data.map((V)=>V.embedding),usage:H.usage?{tokens:H.usage.prompt_tokens}:void 0,providerMetadata:H.providerMetadata,response:{headers:w,body:j}}}},K9$=K.object({data:K.array(K.object({embedding:K.array(K.number())})),usage:K.object({prompt_tokens:K.number()}).nullish(),providerMetadata:K.record(K.string(),K.record(K.string(),K.any())).optional()}),w9$=class{constructor($,X){this.modelId=$,this.config=X,this.specificationVersion="v3",this.maxImagesPerCall=10}get provider(){return this.config.provider}get providerOptionsKey(){return this.config.provider.split(".")[0].trim()}getArgs($){return{...$[this.providerOptionsKey],...$[H9$(this.providerOptionsKey)]}}async doGenerate({prompt:$,n:X,size:Q,aspectRatio:J,seed:v,providerOptions:Y,headers:z,abortSignal:W,files:G,mask:U}){var w,H,j,V,B;let D=[];if(J!=null)D.push({type:"unsupported",feature:"aspectRatio",details:"This model does not support aspect ratio. Use `size` instead."});if(v!=null)D.push({type:"unsupported",feature:"seed"});let F=(j=(H=(w=this.config._internal)==null?void 0:w.currentDate)==null?void 0:H.call(w))!=null?j:new Date,L=this.getArgs(Y);if(G!=null&&G.length>0){let{value:i,responseHeaders:M}=await Tb({url:this.config.url({path:"/images/edits",modelId:this.modelId}),headers:A4(this.config.headers(),z),formData:Lb({model:this.modelId,prompt:$,image:await Promise.all(G.map((R)=>PS(R))),mask:U!=null?await PS(U):void 0,n:X,size:Q,...L}),failedResponseHandler:H4((V=this.config.errorStructure)!=null?V:Bz),successfulResponseHandler:i4(ES),abortSignal:W,fetch:this.config.fetch});return{images:i.data.map((R)=>R.b64_json),warnings:D,response:{timestamp:F,modelId:this.modelId,headers:M}}}let{value:A,responseHeaders:O}=await M4({url:this.config.url({path:"/images/generations",modelId:this.modelId}),headers:A4(this.config.headers(),z),body:{model:this.modelId,prompt:$,n:X,size:Q,...L,response_format:"b64_json"},failedResponseHandler:H4((B=this.config.errorStructure)!=null?B:Bz),successfulResponseHandler:i4(ES),abortSignal:W,fetch:this.config.fetch});return{images:A.data.map((i)=>i.b64_json),warnings:D,response:{timestamp:F,modelId:this.modelId,headers:O}}}},ES=K.object({data:K.array(K.object({b64_json:K.string()}))});async function PS($){if($.type==="url")return qb($.url);let X=$.data instanceof Uint8Array?$.data:l8($.data);return new Blob([X],{type:$.mediaType})}function H9$($){return $.replace(/[_-]([a-z])/g,(X)=>X[1].toUpperCase())}var j9$="2.0.30";function CS($){let X=WU($.baseURL),Q=$.name,J={...$.apiKey&&{Authorization:`Bearer ${$.apiKey}`},...$.headers},v=()=>M0(J,`ai-sdk/openai-compatible/${j9$}`),Y=(j)=>({provider:`${Q}.${j}`,url:({path:V})=>{let B=new URL(`${X}${V}`);if($.queryParams)B.search=new URLSearchParams($.queryParams).toString();return B.toString()},headers:v,fetch:$.fetch}),z=(j)=>W(j),W=(j)=>new $9$(j,{...Y("chat"),includeUsage:$.includeUsage,supportsStructuredOutputs:$.supportsStructuredOutputs,transformRequestBody:$.transformRequestBody,metadataExtractor:$.metadataExtractor}),G=(j)=>new z9$(j,{...Y("completion"),includeUsage:$.includeUsage}),U=(j)=>new U9$(j,{...Y("embedding")}),w=(j)=>new w9$(j,Y("image")),H=(j)=>z(j);return H.specificationVersion="v3",H.languageModel=z,H.chatModel=W,H.completionModel=G,H.embeddingModel=U,H.textEmbeddingModel=U,H.imageModel=w,H}var uS=[{name:"DeepSeek",baseURL:"https://api.deepseek.com",apiKey:"",model:"deepseek-v4-flash"},{name:"OpenAI",baseURL:"https://api.openai.com/v1",apiKey:"",model:"gpt-4o"},{name:"OpenRouter",baseURL:"https://openrouter.ai/api/v1",apiKey:"",model:"deepseek/deepseek-v4-flash"},{name:"GLM",baseURL:"https://open.bigmodel.cn/api/paas/v4",apiKey:"",model:"GLM-4.5"},{name:"Other",baseURL:"",apiKey:"",model:""}],eO="DeepSeek";var V9$={DeepSeek:"deepseek",OpenAI:"openAI",OpenRouter:"openrouter",GLM:"glm",Other:"other"},B9$={OpenAI:"OpenAI",DeepSeek:"DeepSeek",OpenRouter:"OpenRouter",GLM:"GLM",Other:"Other"};function _S($){if(!$.ai&&$.aiProviders)return!1;if(!$.ai)return!1;let X=$.ai,Q=uS.map((v)=>{let Y=V9$[v.name],z=Y?X[Y]:void 0;return{name:v.name,baseURL:z?.baseURL?.trim()?z.baseURL:v.baseURL,apiKey:z?.apiKey??v.apiKey??"",model:z?.model?.trim()?z.model:v.model}});$.aiProviders=Q;let J=$.selectedAI;if(J)$.selectedAIProvider=B9$[J]||eO;else $.selectedAIProvider=eO;return delete $.ai,delete $.selectedAI,!0}var dC=x$(require("node:http")),oC=x$(require("node:https")),aC=x$(vC(),1),rC=x$(KC(),1),tC=x$(hC(),1);var Ew=x$(require("node:zlib")),Pw=require("node:util"),xQ$=Pw.promisify(Ew.default.gunzip),bQ$=Pw.promisify(Ew.default.inflate),iQ$=Pw.promisify(Ew.default.brotliDecompress);async function AL($,X){if(!X||typeof X!=="string")return $;let Q=X.split(",")[0]?.trim().toLowerCase();if(Q==="gzip"||Q==="x-gzip")return xQ$($);if(Q==="deflate")return bQ$($);if(Q==="br")return iQ$($);return $}function RQ$($){let X=$??502;if(X===304)return 200;return X}function EQ$($){let X={};for(let[Q,J]of Object.entries($)){if(J===void 0)continue;let v=Q.toLowerCase();if(v==="content-encoding"||v==="content-length")continue;if(Array.isArray(J))X[Q]=J.join(", ");else X[Q]=J}return X}async function lC($,X){let Q=await AL(X,$.headers["content-encoding"]),J=EQ$($.headers);return J["Content-Length"]=String(Q.length),new Response(Q,{status:RQ$($.statusCode),statusText:$.statusMessage??"",headers:J})}function qv($,X){if($ instanceof Request)return X!==void 0?new Request($,X):$;let Q=typeof $==="string"?$:$.href;return new Request(Q,X)}function sC(){return typeof globalThis.Bun<"u"}function v9($){try{let X=new URL($),Q=X.protocol==="https:"?"443":X.protocol==="http:"?"80":"",J=X.port||Q;return J?`${X.hostname}:${J}`:X.hostname}catch{return"(invalid-proxy-url)"}}function eC($,X){let Q=new URL($);if(Q.protocol==="socks5:"||Q.protocol==="socks5h:")return"socks5";if(sC())return"bun-native";if(X){if(new URL(X).protocol==="http:")return"node-forward"}return"node-connect"}function pC($,X,Q){return async(J,v)=>{let Y=qv(J,v),z=new URL(Y.url);Q?.debug({proxyMode:X,method:Y.method,targetHost:z.host,targetPath:z.pathname},"[ProxiedFetch] outbound request");try{let W=await $(Y);return Q?.debug({proxyMode:X,status:W.status,targetHost:z.host},"[ProxiedFetch] outbound response"),W}catch(W){throw Q?.debug({proxyMode:X,targetHost:z.host,err:W,errorMessage:W instanceof Error?W.message:String(W)},"[ProxiedFetch] outbound failed"),W}}}function PQ$($){let X=new Headers($.headers);return X.delete("accept-encoding"),X.set("Host",new URL($.url).host),Object.fromEntries(X.entries())}function $u($,X,Q){let J=new URL($.url),v=$.method.toUpperCase(),Y=v!=="GET"&&v!=="HEAD",z=J.protocol==="https:",W=PQ$($);return new Promise((G,U)=>{let w={hostname:J.hostname,port:Number(J.port)||(z?443:80),path:J.pathname+J.search,method:v,headers:W,agent:X,timeout:30000},H=(z?oC.default:dC.default).request(w,(j)=>{let V=[];j.on("data",(B)=>V.push(B)),j.on("end",()=>{lC(j,Buffer.concat(V)).then(G,U)}),j.on("error",U)});if(H.on("error",U),H.on("timeout",()=>{H.destroy(),U(Error(Q))}),Y)$.arrayBuffer().then((j)=>{H.write(Buffer.from(j)),H.end()},U);else H.end()})}function TQ$($,X){let J=new URL($.url).protocol==="https:",v=J?new rC.HttpsProxyAgent(X):new aC.HttpProxyAgent(X);return $u($,v,J?"HTTPS proxy request timeout":"HTTP proxy request timeout")}function SQ$($,X){return $u($,new tC.SocksProxyAgent(X),"SOCKS5 proxy request timeout")}function Tw($,X){let Q=new URL($);if(Q.protocol==="socks5:"||Q.protocol==="socks5h:")return X?.debug({proxyMode:"socks5",httpProxyHost:v9($)},"[ProxiedFetch] using outbound proxy"),pC((v)=>SQ$(v,$),"socks5",X);if(Q.protocol!=="http:"&&Q.protocol!=="https:")throw Error(`Unsupported proxy scheme: "${Q.protocol}". Use http://, https://, or socks5://.`);if(sC())return X?.debug({proxyMode:"bun-native",httpProxyHost:v9($)},"[ProxiedFetch] using outbound proxy"),pC(async(v)=>{let Y=v.method,z=new Headers(v.headers),W=Y.toUpperCase()!=="GET"&&Y.toUpperCase()!=="HEAD"?await v.arrayBuffer():void 0;return fetch(v.url,{method:Y,headers:z,body:W,proxy:$})},"bun-native",X);return X?.debug({proxyMode:"node-connect",httpProxyHost:v9($)},"[ProxiedFetch] using outbound proxy"),async(v,Y)=>{let z=qv(v,Y),W=new URL(z.url),G=W.protocol==="https:"?"node-connect":"node-forward";X?.debug({proxyMode:G,method:z.method,targetHost:W.host,targetPath:W.pathname},"[ProxiedFetch] outbound request");try{let U=await TQ$(z,$);return X?.debug({proxyMode:G,status:U.status,targetHost:W.host},"[ProxiedFetch] outbound response"),U}catch(U){throw X?.debug({proxyMode:G,targetHost:W.host,err:U,errorMessage:U instanceof Error?U.message:String(U)},"[ProxiedFetch] outbound failed"),U}}}var Xu=require("node:buffer"),Qu=require("node:fs/promises"),vu=require("node:url"),Ju=require("node:path");var Yu="image/jpeg",CQ$={".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".webp":"image/webp",".svg":"image/svg+xml",".ico":"image/x-icon",".bmp":"image/bmp",".avif":"image/avif",".apng":"image/apng"};function uQ$($){return CQ$[$.toLowerCase()]??Yu}function _Q$($){if($.startsWith("//"))return`https:${$}`;return $}var yQ$={accept:"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8","accept-language":"en-US,en;q=0.9","cache-control":"no-cache","sec-fetch-dest":"image","sec-fetch-mode":"no-cors","sec-fetch-site":"cross-site","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"};function Iv($){if(!($ instanceof Error))return String($);let X=$.cause,Q=X&&typeof X==="object"&&"code"in X?String(X.code):void 0,J=X instanceof Error?X.message:void 0,v=!Q&&typeof $==="object"&&"code"in $?String($.code):void 0,Y=!Q&&typeof $==="object"&&"errno"in $?String($.errno):void 0,z=Q??v??Y,W=J,G=[];if(z)G.push(z);if(W&&W!==z)G.push(W);if(G.length>0)return`${$.message} (${G.join(": ")})`;return $.message}function fQ$($){let X=_Q$($);if(X.startsWith("file://")){let Q=vu.fileURLToPath(X);return{kind:"file",normalizedUrl:X,platformPath:Q}}if(X.startsWith("http://")||X.startsWith("https://"))return{kind:"http",normalizedUrl:X};throw Error(`Invalid image URL: ${$}. Must be http://, https://, protocol-relative (//), or file://`)}async function Nz($,X){let{allowlist:Q,logger:J,fetchImpl:v=fetch}=X,Y=fQ$($);if(J?.info({url:Y.normalizedUrl,kind:Y.kind},"[DownloadImage] processing request"),Y.kind==="file"){let w=Y.platformPath,H=Z.posix(w);if(!b4(H,Q))throw Error(`Permission denied: file ${w} is not allowed to be read`);let j=await Qu.readFile(w),V=Ju.extname(w),B=uQ$(V);return J?.info({platformPath:w,bytes:j.length,contentType:B},"[DownloadImage] read file"),{buffer:j,contentType:B}}let z;try{z=await v(Y.normalizedUrl,{method:"GET",headers:yQ$})}catch(w){throw Error(`Failed to download image: ${Iv(w)}`)}if(!z.ok)throw Error(`HTTP error! status: ${z.status}`);let W=z.headers.get("content-type")??Yu,G=await z.arrayBuffer(),U=Xu.Buffer.from(G);return J?.info({url:Y.normalizedUrl,bytes:U.length,contentType:W},"[DownloadImage] fetched remote image"),{buffer:U,contentType:W}}var uw=30000,_w=31000,Cw=new Set(["api.themoviedb.org","api4.thetvdb.com","mediadb.vercel.app","tmdb-mcp-server.imlc.me","httpbin.io","api.deepseek.com","api.openai.com","openrouter.ai","open.bigmodel.cn"]),kQ$=new Set(["connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailer","transfer-encoding","upgrade"]),nQ$=new Set(["connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailer","transfer-encoding","upgrade","content-length","content-encoding"]),gQ$=new Set(["x-smm-proxy-upstream-baseurl","x-http-proxy"]),cQ$=new Set(["if-none-match","if-modified-since","if-match","if-unmodified-since"]);function ZL($,X,Q){let J=new URL($),v=J.pathname.replace(/\/+$/,""),Y=X.startsWith("/")?X:`/${X}`,z=`${v}${Y}`,W=Q.startsWith("?")?Q:"";return`${J.origin}${z}${W}`}function qL($,X){let Q;try{Q=new URL($)}catch{throw Error(`Invalid upstream base URL: "${$}"`)}if(Q.protocol!=="https:"&&Q.protocol!=="http:")throw Error(`Upstream base URL must use http or https protocol, got: "${Q.protocol}"`);if(!X.has(Q.hostname))throw Error(`Upstream host "${Q.hostname}" is not allowed. Allowed hosts: ${[...X].join(", ")}`);return Q}function IL($,X){let Q=new Headers;return $.headers.forEach((J,v)=>{let Y=v.toLowerCase();if(kQ$.has(Y))return;if(gQ$.has(Y))return;if(cQ$.has(Y))return;Q.set(v,J)}),Q.set("Host",X.host),Q}function ML($){let X=new Headers;return $.headers.forEach((Q,J)=>{let v=J.toLowerCase();if(nQ$.has(v))return;X.set(J,Q)}),X}function Wu(){return{"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS","Access-Control-Allow-Headers":"*","Access-Control-Max-Age":"86400"}}function Sw($,X={}){let Q=new Headers;if(X.headers){let J=X.headers;if(J instanceof Headers)J.forEach((v,Y)=>Q.set(Y,v));else if(Array.isArray(J))for(let[v,Y]of J)Q.set(v,Y);else for(let[v,Y]of Object.entries(J))Q.set(v,Y)}for(let[J,v]of Object.entries(Wu()))if(!Q.has(J))Q.set(J,v);return new Response($,{status:X.status??200,statusText:X.statusText,headers:Q})}function mQ$(){return{debug:()=>{},info:()=>{},warn:()=>{},error:()=>{}}}function hQ$($,X,Q){let J=$?.trim();if(!Boolean(J))return{viaHttpProxy:!1,proxyMode:"direct"};let Y=v9(J);if(!X)return{viaHttpProxy:!0,httpProxyHost:Y,proxyMode:"direct-fallback"};return{viaHttpProxy:!0,httpProxyHost:Y,proxyMode:eC(J,Q)}}var lQ$={ENOTFOUND:{message:"DNS resolution failed for upstream host",code:"DNS_RESOLUTION_FAILED"},ECONNREFUSED:{message:"Connection refused by upstream host",code:"CONNECTION_REFUSED"},ConnectionRefused:{message:"Connection refused by upstream host",code:"CONNECTION_REFUSED"},ECONNRESET:{message:"Connection was reset by upstream host",code:"CONNECTION_RESET"},ETIMEDOUT:{message:"Connection to upstream host timed out",code:"CONNECTION_TIMEOUT"},ENETUNREACH:{message:"Upstream network is unreachable",code:"NETWORK_UNREACHABLE"},ECONNABORTED:{message:"Connection was aborted",code:"CONNECTION_ABORTED"},UND_ERR_CONNECT_TIMEOUT:{message:"Connection to upstream host timed out",code:"CONNECTION_TIMEOUT"},UND_ERR_HEADERS_TIMEOUT:{message:"Upstream host did not respond with headers in time",code:"HEADERS_TIMEOUT"}},pQ$=new Set(["ERR_TLS_CERT_ALTNAME_INVALID","CERT_HAS_EXPIRED","DEPTH_ZERO_SELF_SIGNED_CERT","UNABLE_TO_VERIFY_LEAF_SIGNATURE"]);function dQ$($){if(!($ instanceof Error))return{originalError:String($)};let X=Iv($),Q,J;if(typeof $==="object"&&"code"in $)Q=String($.code);else if(typeof $==="object"&&"errno"in $)Q=String($.errno);if(!Q){let v=$;for(let Y=0;Y<3;Y++){let z=v.cause;if(!(z instanceof Error))break;if(!Q)Q=z.code;if(z.message&&z.message!==$.message)J=z.message;v=z}}return{originalError:X,systemCode:Q,causeMessage:J}}function zu($){if(pQ$.has($))return{message:"TLS certificate validation failed for upstream host",code:"TLS_ERROR"};return lQ$[$]}function oQ$($){if(!($ instanceof Error))return{message:String($),code:"UPSTREAM_REQUEST_FAILED"};let X=$;for(let J=0;J<3;J++){if(!(X instanceof Error))break;let v=X.code;if(typeof v==="string"){let Y=zu(v);if(Y)return Y}if(!v){let Y=X.errno;if(typeof Y==="string"){let z=zu(Y);if(z)return z}}X=X.cause}let Q=$.message;if(Q.includes("timeout"))return{message:"Proxy request timed out",code:"PROXY_TIMEOUT"};if(Q.includes("CONNECT refused")){let J=Q.match(/HTTP\/\d\.\d\s+(\d+)/);return{message:J?`Proxy CONNECT tunnel refused with status ${J[1]}`:"Proxy CONNECT tunnel was refused by the proxy server",code:"PROXY_CONNECT_REFUSED"}}if(Q.includes("Unsupported proxy scheme"))return{message:Q,code:"UNSUPPORTED_PROXY_SCHEME"};return{message:$.message,code:"UPSTREAM_REQUEST_FAILED"}}function aQ$($,X){return{type:"about:blank",title:X===502?"Bad Gateway":X===400?"Bad Request":"Upstream Error",status:X,detail:$.message}}async function yw($,X={}){let Q=X.logger??mQ$(),J=X.fetchImpl??fetch,v;if(X.resolveAllowedUpstreamHosts)try{v=await X.resolveAllowedUpstreamHosts()}catch(V){Q.error({err:V,errorMessage:V instanceof Error?V.message:String(V)},"[Reverse Proxy] failed to resolve allowed upstream hosts, falling back to defaults"),v=X.allowedUpstreamHosts??Cw}else v=X.allowedUpstreamHosts??Cw;if($.method==="OPTIONS")return new Response(null,{status:204,headers:Wu()});let Y=$.headers.get("X-Http-Proxy"),z=J,W=!1;if(Y?.trim()&&X.createProxiedFetch)try{let V=X.createProxiedFetch(Y,Q);if(V)z=V,W=!0;else Q.warn({httpProxyHost:v9(Y)},"[Reverse Proxy] createProxiedFetch returned undefined; using direct fetch")}catch(V){Q.error({httpProxyHost:v9(Y),err:V,errorMessage:V instanceof Error?V.message:String(V)},"[Reverse Proxy] failed to create proxied fetch, falling back to direct")}else if(Y?.trim()&&!X.createProxiedFetch)Q.warn({httpProxyHost:v9(Y)},"[Reverse Proxy] X-Http-Proxy set but createProxiedFetch is not configured; using direct fetch");let G=$.headers.get("X-SMM-Proxy-Upstream-BaseURL");if(!G)return Sw(JSON.stringify({type:"about:blank",title:"Bad Request",status:400,detail:"Missing X-SMM-Proxy-Upstream-BaseURL header"}),{status:400,headers:{"Content-Type":"application/problem+json"}});let U;try{U=qL(G,v)}catch(V){return Sw(JSON.stringify({type:"about:blank",title:"Bad Request",status:400,detail:V instanceof Error?V.message:"Invalid upstream base URL"}),{status:400,headers:{"Content-Type":"application/problem+json"}})}let w=new URL($.url),H=ZL(G,w.pathname,w.search),j=hQ$(Y,W,H);try{let V=IL($,U),B=$.method,D=new Request(H,{method:B,headers:V,body:B!=="GET"&&B!=="HEAD"?$.body:void 0,...B!=="GET"&&B!=="HEAD"?{duplex:"half"}:{}});Q.info({method:B,forwardUrl:H,upstreamHost:U.host,incomingPath:w.pathname,upstreamBaseURL:G,...j},"[Reverse Proxy] forwarding request");let F=await z(D),L=ML(F),A=await F.arrayBuffer();return Q.info({method:B,forwardUrl:H,status:F.status,responseBytes:A.byteLength,...j},"[Reverse Proxy] upstream response"),Sw(A,{status:F.status,statusText:F.statusText,headers:L})}catch(V){let B=oQ$(V),D=dQ$(V);Q.error({err:V,errorMessage:V instanceof Error?V.message:String(V),originalError:D.originalError,systemCode:D.systemCode,causeMessage:D.causeMessage,method:$.method,forwardUrl:H,incomingPath:w.pathname,upstreamBaseURL:G,...j},"[Reverse Proxy] upstream request failed");let F=aQ$(B,502);return Sw(JSON.stringify(F),{status:502,headers:{"Content-Type":"application/problem+json"}})}}var Gu=x$(require("node:http")),Uu=x$(require("node:net")),Ku=require("node:stream");function xL($={}){return async(X,Q)=>{try{let J=rQ$(X),v=await yw(J,$);await sQ$(Q,v)}catch(J){let v=$.logger;if(v)v.error({err:J,method:X.method,url:X.url},"[Reverse Proxy] node handler error");if(!Q.headersSent)Q.writeHead(500,{"Content-Type":"text/plain; charset=utf-8"});Q.end("Internal Server Error")}}}function rQ$($){let Q=`http://${$.headers.host??"127.0.0.1"}${$.url??"/"}`,J=new Headers;tQ$($.headers,J);let v=$.method??"GET",Y={method:v,headers:J};if(v!=="GET"&&v!=="HEAD")Y.body=Ku.Readable.toWeb($),Y.duplex="half";return new Request(Q,Y)}function tQ$($,X){for(let[Q,J]of Object.entries($)){if(J===void 0)continue;if(Array.isArray(J))for(let v of J)X.append(Q,v);else X.set(Q,J)}}async function sQ$($,X){let Q=X.body===null?null:Buffer.from(await X.arrayBuffer());if($.statusCode=X.status,$.statusMessage=X.statusText,X.headers.forEach((J,v)=>{let Y=v.toLowerCase();if(Y==="content-length"||Y==="content-encoding")return;try{$.setHeader(v,J)}catch{}}),Q===null||Q.length===0){$.end();return}$.setHeader("Content-Length",Q.length),$.end(Q)}function eQ$($,X="127.0.0.1"){return new Promise((Q)=>{let J=Uu.default.createServer();J.once("error",()=>{J.removeAllListeners(),Q(!1)}),J.once("listening",()=>{J.close(()=>Q(!0))}),J.listen($,X)})}async function bL($=new Set,X={start:uw,end:_w},Q=iJ()){for(let J=X.start;J<=X.end;J++){if($.has(J))continue;if(await eQ$(J,Q))return J}throw Error(`Could not find an available port in range ${X.start}-${X.end}`)}function wu($={}){let X=null,Q=null,J=xL($);async function v(){if(X){$.logger?.warn({},"[Reverse Proxy] already running");return}let z=$.bindAddress??iJ(),W=RJ(z),G;try{if(typeof $.port==="number")G=$.port;else G=await bL($.reservedPorts,$.portRange,z)}catch(w){$.logger?.error({err:w},"[Reverse Proxy] failed to find available port"),Q=null;return}let U=Gu.default.createServer(J);await new Promise((w,H)=>{U.once("error",(B)=>{U.removeListener("listening",j),H(B)});let j=()=>{U.removeListener("error",V),w()},V=(B)=>H(B);U.once("listening",j),U.listen(G,z)}),X=U,Q=`http://${W}:${G}`,$.logger?.info({url:Q,bindAddress:z},"[Reverse Proxy] started")}async function Y(){if(!X)return;let z=X;X=null,Q=null,await new Promise((W)=>{z.close(()=>W())}),$.logger?.info({},"[Reverse Proxy] stopped")}return{get url(){return Q},start:v,stop:Y}}var iL=require("node:stream"),EL=x$(require("node:http")),PL=x$(require("node:https")),fw=x$(require("node:zlib"));var $v$=new Set(["connection","keep-alive","proxy-authenticate","proxy-authorization","te","trailer","transfer-encoding","upgrade","host"]),Xv$=new Set(["if-none-match","if-modified-since","if-match","if-unmodified-since"]),Qv$=new Set(["content-encoding","content-length"]);function Hu($){let X={};return $.headers.forEach((Q,J)=>{let v=J.toLowerCase();if($v$.has(v))return;if(Xv$.has(v))return;if(v==="accept-encoding")return;X[J]=Q}),X}function vv$($){let X=$??502;if(X===304)return 200;return X}function RL($,X,Q,J,v){return new Response($,{status:vv$(X),statusText:Q??"",headers:Jv$(J,v)})}function Jv$($,X){let Q=new Headers;for(let[J,v]of Object.entries($)){if(v===void 0)continue;let Y=J.toLowerCase();if(Qv$.has(Y))continue;if(Array.isArray(v))for(let z of v)Q.append(J,z);else Q.append(J,v)}if(X!==void 0&&X>=0)Q.set("Content-Length",String(X));return Q}function Yv$($){if(!$||typeof $!=="string")return null;let X=$.split(",")[0]?.trim().toLowerCase();if(X==="gzip"||X==="x-gzip")return fw.default.createGunzip();if(X==="deflate")return fw.default.createInflate();if(X==="br")return fw.default.createBrotliDecompress();return null}function zv$($){let X=new URL($.url),Q=X.protocol==="https:";if(!Q&&X.protocol!=="http:")return Promise.reject(Error(`Unsupported URL protocol: ${X.protocol}`));let J=$.method.toUpperCase(),v=X.port?Number(X.port):Q?443:80,Y=`${X.pathname}${X.search}`,z=Hu($);return new Promise((W,G)=>{let U=(w)=>{let H=[];w.on("data",(j)=>H.push(j)),w.on("end",()=>{(async()=>{try{let j=Buffer.concat(H),V=await AL(j,w.headers["content-encoding"]),B=Buffer.from(V);W(RL(B,w.statusCode,w.statusMessage,w.headers,B.length))}catch(j){G(j)}})()}),w.on("error",G)};(async()=>{try{let w;if(J!=="GET"&&J!=="HEAD")w=Buffer.from(await $.arrayBuffer());let H={hostname:X.hostname,port:v,path:Y,method:$.method,headers:z},j=Q?PL.default.request(H,U):EL.default.request(H,U);if(j.on("error",G),w!==void 0&&w.length>0)j.write(w);j.end()}catch(w){G(w)}})()})}function Wv$($){let X=new URL($.url),Q=X.protocol==="https:";if(!Q&&X.protocol!=="http:")return Promise.reject(Error(`Unsupported URL protocol: ${X.protocol}`));let J=$.method.toUpperCase(),v=X.port?Number(X.port):Q?443:80,Y=`${X.pathname}${X.search}`,z=Hu($);return new Promise((W,G)=>{let U=(w)=>{let H=w.headers["content-encoding"],j=Yv$(H);if(j){let V=w.pipe(j);j.on("error",G);let B=iL.Readable.toWeb(V);W(RL(B,w.statusCode,w.statusMessage,w.headers))}else{let V=iL.Readable.toWeb(w);W(RL(V,w.statusCode,w.statusMessage,w.headers))}};(async()=>{try{let w;if(J!=="GET"&&J!=="HEAD")w=Buffer.from(await $.arrayBuffer());let H={hostname:X.hostname,port:v,path:Y,method:$.method,headers:z},j=Q?PL.default.request(H,U):EL.default.request(H,U);if(j.on("error",G),w!==void 0&&w.length>0)j.write(w);j.end()}catch(w){G(w)}})()})}function ju(){return($,X)=>{return zv$(qv($,X))}}function Vu(){return($,X)=>{return Wv$(qv($,X))}}var TL=x$(require("node:path")),H8=require("node:fs/promises"),Bu=require("node:fs");function Oz($,X){return $.startsWith(`${X}:`)}var Mv="File Already Existed";function kw($){return`${Mv}: ${$}`}var Gv$="File Not Found";function nw($){return`${Gv$}: ${$}`}var Uv$=k.object({path:k.string().min(1,"Path is required"),mode:k.enum(["overwrite","append","create"]),data:k.string()}),gw=new Map;async function Kv$($){let X=gw.get($)||Promise.resolve(),Q,J=new Promise((v)=>{Q=v});gw.set($,J);try{await X}catch{}return()=>{if(Q(),gw.get($)===J)gw.delete($)}}async function wv$($){try{return await H8.access($,Bu.constants.F_OK),!0}catch{return!1}}async function cw($,X,Q=""){let{logger:J,allowlist:v}=X;try{J?.info({traceId:Q},"doWriteFile: Starting file write operation");let Y=Uv$.safeParse($);if(!Y.success)return J?.error({traceId:Q,error:Y.error},"doWriteFile: Validation failed"),{error:`Validation failed: ${Y.error.issues.map((j)=>j.message).join(", ")}`};let{path:z,mode:W,data:G}=Y.data;J?.debug({traceId:Q,filePath:z,mode:W,dataSize:G.length},"doWriteFile: Processing write request");let U=TL.default.resolve(z),w=Z.posix(U);if(!b4(w,v))return J?.warn({traceId:Q,filePath:z},"doWriteFile: Path not in allowlist"),{error:`Path "${z}" is not in allowlist`};let H=await Kv$(U);try{let j=U,V=TL.default.dirname(j);try{await H8.mkdir(V,{recursive:!0}),J?.debug({traceId:Q,parentDir:V},"doWriteFile: Parent directory ensured")}catch(B){J?.warn({traceId:Q,error:B},"doWriteFile: Failed to ensure parent directory")}if(W==="create"){if(J?.debug({traceId:Q,path:j},"doWriteFile: Create mode"),await wv$(j))return J?.error({traceId:Q,path:j},"doWriteFile: File already exists"),{error:kw(j)};try{return await H8.writeFile(j,G,"utf-8"),J?.info({traceId:Q,path:j,size:G.length},"doWriteFile: File written successfully (create mode)"),{}}catch(B){return J?.error({traceId:Q,path:j,error:B},"doWriteFile: Failed to write file (create mode)"),{error:`Failed to write file: ${B instanceof Error?B.message:"Unknown error"}`}}}if(W==="overwrite"){J?.debug({traceId:Q,path:j},"doWriteFile: Overwrite mode");try{return await H8.writeFile(j,G,"utf-8"),J?.info({traceId:Q,path:j,size:G.length},"doWriteFile: File written successfully (overwrite mode)"),{}}catch(B){return J?.error({traceId:Q,path:j,error:B},"doWriteFile: Failed to write file (overwrite mode)"),{error:`Failed to write file: ${B instanceof Error?B.message:"Unknown error"}`}}}if(W==="append"){J?.debug({traceId:Q,path:j},"doWriteFile: Append mode");try{return await H8.appendFile(j,G,"utf-8"),J?.info({traceId:Q,path:j,appendedSize:G.length},"doWriteFile: Data appended successfully"),{}}catch(B){return J?.error({traceId:Q,path:j,error:B},"doWriteFile: Failed to append to file"),{error:`Failed to append to file: ${B instanceof Error?B.message:"Unknown error"}`}}}return J?.error({traceId:Q,mode:W},"doWriteFile: Invalid mode"),{error:`Invalid mode: ${W}`}}finally{H()}}catch(Y){let z=Y instanceof Error?{message:Y.message,stack:Y.stack,name:Y.name}:{error:Y};return J?.error({traceId:Q,error:z},"doWriteFile: Unexpected error"),{error:`Unexpected error: ${Y instanceof Error?Y.message:"Unknown error"}`}}}var Du=x$(require("node:path")),mw=require("node:fs/promises"),Fu=require("node:fs");var Hv$=k.object({path:k.string().min(1,"Path is required"),requireValidPath:k.boolean().optional()});async function jv$($){try{return await mw.access($,Fu.constants.F_OK),!0}catch{return!1}}async function SL($){if(!await jv$($))return null;try{return await mw.readFile($,"utf-8")}catch{return null}}async function hw($,X){let{logger:Q,allowlist:J}=X;try{let v=Hv$.safeParse($);if(!v.success)return Q?.info({issues:v.error.issues},"doReadFile: validation failed"),{error:`Validation failed: ${v.error.issues.map((w)=>w.message).join(", ")}`};let{path:Y,requireValidPath:z}=v.data;Q?.debug({filePath:Y,requireValidPath:z},"doReadFile: processing request");let W=Z.posix(Y),G=Du.default.posix.resolve(W);if(z===void 0||z===!0){if(!b4(G,J))return Q?.warn({filePath:Y},"doReadFile: path not in allowlist"),{error:`Path "${Y}" is not in the allowlist`}}let U=Z.toPlatformPath(G);try{let w=await SL(U);if(w===null)return Q?.info({filePath:Y,platformPath:U},"doReadFile: file not found"),{error:nw(Y)};return{data:w}}catch(w){return Q?.error({filePath:Y,platformPath:U,error:w},"doReadFile: failed to read file"),{error:`Failed to read file: ${w instanceof Error?w.message:"Unknown error"}`}}}catch(v){return Q?.error({error:v},"doReadFile: unexpected error"),{error:`Unexpected error: ${v instanceof Error?v.message:"Unknown error"}`}}}var Nu=x$(require("node:path")),lw=require("node:fs/promises");var Vv$=k.object({path:k.string().min(1,"Path is required")});async function pw($,X){let{logger:Q,allowlist:J}=X;try{let v=Vv$.safeParse($);if(!v.success)return Q?.info({issues:v.error.issues},"doDeleteFile: validation failed"),{error:`Validation Failed: ${v.error.issues.map((U)=>U.message).join(", ")}`};let{path:Y}=v.data;Q?.debug({filePath:Y},"doDeleteFile: processing request");let z=Nu.default.resolve(Y),W=Z.posix(z);if(!b4(W,J))return Q?.warn({filePath:W},"doDeleteFile: path not in allowlist"),{error:`Path "${Y}" is not in the allowlist`};let G=Z.toPlatformPath(W);try{if(!(await lw.stat(G)).isFile())return Q?.info({filePath:G},"doDeleteFile: path is not a file"),{error:`Path Is Directory: ${Y} is a directory, not a file`}}catch(U){if(U.code==="ENOENT")return Q?.info({filePath:G},"doDeleteFile: file already absent"),{data:{path:G}};return Q?.error({filePath:G,error:U},"doDeleteFile: cannot access file"),{error:`Cannot access file: ${U instanceof Error?U.message:"Unknown error"}`}}try{return await lw.unlink(G),Q?.info({filePath:G},"doDeleteFile: file deleted successfully"),{data:{path:G}}}catch(U){let w=U.code;if(w==="ENOENT")return Q?.info({filePath:G},"doDeleteFile: file already absent during unlink"),{data:{path:G}};if(w==="EACCES"||w==="EPERM")return Q?.warn({filePath:G},"doDeleteFile: permission denied"),{error:`Permission denied: Cannot delete file ${Y}`};return Q?.error({filePath:G,error:U},"doDeleteFile: unlink failed"),{error:`Failed to delete file ${Y}: ${U instanceof Error?U.message:"Unknown error"}`}}}catch(v){return Q?.error({error:v},"doDeleteFile: unexpected error"),{error:`Unexpected Error: ${v instanceof Error?v.message:"Unknown error"}`}}}var Ou=x$(require("node:path")),dw=require("node:fs/promises");var Bv$=k.object({path:k.string().min(1,"Path is required")});async function ow($,X){let{logger:Q,allowlist:J}=X;try{let v=Bv$.safeParse($);if(!v.success)return Q?.info({issues:v.error.issues},"doDeleteFolder: validation failed"),{error:`Validation Failed: ${v.error.issues.map((U)=>U.message).join(", ")}`};let{path:Y}=v.data;Q?.debug({folderPath:Y},"doDeleteFolder: processing request");let z=Ou.default.resolve(Y),W=Z.posix(z);if(!b4(W,J))return Q?.warn({folderPath:W},"doDeleteFolder: path not in allowlist"),{error:`Path "${Y}" is not in the allowlist`};let G=Z.toPlatformPath(W);try{if(!(await dw.stat(G)).isDirectory())return Q?.info({folderPath:G},"doDeleteFolder: path is not a directory"),{error:`Path Is File: ${Y} is a file, not a directory`}}catch(U){if(U.code==="ENOENT")return Q?.info({folderPath:G},"doDeleteFolder: folder already absent"),{data:{path:G}};return Q?.error({folderPath:G,error:U},"doDeleteFolder: cannot access path"),{error:`Cannot access path: ${U instanceof Error?U.message:"Unknown error"}`}}try{return await dw.rm(G,{recursive:!0,force:!0}),Q?.info({folderPath:G},"doDeleteFolder: folder deleted successfully"),{data:{path:G}}}catch(U){let w=U.code;if(w==="ENOENT")return Q?.info({folderPath:G},"doDeleteFolder: folder already absent during rm"),{data:{path:G}};if(w==="EACCES"||w==="EPERM")return Q?.warn({folderPath:G},"doDeleteFolder: permission denied"),{error:`Permission denied: Cannot delete folder ${Y}`};return Q?.error({folderPath:G,error:U},"doDeleteFolder: rm failed"),{error:`Failed to delete folder ${Y}: ${U instanceof Error?U.message:"Unknown error"}`}}}catch(v){return Q?.error({error:v},"doDeleteFolder: unexpected error"),{error:`Unexpected Error: ${v instanceof Error?v.message:"Unknown error"}`}}}var Dv$=k.object({mediaFolderPath:k.string().min(1,"The absolute path of the media folder is required"),recursively:k.boolean().optional(),videoFileOnly:k.boolean().optional()});async function Lz($,X=e8){let Q=Dv$.safeParse($);if(!Q.success){let Y=Q.error.issues.map((z)=>z.message).join(", ");return{...t1(),error:`Validation Failed: ${Y}`}}let J=k$(Q.data.mediaFolderPath,"mediaFolderPath");if(typeof J!=="string")return{...t1(),error:zK};let v=t1();if(!await Uv(J,X))return{...v,error:WK};try{let Y=await s1({path:J,recursively:Q.data.recursively??!0,onlyFiles:!0},X);if(Y.error)return{...v,error:Y.error};let z=Y.data?.items.filter((W)=>!W.isDirectory).map((W)=>W.path)??[];return J6(wv(z,Q.data.videoFileOnly??!1))}catch(Y){return{...v,...Y6(Y)}}}function Lu($,X){let Q=new Z($).abs("posix").replace(/^\/[A-Za-z](?::|\/)/,"");for(let J of X){let v=new Z(J).abs("posix").replace(/^\/[A-Za-z](?::|\/)/,""),Y=v.endsWith("/")?v:`${v}/`;if(Q===v||Q.startsWith(Y))return J}return null}var xv=require("node:fs/promises"),Au=x$(require("node:path"));async function Fv$($){try{return(await xv.stat($)).isDirectory()}catch{return!1}}async function Nv$($,X){let Q=new Z($).platformAbsPath(),J=new Z(X).platformAbsPath();try{let v=Au.default.dirname(J);if(await xv.mkdir(v,{recursive:!0}),!await Fv$(v))return{success:!1,error:`Destination directory does not exist and could not be created: ${v}`};return await xv.rename(Q,J),{success:!0}}catch(v){let Y=v instanceof Error?v.message:"Unknown error";return{success:!1,error:Y.includes("ENOENT")?`Source file does not exist: ${Q}`:Y}}}async function Zu($,X={}){if($.length===0)return{success:!1,errors:["No rename operations provided"]};let Q=[],J=[];for(let v of $){let Y=await Nv$(v.from,v.to);if(Y.success)J.push(v);else Q.push(`Failed to rename "${v.from}" to "${v.to}": ${Y.error??"Unknown error"}`)}if(Q.length>0)return{success:!1,errors:Q,successfulRenames:J.length>0?J:void 0};return{success:!0,successfulRenames:J}}async function qu($,X){return Qw($,X,nX())}var Ov$=k.object({files:k.array(k.object({from:k.string().min(1,"The source file path, absolute path in platform-specific format"),to:k.string().min(1,"The target file path, absolute path in platform-specific format")})).min(1,"At least one file rename is required"),traceId:k.string().optional(),strict:k.boolean().optional(),mediaFolder:k.string().optional(),clientId:k.string().optional()});async function Lv$($,X,Q,J){let v=r1(Q);if(!v){Q.logger?.warn({mediaFolder:$},"[renameFiles] appDataDir not configured, skip metadata update");return}let Y=await N1(v,$);if(!Y){Q.logger?.debug({mediaFolder:$},"[renameFiles] media metadata not found, skip update");return}let z=Z.posix($);if(Y.mediaFolderPath!==z){Q.logger?.warn({providedPath:z,metadataPath:Y.mediaFolderPath},"[renameFiles] folder path mismatch, skip metadata update");return}let W=cT(Y,X);await cK(v,W),Q.broadcast?.({clientId:J,event:"mediaMetadataUpdated",data:{folderPath:z}})}function Av$($,X){for(let Q of $){let J=Z.posix(Q);if(!b4(J,X))return Q}return}async function Az($,X=e8,Q){let J=Ov$.safeParse($);if(!J.success)return{error:`Validation Failed: ${J.error.issues.map((R)=>R.message).join(", ")}`};let{files:v,traceId:Y,strict:z,mediaFolder:W,clientId:G}=J.data,U=z??!0,w=Q??G,H=Y?{traceId:Y}:{};if(U&&!W)return{error:"Validation Failed: mediaFolder is required when strict is true"};let j=await G8(X),V=W??Lu(v[0].from,j.folders??[]);if(V===null)return{error:`Media folder not found for ${v[0].from}`};let B=Z.posix(V),D=[V,...v.flatMap((M)=>[M.from,M.to])],F=Av$(D,X.allowlist);if(F!==void 0)return{error:`Path "${F}" is not in the allowlist`};let L=await qu(v,B);if(!L.isValid)return{error:L.errors.join(", ")};let A=await Zu(L.validatedRenames,{logPrefix:"[POST /api/renameFiles]"}),O=(A.successfulRenames??[]).map((M)=>M.from),i=[];if(!A.success&&A.errors)for(let M of A.errors){let R=M.match(/^Failed to rename "([^"]+)"/);i.push({path:R?.[1]??"unknown",error:M})}if(X.logger?.info({...H,succeededCount:O.length,failedCount:i.length},"[POST /api/renameFiles] completed"),A.successfulRenames&&A.successfulRenames.length>0)await Lv$(B,A.successfulRenames,X,w);return{data:{succeeded:O,failed:i}}}var Zv$=k.object({mediaFolderPath:k.string().min(1,"mediaFolderPath is required")}),qv$=k.object({id:k.string().optional(),task:k.enum(["recognize-media-file","rename-files"]),mediaFolderPath:k.string().min(1,"mediaFolderPath is required"),creator:k.enum(["app","ai"])}),Iv$=k.object({season:k.number(),episode:k.number(),path:k.string()}),Mv$=k.object({from:k.string(),to:k.string()}),xv$=k.object({id:k.string().min(1,"id is required")}),bv$=k.object({id:k.string().min(1,"id is required"),status:k.enum(["preparing","pending","completed","rejected"]).optional(),files:k.union([k.array(Iv$),k.array(Mv$)]).optional()});function aw($){return $.chat?.fs??J4()}function rw($){return`Error Reason: Invalid request body: ${$.issues.map((X)=>X.message).join(", ")}`}async function Zz($,X={allowlist:[]}){let Q=Zv$.safeParse($);if(!Q.success)return{error:rw(Q.error)};let J=r1(X);if(!J)return{error:"Error Reason: appDataDir is not configured"};return{data:{plans:await wS(J,Q.data.mediaFolderPath,aw(X))}}}async function qz($,X={allowlist:[]}){let Q=qv$.safeParse($);if(!Q.success)return{error:rw(Q.error)};let J=r1(X);if(!J)return{error:"Error Reason: appDataDir is not configured"};return{data:{plan:await KS(J,Q.data,aw(X))}}}async function Iz($,X={allowlist:[]}){let Q=xv$.safeParse($);if(!Q.success)return{error:rw(Q.error)};let J=r1(X);if(!J)return{error:"Error Reason: appDataDir is not configured"};let v=await tK(J,Q.data.id.trim(),aw(X));if(!v)return{error:`Error Reason: Plan with id "${Q.data.id.trim()}" not found`};return{data:{plan:v}}}async function Mz($,X={allowlist:[]}){let Q=bv$.safeParse($);if(!Q.success)return{error:rw(Q.error)};let J=r1(X);if(!J)return{error:"Error Reason: appDataDir is not configured"};let{id:v,status:Y,files:z}=Q.data,W=await Hv(J,v.trim(),{status:Y,files:z},aw(X));if(!W)return{error:`Error Reason: Plan with id "${v}" not found`};return{data:{plan:W}}}async function Iu($,X=J4(),Q){return sK($,X,Q)}var Mu=require("node:buffer"),tw=require("node:fs/promises"),xu=require("node:fs"),bu=x$(require("node:path"));var iv$=k.object({url:k.string().min(1,"url is required"),path:k.string().min(1,"path is required")});async function Rv$($){try{return await tw.access($,xu.constants.F_OK),!0}catch{return!1}}function Ev$($){if($.startsWith("//"))return`https:${$}`;return $}async function xz($,X){let{allowlist:Q,logger:J,fetchImpl:v=fetch}=X;try{let Y=iv$.safeParse($);if(!Y.success)return J?.info({issues:Y.error.issues},"[DownloadImageAsFile] validation failed"),{data:{url:typeof $?.url==="string"?$.url:"",path:typeof $?.path==="string"?$.path:""},error:`Validation failed: ${Y.error.issues.map((B)=>B.message).join(", ")}`};let{url:z,path:W}=Y.data;J?.debug({url:z,destPath:W},"[DownloadImageAsFile] processing request");let G=bu.default.posix.resolve(Z.posix(W));if(!b4(G,Q))return J?.warn({destPath:W,posixDestPath:G},"[DownloadImageAsFile] destination not in allowlist"),{data:{url:z,path:W},error:`Path "${W}" is not in the allowlist`};let U=Z.toPlatformPath(G);if(await Rv$(U))return J?.info({platformDestPath:U},"[DownloadImageAsFile] destination already exists"),{data:{url:z,path:W},error:kw(U)};let w=Ev$(z);if(!w.startsWith("http://")&&!w.startsWith("https://"))return{data:{url:z,path:W},error:`Invalid image URL: ${z}. Must be http://, https://, or protocol-relative (//)`};J?.debug({url:w,destPath:U},"[DownloadImageAsFile] fetching image");let H;try{H=await v(w,{method:"GET",headers:{accept:"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8","accept-language":"en-US,en;q=0.9","cache-control":"no-cache","sec-fetch-dest":"image","sec-fetch-mode":"no-cors","sec-fetch-site":"cross-site","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"}})}catch(B){return{data:{url:z,path:W},error:`Image URL fetch failed: ${Iv(B)}`}}if(!H.ok)return J?.warn({url:w,status:H.status},"[DownloadImageAsFile] non-2xx response"),{data:{url:z,path:W},error:`HTTP error! status: ${H.status}`};let j=await H.arrayBuffer(),V=Mu.Buffer.from(j);return await tw.writeFile(U,V),J?.info({url:w,destPath:U,bytes:V.length},"[DownloadImageAsFile] wrote file"),{data:{url:z,path:W}}}catch(Y){return J?.error({error:Y},"[DownloadImageAsFile] unexpected error"),{data:{url:typeof $?.url==="string"?$.url:"",path:typeof $?.path==="string"?$.path:""},error:`Unexpected Error: ${Iv(Y)}`}}}var iu=require("node:buffer"),sw=require("node:fs/promises"),Ru=require("node:fs"),ew=x$(require("node:path"));var Pv$=k.object({path:k.string().min(1,"path is required")}),Eu=[".jpg",".jpeg",".png",".gif",".webp",".svg",".bmp",".ico",".tiff",".tif"],Tv$={".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".webp":"image/webp",".svg":"image/svg+xml",".bmp":"image/bmp",".ico":"image/x-icon",".tiff":"image/tiff",".tif":"image/tiff"};function Sv$($){let X=ew.default.extname($).toLowerCase();return Eu.includes(X)}function Cv$($){let X=ew.default.extname($).toLowerCase();return Tv$[X]??"image/jpeg"}async function uv$($){try{return await sw.access($,Ru.constants.F_OK),!0}catch{return!1}}async function bz($,X){let{allowlist:Q,logger:J}=X;try{let v=Pv$.safeParse($);if(!v.success)return J?.info({issues:v.error.issues},"[ReadImage] validation failed"),{error:`Validation failed: ${v.error.issues.map((G)=>G.message).join(", ")}`};let{path:Y}=v.data,z=ew.default.posix.resolve(Z.posix(Y));if(!b4(z,Q))return J?.warn({filePath:Y,posixPath:z},"[ReadImage] path not in allowlist"),{error:`Path "${Y}" is not in the allowlist`};let W=Z.toPlatformPath(z);if(!Sv$(W))return{error:`File is not a valid image file. Supported formats: ${Eu.join(", ")}`};if(!await uv$(W))return{error:nw(Y)};try{let G=await sw.readFile(W),U=iu.Buffer.from(G).toString("base64");return{data:`data:${Cv$(W)};base64,${U}`}}catch(G){return J?.error({filePath:Y,platformPath:W,error:G},"[ReadImage] failed to read file"),{error:`Failed to read image file: ${G instanceof Error?G.message:"Unknown error"}`}}}catch(v){return J?.error({error:v},"[ReadImage] unexpected error"),{error:`Unexpected error: ${v instanceof Error?v.message:"Unknown error"}`}}}function M6($,X,Q,J){let v=J??{},Y=`[Discover] ${Q}`;if($){$[X](v,Y);return}let z=J===void 0?Y:`${Y} ${JSON.stringify(J)}`;if(X==="error")console.error(z);else if(X==="warn")console.warn(z);else console.log(z)}var uL="https://lawrenceching.github.io/SMM/config.json",Rz=1e4,$H={mediaDatabases:[],reverseProxies:[]},CL=[{type:"tmdb",url:"https://mediadb.vercel.app/api/tmdb",authorizationMethod:"none"},{type:"tmdb",url:"https://1255396852-23teay8jtp.ap-hongkong.tencentscf.com",authorizationMethod:"none"},{type:"tvdb",url:"https://mediadb.vercel.app/api/tvdb",authorizationMethod:"none"},{type:"tvdb",url:"https://1255396852-24lotax0vl.ap-hongkong.tencentscf.com",authorizationMethod:"none"},{type:"tmdb-asset",url:"https://1255396852-19bqcvs6wn.ap-hongkong.tencentscf.com",authorizationMethod:"none"},{type:"tvdb-asset",url:"https://1255396852-2gz8ynvtkt.ap-hongkong.tencentscf.com",authorizationMethod:"none"}],Pu={mediaDatabases:CL,reverseProxies:[]};function BX($,X,Q){return M6($,"warn",`using hardcoded fallback mediaDatabases (${X})`,{mediaDatabasesCount:CL.length,...Q}),{mediaDatabases:[...CL],reverseProxies:[]}}function _v$(){let $=process.env.EXTERNAL_CONFIG_FILE_URL?.trim();if($)return{url:$,urlFromEnv:!0};return{url:uL,urlFromEnv:!1}}function Tu($){if(typeof $!=="string")return"none";if($==="date-token")return"date-token";return"none"}function yv$($){let X=($.baseUrl??$.url??"").trim();if(!X)return null;let Q=$.type;if(Q!=="tmdb"&&Q!=="tvdb"&&Q!=="tmdb-asset"&&Q!=="tvdb-asset")return null;return{type:Q,url:X,authorizationMethod:Tu($.authorizationMethod)}}function fv$($){let X=($.id??"").trim(),Q=($.url??"").trim();if(!X||!Q)return null;let J=$.type;if(J!=="general")return null;return{id:X,type:J,url:Q,authorizationMethod:Tu($.authMethod)}}function kv$($){if(!Array.isArray($))return[];let X=[];for(let Q of $){if(!Q||typeof Q!=="object")continue;let J=yv$(Q);if(J)X.push(J)}return X}function nv$($){if(!Array.isArray($))return[];let X=[];for(let Q of $){if(!Q||typeof Q!=="object")continue;let J=fv$(Q);if(J)X.push(J)}return X}function gv$($){if($===void 0||$===null)return{};if(typeof $!=="string")return{invalidReason:`expected string, got ${typeof $}`};let X=$.trim();if(X.length===0)return{invalidReason:"blank string"};return{latestVersion:X}}function iz($){if(!($ instanceof Error))return{message:String($)};let X=$.cause;return{message:$.message,name:$.name,cause:X instanceof Error?X.message:X!==void 0?String(X):void 0,stack:$.stack}}function cv$($){return $ instanceof Error&&$.name==="AbortError"||typeof DOMException<"u"&&$ instanceof DOMException&&$.name==="AbortError"}function mv$($){return $!==null&&typeof $==="object"&&!Array.isArray($)}async function hv$($,X){try{let Q=await X.text();if(!Q)return;return Q.length>300?`${Q.slice(0,300)}…`:Q}catch(Q){M6($,"warn","failed to read response body for preview",{err:iz(Q)});return}}function lv$($,X,Q,J,v,Y){let z=Array.isArray(J.mediaDatabases)?J.mediaDatabases.length:null,W=Array.isArray(J.reverseProxies)?J.reverseProxies.length:null,G={url:X,durationMs:Q,latestVersion:Y,hasLatestVersionField:"latestVersion"in J,rawReverseProxiesCount:W};if(z===null){M6($,"warn","remote config missing mediaDatabases array",{...G,hasMediaDatabasesField:!1});return}if(z===0){M6($,"warn","remote config has empty mediaDatabases array",{...G,rawMediaDatabasesCount:z});return}if(v.length===0)M6($,"warn","remote config mediaDatabases entries were all filtered out during normalization",{...G,rawMediaDatabasesCount:z,normalizedMediaDatabasesCount:0})}async function Ez($={}){let{logger:X,fetchImpl:Q=fetch}=$,{url:J,urlFromEnv:v}=_v$(),Y=Date.now(),z=new AbortController,W=setTimeout(()=>z.abort(),Rz);M6(X,"info","fetching remote config",{url:J,urlFromEnv:v,timeoutMs:Rz,method:"GET"});try{let G;try{G=await Q(J,{method:"GET",signal:z.signal,headers:{Accept:"application/json"}})}catch(B){let D=Date.now()-Y,F=cv$(B)||z.signal.aborted;return M6(X,"error",F?"remote config fetch aborted (timeout or cancellation)":"failed to fetch remote config",{url:J,urlFromEnv:v,durationMs:D,aborted:F,signalAborted:z.signal.aborted,timedOut:F&&D>=Rz-50,err:iz(B)}),BX(X,F?"fetch aborted":"fetch failed",{url:J,urlFromEnv:v,durationMs:D})}let U=Date.now()-Y;if(M6(X,"info","remote config HTTP response",{url:J,urlFromEnv:v,durationMs:U,status:G.status,statusText:G.statusText,contentType:G.headers.get("content-type"),redirected:G.redirected,responseUrl:G.url}),!G.ok){let B=await hv$(X,G);return M6(X,"error","remote config returned non-OK status",{url:J,urlFromEnv:v,durationMs:U,status:G.status,statusText:G.statusText,contentType:G.headers.get("content-type"),bodyPreview:B}),BX(X,"non-OK status",{url:J,status:G.status})}let w;try{w=await G.json()}catch(B){return M6(X,"error","remote config response is not valid JSON",{url:J,urlFromEnv:v,durationMs:U,contentType:G.headers.get("content-type"),err:iz(B)}),BX(X,"invalid JSON",{url:J})}if(!mv$(w))return M6(X,"error","remote config JSON root is not an object",{url:J,urlFromEnv:v,durationMs:U,rootType:w===null?"null":Array.isArray(w)?"array":typeof w}),BX(X,"JSON root is not an object",{url:J});let H,j,V;try{H=kv$(w.mediaDatabases),j=nv$(w.reverseProxies);let B=gv$(w.latestVersion);if(B.invalidReason)M6(X,"warn","remote config latestVersion ignored",{url:J,reason:B.invalidReason,rawType:typeof w.latestVersion});V=B.latestVersion}catch(B){return M6(X,"error","failed to normalize remote config",{url:J,urlFromEnv:v,durationMs:U,err:iz(B)}),BX(X,"normalize failed",{url:J})}if(H.length===0)return lv$(X,J,U,w,H,V),{mediaDatabases:BX(X,"empty mediaDatabases",{url:J}).mediaDatabases,reverseProxies:j,latestVersion:V};return M6(X,"info","remote config loaded",{url:J,urlFromEnv:v,durationMs:U,mediaDatabasesCount:H.length,reverseProxiesCount:j.length,mediaDatabaseTypes:[...new Set(H.map((B)=>B.type))],latestVersion:V,hasLatestVersionField:"latestVersion"in w}),{mediaDatabases:H,reverseProxies:j,latestVersion:V}}catch(G){let U=Date.now()-Y;return M6(X,"error","unexpected error while loading remote config",{url:J,urlFromEnv:v,durationMs:U,signalAborted:z.signal.aborted,err:iz(G)}),BX(X,"unexpected error",{url:J,durationMs:U})}finally{clearTimeout(W)}}async function Su($={}){return(await Ez($)).mediaDatabases}var Cu={data:{path:"",items:[],size:0}};async function Pz($,X,Q){if($.method!=="GET"||Q.url.pathname!=="/api/listFiles")return!1;let J=Q.url.searchParams;Q.config.logger?.info({query:Object.fromEntries(J)},"[ListFiles] GET /api/listFiles");try{let v={path:J.get("path")??""},Y=bJ(J.get("onlyFiles"));if(Y!==void 0)v.onlyFiles=Y;let z=bJ(J.get("onlyFolders"));if(z!==void 0)v.onlyFolders=z;let W=bJ(J.get("includeHiddenFiles"));if(W!==void 0)v.includeHiddenFiles=W;let G=bJ(J.get("recursively"));if(G!==void 0)v.recursively=G;let U=await s1(v,Q.config);if(U.error)Q.config.logger?.info({body:v,resultError:U.error},"[ListFiles] GET result has error");return f(X,200,U),!0}catch(v){return Q.config.logger?.error({error:v},"ListFiles GET route error"),f(X,200,{...Cu,error:`Unexpected Error: ${v instanceof Error?v.message:"Failed to process list files request"}`}),!0}}async function Tz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/listFiles")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[ListFiles] POST /api/listFiles");let v=await s1(J,Q.config);if(v.error)Q.config.logger?.info({rawBody:J,resultError:v.error},"[ListFiles] POST result has error");return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"ListFiles POST route error"),f(X,200,{...Cu,error:`Unexpected Error: ${J instanceof Error?J.message:"Failed to process list files request"}`}),!0}}async function Sz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/hello")return!1;if(Q.config.hello===void 0)return f(X,200,{error:"hello not configured"}),!0;let J=j5(Q.config.hello);return f(X,200,J),!0}var pv$=k.object({path:k.string().min(1,"path is required")});async function Cz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/isFolderAvailable")return!1;try{let J=await R$($),v=pv$.safeParse(J);if(!v.success)return f(X,400,{error:"Validation failed",details:v.error.issues.map((z)=>({path:z.path.join("."),message:z.message}))}),!0;let Y=await gK(J,Q.config);return f(X,200,Y),!0}catch(J){return Q.config.logger?.debug({error:J},"[IsFolderAvailable] invalid JSON"),f(X,400,{error:"Invalid JSON body"}),!0}}async function uz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/readFile")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[ReadFile] POST /api/readFile");let v=await hw(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"ReadFile POST route error"),f(X,400,{error:"Invalid JSON body",details:J instanceof Error?J.message:"Unknown error"}),!0}}async function _z($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/writeFile")return!1;let J=$.headers["x-trace-id"],v=typeof J==="string"?J:Array.isArray(J)?J[0]??"":"";try{let Y=await R$($);Q.config.logger?.info({traceId:v,rawBody:Y},"POST /api/writeFile");let z=await cw(Y,Q.config,v);if(z.error){if(Oz(z.error,Mv))return f(X,200,z),!0;return f(X,400,z),!0}return f(X,200,z),!0}catch(Y){let z={error:"Failed to process write file request",details:Y instanceof Error?Y.message:"Unknown error"};return f(X,500,z),!0}}async function yz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/deleteFile")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[DeleteFile] POST /api/deleteFile");let v=await pw(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"DeleteFile POST route error"),f(X,400,{error:"Invalid JSON body",details:J instanceof Error?J.message:"Unknown error"}),!0}}async function fz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/deleteFolder")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[DeleteFolder] POST /api/deleteFolder");let v=await ow(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"DeleteFolder POST route error"),f(X,400,{error:"Invalid JSON body",details:J instanceof Error?J.message:"Unknown error"}),!0}}async function kz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/getEpisodes")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[GetEpisodes] POST /api/getEpisodes");let v=await U8(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"[GetEpisodes] route error"),f(X,200,{...zX(),error:`Unexpected Error: ${J instanceof Error?J.message:"Failed to process getEpisodes request"}`}),!0}}async function nz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/listFilesInMediaFolder")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[ListFilesInMediaFolder] POST /api/listFilesInMediaFolder");let v=await Lz(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"[ListFilesInMediaFolder] route error"),f(X,200,{...t1(),error:`Unexpected Error: ${J instanceof Error?J.message:"Failed to process listFilesInMediaFolder request"}`}),!0}}async function gz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/renameFolder")return!1;try{let J=await R$($);Q.config.logger?.info({from:J.from,to:J.to},"[RenameFolder] POST /api/renameFolder");let v=await WX(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"[RenameFolder] route error"),f(X,200,{error:"Unexpected Error: Failed to process rename folder request",details:J instanceof Error?J.message:"Unknown error"}),!0}}async function cz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/renameFiles")return!1;try{let J=await R$($),v=$.headers.clientid??$.headers.clientId,Y=typeof v==="string"?v:void 0;Q.config.logger?.info({fileCount:J.files?.length,traceId:J.traceId},"[RenameFiles] POST /api/renameFiles");let z=await Az(J,Q.config,Y);return f(X,200,z),!0}catch(J){return Q.config.logger?.error({error:J},"[RenameFiles] route error"),f(X,200,{error:`Unexpected Error: ${J instanceof Error?J.message:"Unknown error"}`}),!0}}async function mz($,X,Q){if($.method!=="GET"||Q.url.pathname!=="/api/image")return!1;let J=Q.url.searchParams.get("url");if(!J)return f(X,400,{error:"Missing required query parameter: url"}),!0;try{let v=await Nz(J,Q.config);return X.writeHead(200,{"Content-Type":v.contentType,"Content-Length":v.buffer.length.toString(),"Cache-Control":"public, max-age=31536000"}),X.end(v.buffer),!0}catch(v){let Y=v instanceof Error?v.message:String(v);return Q.config.logger?.error({url:J,error:Y},"DownloadImage GET route error"),f(X,500,{error:`Failed to download image: ${Y}`}),!0}}async function hz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/downloadImage")return!1;try{let J=await R$($),{url:v,path:Y,httpProxy:z}=J;Q.config.logger?.info({url:v,path:Y},"[DownloadImageAsFile] POST /api/downloadImage");let W=z?.trim(),G=W?Tw(W,Q.config.logger):Q.config.fetchImpl,U=Q.config.allowlist;if(Q.config.resolveAllowlist)try{U=await Q.config.resolveAllowlist()}catch(H){Q.config.logger?.warn({error:H},"[DownloadImageAsFile] failed to resolve allowlist, falling back to static"),U=Q.config.allowlist}let w=await xz(J,{...Q.config,allowlist:U,fetchImpl:G});return f(X,200,w),!0}catch(J){return Q.config.logger?.error({error:J},"DownloadImageAsFile POST route error"),f(X,400,{error:"Invalid JSON body",details:J instanceof Error?J.message:"Unknown error"}),!0}}async function lz($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/readImage")return!1;try{let J=await R$($);Q.config.logger?.info({rawBody:J},"[ReadImage] POST /api/readImage");let v=await bz(J,Q.config);return f(X,200,v),!0}catch(J){return Q.config.logger?.error({error:J},"ReadImage POST route error"),f(X,400,{error:"Invalid JSON body",details:J instanceof Error?J.message:"Unknown error"}),!0}}async function pz($,X,Q){if($.method!=="GET"||Q.url.pathname!=="/api/discover")return!1;try{let v={data:await Ez({logger:Q.config.logger,fetchImpl:Q.config.fetchImpl})};return f(X,200,v),!0}catch(J){let v=J instanceof Error?J.message:String(J);return Q.config.logger?.error({error:v},"[Discover] handleDiscoverGet unexpected throw"),f(X,200,{data:{...$H}}),!0}}function dv$($){return bv($)}function bv($){if(!$||typeof $!=="object")return;let X=$,Q={};if(typeof X.host==="string"&&X.host.trim()!=="")Q.hostname=X.host;if(typeof X.port==="number"&&Number.isFinite(X.port))Q.port=X.port;return Object.keys(Q).length>0?Q:void 0}async function dz($,X){try{let Q=dv$(X);return await $.start(Q),{status:200,body:$.getState()}}catch(Q){let J=Q instanceof Error?Q.message:String(Q);return{status:500,body:{...$.getState(),error:J}}}}async function oz($){try{return await $.stop(),{status:200,body:$.getState()}}catch(X){return{status:500,body:{status:"error",error:X instanceof Error?X.message:String(X)}}}}function az($){return{status:200,body:$.getState()}}async function _L($,X,Q){let J;try{J=await X()}catch(v){Q?.warn({err:v},"applyMcpLifecycleFromConfig: failed to read user config, skipping MCP update");return}if(!J.enableMcpServer){await $.stop();return}try{await $.start({hostname:J.mcpHost,port:J.mcpPort})}catch(v){throw Q?.error({err:v,hostname:J.mcpHost,port:J.mcpPort},"MCP server failed to start on boot"),v}}async function XH($,X,Q){if($.method!=="PUT"||Q.url.pathname!=="/api/mcp/start")return!1;let J=Q.config.mcp?.manager;if(!J)return f(X,200,{error:"MCP lifecycle not configured"}),!0;let v=await R$($),Y=await dz(J,v);return f(X,Y.status,Y.body),!0}async function QH($,X,Q){if($.method!=="PUT"||Q.url.pathname!=="/api/mcp/stop")return!1;let J=Q.config.mcp?.manager;if(!J)return f(X,200,{error:"MCP lifecycle not configured"}),!0;let v=await oz(J);return f(X,v.status,v.body),!0}async function vH($,X,Q){if($.method!=="GET"||Q.url.pathname!=="/api/mcp/status")return!1;let J=Q.config.mcp?.manager;if(!J)return f(X,200,{error:"MCP lifecycle not configured"}),!0;let v=az(J);return f(X,v.status,v.body),!0}var JH="127.0.0.1",YH=30001;function uu($){return $ instanceof Error?$.message:String($)}function ov$($,X){return{hostname:X?.hostname??$.mcpHost??JH,port:X?.port??$.mcpPort??YH}}async function rz($,X,Q,J){let v=bv(Q),Y=await G8(X),{hostname:z,port:W}=ov$(Y,v);try{await $.start({hostname:z,port:W});let G=$.getState();if(G.status==="error")return{data:G,error:`Error Reason: ${G.error??"Failed to start MCP server"}`};if(J?.persistUserConfig!==!1)await Kv(X,{...Y,enableMcpServer:!0,mcpHost:z,mcpPort:W});return{data:G,error:null}}catch(G){let U=uu(G);return{data:{...$.getState(),status:"error",error:U},error:`Error Reason: ${U}`}}}async function tz($,X,Q){let J=await G8(X);try{await $.stop();let v=$.getState();if(v.status==="error")return{data:v,error:`Error Reason: ${v.error??"Failed to stop MCP server"}`};return{data:v,error:null}}catch(v){let Y=uu(v);return{data:{status:"error",error:Y},error:`Error Reason: ${Y}`}}finally{if(Q?.persistUserConfig!==!1)await Kv(X,{...J,enableMcpServer:!1})}}async function sz($,X){let Q=$.getState();if(Q.status!=="running"){let J=await G8(X);if(J.enableMcpServer)await Kv(X,{...J,enableMcpServer:!1})}return{data:Q,error:null}}async function zH($,X,Q){if($.method!=="GET"||Q.url.pathname!=="/api/get-mcp-server-status")return!1;let J=Q.config.mcp?.manager;if(!J)return f(X,200,{error:"Error Reason: MCP lifecycle not configured"}),!0;try{let v=await sz(J,Q.config);f(X,200,v)}catch(v){let Y=v instanceof Error?v.message:String(v);f(X,200,{error:`Error Reason: ${Y}`})}return!0}async function WH($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/start-mcp-server")return!1;let J=Q.config.mcp?.manager;if(!J)return f(X,200,{error:"Error Reason: MCP lifecycle not configured"}),!0;let v=await R$($),Y=await rz(J,Q.config,v,{persistUserConfig:!0});return f(X,200,Y),!0}async function GH($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/stop-mcp-server")return!1;let J=Q.config.mcp?.manager;if(!J)return f(X,200,{error:"Error Reason: MCP lifecycle not configured"}),!0;let v=await tz(J,Q.config,{persistUserConfig:!0});return f(X,200,v),!0}async function ez($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/getPlans")return!1;try{let J=await R$($),v=await Zz(J,Q.config);f(X,200,v)}catch(J){Q.config.logger?.error({error:J},"[GetPlans] route error"),f(X,200,{error:`Error Reason: ${J instanceof Error?J.message:"Unknown error"}`})}return!0}async function yL($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/getPlanById")return!1;try{let J=await R$($),v=await Iz(J,Q.config);f(X,200,v)}catch(J){Q.config.logger?.error({error:J},"[GetPlanById] route error"),f(X,200,{error:`Error Reason: ${J instanceof Error?J.message:"Unknown error"}`})}return!0}async function $7($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/createPlan")return!1;try{let J=await R$($),v=await qz(J,Q.config);f(X,200,v)}catch(J){Q.config.logger?.error({error:J},"[CreatePlan] route error"),f(X,200,{error:`Error Reason: ${J instanceof Error?J.message:"Unknown error"}`})}return!0}async function X7($,X,Q){if($.method!=="POST"||Q.url.pathname!=="/api/updatePlan")return!1;try{let J=await R$($),v=await Mz(J,Q.config);f(X,200,v)}catch(J){Q.config.logger?.error({error:J},"[UpdatePlan] route error"),f(X,200,{error:`Error Reason: ${J instanceof Error?J.message:"Unknown error"}`})}return!0}var fL=[Pz,Tz,_z,Sz,Cz,kz,nz,gz,cz,uz,yz,fz,mz,hz,lz,pz,Uw,zH,WH,GH,XH,QH,vH,ez,yL,$7,X7];function kL($,X={}){let Q=X.fallbackPort??3001;return(J,v)=>{nL(J,v,$,Q)}}async function nL($,X,Q,J=3001){let v=CI($,J);if(W5($,X,Q))return;let Y={config:Q,url:v};for(let z of fL)if(await z($,X,Y))return;f(X,404,{error:`Not found: ${$.method??"UNKNOWN"} ${v.pathname}`})}function _u($,X){let Q=typeof $.address()==="object"&&$.address()!==null?$.address().port:3001;$.on("request",kL(X,{fallbackPort:Q}))}var Dk=x$(Bk(),1),{Server:Fk,Namespace:TP$,Socket:SP$}=Dk.default;function rv($,X,Q){if($)$.info(X,Q)}function f5$($,X,Q){if($)$.warn(X,Q)}function k5$($,X,Q){if($)$.error(X,Q)}function Nk($,X){$.on("connection",(Q)=>{rv(X,{socketId:Q.id,rooms:Array.from(Q.rooms)},"[DEBUG] socket.io connection established"),Q.emit("hello"),rv(X,{socketId:Q.id,event:"hello"},"socket.io message sent"),Q.on("userAgent",(J)=>{let v=J?.userAgent,Y=J?.clientId;if(rv(X,{socketId:Q.id,userAgent:v,clientId:Y},"[DEBUG] socket.io received userAgent"),Y){Q.join(Y);let z=$.sockets.adapter.rooms.get(Y);rv(X,{socketId:Q.id,clientId:Y,rooms:Array.from(Q.rooms),socketsInRoom:z?Array.from(z):[]},"[DEBUG] socket.io client joined room")}else f5$(X,{socketId:Q.id},"[DEBUG] socket.io userAgent event missing clientId")}),Q.on("disconnect",(J)=>{rv(X,{socketId:Q.id,reason:J},"socket.io connection closed")}),Q.on("error",(J)=>{k5$(X,{socketId:Q.id,error:J.message},"socket.io connection error")})}),rv(X,{},"Socket.IO connection handlers initialized")}function Ok($,X,Q){if($)$.info(X,Q)}function n5$($,X,Q){if($)$.warn(X,Q)}function g5$($,X,Q){if($)$.error(X,Q)}function c5$($,X,Q){if($)$.debug(X,Q)}function m5$($){return Array.from($.rooms).find((Q)=>Q!==$.id)??null}function Lk($,X){function Q(U){Ok(X,{event:U.event,type:"broadcast"},"socket.io message broadcast"),$.emit(U.event,U.data)}function J(){let U=Array.from($.sockets.sockets.values());if(U.length===0)return null;for(let w of U){let H=m5$(w);if(H)return{socket:w,clientId:H}}return null}function v(U){if(U){let H=$.sockets.adapter.rooms.get(U);if(H&&H.size>0){let j=Array.from(H)[0];if(j){let V=$.sockets.sockets.get(j);if(V)return{socket:V,clientId:U}}}n5$(X,{clientId:U,availableRooms:Array.from($.sockets.adapter.rooms.keys())},"[DEBUG] no sockets found in specified room, falling back to first available socket")}let w=J();if(!w)throw Error("No active Socket.IO connections available");return w}async function Y(U,w){let{socket:H,clientId:j}=v(U.clientId),V=performance.now();Ok(X,{clientId:j,socketId:H.id,event:U.event,timeoutMs:w??"none",data:U.data},"[DEBUG] socket.io sending message to specific socket with acknowledgement");try{let B;if(w!==void 0&&w>0)B=await H.timeout(w).emitWithAck(U.event,U.data);else B=await H.emitWithAck(U.event,U.data);let D=performance.now()-V;return c5$(X,{clientId:j,socketId:H.id,event:U.event,hasResponse:B!==void 0&&B!==null,response:B,durationMs:Math.round(D)},"socket.io acknowledgement received successfully"),B}catch(B){let D=performance.now()-V,F=B instanceof Error?B.message:String(B);throw g5$(X,{clientId:j,socketId:H.id,event:U.event,error:F,errorType:typeof B,errorDetails:B,durationMs:Math.round(D)},"[DEBUG] socket.io acknowledgement timeout or error"),Error(`Socket.IO request timed out or failed: ${F}`)}}function z(){let U=Array.from($.sockets.sockets.values());if(U.length===0)return null;let w=U[0];if(!w)return null;return Array.from(w.rooms).find((j)=>j!==w.id)??null}function W(U){let w=$.sockets.adapter.rooms.get(U);return w!==void 0&&w.size>0}function G(){let U=[],w=Array.from($.sockets.sockets.values());for(let H of w){let j=Array.from(H.rooms).find((V)=>V!==H.id);if(j)U.push(j)}return U}return{broadcast:Q,acknowledge:Y,getFirstAvailableSocket:J,findSocketByClientId:v,getFirstActiveConnection:z,isClientConnected:W,getConnectedClientIds:G}}var h5$={origin:"*",methods:["GET","POST"]};function Ak($,X={}){let Q=X.cors??h5$,J=new Fk($,{cors:Q,path:X.path??"/socket.io/"});Nk(J,X.logger);let v=Lk(J,X.logger);return{io:J,broadcast:v.broadcast,acknowledge:v.acknowledge,getSocketIOInstance:()=>J,getFirstAvailableSocket:v.getFirstAvailableSocket,findSocketByClientId:v.findSocketByClientId,getFirstActiveConnection:v.getFirstActiveConnection,isClientConnected:v.isClientConnected,getConnectedClientIds:v.getConnectedClientIds}}var p5$=q("ZodMiniType",($,X)=>{if(!$._zod)throw Error("Uninitialized schema in ZodMiniType.");J$.init($,X),$.def=X,$.type=X.type,$.parse=(Q,J)=>y9($,Q,J,{callee:$.parse}),$.safeParse=(Q,J)=>P8($,Q,J),$.parseAsync=async(Q,J)=>f9($,Q,J,{callee:$.parseAsync}),$.safeParseAsync=async(Q,J)=>T8($,Q,J),$.check=(...Q)=>{return $.clone({...X,checks:[...X.checks??[],...Q.map((J)=>typeof J==="function"?{_zod:{check:J,def:{check:"custom"},onattach:[]}}:J)]},{parent:!0})},$.with=$.check,$.clone=(Q,J)=>m4($,Q,J),$.brand=()=>$,$.register=(Q,J)=>{return Q.add($,J),$},$.apply=(Q)=>Q($)});var d5$=q("ZodMiniObject",($,X)=>{jW.init($,X),p5$.init($,X),K$($,"shape",()=>X.shape)});function aA($,X){let Q={type:"object",shape:$??{},...u(X)};return new d5$(Q)}function i1($){return!!$._zod}function MX($){let X=Object.values($);if(X.length===0)return aA({});let Q=X.every(i1),J=X.every((v)=>!i1(v));if(Q)return aA($);if(J)return l3($);throw Error("Mixed Zod versions detected in object shape.")}function j9($,X){if(i1($))return P8($,X);return $.safeParse(X)}async function Xj($,X){if(i1($))return await T8($,X);return await $.safeParseAsync(X)}function V9($){if(!$)return;let X;if(i1($))X=$._zod?.def?.shape;else X=$.shape;if(!X)return;if(typeof X==="function")try{return X()}catch{return}return X}function tv($){if(!$)return;if(typeof $==="object"){let X=$,Q=$;if(!X._def&&!Q._zod){let J=Object.values($);if(J.length>0&&J.every((v)=>typeof v==="object"&&v!==null&&(v._def!==void 0||v._zod!==void 0||typeof v.parse==="function")))return MX($)}}if(i1($)){let Q=$._zod?.def;if(Q&&(Q.type==="object"||Q.shape!==void 0))return $}else if($.shape!==void 0)return $;return}function Qj($){if($&&typeof $==="object"){if("message"in $&&typeof $.message==="string")return $.message;if("issues"in $&&Array.isArray($.issues)&&$.issues.length>0){let X=$.issues[0];if(X&&typeof X==="object"&&"message"in X)return String(X.message)}try{return JSON.stringify($)}catch{return String($)}}return String($)}function Zk($){return $.description}function qk($){if(i1($))return $._zod?.def?.type==="optional";let X=$;if(typeof $.isOptional==="function")return $.isOptional();return X._def?.typeName==="ZodOptional"}function vj($){if(i1($)){let Y=$._zod?.def;if(Y){if(Y.value!==void 0)return Y.value;if(Array.isArray(Y.values)&&Y.values.length>0)return Y.values[0]}}let Q=$._def;if(Q){if(Q.value!==void 0)return Q.value;if(Array.isArray(Q.values)&&Q.values.length>0)return Q.values[0]}let J=$.value;if(J!==void 0)return J;return}var rA="2025-11-25",Ik="2025-03-26",F7=[rA,"2025-06-18","2025-03-26","2024-11-05","2024-10-07"],B9="io.modelcontextprotocol/related-task",Yj="2.0",a4=tG(($)=>$!==null&&(typeof $==="object"||typeof $==="function")),Mk=C$([x(),P$().int()]),xk=x(),tP$=l4({ttl:C$([P$(),AQ()]).optional(),pollInterval:P$().optional()}),a5$=h({ttl:P$().optional()}),r5$=h({taskId:x()}),tA=l4({progressToken:Mk.optional(),[B9]:r5$.optional()}),j1=h({_meta:tA.optional()}),N7=j1.extend({task:a5$.optional()}),bk=($)=>N7.safeParse($).success,r4=h({method:x(),params:j1.loose().optional()}),R1=h({_meta:tA.optional()}),E1=h({method:x(),params:R1.loose().optional()}),t4=l4({_meta:tA.optional()}),zj=C$([x(),P$().int()]),ik=h({jsonrpc:d(Yj),id:zj,...r4.shape}).strict(),D9=($)=>ik.safeParse($).success,Rk=h({jsonrpc:d(Yj),...E1.shape}).strict(),Ek=($)=>Rk.safeParse($).success,sA=h({jsonrpc:d(Yj),id:zj,result:t4}).strict(),L8=($)=>sA.safeParse($).success;var s;(function($){$[$.ConnectionClosed=-32000]="ConnectionClosed",$[$.RequestTimeout=-32001]="RequestTimeout",$[$.ParseError=-32700]="ParseError",$[$.InvalidRequest=-32600]="InvalidRequest",$[$.MethodNotFound=-32601]="MethodNotFound",$[$.InvalidParams=-32602]="InvalidParams",$[$.InternalError=-32603]="InternalError",$[$.UrlElicitationRequired=-32042]="UrlElicitationRequired"})(s||(s={}));var eA=h({jsonrpc:d(Yj),id:zj.optional(),error:h({code:P$().int(),message:x(),data:f$().optional()})}).strict();var ev=($)=>eA.safeParse($).success;var $Z=C$([ik,Rk,sA,eA]),sP$=C$([sA,eA]),Wj=t4.strict(),t5$=R1.extend({requestId:zj.optional(),reason:x().optional()}),Gj=E1.extend({method:d("notifications/cancelled"),params:t5$}),s5$=h({src:x(),mimeType:x().optional(),sizes:N$(x()).optional(),theme:p4(["light","dark"]).optional()}),O7=h({icons:N$(s5$).optional()}),sv=h({name:x(),title:x().optional()}),Pk=sv.extend({...sv.shape,...O7.shape,version:x(),websiteUrl:x().optional(),description:x().optional()}),e5$=ZQ(h({applyDefaults:K4().optional()}),l$(x(),f$())),$W$=EY(($)=>{if($&&typeof $==="object"&&!Array.isArray($)){if(Object.keys($).length===0)return{form:{}}}return $},ZQ(h({form:e5$.optional(),url:a4.optional()}),l$(x(),f$()).optional())),XW$=l4({list:a4.optional(),cancel:a4.optional(),requests:l4({sampling:l4({createMessage:a4.optional()}).optional(),elicitation:l4({create:a4.optional()}).optional()}).optional()}),QW$=l4({list:a4.optional(),cancel:a4.optional(),requests:l4({tools:l4({call:a4.optional()}).optional()}).optional()}),vW$=h({experimental:l$(x(),a4).optional(),sampling:h({context:a4.optional(),tools:a4.optional()}).optional(),elicitation:$W$.optional(),roots:h({listChanged:K4().optional()}).optional(),tasks:XW$.optional()}),JW$=j1.extend({protocolVersion:x(),capabilities:vW$,clientInfo:Pk}),Uj=r4.extend({method:d("initialize"),params:JW$}),XZ=($)=>Uj.safeParse($).success,YW$=h({experimental:l$(x(),a4).optional(),logging:a4.optional(),completions:a4.optional(),prompts:h({listChanged:K4().optional()}).optional(),resources:h({subscribe:K4().optional(),listChanged:K4().optional()}).optional(),tools:h({listChanged:K4().optional()}).optional(),tasks:QW$.optional()}),zW$=t4.extend({protocolVersion:x(),capabilities:YW$,serverInfo:Pk,instructions:x().optional()}),QZ=E1.extend({method:d("notifications/initialized"),params:R1.optional()});var Kj=r4.extend({method:d("ping"),params:j1.optional()}),WW$=h({progress:P$(),total:a$(P$()),message:a$(x())}),GW$=h({...R1.shape,...WW$.shape,progressToken:Mk}),wj=E1.extend({method:d("notifications/progress"),params:GW$}),UW$=j1.extend({cursor:xk.optional()}),L7=r4.extend({params:UW$.optional()}),A7=t4.extend({nextCursor:xk.optional()}),KW$=p4(["working","input_required","completed","failed","cancelled"]),Z7=h({taskId:x(),status:KW$,ttl:C$([P$(),AQ()]),createdAt:x(),lastUpdatedAt:x(),pollInterval:a$(P$()),statusMessage:a$(x())}),$J=t4.extend({task:Z7}),wW$=R1.merge(Z7),q7=E1.extend({method:d("notifications/tasks/status"),params:wW$}),Hj=r4.extend({method:d("tasks/get"),params:j1.extend({taskId:x()})}),jj=t4.merge(Z7),Vj=r4.extend({method:d("tasks/result"),params:j1.extend({taskId:x()})}),eP$=t4.loose(),Bj=L7.extend({method:d("tasks/list")}),Dj=A7.extend({tasks:N$(Z7)}),Fj=r4.extend({method:d("tasks/cancel"),params:j1.extend({taskId:x()})}),Tk=t4.merge(Z7),Sk=h({uri:x(),mimeType:a$(x()),_meta:l$(x(),f$()).optional()}),Ck=Sk.extend({text:x()}),vZ=x().refine(($)=>{try{return atob($),!0}catch{return!1}},{message:"Invalid Base64 string"}),uk=Sk.extend({blob:vZ}),I7=p4(["user","assistant"]),XJ=h({audience:N$(I7).optional(),priority:P$().min(0).max(1).optional(),lastModified:k8.datetime({offset:!0}).optional()}),_k=h({...sv.shape,...O7.shape,uri:x(),description:a$(x()),mimeType:a$(x()),annotations:XJ.optional(),_meta:a$(l4({}))}),HW$=h({...sv.shape,...O7.shape,uriTemplate:x(),description:a$(x()),mimeType:a$(x()),annotations:XJ.optional(),_meta:a$(l4({}))}),Nj=L7.extend({method:d("resources/list")}),jW$=A7.extend({resources:N$(_k)}),Oj=L7.extend({method:d("resources/templates/list")}),VW$=A7.extend({resourceTemplates:N$(HW$)}),JZ=j1.extend({uri:x()}),BW$=JZ,Lj=r4.extend({method:d("resources/read"),params:BW$}),DW$=t4.extend({contents:N$(C$([Ck,uk]))}),FW$=E1.extend({method:d("notifications/resources/list_changed"),params:R1.optional()}),NW$=JZ,OW$=r4.extend({method:d("resources/subscribe"),params:NW$}),LW$=JZ,AW$=r4.extend({method:d("resources/unsubscribe"),params:LW$}),ZW$=R1.extend({uri:x()}),qW$=E1.extend({method:d("notifications/resources/updated"),params:ZW$}),IW$=h({name:x(),description:a$(x()),required:a$(K4())}),MW$=h({...sv.shape,...O7.shape,description:a$(x()),arguments:a$(N$(IW$)),_meta:a$(l4({}))}),Aj=L7.extend({method:d("prompts/list")}),xW$=A7.extend({prompts:N$(MW$)}),bW$=j1.extend({name:x(),arguments:l$(x(),x()).optional()}),Zj=r4.extend({method:d("prompts/get"),params:bW$}),YZ=h({type:d("text"),text:x(),annotations:XJ.optional(),_meta:l$(x(),f$()).optional()}),zZ=h({type:d("image"),data:vZ,mimeType:x(),annotations:XJ.optional(),_meta:l$(x(),f$()).optional()}),WZ=h({type:d("audio"),data:vZ,mimeType:x(),annotations:XJ.optional(),_meta:l$(x(),f$()).optional()}),iW$=h({type:d("tool_use"),name:x(),id:x(),input:l$(x(),f$()),_meta:l$(x(),f$()).optional()}),RW$=h({type:d("resource"),resource:C$([Ck,uk]),annotations:XJ.optional(),_meta:l$(x(),f$()).optional()}),EW$=_k.extend({type:d("resource_link")}),GZ=C$([YZ,zZ,WZ,EW$,RW$]),PW$=h({role:I7,content:GZ}),TW$=t4.extend({description:x().optional(),messages:N$(PW$)}),SW$=E1.extend({method:d("notifications/prompts/list_changed"),params:R1.optional()}),CW$=h({title:x().optional(),readOnlyHint:K4().optional(),destructiveHint:K4().optional(),idempotentHint:K4().optional(),openWorldHint:K4().optional()}),uW$=h({taskSupport:p4(["required","optional","forbidden"]).optional()}),yk=h({...sv.shape,...O7.shape,description:x().optional(),inputSchema:h({type:d("object"),properties:l$(x(),a4).optional(),required:N$(x()).optional()}).catchall(f$()),outputSchema:h({type:d("object"),properties:l$(x(),a4).optional(),required:N$(x()).optional()}).catchall(f$()).optional(),annotations:CW$.optional(),execution:uW$.optional(),_meta:l$(x(),f$()).optional()}),qj=L7.extend({method:d("tools/list")}),_W$=A7.extend({tools:N$(yk)}),Ij=t4.extend({content:N$(GZ).default([]),structuredContent:l$(x(),f$()).optional(),isError:K4().optional()}),$T$=Ij.or(t4.extend({toolResult:f$()})),yW$=N7.extend({name:x(),arguments:l$(x(),f$()).optional()}),QJ=r4.extend({method:d("tools/call"),params:yW$}),fW$=E1.extend({method:d("notifications/tools/list_changed"),params:R1.optional()}),XT$=h({autoRefresh:K4().default(!0),debounceMs:P$().int().nonnegative().default(300)}),M7=p4(["debug","info","notice","warning","error","critical","alert","emergency"]),kW$=j1.extend({level:M7}),UZ=r4.extend({method:d("logging/setLevel"),params:kW$}),nW$=R1.extend({level:M7,logger:x().optional(),data:f$()}),gW$=E1.extend({method:d("notifications/message"),params:nW$}),cW$=h({name:x().optional()}),mW$=h({hints:N$(cW$).optional(),costPriority:P$().min(0).max(1).optional(),speedPriority:P$().min(0).max(1).optional(),intelligencePriority:P$().min(0).max(1).optional()}),hW$=h({mode:p4(["auto","required","none"]).optional()}),lW$=h({type:d("tool_result"),toolUseId:x().describe("The unique identifier for the corresponding tool call."),content:N$(GZ).default([]),structuredContent:h({}).loose().optional(),isError:K4().optional(),_meta:l$(x(),f$()).optional()}),pW$=bY("type",[YZ,zZ,WZ]),Jj=bY("type",[YZ,zZ,WZ,iW$,lW$]),dW$=h({role:I7,content:C$([Jj,N$(Jj)]),_meta:l$(x(),f$()).optional()}),oW$=N7.extend({messages:N$(dW$),modelPreferences:mW$.optional(),systemPrompt:x().optional(),includeContext:p4(["none","thisServer","allServers"]).optional(),temperature:P$().optional(),maxTokens:P$().int(),stopSequences:N$(x()).optional(),metadata:a4.optional(),tools:N$(yk).optional(),toolChoice:hW$.optional()}),aW$=r4.extend({method:d("sampling/createMessage"),params:oW$}),x7=t4.extend({model:x(),stopReason:a$(p4(["endTurn","stopSequence","maxTokens"]).or(x())),role:I7,content:pW$}),KZ=t4.extend({model:x(),stopReason:a$(p4(["endTurn","stopSequence","maxTokens","toolUse"]).or(x())),role:I7,content:C$([Jj,N$(Jj)])}),rW$=h({type:d("boolean"),title:x().optional(),description:x().optional(),default:K4().optional()}),tW$=h({type:d("string"),title:x().optional(),description:x().optional(),minLength:P$().optional(),maxLength:P$().optional(),format:p4(["email","uri","date","date-time"]).optional(),default:x().optional()}),sW$=h({type:p4(["number","integer"]),title:x().optional(),description:x().optional(),minimum:P$().optional(),maximum:P$().optional(),default:P$().optional()}),eW$=h({type:d("string"),title:x().optional(),description:x().optional(),enum:N$(x()),default:x().optional()}),$G$=h({type:d("string"),title:x().optional(),description:x().optional(),oneOf:N$(h({const:x(),title:x()})),default:x().optional()}),XG$=h({type:d("string"),title:x().optional(),description:x().optional(),enum:N$(x()),enumNames:N$(x()).optional(),default:x().optional()}),QG$=C$([eW$,$G$]),vG$=h({type:d("array"),title:x().optional(),description:x().optional(),minItems:P$().optional(),maxItems:P$().optional(),items:h({type:d("string"),enum:N$(x())}),default:N$(x()).optional()}),JG$=h({type:d("array"),title:x().optional(),description:x().optional(),minItems:P$().optional(),maxItems:P$().optional(),items:h({anyOf:N$(h({const:x(),title:x()}))}),default:N$(x()).optional()}),YG$=C$([vG$,JG$]),zG$=C$([XG$,QG$,YG$]),WG$=C$([zG$,rW$,tW$,sW$]),GG$=N7.extend({mode:d("form").optional(),message:x(),requestedSchema:h({type:d("object"),properties:l$(x(),WG$),required:N$(x()).optional()})}),UG$=N7.extend({mode:d("url"),message:x(),elicitationId:x(),url:x().url()}),KG$=C$([GG$,UG$]),wG$=r4.extend({method:d("elicitation/create"),params:KG$}),HG$=R1.extend({elicitationId:x()}),jG$=E1.extend({method:d("notifications/elicitation/complete"),params:HG$}),vJ=t4.extend({action:p4(["accept","decline","cancel"]),content:EY(($)=>$===null?void 0:$,l$(x(),C$([x(),P$(),K4(),N$(x())])).optional())}),VG$=h({type:d("ref/resource"),uri:x()});var BG$=h({type:d("ref/prompt"),name:x()}),DG$=j1.extend({ref:C$([BG$,VG$]),argument:h({name:x(),value:x()}),context:h({arguments:l$(x(),x()).optional()}).optional()}),Mj=r4.extend({method:d("completion/complete"),params:DG$});function fk($){if($.params.ref.type!=="ref/prompt")throw TypeError(`Expected CompleteRequestPrompt, but got ${$.params.ref.type}`)}function kk($){if($.params.ref.type!=="ref/resource")throw TypeError(`Expected CompleteRequestResourceTemplate, but got ${$.params.ref.type}`)}var FG$=t4.extend({completion:l4({values:N$(x()).max(100),total:a$(P$().int()),hasMore:a$(K4())})}),NG$=h({uri:x().startsWith("file://"),name:x().optional(),_meta:l$(x(),f$()).optional()}),OG$=r4.extend({method:d("roots/list"),params:j1.optional()}),wZ=t4.extend({roots:N$(NG$)}),LG$=E1.extend({method:d("notifications/roots/list_changed"),params:R1.optional()}),QT$=C$([Kj,Uj,Mj,UZ,Zj,Aj,Nj,Oj,Lj,OW$,AW$,QJ,qj,Hj,Vj,Bj,Fj]),vT$=C$([Gj,wj,QZ,LG$,q7]),JT$=C$([Wj,x7,KZ,vJ,wZ,jj,Dj,$J]),YT$=C$([Kj,aW$,wG$,OG$,Hj,Vj,Bj,Fj]),zT$=C$([Gj,wj,gW$,qW$,FW$,fW$,SW$,q7,jG$]),WT$=C$([Wj,zW$,FG$,TW$,xW$,jW$,VW$,DW$,Ij,_W$,jj,Dj,$J]);class o extends Error{constructor($,X,Q){super(`MCP error ${$}: ${X}`);this.code=$,this.data=Q,this.name="McpError"}static fromError($,X,Q){if($===s.UrlElicitationRequired&&Q){let J=Q;if(J.elicitations)return new nk(J.elicitations,X)}return new o($,X,Q)}}class nk extends o{constructor($,X=`URL elicitation${$.length>1?"s":""} required`){super(s.UrlElicitationRequired,X,{elicitations:$})}get elicitations(){return this.data?.elicitations??[]}}function F9($){return $==="completed"||$==="failed"||$==="cancelled"}var ck=Symbol("Let zodToJsonSchema decide on which parser to use");var gk={name:void 0,$refStrategy:"root",basePath:["#"],effectStrategy:"input",pipeStrategy:"all",dateStrategy:"format:date-time",mapStrategy:"entries",removeAdditionalStrategy:"passthrough",allowedAdditionalProperties:!0,rejectedAdditionalProperties:!1,definitionPath:"definitions",target:"jsonSchema7",strictUnions:!1,definitions:{},errorMessages:!1,markdownDescription:!1,patternStrategy:"escape",applyRegexFlags:!1,emailStrategy:"format:email",base64Strategy:"contentEncoding:base64",nameStrategy:"ref",openAiAnyTypeName:"OpenAiAnyType"},mk=($)=>typeof $==="string"?{...gk,name:$}:{...gk,...$};var hk=($)=>{let X=mk($),Q=X.name!==void 0?[...X.basePath,X.definitionPath,X.name]:X.basePath;return{...X,flags:{hasReferencedOpenAiAnyType:!1},currentPath:Q,propertyPath:void 0,seen:new Map(Object.entries(X.definitions).map(([J,v])=>[v._def,{def:v._def,path:[...X.basePath,X.definitionPath,J],jsonSchema:void 0}]))}};function HZ($,X,Q,J){if(!J?.errorMessages)return;if(Q)$.errorMessage={...$.errorMessage,[X]:Q}}function I$($,X,Q,J,v){$[X]=Q,HZ($,X,J,v)}var xj=($,X)=>{let Q=0;for(;Q<$.length&&Q{return Q$($.innerType._def,X)};function jZ($,X,Q){let J=Q??X.dateStrategy;if(Array.isArray(J))return{anyOf:J.map((v,Y)=>jZ($,X,v))};switch(J){case"string":case"format:date-time":return{type:"string",format:"date-time"};case"format:date":return{type:"string",format:"date"};case"integer":return AG$($,X)}}var AG$=($,X)=>{let Q={type:"integer",format:"unix-time"};if(X.target==="openApi3")return Q;for(let J of $.checks)switch(J.kind){case"min":I$(Q,"minimum",J.value,J.message,X);break;case"max":I$(Q,"maximum",J.value,J.message,X);break}return Q};function ak($,X){return{...Q$($.innerType._def,X),default:$.defaultValue()}}function rk($,X){return X.effectStrategy==="input"?Q$($.schema._def,X):$4(X)}function tk($){return{type:"string",enum:Array.from($.values)}}var ZG$=($)=>{if("type"in $&&$.type==="string")return!1;return"allOf"in $};function sk($,X){let Q=[Q$($.left._def,{...X,currentPath:[...X.currentPath,"allOf","0"]}),Q$($.right._def,{...X,currentPath:[...X.currentPath,"allOf","1"]})].filter((Y)=>!!Y),J=X.target==="jsonSchema2019-09"?{unevaluatedProperties:!1}:void 0,v=[];return Q.forEach((Y)=>{if(ZG$(Y)){if(v.push(...Y.allOf),Y.unevaluatedProperties===void 0)J=void 0}else{let z=Y;if("additionalProperties"in Y&&Y.additionalProperties===!1){let{additionalProperties:W,...G}=Y;z=G}else J=void 0;v.push(z)}}),v.length?{allOf:v,...J}:void 0}function ek($,X){let Q=typeof $.value;if(Q!=="bigint"&&Q!=="number"&&Q!=="boolean"&&Q!=="string")return{type:Array.isArray($.value)?"array":"object"};if(X.target==="openApi3")return{type:Q==="bigint"?"integer":Q,enum:[$.value]};return{type:Q==="bigint"?"integer":Q,const:$.value}}var VZ=void 0,J0={cuid:/^[cC][^\s-]{8,}$/,cuid2:/^[0-9a-z]+$/,ulid:/^[0-9A-HJKMNP-TV-Z]{26}$/,email:/^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/,emoji:()=>{if(VZ===void 0)VZ=RegExp("^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$","u");return VZ},uuid:/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/,ipv4:/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,ipv4Cidr:/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/,ipv6:/^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/,ipv6Cidr:/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,base64:/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/,base64url:/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/,nanoid:/^[a-zA-Z0-9_-]{21}$/,jwt:/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/};function ij($,X){let Q={type:"string"};if($.checks)for(let J of $.checks)switch(J.kind){case"min":I$(Q,"minLength",typeof Q.minLength==="number"?Math.max(Q.minLength,J.value):J.value,J.message,X);break;case"max":I$(Q,"maxLength",typeof Q.maxLength==="number"?Math.min(Q.maxLength,J.value):J.value,J.message,X);break;case"email":switch(X.emailStrategy){case"format:email":Y0(Q,"email",J.message,X);break;case"format:idn-email":Y0(Q,"idn-email",J.message,X);break;case"pattern:zod":i6(Q,J0.email,J.message,X);break}break;case"url":Y0(Q,"uri",J.message,X);break;case"uuid":Y0(Q,"uuid",J.message,X);break;case"regex":i6(Q,J.regex,J.message,X);break;case"cuid":i6(Q,J0.cuid,J.message,X);break;case"cuid2":i6(Q,J0.cuid2,J.message,X);break;case"startsWith":i6(Q,RegExp(`^${BZ(J.value,X)}`),J.message,X);break;case"endsWith":i6(Q,RegExp(`${BZ(J.value,X)}$`),J.message,X);break;case"datetime":Y0(Q,"date-time",J.message,X);break;case"date":Y0(Q,"date",J.message,X);break;case"time":Y0(Q,"time",J.message,X);break;case"duration":Y0(Q,"duration",J.message,X);break;case"length":I$(Q,"minLength",typeof Q.minLength==="number"?Math.max(Q.minLength,J.value):J.value,J.message,X),I$(Q,"maxLength",typeof Q.maxLength==="number"?Math.min(Q.maxLength,J.value):J.value,J.message,X);break;case"includes":{i6(Q,RegExp(BZ(J.value,X)),J.message,X);break}case"ip":{if(J.version!=="v6")Y0(Q,"ipv4",J.message,X);if(J.version!=="v4")Y0(Q,"ipv6",J.message,X);break}case"base64url":i6(Q,J0.base64url,J.message,X);break;case"jwt":i6(Q,J0.jwt,J.message,X);break;case"cidr":{if(J.version!=="v6")i6(Q,J0.ipv4Cidr,J.message,X);if(J.version!=="v4")i6(Q,J0.ipv6Cidr,J.message,X);break}case"emoji":i6(Q,J0.emoji(),J.message,X);break;case"ulid":{i6(Q,J0.ulid,J.message,X);break}case"base64":{switch(X.base64Strategy){case"format:binary":{Y0(Q,"binary",J.message,X);break}case"contentEncoding:base64":{I$(Q,"contentEncoding","base64",J.message,X);break}case"pattern:zod":{i6(Q,J0.base64,J.message,X);break}}break}case"nanoid":i6(Q,J0.nanoid,J.message,X);case"toLowerCase":case"toUpperCase":case"trim":break;default:((v)=>{})(J)}return Q}function BZ($,X){return X.patternStrategy==="escape"?IG$($):$}var qG$=new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789");function IG$($){let X="";for(let Q=0;Q<$.length;Q++){if(!qG$.has($[Q]))X+="\\";X+=$[Q]}return X}function Y0($,X,Q,J){if($.format||$.anyOf?.some((v)=>v.format)){if(!$.anyOf)$.anyOf=[];if($.format){if($.anyOf.push({format:$.format,...$.errorMessage&&J.errorMessages&&{errorMessage:{format:$.errorMessage.format}}}),delete $.format,$.errorMessage){if(delete $.errorMessage.format,Object.keys($.errorMessage).length===0)delete $.errorMessage}}$.anyOf.push({format:X,...Q&&J.errorMessages&&{errorMessage:{format:Q}}})}else I$($,"format",X,Q,J)}function i6($,X,Q,J){if($.pattern||$.allOf?.some((v)=>v.pattern)){if(!$.allOf)$.allOf=[];if($.pattern){if($.allOf.push({pattern:$.pattern,...$.errorMessage&&J.errorMessages&&{errorMessage:{pattern:$.errorMessage.pattern}}}),delete $.pattern,$.errorMessage){if(delete $.errorMessage.pattern,Object.keys($.errorMessage).length===0)delete $.errorMessage}}$.allOf.push({pattern:$n(X,J),...Q&&J.errorMessages&&{errorMessage:{pattern:Q}}})}else I$($,"pattern",$n(X,J),Q,J)}function $n($,X){if(!X.applyRegexFlags||!$.flags)return $.source;let Q={i:$.flags.includes("i"),m:$.flags.includes("m"),s:$.flags.includes("s")},J=Q.i?$.source.toLowerCase():$.source,v="",Y=!1,z=!1,W=!1;for(let G=0;G({...J,[v]:Q$($.valueType._def,{...X,currentPath:[...X.currentPath,"properties",v]})??$4(X)}),{}),additionalProperties:X.rejectedAdditionalProperties};let Q={type:"object",additionalProperties:Q$($.valueType._def,{...X,currentPath:[...X.currentPath,"additionalProperties"]})??X.allowedAdditionalProperties};if(X.target==="openApi3")return Q;if($.keyType?._def.typeName===b.ZodString&&$.keyType._def.checks?.length){let{type:J,...v}=ij($.keyType._def,X);return{...Q,propertyNames:v}}else if($.keyType?._def.typeName===b.ZodEnum)return{...Q,propertyNames:{enum:$.keyType._def.values}};else if($.keyType?._def.typeName===b.ZodBranded&&$.keyType._def.type._def.typeName===b.ZodString&&$.keyType._def.type._def.checks?.length){let{type:J,...v}=bj($.keyType._def,X);return{...Q,propertyNames:v}}return Q}function Xn($,X){if(X.mapStrategy==="record")return Rj($,X);let Q=Q$($.keyType._def,{...X,currentPath:[...X.currentPath,"items","items","0"]})||$4(X),J=Q$($.valueType._def,{...X,currentPath:[...X.currentPath,"items","items","1"]})||$4(X);return{type:"array",maxItems:125,items:{type:"array",items:[Q,J],minItems:2,maxItems:2}}}function Qn($){let X=$.values,J=Object.keys($.values).filter((Y)=>{return typeof X[X[Y]]!=="number"}).map((Y)=>X[Y]),v=Array.from(new Set(J.map((Y)=>typeof Y)));return{type:v.length===1?v[0]==="string"?"string":"number":["string","number"],enum:J}}function vn($){return $.target==="openAi"?void 0:{not:$4({...$,currentPath:[...$.currentPath,"not"]})}}function Jn($){return $.target==="openApi3"?{enum:["null"],nullable:!0}:{type:"null"}}var b7={ZodString:"string",ZodNumber:"number",ZodBigInt:"integer",ZodBoolean:"boolean",ZodNull:"null"};function zn($,X){if(X.target==="openApi3")return Yn($,X);let Q=$.options instanceof Map?Array.from($.options.values()):$.options;if(Q.every((J)=>(J._def.typeName in b7)&&(!J._def.checks||!J._def.checks.length))){let J=Q.reduce((v,Y)=>{let z=b7[Y._def.typeName];return z&&!v.includes(z)?[...v,z]:v},[]);return{type:J.length>1?J:J[0]}}else if(Q.every((J)=>J._def.typeName==="ZodLiteral"&&!J.description)){let J=Q.reduce((v,Y)=>{let z=typeof Y._def.value;switch(z){case"string":case"number":case"boolean":return[...v,z];case"bigint":return[...v,"integer"];case"object":if(Y._def.value===null)return[...v,"null"];case"symbol":case"undefined":case"function":default:return v}},[]);if(J.length===Q.length){let v=J.filter((Y,z,W)=>W.indexOf(Y)===z);return{type:v.length>1?v:v[0],enum:Q.reduce((Y,z)=>{return Y.includes(z._def.value)?Y:[...Y,z._def.value]},[])}}}else if(Q.every((J)=>J._def.typeName==="ZodEnum"))return{type:"string",enum:Q.reduce((J,v)=>[...J,...v._def.values.filter((Y)=>!J.includes(Y))],[])};return Yn($,X)}var Yn=($,X)=>{let Q=($.options instanceof Map?Array.from($.options.values()):$.options).map((J,v)=>Q$(J._def,{...X,currentPath:[...X.currentPath,"anyOf",`${v}`]})).filter((J)=>!!J&&(!X.strictUnions||typeof J==="object"&&Object.keys(J).length>0));return Q.length?{anyOf:Q}:void 0};function Wn($,X){if(["ZodString","ZodNumber","ZodBigInt","ZodBoolean","ZodNull"].includes($.innerType._def.typeName)&&(!$.innerType._def.checks||!$.innerType._def.checks.length)){if(X.target==="openApi3")return{type:b7[$.innerType._def.typeName],nullable:!0};return{type:[b7[$.innerType._def.typeName],"null"]}}if(X.target==="openApi3"){let J=Q$($.innerType._def,{...X,currentPath:[...X.currentPath]});if(J&&"$ref"in J)return{allOf:[J],nullable:!0};return J&&{...J,nullable:!0}}let Q=Q$($.innerType._def,{...X,currentPath:[...X.currentPath,"anyOf","0"]});return Q&&{anyOf:[Q,{type:"null"}]}}function Gn($,X){let Q={type:"number"};if(!$.checks)return Q;for(let J of $.checks)switch(J.kind){case"int":Q.type="integer",HZ(Q,"type",J.message,X);break;case"min":if(X.target==="jsonSchema7")if(J.inclusive)I$(Q,"minimum",J.value,J.message,X);else I$(Q,"exclusiveMinimum",J.value,J.message,X);else{if(!J.inclusive)Q.exclusiveMinimum=!0;I$(Q,"minimum",J.value,J.message,X)}break;case"max":if(X.target==="jsonSchema7")if(J.inclusive)I$(Q,"maximum",J.value,J.message,X);else I$(Q,"exclusiveMaximum",J.value,J.message,X);else{if(!J.inclusive)Q.exclusiveMaximum=!0;I$(Q,"maximum",J.value,J.message,X)}break;case"multipleOf":I$(Q,"multipleOf",J.value,J.message,X);break}return Q}function Un($,X){let Q=X.target==="openAi",J={type:"object",properties:{}},v=[],Y=$.shape();for(let W in Y){let G=Y[W];if(G===void 0||G._def===void 0)continue;let U=xG$(G);if(U&&Q){if(G._def.typeName==="ZodOptional")G=G._def.innerType;if(!G.isNullable())G=G.nullable();U=!1}let w=Q$(G._def,{...X,currentPath:[...X.currentPath,"properties",W],propertyPath:[...X.currentPath,"properties",W]});if(w===void 0)continue;if(J.properties[W]=w,!U)v.push(W)}if(v.length)J.required=v;let z=MG$($,X);if(z!==void 0)J.additionalProperties=z;return J}function MG$($,X){if($.catchall._def.typeName!=="ZodNever")return Q$($.catchall._def,{...X,currentPath:[...X.currentPath,"additionalProperties"]});switch($.unknownKeys){case"passthrough":return X.allowedAdditionalProperties;case"strict":return X.rejectedAdditionalProperties;case"strip":return X.removeAdditionalStrategy==="strict"?X.allowedAdditionalProperties:X.rejectedAdditionalProperties}}function xG$($){try{return $.isOptional()}catch{return!0}}var Kn=($,X)=>{if(X.currentPath.toString()===X.propertyPath?.toString())return Q$($.innerType._def,X);let Q=Q$($.innerType._def,{...X,currentPath:[...X.currentPath,"anyOf","1"]});return Q?{anyOf:[{not:$4(X)},Q]}:$4(X)};var wn=($,X)=>{if(X.pipeStrategy==="input")return Q$($.in._def,X);else if(X.pipeStrategy==="output")return Q$($.out._def,X);let Q=Q$($.in._def,{...X,currentPath:[...X.currentPath,"allOf","0"]}),J=Q$($.out._def,{...X,currentPath:[...X.currentPath,"allOf",Q?"1":"0"]});return{allOf:[Q,J].filter((v)=>v!==void 0)}};function Hn($,X){return Q$($.type._def,X)}function jn($,X){let J={type:"array",uniqueItems:!0,items:Q$($.valueType._def,{...X,currentPath:[...X.currentPath,"items"]})};if($.minSize)I$(J,"minItems",$.minSize.value,$.minSize.message,X);if($.maxSize)I$(J,"maxItems",$.maxSize.value,$.maxSize.message,X);return J}function Vn($,X){if($.rest)return{type:"array",minItems:$.items.length,items:$.items.map((Q,J)=>Q$(Q._def,{...X,currentPath:[...X.currentPath,"items",`${J}`]})).reduce((Q,J)=>J===void 0?Q:[...Q,J],[]),additionalItems:Q$($.rest._def,{...X,currentPath:[...X.currentPath,"additionalItems"]})};else return{type:"array",minItems:$.items.length,maxItems:$.items.length,items:$.items.map((Q,J)=>Q$(Q._def,{...X,currentPath:[...X.currentPath,"items",`${J}`]})).reduce((Q,J)=>J===void 0?Q:[...Q,J],[])}}function Bn($){return{not:$4($)}}function Dn($){return $4($)}var Fn=($,X)=>{return Q$($.innerType._def,X)};var Nn=($,X,Q)=>{switch(X){case b.ZodString:return ij($,Q);case b.ZodNumber:return Gn($,Q);case b.ZodObject:return Un($,Q);case b.ZodBigInt:return pk($,Q);case b.ZodBoolean:return dk();case b.ZodDate:return jZ($,Q);case b.ZodUndefined:return Bn(Q);case b.ZodNull:return Jn(Q);case b.ZodArray:return lk($,Q);case b.ZodUnion:case b.ZodDiscriminatedUnion:return zn($,Q);case b.ZodIntersection:return sk($,Q);case b.ZodTuple:return Vn($,Q);case b.ZodRecord:return Rj($,Q);case b.ZodLiteral:return ek($,Q);case b.ZodEnum:return tk($);case b.ZodNativeEnum:return Qn($);case b.ZodNullable:return Wn($,Q);case b.ZodOptional:return Kn($,Q);case b.ZodMap:return Xn($,Q);case b.ZodSet:return jn($,Q);case b.ZodLazy:return()=>$.getter()._def;case b.ZodPromise:return Hn($,Q);case b.ZodNaN:case b.ZodNever:return vn(Q);case b.ZodEffects:return rk($,Q);case b.ZodAny:return $4(Q);case b.ZodUnknown:return Dn(Q);case b.ZodDefault:return ak($,Q);case b.ZodBranded:return bj($,Q);case b.ZodReadonly:return Fn($,Q);case b.ZodCatch:return ok($,Q);case b.ZodPipeline:return wn($,Q);case b.ZodFunction:case b.ZodVoid:case b.ZodSymbol:return;default:return((J)=>{return})(X)}};function Q$($,X,Q=!1){let J=X.seen.get($);if(X.override){let W=X.override?.($,X,J,Q);if(W!==ck)return W}if(J&&!Q){let W=bG$(J,X);if(W!==void 0)return W}let v={def:$,path:X.currentPath,jsonSchema:void 0};X.seen.set($,v);let Y=Nn($,$.typeName,X),z=typeof Y==="function"?Q$(Y(),X):Y;if(z)iG$($,X,z);if(X.postProcess){let W=X.postProcess(z,$,X);return v.jsonSchema=z,W}return v.jsonSchema=z,z}var bG$=($,X)=>{switch(X.$refStrategy){case"root":return{$ref:$.path.join("/")};case"relative":return{$ref:xj(X.currentPath,$.path)};case"none":case"seen":{if($.path.lengthX.currentPath[J]===Q))return console.warn(`Recursive reference detected at ${X.currentPath.join("/")}! Defaulting to any`),$4(X);return X.$refStrategy==="seen"?$4(X):void 0}}},iG$=($,X,Q)=>{if($.description){if(Q.description=$.description,X.markdownDescription)Q.markdownDescription=$.description}return Q};var DZ=($,X)=>{let Q=hk(X),J=typeof X==="object"&&X.definitions?Object.entries(X.definitions).reduce((G,[U,w])=>({...G,[U]:Q$(w._def,{...Q,currentPath:[...Q.basePath,Q.definitionPath,U]},!0)??$4(Q)}),{}):void 0,v=typeof X==="string"?X:X?.nameStrategy==="title"?void 0:X?.name,Y=Q$($._def,v===void 0?Q:{...Q,currentPath:[...Q.basePath,Q.definitionPath,v]},!1)??$4(Q),z=typeof X==="object"&&X.name!==void 0&&X.nameStrategy==="title"?X.name:void 0;if(z!==void 0)Y.title=z;if(Q.flags.hasReferencedOpenAiAnyType){if(!J)J={};if(!J[Q.openAiAnyTypeName])J[Q.openAiAnyTypeName]={type:["string","number","integer","boolean","array","null"],items:{$ref:Q.$refStrategy==="relative"?"1":[...Q.basePath,Q.definitionPath,Q.openAiAnyTypeName].join("/")}}}let W=v===void 0?J?{...Y,[Q.definitionPath]:J}:Y:{$ref:[...Q.$refStrategy==="relative"?[]:Q.basePath,Q.definitionPath,v].join("/"),[Q.definitionPath]:{...J,[v]:Y}};if(Q.target==="jsonSchema7")W.$schema="http://json-schema.org/draft-07/schema#";else if(Q.target==="jsonSchema2019-09"||Q.target==="openAi")W.$schema="https://json-schema.org/draft/2019-09/schema#";if(Q.target==="openAi"&&(("anyOf"in W)||("oneOf"in W)||("allOf"in W)||("type"in W)&&Array.isArray(W.type)))console.warn("Warning: OpenAI may not support schemas with unions as roots! Try wrapping it in an object property.");return W};function RG$($){if(!$)return"draft-7";if($==="jsonSchema7"||$==="draft-7")return"draft-7";if($==="jsonSchema2019-09"||$==="draft-2020-12")return"draft-2020-12";return"draft-7"}function FZ($,X){if(i1($))return s0($,{target:RG$(X?.target),io:X?.pipeStrategy??"input"});return DZ($,{strictUnions:X?.strictUnions??!0,pipeStrategy:X?.pipeStrategy??"input"})}function NZ($){let Q=V9($)?.method;if(!Q)throw Error("Schema is missing a method literal");let J=vj(Q);if(typeof J!=="string")throw Error("Schema method literal must be a string");return J}function OZ($,X){let Q=j9($,X);if(!Q.success)throw Q.error;return Q.data}var EG$=60000;class LZ{constructor($){if(this._options=$,this._requestMessageId=0,this._requestHandlers=new Map,this._requestHandlerAbortControllers=new Map,this._notificationHandlers=new Map,this._responseHandlers=new Map,this._progressHandlers=new Map,this._timeoutInfo=new Map,this._pendingDebouncedNotifications=new Set,this._taskProgressTokens=new Map,this._requestResolvers=new Map,this.setNotificationHandler(Gj,(X)=>{this._oncancel(X)}),this.setNotificationHandler(wj,(X)=>{this._onprogress(X)}),this.setRequestHandler(Kj,(X)=>({})),this._taskStore=$?.taskStore,this._taskMessageQueue=$?.taskMessageQueue,this._taskStore)this.setRequestHandler(Hj,async(X,Q)=>{let J=await this._taskStore.getTask(X.params.taskId,Q.sessionId);if(!J)throw new o(s.InvalidParams,"Failed to retrieve task: Task not found");return{...J}}),this.setRequestHandler(Vj,async(X,Q)=>{let J=async()=>{let v=X.params.taskId;if(this._taskMessageQueue){let z;while(z=await this._taskMessageQueue.dequeue(v,Q.sessionId)){if(z.type==="response"||z.type==="error"){let W=z.message,G=W.id,U=this._requestResolvers.get(G);if(U)if(this._requestResolvers.delete(G),z.type==="response")U(W);else{let w=W,H=new o(w.error.code,w.error.message,w.error.data);U(H)}else{let w=z.type==="response"?"Response":"Error";this._onerror(Error(`${w} handler missing for request ${G}`))}continue}await this._transport?.send(z.message,{relatedRequestId:Q.requestId})}}let Y=await this._taskStore.getTask(v,Q.sessionId);if(!Y)throw new o(s.InvalidParams,`Task not found: ${v}`);if(!F9(Y.status))return await this._waitForTaskUpdate(v,Q.signal),await J();if(F9(Y.status)){let z=await this._taskStore.getTaskResult(v,Q.sessionId);return this._clearTaskQueue(v),{...z,_meta:{...z._meta,[B9]:{taskId:v}}}}return await J()};return await J()}),this.setRequestHandler(Bj,async(X,Q)=>{try{let{tasks:J,nextCursor:v}=await this._taskStore.listTasks(X.params?.cursor,Q.sessionId);return{tasks:J,nextCursor:v,_meta:{}}}catch(J){throw new o(s.InvalidParams,`Failed to list tasks: ${J instanceof Error?J.message:String(J)}`)}}),this.setRequestHandler(Fj,async(X,Q)=>{try{let J=await this._taskStore.getTask(X.params.taskId,Q.sessionId);if(!J)throw new o(s.InvalidParams,`Task not found: ${X.params.taskId}`);if(F9(J.status))throw new o(s.InvalidParams,`Cannot cancel task in terminal status: ${J.status}`);await this._taskStore.updateTaskStatus(X.params.taskId,"cancelled","Client cancelled task execution.",Q.sessionId),this._clearTaskQueue(X.params.taskId);let v=await this._taskStore.getTask(X.params.taskId,Q.sessionId);if(!v)throw new o(s.InvalidParams,`Task not found after cancellation: ${X.params.taskId}`);return{_meta:{},...v}}catch(J){if(J instanceof o)throw J;throw new o(s.InvalidRequest,`Failed to cancel task: ${J instanceof Error?J.message:String(J)}`)}})}async _oncancel($){if(!$.params.requestId)return;this._requestHandlerAbortControllers.get($.params.requestId)?.abort($.params.reason)}_setupTimeout($,X,Q,J,v=!1){this._timeoutInfo.set($,{timeoutId:setTimeout(J,X),startTime:Date.now(),timeout:X,maxTotalTimeout:Q,resetTimeoutOnProgress:v,onTimeout:J})}_resetTimeout($){let X=this._timeoutInfo.get($);if(!X)return!1;let Q=Date.now()-X.startTime;if(X.maxTotalTimeout&&Q>=X.maxTotalTimeout)throw this._timeoutInfo.delete($),o.fromError(s.RequestTimeout,"Maximum total timeout exceeded",{maxTotalTimeout:X.maxTotalTimeout,totalElapsed:Q});return clearTimeout(X.timeoutId),X.timeoutId=setTimeout(X.onTimeout,X.timeout),!0}_cleanupTimeout($){let X=this._timeoutInfo.get($);if(X)clearTimeout(X.timeoutId),this._timeoutInfo.delete($)}async connect($){if(this._transport)throw Error("Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.");this._transport=$;let X=this.transport?.onclose;this._transport.onclose=()=>{X?.(),this._onclose()};let Q=this.transport?.onerror;this._transport.onerror=(v)=>{Q?.(v),this._onerror(v)};let J=this._transport?.onmessage;this._transport.onmessage=(v,Y)=>{if(J?.(v,Y),L8(v)||ev(v))this._onresponse(v);else if(D9(v))this._onrequest(v,Y);else if(Ek(v))this._onnotification(v);else this._onerror(Error(`Unknown message type: ${JSON.stringify(v)}`))},await this._transport.start()}_onclose(){let $=this._responseHandlers;this._responseHandlers=new Map,this._progressHandlers.clear(),this._taskProgressTokens.clear(),this._pendingDebouncedNotifications.clear();for(let Q of this._requestHandlerAbortControllers.values())Q.abort();this._requestHandlerAbortControllers.clear();let X=o.fromError(s.ConnectionClosed,"Connection closed");this._transport=void 0,this.onclose?.();for(let Q of $.values())Q(X)}_onerror($){this.onerror?.($)}_onnotification($){let X=this._notificationHandlers.get($.method)??this.fallbackNotificationHandler;if(X===void 0)return;Promise.resolve().then(()=>X($)).catch((Q)=>this._onerror(Error(`Uncaught error in notification handler: ${Q}`)))}_onrequest($,X){let Q=this._requestHandlers.get($.method)??this.fallbackRequestHandler,J=this._transport,v=$.params?._meta?.[B9]?.taskId;if(Q===void 0){let U={jsonrpc:"2.0",id:$.id,error:{code:s.MethodNotFound,message:"Method not found"}};if(v&&this._taskMessageQueue)this._enqueueTaskMessage(v,{type:"error",message:U,timestamp:Date.now()},J?.sessionId).catch((w)=>this._onerror(Error(`Failed to enqueue error response: ${w}`)));else J?.send(U).catch((w)=>this._onerror(Error(`Failed to send an error response: ${w}`)));return}let Y=new AbortController;this._requestHandlerAbortControllers.set($.id,Y);let z=bk($.params)?$.params.task:void 0,W=this._taskStore?this.requestTaskStore($,J?.sessionId):void 0,G={signal:Y.signal,sessionId:J?.sessionId,_meta:$.params?._meta,sendNotification:async(U)=>{if(Y.signal.aborted)return;let w={relatedRequestId:$.id};if(v)w.relatedTask={taskId:v};await this.notification(U,w)},sendRequest:async(U,w,H)=>{if(Y.signal.aborted)throw new o(s.ConnectionClosed,"Request was cancelled");let j={...H,relatedRequestId:$.id};if(v&&!j.relatedTask)j.relatedTask={taskId:v};let V=j.relatedTask?.taskId??v;if(V&&W)await W.updateTaskStatus(V,"input_required");return await this.request(U,w,j)},authInfo:X?.authInfo,requestId:$.id,requestInfo:X?.requestInfo,taskId:v,taskStore:W,taskRequestedTtl:z?.ttl,closeSSEStream:X?.closeSSEStream,closeStandaloneSSEStream:X?.closeStandaloneSSEStream};Promise.resolve().then(()=>{if(z)this.assertTaskHandlerCapability($.method)}).then(()=>Q($,G)).then(async(U)=>{if(Y.signal.aborted)return;let w={result:U,jsonrpc:"2.0",id:$.id};if(v&&this._taskMessageQueue)await this._enqueueTaskMessage(v,{type:"response",message:w,timestamp:Date.now()},J?.sessionId);else await J?.send(w)},async(U)=>{if(Y.signal.aborted)return;let w={jsonrpc:"2.0",id:$.id,error:{code:Number.isSafeInteger(U.code)?U.code:s.InternalError,message:U.message??"Internal error",...U.data!==void 0&&{data:U.data}}};if(v&&this._taskMessageQueue)await this._enqueueTaskMessage(v,{type:"error",message:w,timestamp:Date.now()},J?.sessionId);else await J?.send(w)}).catch((U)=>this._onerror(Error(`Failed to send response: ${U}`))).finally(()=>{this._requestHandlerAbortControllers.delete($.id)})}_onprogress($){let{progressToken:X,...Q}=$.params,J=Number(X),v=this._progressHandlers.get(J);if(!v){this._onerror(Error(`Received a progress notification for an unknown token: ${JSON.stringify($)}`));return}let Y=this._responseHandlers.get(J),z=this._timeoutInfo.get(J);if(z&&Y&&z.resetTimeoutOnProgress)try{this._resetTimeout(J)}catch(W){this._responseHandlers.delete(J),this._progressHandlers.delete(J),this._cleanupTimeout(J),Y(W);return}v(Q)}_onresponse($){let X=Number($.id),Q=this._requestResolvers.get(X);if(Q){if(this._requestResolvers.delete(X),L8($))Q($);else{let Y=new o($.error.code,$.error.message,$.error.data);Q(Y)}return}let J=this._responseHandlers.get(X);if(J===void 0){this._onerror(Error(`Received a response for an unknown message ID: ${JSON.stringify($)}`));return}this._responseHandlers.delete(X),this._cleanupTimeout(X);let v=!1;if(L8($)&&$.result&&typeof $.result==="object"){let Y=$.result;if(Y.task&&typeof Y.task==="object"){let z=Y.task;if(typeof z.taskId==="string")v=!0,this._taskProgressTokens.set(z.taskId,X)}}if(!v)this._progressHandlers.delete(X);if(L8($))J($);else{let Y=o.fromError($.error.code,$.error.message,$.error.data);J(Y)}}get transport(){return this._transport}async close(){await this._transport?.close()}async*requestStream($,X,Q){let{task:J}=Q??{};if(!J){try{yield{type:"result",result:await this.request($,X,Q)}}catch(Y){yield{type:"error",error:Y instanceof o?Y:new o(s.InternalError,String(Y))}}return}let v;try{let Y=await this.request($,$J,Q);if(Y.task)v=Y.task.taskId,yield{type:"taskCreated",task:Y.task};else throw new o(s.InternalError,"Task creation did not return a task");while(!0){let z=await this.getTask({taskId:v},Q);if(yield{type:"taskStatus",task:z},F9(z.status)){if(z.status==="completed")yield{type:"result",result:await this.getTaskResult({taskId:v},X,Q)};else if(z.status==="failed")yield{type:"error",error:new o(s.InternalError,`Task ${v} failed`)};else if(z.status==="cancelled")yield{type:"error",error:new o(s.InternalError,`Task ${v} was cancelled`)};return}if(z.status==="input_required"){yield{type:"result",result:await this.getTaskResult({taskId:v},X,Q)};return}let W=z.pollInterval??this._options?.defaultTaskPollInterval??1000;await new Promise((G)=>setTimeout(G,W)),Q?.signal?.throwIfAborted()}}catch(Y){yield{type:"error",error:Y instanceof o?Y:new o(s.InternalError,String(Y))}}}request($,X,Q){let{relatedRequestId:J,resumptionToken:v,onresumptiontoken:Y,task:z,relatedTask:W}=Q??{};return new Promise((G,U)=>{let w=(L)=>{U(L)};if(!this._transport){w(Error("Not connected"));return}if(this._options?.enforceStrictCapabilities===!0)try{if(this.assertCapabilityForMethod($.method),z)this.assertTaskCapability($.method)}catch(L){w(L);return}Q?.signal?.throwIfAborted();let H=this._requestMessageId++,j={...$,jsonrpc:"2.0",id:H};if(Q?.onprogress)this._progressHandlers.set(H,Q.onprogress),j.params={...$.params,_meta:{...$.params?._meta||{},progressToken:H}};if(z)j.params={...j.params,task:z};if(W)j.params={...j.params,_meta:{...j.params?._meta||{},[B9]:W}};let V=(L)=>{this._responseHandlers.delete(H),this._progressHandlers.delete(H),this._cleanupTimeout(H),this._transport?.send({jsonrpc:"2.0",method:"notifications/cancelled",params:{requestId:H,reason:String(L)}},{relatedRequestId:J,resumptionToken:v,onresumptiontoken:Y}).catch((O)=>this._onerror(Error(`Failed to send cancellation: ${O}`)));let A=L instanceof o?L:new o(s.RequestTimeout,String(L));U(A)};this._responseHandlers.set(H,(L)=>{if(Q?.signal?.aborted)return;if(L instanceof Error)return U(L);try{let A=j9(X,L.result);if(!A.success)U(A.error);else G(A.data)}catch(A){U(A)}}),Q?.signal?.addEventListener("abort",()=>{V(Q?.signal?.reason)});let B=Q?.timeout??EG$,D=()=>V(o.fromError(s.RequestTimeout,"Request timed out",{timeout:B}));this._setupTimeout(H,B,Q?.maxTotalTimeout,D,Q?.resetTimeoutOnProgress??!1);let F=W?.taskId;if(F){let L=(A)=>{let O=this._responseHandlers.get(H);if(O)O(A);else this._onerror(Error(`Response handler missing for side-channeled request ${H}`))};this._requestResolvers.set(H,L),this._enqueueTaskMessage(F,{type:"request",message:j,timestamp:Date.now()}).catch((A)=>{this._cleanupTimeout(H),U(A)})}else this._transport.send(j,{relatedRequestId:J,resumptionToken:v,onresumptiontoken:Y}).catch((L)=>{this._cleanupTimeout(H),U(L)})})}async getTask($,X){return this.request({method:"tasks/get",params:$},jj,X)}async getTaskResult($,X,Q){return this.request({method:"tasks/result",params:$},X,Q)}async listTasks($,X){return this.request({method:"tasks/list",params:$},Dj,X)}async cancelTask($,X){return this.request({method:"tasks/cancel",params:$},Tk,X)}async notification($,X){if(!this._transport)throw Error("Not connected");this.assertNotificationCapability($.method);let Q=X?.relatedTask?.taskId;if(Q){let z={...$,jsonrpc:"2.0",params:{...$.params,_meta:{...$.params?._meta||{},[B9]:X.relatedTask}}};await this._enqueueTaskMessage(Q,{type:"notification",message:z,timestamp:Date.now()});return}if((this._options?.debouncedNotificationMethods??[]).includes($.method)&&!$.params&&!X?.relatedRequestId&&!X?.relatedTask){if(this._pendingDebouncedNotifications.has($.method))return;this._pendingDebouncedNotifications.add($.method),Promise.resolve().then(()=>{if(this._pendingDebouncedNotifications.delete($.method),!this._transport)return;let z={...$,jsonrpc:"2.0"};if(X?.relatedTask)z={...z,params:{...z.params,_meta:{...z.params?._meta||{},[B9]:X.relatedTask}}};this._transport?.send(z,X).catch((W)=>this._onerror(W))});return}let Y={...$,jsonrpc:"2.0"};if(X?.relatedTask)Y={...Y,params:{...Y.params,_meta:{...Y.params?._meta||{},[B9]:X.relatedTask}}};await this._transport.send(Y,X)}setRequestHandler($,X){let Q=NZ($);this.assertRequestHandlerCapability(Q),this._requestHandlers.set(Q,(J,v)=>{let Y=OZ($,J);return Promise.resolve(X(Y,v))})}removeRequestHandler($){this._requestHandlers.delete($)}assertCanSetRequestHandler($){if(this._requestHandlers.has($))throw Error(`A request handler for ${$} already exists, which would be overridden`)}setNotificationHandler($,X){let Q=NZ($);this._notificationHandlers.set(Q,(J)=>{let v=OZ($,J);return Promise.resolve(X(v))})}removeNotificationHandler($){this._notificationHandlers.delete($)}_cleanupTaskProgressHandler($){let X=this._taskProgressTokens.get($);if(X!==void 0)this._progressHandlers.delete(X),this._taskProgressTokens.delete($)}async _enqueueTaskMessage($,X,Q){if(!this._taskStore||!this._taskMessageQueue)throw Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");let J=this._options?.maxTaskQueueSize;await this._taskMessageQueue.enqueue($,X,Q,J)}async _clearTaskQueue($,X){if(this._taskMessageQueue){let Q=await this._taskMessageQueue.dequeueAll($,X);for(let J of Q)if(J.type==="request"&&D9(J.message)){let v=J.message.id,Y=this._requestResolvers.get(v);if(Y)Y(new o(s.InternalError,"Task cancelled or completed")),this._requestResolvers.delete(v);else this._onerror(Error(`Resolver missing for request ${v} during task ${$} cleanup`))}}}async _waitForTaskUpdate($,X){let Q=this._options?.defaultTaskPollInterval??1000;try{let J=await this._taskStore?.getTask($);if(J?.pollInterval)Q=J.pollInterval}catch{}return new Promise((J,v)=>{if(X.aborted){v(new o(s.InvalidRequest,"Request cancelled"));return}let Y=setTimeout(J,Q);X.addEventListener("abort",()=>{clearTimeout(Y),v(new o(s.InvalidRequest,"Request cancelled"))},{once:!0})})}requestTaskStore($,X){let Q=this._taskStore;if(!Q)throw Error("No task store configured");return{createTask:async(J)=>{if(!$)throw Error("No request provided");return await Q.createTask(J,$.id,{method:$.method,params:$.params},X)},getTask:async(J)=>{let v=await Q.getTask(J,X);if(!v)throw new o(s.InvalidParams,"Failed to retrieve task: Task not found");return v},storeTaskResult:async(J,v,Y)=>{await Q.storeTaskResult(J,v,Y,X);let z=await Q.getTask(J,X);if(z){let W=q7.parse({method:"notifications/tasks/status",params:z});if(await this.notification(W),F9(z.status))this._cleanupTaskProgressHandler(J)}},getTaskResult:(J)=>{return Q.getTaskResult(J,X)},updateTaskStatus:async(J,v,Y)=>{let z=await Q.getTask(J,X);if(!z)throw new o(s.InvalidParams,`Task "${J}" not found - it may have been cleaned up`);if(F9(z.status))throw new o(s.InvalidParams,`Cannot update task "${J}" from terminal status "${z.status}" to "${v}". Terminal states (completed, failed, cancelled) cannot transition to other states.`);await Q.updateTaskStatus(J,v,Y,X);let W=await Q.getTask(J,X);if(W){let G=q7.parse({method:"notifications/tasks/status",params:W});if(await this.notification(G),F9(W.status))this._cleanupTaskProgressHandler(J)}},listTasks:(J)=>{return Q.listTasks(J,X)}}}}function On($){return $!==null&&typeof $==="object"&&!Array.isArray($)}function Ln($,X){let Q={...$};for(let J in X){let v=J,Y=X[v];if(Y===void 0)continue;let z=Q[v];if(On(z)&&On(Y))Q[v]={...z,...Y};else Q[v]=Y}return Q}var Zm=x$(aq(),1),qm=x$(Am(),1);function AV$(){let $=new Zm.default({strict:!1,validateFormats:!0,validateSchema:!1,allErrors:!0});return qm.default($),$}class vI{constructor($){this._ajv=$??AV$()}getValidator($){let X="$id"in $&&typeof $.$id==="string"?this._ajv.getSchema($.$id)??this._ajv.compile($):this._ajv.compile($);return(Q)=>{if(X(Q))return{valid:!0,data:Q,errorMessage:void 0};else return{valid:!1,data:void 0,errorMessage:this._ajv.errorsText(X.errors)}}}}class JI{constructor($){this._server=$}requestStream($,X,Q){return this._server.requestStream($,X,Q)}createMessageStream($,X){let Q=this._server.getClientCapabilities();if(($.tools||$.toolChoice)&&!Q?.sampling?.tools)throw Error("Client does not support sampling tools capability.");if($.messages.length>0){let J=$.messages[$.messages.length-1],v=Array.isArray(J.content)?J.content:[J.content],Y=v.some((U)=>U.type==="tool_result"),z=$.messages.length>1?$.messages[$.messages.length-2]:void 0,W=z?Array.isArray(z.content)?z.content:[z.content]:[],G=W.some((U)=>U.type==="tool_use");if(Y){if(v.some((U)=>U.type!=="tool_result"))throw Error("The last message must contain only tool_result content if any is present");if(!G)throw Error("tool_result blocks are not matching any tool_use from the previous message")}if(G){let U=new Set(W.filter((H)=>H.type==="tool_use").map((H)=>H.id)),w=new Set(v.filter((H)=>H.type==="tool_result").map((H)=>H.toolUseId));if(U.size!==w.size||![...U].every((H)=>w.has(H)))throw Error("ids of tool_result blocks and tool_use blocks from previous message do not match")}}return this.requestStream({method:"sampling/createMessage",params:$},x7,X)}elicitInputStream($,X){let Q=this._server.getClientCapabilities(),J=$.mode??"form";switch(J){case"url":{if(!Q?.elicitation?.url)throw Error("Client does not support url elicitation.");break}case"form":{if(!Q?.elicitation?.form)throw Error("Client does not support form elicitation.");break}}let v=J==="form"&&$.mode===void 0?{...$,mode:"form"}:$;return this.requestStream({method:"elicitation/create",params:v},vJ,X)}async getTask($,X){return this._server.getTask({taskId:$},X)}async getTaskResult($,X,Q){return this._server.getTaskResult({taskId:$},X,Q)}async listTasks($,X){return this._server.listTasks($?{cursor:$}:void 0,X)}async cancelTask($,X){return this._server.cancelTask({taskId:$},X)}}function Im($,X,Q){if(!$)throw Error(`${Q} does not support task creation (required for ${X})`);switch(X){case"tools/call":if(!$.tools?.call)throw Error(`${Q} does not support task creation for tools/call (required for ${X})`);break;default:break}}function Mm($,X,Q){if(!$)throw Error(`${Q} does not support task creation (required for ${X})`);switch(X){case"sampling/createMessage":if(!$.sampling?.createMessage)throw Error(`${Q} does not support task creation for sampling/createMessage (required for ${X})`);break;case"elicitation/create":if(!$.elicitation?.create)throw Error(`${Q} does not support task creation for elicitation/create (required for ${X})`);break;default:break}}class YI extends LZ{constructor($,X){super(X);if(this._serverInfo=$,this._loggingLevels=new Map,this.LOG_LEVEL_SEVERITY=new Map(M7.options.map((Q,J)=>[Q,J])),this.isMessageIgnored=(Q,J)=>{let v=this._loggingLevels.get(J);return v?this.LOG_LEVEL_SEVERITY.get(Q)this._oninitialize(Q)),this.setNotificationHandler(QZ,()=>this.oninitialized?.()),this._capabilities.logging)this.setRequestHandler(UZ,async(Q,J)=>{let v=J.sessionId||J.requestInfo?.headers["mcp-session-id"]||void 0,{level:Y}=Q.params,z=M7.safeParse(Y);if(z.success)this._loggingLevels.set(v,z.data);return{}})}get experimental(){if(!this._experimental)this._experimental={tasks:new JI(this)};return this._experimental}registerCapabilities($){if(this.transport)throw Error("Cannot register capabilities after connecting to transport");this._capabilities=Ln(this._capabilities,$)}setRequestHandler($,X){let J=V9($)?.method;if(!J)throw Error("Schema is missing a method literal");let v;if(i1(J)){let z=J;v=z._zod?.def?.value??z.value}else{let z=J;v=z._def?.value??z.value}if(typeof v!=="string")throw Error("Schema method literal must be a string");if(v==="tools/call"){let z=async(W,G)=>{let U=j9(QJ,W);if(!U.success){let V=U.error instanceof Error?U.error.message:String(U.error);throw new o(s.InvalidParams,`Invalid tools/call request: ${V}`)}let{params:w}=U.data,H=await Promise.resolve(X(W,G));if(w.task){let V=j9($J,H);if(!V.success){let B=V.error instanceof Error?V.error.message:String(V.error);throw new o(s.InvalidParams,`Invalid task creation result: ${B}`)}return V.data}let j=j9(Ij,H);if(!j.success){let V=j.error instanceof Error?j.error.message:String(j.error);throw new o(s.InvalidParams,`Invalid tools/call result: ${V}`)}return j.data};return super.setRequestHandler($,z)}return super.setRequestHandler($,X)}assertCapabilityForMethod($){switch($){case"sampling/createMessage":if(!this._clientCapabilities?.sampling)throw Error(`Client does not support sampling (required for ${$})`);break;case"elicitation/create":if(!this._clientCapabilities?.elicitation)throw Error(`Client does not support elicitation (required for ${$})`);break;case"roots/list":if(!this._clientCapabilities?.roots)throw Error(`Client does not support listing roots (required for ${$})`);break;case"ping":break}}assertNotificationCapability($){switch($){case"notifications/message":if(!this._capabilities.logging)throw Error(`Server does not support logging (required for ${$})`);break;case"notifications/resources/updated":case"notifications/resources/list_changed":if(!this._capabilities.resources)throw Error(`Server does not support notifying about resources (required for ${$})`);break;case"notifications/tools/list_changed":if(!this._capabilities.tools)throw Error(`Server does not support notifying of tool list changes (required for ${$})`);break;case"notifications/prompts/list_changed":if(!this._capabilities.prompts)throw Error(`Server does not support notifying of prompt list changes (required for ${$})`);break;case"notifications/elicitation/complete":if(!this._clientCapabilities?.elicitation?.url)throw Error(`Client does not support URL elicitation (required for ${$})`);break;case"notifications/cancelled":break;case"notifications/progress":break}}assertRequestHandlerCapability($){if(!this._capabilities)return;switch($){case"completion/complete":if(!this._capabilities.completions)throw Error(`Server does not support completions (required for ${$})`);break;case"logging/setLevel":if(!this._capabilities.logging)throw Error(`Server does not support logging (required for ${$})`);break;case"prompts/get":case"prompts/list":if(!this._capabilities.prompts)throw Error(`Server does not support prompts (required for ${$})`);break;case"resources/list":case"resources/templates/list":case"resources/read":if(!this._capabilities.resources)throw Error(`Server does not support resources (required for ${$})`);break;case"tools/call":case"tools/list":if(!this._capabilities.tools)throw Error(`Server does not support tools (required for ${$})`);break;case"tasks/get":case"tasks/list":case"tasks/result":case"tasks/cancel":if(!this._capabilities.tasks)throw Error(`Server does not support tasks capability (required for ${$})`);break;case"ping":case"initialize":break}}assertTaskCapability($){Mm(this._clientCapabilities?.tasks?.requests,$,"Client")}assertTaskHandlerCapability($){if(!this._capabilities)return;Im(this._capabilities.tasks?.requests,$,"Server")}async _oninitialize($){let X=$.params.protocolVersion;return this._clientCapabilities=$.params.capabilities,this._clientVersion=$.params.clientInfo,{protocolVersion:F7.includes(X)?X:rA,capabilities:this.getCapabilities(),serverInfo:this._serverInfo,...this._instructions&&{instructions:this._instructions}}}getClientCapabilities(){return this._clientCapabilities}getClientVersion(){return this._clientVersion}getCapabilities(){return this._capabilities}async ping(){return this.request({method:"ping"},Wj)}async createMessage($,X){if($.tools||$.toolChoice){if(!this._clientCapabilities?.sampling?.tools)throw Error("Client does not support sampling tools capability.")}if($.messages.length>0){let Q=$.messages[$.messages.length-1],J=Array.isArray(Q.content)?Q.content:[Q.content],v=J.some((G)=>G.type==="tool_result"),Y=$.messages.length>1?$.messages[$.messages.length-2]:void 0,z=Y?Array.isArray(Y.content)?Y.content:[Y.content]:[],W=z.some((G)=>G.type==="tool_use");if(v){if(J.some((G)=>G.type!=="tool_result"))throw Error("The last message must contain only tool_result content if any is present");if(!W)throw Error("tool_result blocks are not matching any tool_use from the previous message")}if(W){let G=new Set(z.filter((w)=>w.type==="tool_use").map((w)=>w.id)),U=new Set(J.filter((w)=>w.type==="tool_result").map((w)=>w.toolUseId));if(G.size!==U.size||![...G].every((w)=>U.has(w)))throw Error("ids of tool_result blocks and tool_use blocks from previous message do not match")}}if($.tools)return this.request({method:"sampling/createMessage",params:$},KZ,X);return this.request({method:"sampling/createMessage",params:$},x7,X)}async elicitInput($,X){switch($.mode??"form"){case"url":{if(!this._clientCapabilities?.elicitation?.url)throw Error("Client does not support url elicitation.");let J=$;return this.request({method:"elicitation/create",params:J},vJ,X)}case"form":{if(!this._clientCapabilities?.elicitation?.form)throw Error("Client does not support form elicitation.");let J=$.mode==="form"?$:{...$,mode:"form"},v=await this.request({method:"elicitation/create",params:J},vJ,X);if(v.action==="accept"&&v.content&&J.requestedSchema)try{let z=this._jsonSchemaValidator.getValidator(J.requestedSchema)(v.content);if(!z.valid)throw new o(s.InvalidParams,`Elicitation response content does not match requested schema: ${z.errorMessage}`)}catch(Y){if(Y instanceof o)throw Y;throw new o(s.InternalError,`Error validating elicitation response: ${Y instanceof Error?Y.message:String(Y)}`)}return v}}}createElicitationCompletionNotifier($,X){if(!this._clientCapabilities?.elicitation?.url)throw Error("Client does not support URL elicitation (required for notifications/elicitation/complete)");return()=>this.notification({method:"notifications/elicitation/complete",params:{elicitationId:$}},X)}async listRoots($,X){return this.request({method:"roots/list",params:$},wZ,X)}async sendLoggingMessage($,X){if(this._capabilities.logging){if(!this.isMessageIgnored($.level,X))return this.notification({method:"notifications/message",params:$})}}async sendResourceUpdated($){return this.notification({method:"notifications/resources/updated",params:$})}async sendResourceListChanged(){return this.notification({method:"notifications/resources/list_changed"})}async sendToolListChanged(){return this.notification({method:"notifications/tools/list_changed"})}async sendPromptListChanged(){return this.notification({method:"notifications/prompts/list_changed"})}}var bm=Symbol.for("mcp.completable");function zI($){return!!$&&typeof $==="object"&&bm in $}function im($){return $[bm]?.complete}var xm;(function($){$.Completable="McpCompletable"})(xm||(xm={}));var ZV$=/^[A-Za-z0-9._-]{1,128}$/;function qV$($){let X=[];if($.length===0)return{isValid:!1,warnings:["Tool name cannot be empty"]};if($.length>128)return{isValid:!1,warnings:[`Tool name exceeds maximum length of 128 characters (current: ${$.length})`]};if($.includes(" "))X.push("Tool name contains spaces, which may cause parsing issues");if($.includes(","))X.push("Tool name contains commas, which may cause parsing issues");if($.startsWith("-")||$.endsWith("-"))X.push("Tool name starts or ends with a dash, which may cause parsing issues in some contexts");if($.startsWith(".")||$.endsWith("."))X.push("Tool name starts or ends with a dot, which may cause parsing issues in some contexts");if(!ZV$.test($)){let Q=$.split("").filter((J)=>!/[A-Za-z0-9._-]/.test(J)).filter((J,v,Y)=>Y.indexOf(J)===v);return X.push(`Tool name contains invalid characters: ${Q.map((J)=>`"${J}"`).join(", ")}`,"Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)"),{isValid:!1,warnings:X}}return{isValid:!0,warnings:X}}function IV$($,X){if(X.length>0){console.warn(`Tool name validation warning for "${$}":`);for(let Q of X)console.warn(` - ${Q}`);console.warn("Tool registration will proceed, but this may cause compatibility issues."),console.warn("Consider updating the tool name to conform to the MCP tool naming standard."),console.warn("See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details.")}}function WI($){let X=qV$($);return IV$($,X.warnings),X.isValid}class GI{constructor($){this._mcpServer=$}registerToolTask($,X,Q){let J={taskSupport:"required",...X.execution};if(J.taskSupport==="forbidden")throw Error(`Cannot register task-based tool '${$}' with taskSupport 'forbidden'. Use registerTool() instead.`);return this._mcpServer._createRegisteredTool($,X.title,X.description,X.inputSchema,X.outputSchema,X.annotations,J,X._meta,Q)}}class KI{constructor($,X){this._registeredResources={},this._registeredResourceTemplates={},this._registeredTools={},this._registeredPrompts={},this._toolHandlersInitialized=!1,this._completionHandlerInitialized=!1,this._resourceHandlersInitialized=!1,this._promptHandlersInitialized=!1,this.server=new YI($,X)}get experimental(){if(!this._experimental)this._experimental={tasks:new GI(this)};return this._experimental}async connect($){return await this.server.connect($)}async close(){await this.server.close()}setToolRequestHandlers(){if(this._toolHandlersInitialized)return;this.server.assertCanSetRequestHandler(i9(qj)),this.server.assertCanSetRequestHandler(i9(QJ)),this.server.registerCapabilities({tools:{listChanged:!0}}),this.server.setRequestHandler(qj,()=>({tools:Object.entries(this._registeredTools).filter(([,$])=>$.enabled).map(([$,X])=>{let Q={name:$,title:X.title,description:X.description,inputSchema:(()=>{let J=tv(X.inputSchema);return J?FZ(J,{strictUnions:!0,pipeStrategy:"input"}):MV$})(),annotations:X.annotations,execution:X.execution,_meta:X._meta};if(X.outputSchema){let J=tv(X.outputSchema);if(J)Q.outputSchema=FZ(J,{strictUnions:!0,pipeStrategy:"output"})}return Q})})),this.server.setRequestHandler(QJ,async($,X)=>{try{let Q=this._registeredTools[$.params.name];if(!Q)throw new o(s.InvalidParams,`Tool ${$.params.name} not found`);if(!Q.enabled)throw new o(s.InvalidParams,`Tool ${$.params.name} disabled`);let J=!!$.params.task,v=Q.execution?.taskSupport,Y="createTask"in Q.handler;if((v==="required"||v==="optional")&&!Y)throw new o(s.InternalError,`Tool ${$.params.name} has taskSupport '${v}' but was not registered with registerToolTask`);if(v==="required"&&!J)throw new o(s.MethodNotFound,`Tool ${$.params.name} requires task augmentation (taskSupport: 'required')`);if(v==="optional"&&!J&&Y)return await this.handleAutomaticTaskPolling(Q,$,X);let z=await this.validateToolInput(Q,$.params.arguments,$.params.name),W=await this.executeToolHandler(Q,z,X);if(J)return W;return await this.validateToolOutput(Q,W,$.params.name),W}catch(Q){if(Q instanceof o){if(Q.code===s.UrlElicitationRequired)throw Q}return this.createToolError(Q instanceof Error?Q.message:String(Q))}}),this._toolHandlersInitialized=!0}createToolError($){return{content:[{type:"text",text:$}],isError:!0}}async validateToolInput($,X,Q){if(!$.inputSchema)return;let v=tv($.inputSchema)??$.inputSchema,Y=await Xj(v,X);if(!Y.success){let z="error"in Y?Y.error:"Unknown error",W=Qj(z);throw new o(s.InvalidParams,`Input validation error: Invalid arguments for tool ${Q}: ${W}`)}return Y.data}async validateToolOutput($,X,Q){if(!$.outputSchema)return;if(!("content"in X))return;if(X.isError)return;if(!X.structuredContent)throw new o(s.InvalidParams,`Output validation error: Tool ${Q} has an output schema but no structured content was provided`);let J=tv($.outputSchema),v=await Xj(J,X.structuredContent);if(!v.success){let Y="error"in v?v.error:"Unknown error",z=Qj(Y);throw new o(s.InvalidParams,`Output validation error: Invalid structured content for tool ${Q}: ${z}`)}}async executeToolHandler($,X,Q){let J=$.handler;if("createTask"in J){if(!Q.taskStore)throw Error("No task store provided.");let Y={...Q,taskStore:Q.taskStore};if($.inputSchema)return await Promise.resolve(J.createTask(X,Y));else return await Promise.resolve(J.createTask(Y))}if($.inputSchema)return await Promise.resolve(J(X,Q));else return await Promise.resolve(J(Q))}async handleAutomaticTaskPolling($,X,Q){if(!Q.taskStore)throw Error("No task store provided for task-capable tool.");let J=await this.validateToolInput($,X.params.arguments,X.params.name),v=$.handler,Y={...Q,taskStore:Q.taskStore},z=J?await Promise.resolve(v.createTask(J,Y)):await Promise.resolve(v.createTask(Y)),W=z.task.taskId,G=z.task,U=G.pollInterval??5000;while(G.status!=="completed"&&G.status!=="failed"&&G.status!=="cancelled"){await new Promise((H)=>setTimeout(H,U));let w=await Q.taskStore.getTask(W);if(!w)throw new o(s.InternalError,`Task ${W} not found during polling`);G=w}return await Q.taskStore.getTaskResult(W)}setCompletionRequestHandler(){if(this._completionHandlerInitialized)return;this.server.assertCanSetRequestHandler(i9(Mj)),this.server.registerCapabilities({completions:{}}),this.server.setRequestHandler(Mj,async($)=>{switch($.params.ref.type){case"ref/prompt":return fk($),this.handlePromptCompletion($,$.params.ref);case"ref/resource":return kk($),this.handleResourceCompletion($,$.params.ref);default:throw new o(s.InvalidParams,`Invalid completion reference: ${$.params.ref}`)}}),this._completionHandlerInitialized=!0}async handlePromptCompletion($,X){let Q=this._registeredPrompts[X.name];if(!Q)throw new o(s.InvalidParams,`Prompt ${X.name} not found`);if(!Q.enabled)throw new o(s.InvalidParams,`Prompt ${X.name} disabled`);if(!Q.argsSchema)return e7;let v=V9(Q.argsSchema)?.[$.params.argument.name];if(!zI(v))return e7;let Y=im(v);if(!Y)return e7;let z=await Y($.params.argument.value,$.params.context);return Em(z)}async handleResourceCompletion($,X){let Q=Object.values(this._registeredResourceTemplates).find((Y)=>Y.resourceTemplate.uriTemplate.toString()===X.uri);if(!Q){if(this._registeredResources[X.uri])return e7;throw new o(s.InvalidParams,`Resource template ${$.params.ref.uri} not found`)}let J=Q.resourceTemplate.completeCallback($.params.argument.name);if(!J)return e7;let v=await J($.params.argument.value,$.params.context);return Em(v)}setResourceRequestHandlers(){if(this._resourceHandlersInitialized)return;this.server.assertCanSetRequestHandler(i9(Nj)),this.server.assertCanSetRequestHandler(i9(Oj)),this.server.assertCanSetRequestHandler(i9(Lj)),this.server.registerCapabilities({resources:{listChanged:!0}}),this.server.setRequestHandler(Nj,async($,X)=>{let Q=Object.entries(this._registeredResources).filter(([v,Y])=>Y.enabled).map(([v,Y])=>({uri:v,name:Y.name,...Y.metadata})),J=[];for(let v of Object.values(this._registeredResourceTemplates)){if(!v.resourceTemplate.listCallback)continue;let Y=await v.resourceTemplate.listCallback(X);for(let z of Y.resources)J.push({...v.metadata,...z})}return{resources:[...Q,...J]}}),this.server.setRequestHandler(Oj,async()=>{return{resourceTemplates:Object.entries(this._registeredResourceTemplates).map(([X,Q])=>({name:X,uriTemplate:Q.resourceTemplate.uriTemplate.toString(),...Q.metadata}))}}),this.server.setRequestHandler(Lj,async($,X)=>{let Q=new URL($.params.uri),J=this._registeredResources[Q.toString()];if(J){if(!J.enabled)throw new o(s.InvalidParams,`Resource ${Q} disabled`);return J.readCallback(Q,X)}for(let v of Object.values(this._registeredResourceTemplates)){let Y=v.resourceTemplate.uriTemplate.match(Q.toString());if(Y)return v.readCallback(Q,Y,X)}throw new o(s.InvalidParams,`Resource ${Q} not found`)}),this._resourceHandlersInitialized=!0}setPromptRequestHandlers(){if(this._promptHandlersInitialized)return;this.server.assertCanSetRequestHandler(i9(Aj)),this.server.assertCanSetRequestHandler(i9(Zj)),this.server.registerCapabilities({prompts:{listChanged:!0}}),this.server.setRequestHandler(Aj,()=>({prompts:Object.entries(this._registeredPrompts).filter(([,$])=>$.enabled).map(([$,X])=>{return{name:$,title:X.title,description:X.description,arguments:X.argsSchema?bV$(X.argsSchema):void 0}})})),this.server.setRequestHandler(Zj,async($,X)=>{let Q=this._registeredPrompts[$.params.name];if(!Q)throw new o(s.InvalidParams,`Prompt ${$.params.name} not found`);if(!Q.enabled)throw new o(s.InvalidParams,`Prompt ${$.params.name} disabled`);if(Q.argsSchema){let J=tv(Q.argsSchema),v=await Xj(J,$.params.arguments);if(!v.success){let W="error"in v?v.error:"Unknown error",G=Qj(W);throw new o(s.InvalidParams,`Invalid arguments for prompt ${$.params.name}: ${G}`)}let Y=v.data,z=Q.callback;return await Promise.resolve(z(Y,X))}else{let J=Q.callback;return await Promise.resolve(J(X))}}),this._promptHandlersInitialized=!0}resource($,X,...Q){let J;if(typeof Q[0]==="object")J=Q.shift();let v=Q[0];if(typeof X==="string"){if(this._registeredResources[X])throw Error(`Resource ${X} is already registered`);let Y=this._createRegisteredResource($,void 0,X,J,v);return this.setResourceRequestHandlers(),this.sendResourceListChanged(),Y}else{if(this._registeredResourceTemplates[$])throw Error(`Resource template ${$} is already registered`);let Y=this._createRegisteredResourceTemplate($,void 0,X,J,v);return this.setResourceRequestHandlers(),this.sendResourceListChanged(),Y}}registerResource($,X,Q,J){if(typeof X==="string"){if(this._registeredResources[X])throw Error(`Resource ${X} is already registered`);let v=this._createRegisteredResource($,Q.title,X,Q,J);return this.setResourceRequestHandlers(),this.sendResourceListChanged(),v}else{if(this._registeredResourceTemplates[$])throw Error(`Resource template ${$} is already registered`);let v=this._createRegisteredResourceTemplate($,Q.title,X,Q,J);return this.setResourceRequestHandlers(),this.sendResourceListChanged(),v}}_createRegisteredResource($,X,Q,J,v){let Y={name:$,title:X,metadata:J,readCallback:v,enabled:!0,disable:()=>Y.update({enabled:!1}),enable:()=>Y.update({enabled:!0}),remove:()=>Y.update({uri:null}),update:(z)=>{if(typeof z.uri<"u"&&z.uri!==Q){if(delete this._registeredResources[Q],z.uri)this._registeredResources[z.uri]=Y}if(typeof z.name<"u")Y.name=z.name;if(typeof z.title<"u")Y.title=z.title;if(typeof z.metadata<"u")Y.metadata=z.metadata;if(typeof z.callback<"u")Y.readCallback=z.callback;if(typeof z.enabled<"u")Y.enabled=z.enabled;this.sendResourceListChanged()}};return this._registeredResources[Q]=Y,Y}_createRegisteredResourceTemplate($,X,Q,J,v){let Y={resourceTemplate:Q,title:X,metadata:J,readCallback:v,enabled:!0,disable:()=>Y.update({enabled:!1}),enable:()=>Y.update({enabled:!0}),remove:()=>Y.update({name:null}),update:(G)=>{if(typeof G.name<"u"&&G.name!==$){if(delete this._registeredResourceTemplates[$],G.name)this._registeredResourceTemplates[G.name]=Y}if(typeof G.title<"u")Y.title=G.title;if(typeof G.template<"u")Y.resourceTemplate=G.template;if(typeof G.metadata<"u")Y.metadata=G.metadata;if(typeof G.callback<"u")Y.readCallback=G.callback;if(typeof G.enabled<"u")Y.enabled=G.enabled;this.sendResourceListChanged()}};this._registeredResourceTemplates[$]=Y;let z=Q.uriTemplate.variableNames;if(Array.isArray(z)&&z.some((G)=>!!Q.completeCallback(G)))this.setCompletionRequestHandler();return Y}_createRegisteredPrompt($,X,Q,J,v){let Y={title:X,description:Q,argsSchema:J===void 0?void 0:MX(J),callback:v,enabled:!0,disable:()=>Y.update({enabled:!1}),enable:()=>Y.update({enabled:!0}),remove:()=>Y.update({name:null}),update:(z)=>{if(typeof z.name<"u"&&z.name!==$){if(delete this._registeredPrompts[$],z.name)this._registeredPrompts[z.name]=Y}if(typeof z.title<"u")Y.title=z.title;if(typeof z.description<"u")Y.description=z.description;if(typeof z.argsSchema<"u")Y.argsSchema=MX(z.argsSchema);if(typeof z.callback<"u")Y.callback=z.callback;if(typeof z.enabled<"u")Y.enabled=z.enabled;this.sendPromptListChanged()}};if(this._registeredPrompts[$]=Y,J){if(Object.values(J).some((W)=>{let G=W instanceof qQ?W._def?.innerType:W;return zI(G)}))this.setCompletionRequestHandler()}return Y}_createRegisteredTool($,X,Q,J,v,Y,z,W,G){WI($);let U={title:X,description:Q,inputSchema:Rm(J),outputSchema:Rm(v),annotations:Y,execution:z,_meta:W,handler:G,enabled:!0,disable:()=>U.update({enabled:!1}),enable:()=>U.update({enabled:!0}),remove:()=>U.update({name:null}),update:(w)=>{if(typeof w.name<"u"&&w.name!==$){if(typeof w.name==="string")WI(w.name);if(delete this._registeredTools[$],w.name)this._registeredTools[w.name]=U}if(typeof w.title<"u")U.title=w.title;if(typeof w.description<"u")U.description=w.description;if(typeof w.paramsSchema<"u")U.inputSchema=MX(w.paramsSchema);if(typeof w.outputSchema<"u")U.outputSchema=MX(w.outputSchema);if(typeof w.callback<"u")U.handler=w.callback;if(typeof w.annotations<"u")U.annotations=w.annotations;if(typeof w._meta<"u")U._meta=w._meta;if(typeof w.enabled<"u")U.enabled=w.enabled;this.sendToolListChanged()}};return this._registeredTools[$]=U,this.setToolRequestHandlers(),this.sendToolListChanged(),U}tool($,...X){if(this._registeredTools[$])throw Error(`Tool ${$} is already registered`);let Q,J,v,Y;if(typeof X[0]==="string")Q=X.shift();if(X.length>1){let W=X[0];if(UI(W)){if(J=X.shift(),X.length>1&&typeof X[0]==="object"&&X[0]!==null&&!UI(X[0]))Y=X.shift()}else if(typeof W==="object"&&W!==null)Y=X.shift()}let z=X[0];return this._createRegisteredTool($,void 0,Q,J,v,Y,{taskSupport:"forbidden"},void 0,z)}registerTool($,X,Q){if(this._registeredTools[$])throw Error(`Tool ${$} is already registered`);let{title:J,description:v,inputSchema:Y,outputSchema:z,annotations:W,_meta:G}=X;return this._createRegisteredTool($,J,v,Y,z,W,{taskSupport:"forbidden"},G,Q)}prompt($,...X){if(this._registeredPrompts[$])throw Error(`Prompt ${$} is already registered`);let Q;if(typeof X[0]==="string")Q=X.shift();let J;if(X.length>1)J=X.shift();let v=X[0],Y=this._createRegisteredPrompt($,void 0,Q,J,v);return this.setPromptRequestHandlers(),this.sendPromptListChanged(),Y}registerPrompt($,X,Q){if(this._registeredPrompts[$])throw Error(`Prompt ${$} is already registered`);let{title:J,description:v,argsSchema:Y}=X,z=this._createRegisteredPrompt($,J,v,Y,Q);return this.setPromptRequestHandlers(),this.sendPromptListChanged(),z}isConnected(){return this.server.transport!==void 0}async sendLoggingMessage($,X){return this.server.sendLoggingMessage($,X)}sendResourceListChanged(){if(this.isConnected())this.server.sendResourceListChanged()}sendToolListChanged(){if(this.isConnected())this.server.sendToolListChanged()}sendPromptListChanged(){if(this.isConnected())this.server.sendPromptListChanged()}}var MV$={type:"object",properties:{}};function Pm($){return $!==null&&typeof $==="object"&&"parse"in $&&typeof $.parse==="function"&&"safeParse"in $&&typeof $.safeParse==="function"}function xV$($){return"_def"in $||"_zod"in $||Pm($)}function UI($){if(typeof $!=="object"||$===null)return!1;if(xV$($))return!1;if(Object.keys($).length===0)return!0;return Object.values($).some(Pm)}function Rm($){if(!$)return;if(UI($))return MX($);return $}function bV$($){let X=V9($);if(!X)return[];return Object.entries(X).map(([Q,J])=>{let v=Zk(J),Y=qk(J);return{name:Q,description:v,required:!Y}})}function i9($){let Q=V9($)?.method;if(!Q)throw Error("Schema is missing a method literal");let J=vj(Q);if(typeof J==="string")return J;throw Error("Schema method literal must be a string")}function Em($){return{completion:{values:$.slice(0,100),total:$.length,hasMore:$.length>100}}}var e7={completion:{values:[],hasMore:!1}};class jV{constructor($={}){this._started=!1,this._hasHandledRequest=!1,this._streamMapping=new Map,this._requestToStreamMapping=new Map,this._requestResponseMap=new Map,this._initialized=!1,this._enableJsonResponse=!1,this._standaloneSseStreamId="_GET_stream",this.sessionIdGenerator=$.sessionIdGenerator,this._enableJsonResponse=$.enableJsonResponse??!1,this._eventStore=$.eventStore,this._onsessioninitialized=$.onsessioninitialized,this._onsessionclosed=$.onsessionclosed,this._allowedHosts=$.allowedHosts,this._allowedOrigins=$.allowedOrigins,this._enableDnsRebindingProtection=$.enableDnsRebindingProtection??!1,this._retryInterval=$.retryInterval}async start(){if(this._started)throw Error("Transport already started");this._started=!0}createJsonErrorResponse($,X,Q,J){let v={code:X,message:Q};if(J?.data!==void 0)v.data=J.data;return new Response(JSON.stringify({jsonrpc:"2.0",error:v,id:null}),{status:$,headers:{"Content-Type":"application/json",...J?.headers}})}validateRequestHeaders($){if(!this._enableDnsRebindingProtection)return;if(this._allowedHosts&&this._allowedHosts.length>0){let X=$.headers.get("host");if(!X||!this._allowedHosts.includes(X)){let Q=`Invalid Host header: ${X}`;return this.onerror?.(Error(Q)),this.createJsonErrorResponse(403,-32000,Q)}}if(this._allowedOrigins&&this._allowedOrigins.length>0){let X=$.headers.get("origin");if(X&&!this._allowedOrigins.includes(X)){let Q=`Invalid Origin header: ${X}`;return this.onerror?.(Error(Q)),this.createJsonErrorResponse(403,-32000,Q)}}return}async handleRequest($,X){if(!this.sessionIdGenerator&&this._hasHandledRequest)throw Error("Stateless transport cannot be reused across requests. Create a new transport per request.");this._hasHandledRequest=!0;let Q=this.validateRequestHeaders($);if(Q)return Q;switch($.method){case"POST":return this.handlePostRequest($,X);case"GET":return this.handleGetRequest($);case"DELETE":return this.handleDeleteRequest($);default:return this.handleUnsupportedRequest()}}async writePrimingEvent($,X,Q,J){if(!this._eventStore)return;if(J<"2025-11-25")return;let v=await this._eventStore.storeEvent(Q,{}),Y=`id: ${v} +data: -// src/mcp/toolHandlers/listFiles.ts -var listFilesInputSchema = exports_external.object({ - folderPath: exports_external.string().describe("The absolute path of the folder to list files from"), - recursive: exports_external.boolean().optional().default(false).describe("Whether to list files recursively (default: false)"), - filter: exports_external.string().optional().describe("Filter pattern for files/folders (supports wildcards)"), - videoFileOnly: exports_external.boolean().optional().default(false).describe("Whether to return only video files (default: false)") -}); -var listFilesOutputSchema = exports_external.object({ - files: exports_external.array(exports_external.string()).describe("Array of file paths"), - count: exports_external.number().describe("Number of files listed") -}); -var TOOL_NAME2 = "list-files"; -var TOOL_DESCRIPTION2 = "List all files in a folder. Accepts paths in POSIX or Windows format. " + "Returns plain file paths (no managed-folder check) so this tool is the " + "preferred way for an external AI assistant to inspect a media folder's " + "contents."; -function registerListFilesTool(server, config2) { - const description = config2.toolDescriptions?.[TOOL_NAME2] ?? TOOL_DESCRIPTION2; - server.registerTool(TOOL_NAME2, { - description, - inputSchema: listFilesInputSchema, - outputSchema: listFilesOutputSchema - }, async (args) => { - const params = args ?? {}; - const { folderPath, recursive, videoFileOnly } = params; - if (typeof folderPath !== "string" || folderPath.trim() === "") { - return createErrorResponse("Invalid path: 'folderPath' must be a non-empty string"); - } - try { - const listResult = await doListFiles({ - path: folderPath, - recursively: recursive ?? false, - onlyFiles: true - }, { - logger: config2.logger, - activatePersistedFileAccess: config2.activatePersistedFileAccess - }); - if (listResult.error) { - return createErrorResponse(listResult.error); - } - const filePaths = listResult.data?.items.filter((item) => !item.isDirectory).map((item) => item.path) ?? []; - const empty = createEmptyListFilesInMediaFolderData(); - const data = buildListFilesInMediaFolderResponse(filePaths, videoFileOnly ?? false); - return createSuccessResponse({ - ...empty, - ...data - }); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +`;if(this._retryInterval!==void 0)Y=`id: ${v} +retry: ${this._retryInterval} +data: -// src/mcp/toolHandlers/renameFolder.ts -function registerRenameFolderTool(server, config2) { - const description = config2.toolDescriptions?.[RENAME_FOLDER] ?? RENAME_FOLDER_DESCRIPTION; - server.registerTool(RENAME_FOLDER, { - description, - inputSchema: renameFolderInputSchema, - outputSchema: renameFolderOutputSchema - }, async (args) => { - const params = args ?? {}; - if (typeof params.from !== "string" || params.from.trim() === "") { - return createErrorResponse("Invalid path: 'from' must be a non-empty string"); - } - if (typeof params.to !== "string" || params.to.trim() === "") { - return createErrorResponse("Invalid path: 'to' must be a non-empty string"); - } - try { - const syntheticConfig = { - allowlist: [], - hello: { - version: "0.0.0", - userDataDir: config2.userDataDir, - appDataDir: config2.appDataDir, - logDir: "", - tmpDir: "", - reverseProxyUrl: null, - osLocale: "en-US", - coreRoutesPort: 0 - }, - appDataDir: config2.appDataDir, - logger: config2.logger - }; - const result = await executeRenameFolder({ from: params.from, to: params.to }, syntheticConfig); - if (result.renamed) { - config2.broadcast?.({ - event: USER_CONFIG_FOLDER_RENAMED_EVENT, - data: { - from: result.from, - to: result.to - } - }); - config2.broadcast?.({ - event: USER_CONFIG_UPDATED_EVENT, - data: {} - }); - } - return createSuccessResponse(result); - } catch (error48) { - return createErrorResponse(error48 instanceof Error ? error48.message : String(error48)); - } - }); -} +`;$.enqueue(X.encode(Y))}async handleGetRequest($){if(!$.headers.get("accept")?.includes("text/event-stream"))return this.createJsonErrorResponse(406,-32000,"Not Acceptable: Client must accept text/event-stream");let Q=this.validateSession($);if(Q)return Q;let J=this.validateProtocolVersion($);if(J)return J;if(this._eventStore){let G=$.headers.get("last-event-id");if(G)return this.replayEvents(G)}if(this._streamMapping.get(this._standaloneSseStreamId)!==void 0)return this.createJsonErrorResponse(409,-32000,"Conflict: Only one SSE stream is allowed per session");let v=new TextEncoder,Y,z=new ReadableStream({start:(G)=>{Y=G},cancel:()=>{this._streamMapping.delete(this._standaloneSseStreamId)}}),W={"Content-Type":"text/event-stream","Cache-Control":"no-cache, no-transform",Connection:"keep-alive"};if(this.sessionId!==void 0)W["mcp-session-id"]=this.sessionId;return this._streamMapping.set(this._standaloneSseStreamId,{controller:Y,encoder:v,cleanup:()=>{this._streamMapping.delete(this._standaloneSseStreamId);try{Y.close()}catch{}}}),new Response(z,{headers:W})}async replayEvents($){if(!this._eventStore)return this.createJsonErrorResponse(400,-32000,"Event store not configured");try{let X;if(this._eventStore.getStreamIdForEventId){if(X=await this._eventStore.getStreamIdForEventId($),!X)return this.createJsonErrorResponse(400,-32000,"Invalid event ID format");if(this._streamMapping.get(X)!==void 0)return this.createJsonErrorResponse(409,-32000,"Conflict: Stream already has an active connection")}let Q={"Content-Type":"text/event-stream","Cache-Control":"no-cache, no-transform",Connection:"keep-alive"};if(this.sessionId!==void 0)Q["mcp-session-id"]=this.sessionId;let J=new TextEncoder,v,Y=new ReadableStream({start:(W)=>{v=W},cancel:()=>{}}),z=await this._eventStore.replayEventsAfter($,{send:async(W,G)=>{if(!this.writeSSEEvent(v,J,G,W)){this.onerror?.(Error("Failed replay events"));try{v.close()}catch{}}}});return this._streamMapping.set(z,{controller:v,encoder:J,cleanup:()=>{this._streamMapping.delete(z);try{v.close()}catch{}}}),new Response(Y,{headers:Q})}catch(X){return this.onerror?.(X),this.createJsonErrorResponse(500,-32000,"Error replaying events")}}writeSSEEvent($,X,Q,J){try{let v=`event: message +`;if(J)v+=`id: ${J} +`;return v+=`data: ${JSON.stringify(Q)} -// src/mcp/toolHandlers/staticText.ts -var README_CONTENT = `# Simple Media Manager (SMM) +`,$.enqueue(X.encode(v)),!0}catch{return!1}}handleUnsupportedRequest(){return new Response(JSON.stringify({jsonrpc:"2.0",error:{code:-32000,message:"Method not allowed."},id:null}),{status:405,headers:{Allow:"GET, POST, DELETE","Content-Type":"application/json"}})}async handlePostRequest($,X){try{let Q=$.headers.get("accept");if(!Q?.includes("application/json")||!Q.includes("text/event-stream"))return this.createJsonErrorResponse(406,-32000,"Not Acceptable: Client must accept both application/json and text/event-stream");let J=$.headers.get("content-type");if(!J||!J.includes("application/json"))return this.createJsonErrorResponse(415,-32000,"Unsupported Media Type: Content-Type must be application/json");let v={headers:Object.fromEntries($.headers.entries()),url:new URL($.url)},Y;if(X?.parsedBody!==void 0)Y=X.parsedBody;else try{Y=await $.json()}catch{return this.createJsonErrorResponse(400,-32700,"Parse error: Invalid JSON")}let z;try{if(Array.isArray(Y))z=Y.map((F)=>$Z.parse(F));else z=[$Z.parse(Y)]}catch{return this.createJsonErrorResponse(400,-32700,"Parse error: Invalid JSON-RPC message")}let W=z.some(XZ);if(W){if(this._initialized&&this.sessionId!==void 0)return this.createJsonErrorResponse(400,-32600,"Invalid Request: Server already initialized");if(z.length>1)return this.createJsonErrorResponse(400,-32600,"Invalid Request: Only one initialization request is allowed");if(this.sessionId=this.sessionIdGenerator?.(),this._initialized=!0,this.sessionId&&this._onsessioninitialized)await Promise.resolve(this._onsessioninitialized(this.sessionId))}if(!W){let F=this.validateSession($);if(F)return F;let L=this.validateProtocolVersion($);if(L)return L}if(!z.some(D9)){for(let F of z)this.onmessage?.(F,{authInfo:X?.authInfo,requestInfo:v});return new Response(null,{status:202})}let U=crypto.randomUUID(),w=z.find((F)=>XZ(F)),H=w?w.params.protocolVersion:$.headers.get("mcp-protocol-version")??Ik;if(this._enableJsonResponse)return new Promise((F)=>{this._streamMapping.set(U,{resolveJson:F,cleanup:()=>{this._streamMapping.delete(U)}});for(let L of z)if(D9(L))this._requestToStreamMapping.set(L.id,U);for(let L of z)this.onmessage?.(L,{authInfo:X?.authInfo,requestInfo:v})});let j=new TextEncoder,V,B=new ReadableStream({start:(F)=>{V=F},cancel:()=>{this._streamMapping.delete(U)}}),D={"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive"};if(this.sessionId!==void 0)D["mcp-session-id"]=this.sessionId;for(let F of z)if(D9(F))this._streamMapping.set(U,{controller:V,encoder:j,cleanup:()=>{this._streamMapping.delete(U);try{V.close()}catch{}}}),this._requestToStreamMapping.set(F.id,U);await this.writePrimingEvent(V,j,U,H);for(let F of z){let L,A;if(D9(F)&&this._eventStore&&H>="2025-11-25")L=()=>{this.closeSSEStream(F.id)},A=()=>{this.closeStandaloneSSEStream()};this.onmessage?.(F,{authInfo:X?.authInfo,requestInfo:v,closeSSEStream:L,closeStandaloneSSEStream:A})}return new Response(B,{status:200,headers:D})}catch(Q){return this.onerror?.(Q),this.createJsonErrorResponse(400,-32700,"Parse error",{data:String(Q)})}}async handleDeleteRequest($){let X=this.validateSession($);if(X)return X;let Q=this.validateProtocolVersion($);if(Q)return Q;return await Promise.resolve(this._onsessionclosed?.(this.sessionId)),await this.close(),new Response(null,{status:200})}validateSession($){if(this.sessionIdGenerator===void 0)return;if(!this._initialized)return this.createJsonErrorResponse(400,-32000,"Bad Request: Server not initialized");let X=$.headers.get("mcp-session-id");if(!X)return this.createJsonErrorResponse(400,-32000,"Bad Request: Mcp-Session-Id header is required");if(X!==this.sessionId)return this.createJsonErrorResponse(404,-32001,"Session not found");return}validateProtocolVersion($){let X=$.headers.get("mcp-protocol-version");if(X!==null&&!F7.includes(X))return this.createJsonErrorResponse(400,-32000,`Bad Request: Unsupported protocol version: ${X} (supported versions: ${F7.join(", ")})`);return}async close(){this._streamMapping.forEach(({cleanup:$})=>{$()}),this._streamMapping.clear(),this._requestResponseMap.clear(),this.onclose?.()}closeSSEStream($){let X=this._requestToStreamMapping.get($);if(!X)return;let Q=this._streamMapping.get(X);if(Q)Q.cleanup()}closeStandaloneSSEStream(){let $=this._streamMapping.get(this._standaloneSseStreamId);if($)$.cleanup()}async send($,X){let Q=X?.relatedRequestId;if(L8($)||ev($))Q=$.id;if(Q===void 0){if(L8($)||ev($))throw Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request");let Y;if(this._eventStore)Y=await this._eventStore.storeEvent(this._standaloneSseStreamId,$);let z=this._streamMapping.get(this._standaloneSseStreamId);if(z===void 0)return;if(z.controller&&z.encoder)this.writeSSEEvent(z.controller,z.encoder,$,Y);return}let J=this._requestToStreamMapping.get(Q);if(!J)throw Error(`No connection established for request ID: ${String(Q)}`);let v=this._streamMapping.get(J);if(!this._enableJsonResponse&&v?.controller&&v?.encoder){let Y;if(this._eventStore)Y=await this._eventStore.storeEvent(J,$);this.writeSSEEvent(v.controller,v.encoder,$,Y)}if(L8($)||ev($)){this._requestResponseMap.set(Q,$);let Y=Array.from(this._requestToStreamMapping.entries()).filter(([W,G])=>G===J).map(([W])=>W);if(Y.every((W)=>this._requestResponseMap.has(W))){if(!v)throw Error(`No connection established for request ID: ${String(Q)}`);if(this._enableJsonResponse&&v.resolveJson){let W={"Content-Type":"application/json"};if(this.sessionId!==void 0)W["mcp-session-id"]=this.sessionId;let G=Y.map((U)=>this._requestResponseMap.get(U));if(G.length===1)v.resolveJson(new Response(JSON.stringify(G[0]),{status:200,headers:W}));else v.resolveJson(new Response(JSON.stringify(G),{status:200,headers:W}))}else v.cleanup();for(let W of Y)this._requestResponseMap.delete(W),this._requestToStreamMapping.delete(W)}}}}function Tm($,X){let Q=X.fs??J4(),J=qS(Q),v=Yw("mcp",X.appDataDir,Q,X.logger,void 0,J),Y=K.object({taskId:K.string().describe("The task ID from begin-recognize-task"),season:K.number().describe("The season number of the episode"),episode:K.number().describe("The episode number"),path:K.string().describe("The absolute path of the media file (POSIX or Windows format)")});$.registerTool(A6,{description:v.description,inputSchema:Y},async(z)=>{let{taskId:W,season:G,episode:U,path:w}=z??{};if(typeof W!=="string"||W.trim()==="")return n("Invalid taskId: 'taskId' must be a non-empty string");if(typeof G!=="number"||G<0)return n("Invalid season: 'season' must be a non-negative number");if(typeof U!=="number"||U<0)return n("Invalid episode: 'episode' must be a non-negative number");if(typeof w!=="string"||w.trim()==="")return n("Invalid path: 'path' must be a non-empty string");try{let H=await v.execute({taskId:W,season:G,episode:U,path:Z.posix(w)});if(typeof H==="object"&&H!==null&&"error"in H&&typeof H.error==="string")return t({success:!1,error:H.error});return t({success:!0,taskId:W})}catch(H){return n(`Error adding recognized file: ${H instanceof Error?H.message:String(H)}`)}})}var Cm=require("node:path");function um($,X,Q){let J=X.fs??J4(),v=$w("mcp",X.appDataDir,J,Q,X.logger,void 0),Y=K.object({taskId:K.string().describe("The task ID returned from begin-rename-task"),from:K.string().describe("The current absolute path of the file to rename"),to:K.string().describe("The new absolute path for the file")});$.registerTool(L6,{description:v.description,inputSchema:Y},async(z)=>{let{taskId:W,from:G,to:U}=z??{};if(typeof W!=="string"||W.trim()==="")return n("Invalid taskId: 'taskId' must be a non-empty string");if(typeof G!=="string"||G.trim()==="")return n("Invalid path: 'from' must be a non-empty string");if(typeof U!=="string"||U.trim()==="")return n("Invalid path: 'to' must be a non-empty string");if(!Sm(G))return n("Invalid path: 'from' must be a video file");if(!Sm(U))return n("Invalid path: 'to' must be a video file");try{let w=await v.execute({taskId:W,from:Z.posix(G),to:Z.posix(U)});if(typeof w==="object"&&w!==null&&"error"in w&&typeof w.error==="string"){let H=w.error;if(H.includes("Not Episode Video File"))return t({success:!1,error:`"${G}" is not video file to any episode, you're not allowed to rename it. Call "get-episodes" tool to get the list of episode video files that needs to rename.`});return t({success:!1,error:H})}return t({success:!0,taskId:W})}catch(w){let H=w instanceof Error?w.message:String(w);if(H.includes("Not Episode Video File"))return n(`"${G}" is not video file to any episode, you're not allowed to rename it. Call "get-episodes" tool to get the list of episode video files that needs to rename.`);return n(H)}})}function Sm($){let X=Cm.extname($).toLowerCase();return mK.includes(X)}function _m($,X){let Q=X.fs??J4(),J=Jw("mcp",X.appDataDir,Q,X.broadcast,X.logger,void 0),v=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder")});$.registerTool(E4,{description:J.description,inputSchema:v},async(Y)=>{let{mediaFolderPath:z}=Y??{};if(typeof z!=="string"||z.trim()==="")return n("Invalid path: 'mediaFolderPath' must be a non-empty string");try{let W=await J.execute({mediaFolderPath:z});if(typeof W==="object"&&W!==null&&"error"in W){let G=W;if(G.error)return n(G.error)}if(typeof W==="object"&&W!==null&&"taskId"in W)return t({success:!0,taskId:W.taskId,mediaFolderPath:Z.posix(z)});return t(W)}catch(W){return n(`Error starting recognize task: ${W instanceof Error?W.message:String(W)}`)}})}function ym($,X,Q){let J=X.fs??J4(),v=eK("mcp",X.appDataDir,J,Q,X.broadcast,X.logger,void 0),Y=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder, in POSIX or Windows format")});$.registerTool(j4,{description:v.description,inputSchema:Y},async(z)=>{let{mediaFolderPath:W}=z??{};if(typeof W!=="string"||W.trim()==="")return n("Invalid path: 'mediaFolderPath' must be a non-empty string");try{let G=await v.execute({mediaFolderPath:W});if(typeof G==="object"&&G!==null&&"error"in G){let U=G;if(U.error)return n(U.error)}if(typeof G==="object"&&G!==null&&"taskId"in G)return t({success:!0,taskId:G.taskId,mediaFolderPath:Z.posix(W)});return X.logger?.error?.({resultType:typeof G,resultKeys:typeof G==="object"&&G!==null?Object.keys(G):[]},`[tool][${j4}] Unexpected agent tool result shape`),t(G)}catch(G){return n(`Error starting rename task: ${G instanceof Error?G.message:String(G)}`)}})}function fm($,X){let Q=X.fs??J4(),J=zw("mcp",X.appDataDir,Q,X.broadcast,X.logger,void 0),v=K.object({taskId:K.string().describe("The task ID from begin-recognize-task")});$.registerTool(o4,{description:J.description,inputSchema:v},async(Y)=>{let{taskId:z}=Y??{};if(typeof z!=="string"||z.trim()==="")return n("Invalid taskId: 'taskId' must be a non-empty string");try{let W=await J.execute({taskId:z});if(typeof W==="object"&&W!==null&&"error"in W){let G=W;if(G.error)return t({success:!1,error:G.error})}return t({success:!0,taskId:z,message:X9})}catch(W){return n(`Error ending recognize task: ${W instanceof Error?W.message:String(W)}`)}})}function km($,X,Q){let J=X.fs??J4(),v=Xw("mcp",X.appDataDir,J,X.broadcast,X.logger,void 0),Y=K.object({taskId:K.string().describe("The task ID returned from begin-rename-task")});$.registerTool(R4,{description:v.description,inputSchema:Y},async(z)=>{let{taskId:W}=z??{};if(typeof W!=="string"||W.trim()==="")return n("Invalid taskId: 'taskId' must be a non-empty string");try{let G=await v.execute({taskId:W});if(typeof G==="object"&&G!==null&&"error"in G){let U=G;if(U.error)return t({success:!1,error:U.error})}return t({success:!0,taskId:W,message:X9})}catch(G){return n(`Error ending rename task: ${G instanceof Error?G.message:String(G)}`)}})}function nm($,X){let Q=X.toolDescriptions?.[$1]??tU;$.registerTool($1,{description:Q,inputSchema:sU,outputSchema:eU},async()=>{try{let J=await X.getUserConfig(),v=fK({configured:J.applicationLanguage,osLocale:kK()}),Y="";if(X.acknowledge)try{Y=(await X.acknowledge({event:"getSelectedMediaMetadata"},1000))?.selectedMediaMetadata?.mediaFolderPath??""}catch{}let z=await RO({selectedMediaFolder:Y,language:v});return t(z)}catch(J){return n(J instanceof Error?J.message:String(J))}})}var iV$=K.object({mediaFolderPath:K.string().describe("The absolute path of the media folder, in POSIX or Windows format"),season:K.number().describe("The season number of the episode"),episode:K.number().describe("The episode number")}),RV$=K.object({videoFilePath:K.string().describe("The absolute path of the video file in platform-specific format"),season:K.number().describe("The season number"),episode:K.number().describe("The episode number"),message:K.string().describe("Status message")}).strict(),gm="get-episode",EV$="Get the absolute video file path for a single episode in a media folder. Returns the resolved video file path plus a status message.";function cm($,X){let Q=X.toolDescriptions?.[gm]??EV$;$.registerTool(gm,{description:Q,inputSchema:iV$,outputSchema:RV$},async(J)=>{let{mediaFolderPath:v,season:Y,episode:z}=J??{};if(typeof v!=="string"||v.trim()==="")return n("Invalid path: 'mediaFolderPath' must be a non-empty string");if(typeof Y!=="number"||Y<0)return n("Invalid season: 'season' must be a non-negative number");if(typeof z!=="number"||z<0)return n("Invalid episode: 'episode' must be a non-negative number");try{let W=await N1(X.appDataDir,v);if(!W)return t({videoFilePath:"",season:Y,episode:z,message:"Media metadata not found. Please ensure the media folder is opened in SMM."});if(!W.mediaFiles||W.mediaFiles.length===0)return t({videoFilePath:"",season:Y,episode:z,message:"No media files found in the media folder metadata."});let G=W.mediaFiles.find((w)=>w.seasonNumber===Y&&w.episodeNumber===z);if(!G)return t({videoFilePath:"",season:Y,episode:z,message:`Episode S${Y}E${z} not found in the media folder.`});let U=Z.toPlatformPath(G.absolutePath);return t({videoFilePath:U,season:Y,episode:z,message:"succeeded"})}catch(W){return n(`Error getting episode: ${W instanceof Error?W.message:String(W)}`)}})}function mm($,X){let Q=X.toolDescriptions?.[a1]??vK;$.registerTool(a1,{description:Q,inputSchema:JK,outputSchema:YK},async(J)=>{let{mediaFolderPath:v}=J??{};if(typeof v!=="string"||v.trim()==="")return n("Invalid path: 'mediaFolderPath' must be a non-empty string");try{let Y=await X.getUserConfig(),z={allowlist:[],hello:{version:"0.0.0",userDataDir:X.userDataDir,appDataDir:X.appDataDir,logDir:"",tmpDir:"",reverseProxyUrl:null,osLocale:"en-US",coreRoutesPort:0},appDataDir:X.appDataDir,logger:X.logger},W=await U8({mediaFolderPath:v},z);if(W.error)return t(W);return t(W)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}function hm($,X){let Q=X.toolDescriptions?.[W8]??RK;$.registerTool(W8,{description:Q,inputSchema:EK,outputSchema:PK},async()=>{try{let J=await X.getUserConfig(),v=await _O(J);if(v.error)return n(v.error);let{error:Y,...z}=v;return t(z)}catch(J){return n(J instanceof Error?J.message:String(J))}})}function lm($,X){let Q=X.toolDescriptions?.[o1]??$K;$.registerTool(o1,{description:Q,inputSchema:XK,outputSchema:QK},async(J)=>{let{mediaFolderPath:v}=J??{};if(typeof v!=="string"||v.trim()==="")return n("Invalid path: 'mediaFolderPath' must be a non-empty string");try{let Y=await X.getUserConfig(),z=await CO({mediaFolderPath:v},Y,X.appDataDir);if(z.error)return t(z);return t(z)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}function pm($,X){let Q=X.toolDescriptions?.[z8]??xK;$.registerTool(z8,{description:Q,inputSchema:bK,outputSchema:iK},async(J)=>{let{path:v}=J??{};if(typeof v!=="string"||v.trim()==="")return n("Invalid path: 'path' must be a non-empty string");try{let Y=await EO(v);return t(Y)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}var PV$=K.object({folderPath:K.string().describe("The absolute path of the folder to list files from"),recursive:K.boolean().optional().default(!1).describe("Whether to list files recursively (default: false)"),filter:K.string().optional().describe("Filter pattern for files/folders (supports wildcards)"),videoFileOnly:K.boolean().optional().default(!1).describe("Whether to return only video files (default: false)")}),TV$=K.object({files:K.array(K.string()).describe("Array of file paths"),count:K.number().describe("Number of files listed")}),dm="list-files",SV$="List all files in a folder. Accepts paths in POSIX or Windows format. Returns plain file paths (no managed-folder check) so this tool is the preferred way for an external AI assistant to inspect a media folder's contents.";function om($,X){let Q=X.toolDescriptions?.[dm]??SV$;$.registerTool(dm,{description:Q,inputSchema:PV$,outputSchema:TV$},async(J)=>{let v=J??{},{folderPath:Y,recursive:z,videoFileOnly:W}=v;if(typeof Y!=="string"||Y.trim()==="")return n("Invalid path: 'folderPath' must be a non-empty string");try{let G=await s1({path:Y,recursively:z??!1,onlyFiles:!0},{logger:X.logger,activatePersistedFileAccess:X.activatePersistedFileAccess});if(G.error)return n(G.error);let U=G.data?.items.filter((j)=>!j.isDirectory).map((j)=>j.path)??[],w=t1(),H=wv(U,W??!1);return t({...w,...H})}catch(G){return n(G instanceof Error?G.message:String(G))}})}function am($,X){let Q=X.toolDescriptions?.[J1]??TK;$.registerTool(J1,{description:Q,inputSchema:SK,outputSchema:CK},async(J)=>{let v=J??{};if(typeof v.from!=="string"||v.from.trim()==="")return n("Invalid path: 'from' must be a non-empty string");if(typeof v.to!=="string"||v.to.trim()==="")return n("Invalid path: 'to' must be a non-empty string");try{let Y={allowlist:[],hello:{version:"0.0.0",userDataDir:X.userDataDir,appDataDir:X.appDataDir,logDir:"",tmpDir:"",reverseProxyUrl:null,osLocale:"en-US",coreRoutesPort:0},appDataDir:X.appDataDir,logger:X.logger},z=await nO({from:v.from,to:v.to},Y);if(z.renamed)X.broadcast?.({event:XS,data:{from:z.from,to:z.to}}),X.broadcast?.({event:$S,data:{}});return t(z)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}function rm($,X){let Q=X.toolDescriptions?.[Y1]??uK;$.registerTool(Y1,{description:Q,inputSchema:_K,outputSchema:yK},async(J)=>{let v=J??{};if(typeof v.mediaFolder!=="string"||v.mediaFolder.trim()==="")return n("Invalid path: 'mediaFolder' must be a non-empty string");if(typeof v.from!=="string"||v.from.trim()==="")return n("Invalid path: 'from' must be a non-empty string");if(typeof v.to!=="string"||v.to.trim()==="")return n("Invalid path: 'to' must be a non-empty string");try{if(X.acknowledge){let z=hK(v.from,v.to),W=await X.acknowledge({event:"askForConfirmation",data:{message:z},clientId:"mcp"},30000);if(!(W?.confirmed??W?.response==="yes"))return t(lK(v.mediaFolder,v.from,v.to))}let Y=await gO({mediaFolder:v.mediaFolder,from:v.from,to:v.to},X.renameEpisodeFile);if(Y.renamed)X.broadcast?.({event:"mediaMetadataUpdated",data:{folderPath:Z.posix(v.mediaFolder)}});if(Y.error&&!Y.renamed)return t(Y);return t(Y)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}function tm($,X){let Q=X.toolDescriptions?.[Z6]??GK;$.registerTool(Z6,{description:Q,inputSchema:UK,outputSchema:KK},async(J)=>{let v=J??{};if(typeof v.path!=="string"||v.path.trim()==="")return n("Invalid path: 'path' must be a non-empty string");try{let Y=await Kz({path:v.path,language:v.language},X.scrapeFolder);if(Y.error)return n(Y.error);return t(Y)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}function sm($,X){let Q=X.toolDescriptions?.[q6]??HK;$.registerTool(q6,{description:Q,inputSchema:jK,outputSchema:VK},async(J)=>{let v=J??{};if(typeof v.id!=="string"||v.id.trim()==="")return n("Invalid id: 'id' must be a non-empty string");try{let Y=await Hz(v.id,X.getJob);if(Y.error)return n(Y.error);return t(Y)}catch(Y){return n(Y instanceof Error?Y.message:String(Y))}})}function em($,X){let Q=X.toolDescriptions?.[X1]??FK,J=X.toolDescriptions?.[Q1]??LK,v=X.toolDescriptions?.[v1]??qK;$.registerTool(X1,{description:Q,inputSchema:NK,outputSchema:OK},async(Y)=>{try{let z=await xO(Y??{},X.searchInTmdb);if(z.error)return n(z.error);return t(z)}catch(z){return n(z instanceof Error?z.message:String(z))}}),$.registerTool(Q1,{description:J,inputSchema:AK,outputSchema:ZK},async(Y)=>{try{let z=await bO(Y??{},X.getMovieInTmdb);if(z.error)return n(z.error);return t(z)}catch(z){return n(z instanceof Error?z.message:String(z))}}),$.registerTool(v1,{description:v,inputSchema:IK,outputSchema:MK},async(Y)=>{try{let z=await iO(Y??{},X.getTvShowInTmdb);if(z.error)return n(z.error);return t(z)}catch(z){return n(z instanceof Error?z.message:String(z))}})}var CV$=`# Simple Media Manager (SMM) SMM 用于管理电视剧、电影或音乐的本地文件夹。 当用户导入文件夹时,SMM可能无法自动识别该文件夹属于哪部电视剧或电影, 也无法识别电视剧每一集对应的本地视频文件。 @@ -72744,8 +321,7 @@ SMM 用于管理电视剧、电影或音乐的本地文件夹。 ### 整理文件夹 依次完成识别文件夹, 识别季集视频文件, 重命名季集视频文件. -`; -var HOW_TO_RENAME_EPISODE_VIDEO_FILES = `# 如何使用 SMM MCP tool 重命名媒体文件 +`,uV$=`# 如何使用 SMM MCP tool 重命名媒体文件 当用户要求重命名视频文件或媒体文件, 或整理视频文件的目录结构, 表示用户想执行此操作. @@ -72785,8 +361,7 @@ FolderName: 季集文件夹名称, 如 "Season 01", "Season 02", "Season 03".特 **NOTE** SMM只支持重命名已识别的季集视频文件. "已识别"表示SMM知道该视频文件是哪一季的哪一集. 请使用 "get-episode" 工具来获得已识别的视频文件, **不要**使用 "list-files" 工具来获得所有视频文件. 当"get-episode"没有返回视频文件路径时, 表示SMM不知道该季该集对应的视频文件, AI助手可以跳过该季该集的视频文件重命名. -`; -var HOW_TO_RECOGNIZE_EPISODE_VIDEO_FILES = `# 如何使用 SMM MCP tool 识别季集视频文件 +`,_V$=`# 如何使用 SMM MCP tool 识别季集视频文件 当用户要求识别季集视频文件, 或将视频文件关联到具体的季集时, 表示用户想执行此操作. @@ -72801,85 +376,4 @@ AI助手应该参考以下步骤: 5. 使用 "begin-recognize-task" 工具开始识别任务 6. 使用 "add-recognized-file" 工具添加每个视频文件的识别结果 7. 使用 "end-recognize-task" 工具结束识别任务 -`; -var STATIC_TEXT_TOOLS = { - "how-to-rename-episode-video-files": HOW_TO_RENAME_EPISODE_VIDEO_FILES, - "how-to-recognize-episode-video-files": HOW_TO_RECOGNIZE_EPISODE_VIDEO_FILES, - readme: README_CONTENT -}; -var STATIC_TEXT_TOOL_DESCRIPTIONS = { - "how-to-rename-episode-video-files": "Returns step-by-step guidance for renaming episode video files. Call this before starting a rename task.", - "how-to-recognize-episode-video-files": "Returns step-by-step guidance for recognising which local video files correspond to which episodes. Call this before starting a recognise task.", - readme: "Returns the SMM overview: core concepts, common operations, and terminology." -}; -var staticTextInputSchema = exports_external.object({}); -var staticTextOutputSchema = exports_external.object({ - text: exports_external.string().describe("Markdown content") -}); -function registerStaticTextTools(server, config2) { - for (const [name21, content] of Object.entries(STATIC_TEXT_TOOLS)) { - const toolName = name21; - const description = config2.toolDescriptions?.[toolName] ?? STATIC_TEXT_TOOL_DESCRIPTIONS[toolName]; - server.registerTool(toolName, { - description, - inputSchema: staticTextInputSchema, - outputSchema: staticTextOutputSchema - }, async () => { - return createSuccessResponse({ text: content }); - }); - } -} - -// src/mcp/createServer.ts -async function createMcpStreamableHttpHandler(config2) { - const fs = config2.fs ?? defaultChatFs(); - const renameFilesTaskDeps = defaultRenameFilesTaskDeps(config2.appDataDir); - const server = new McpServer({ - name: "Simple Media Manager (SMM)", - version: "1.0.0", - description: "Simple Media Manager (SMM) exposes tools to query media from TMDB and manage media folder in SMM" - }, { - capabilities: { - tools: {} - } - }); - await registerGetApplicationContextTool(server, config2); - registerGetMediaFoldersTool(server, config2); - registerIsFolderExistTool(server, config2); - registerListFilesTool(server, config2); - registerGetMediaMetadataTool(server, config2); - registerStaticTextTools(server, config2); - if (!config2.disabledTools?.includes(RENAME_FOLDER)) { - registerRenameFolderTool(server, config2); - } - registerBeginRenameTaskTool(server, config2, renameFilesTaskDeps); - registerAddRenameFileTool(server, config2, renameFilesTaskDeps); - registerEndRenameTaskTool(server, config2, renameFilesTaskDeps); - registerBeginRecognizeTaskTool(server, config2); - registerAddRecognizedFileTool(server, config2); - registerEndRecognizeTaskTool(server, config2); - registerGetEpisodeTool(server, config2); - registerGetEpisodesTool(server, config2); - await server.connect(new WebStandardStreamableHTTPServerTransport({})); - return async (req) => { - await server.close(); - const transport = new WebStandardStreamableHTTPServerTransport({}); - await server.connect(transport); - return transport.handleRequest(req); - }; -} -// src/mcp/response.ts -function createSuccessResponse(data) { - return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], - structuredContent: data - }; -} -function createErrorResponse(message) { - return { - content: [{ type: "text", text: message }], - isError: true - }; -} -// src/mcp/index.ts -var MCP_TOOL_NAMES = { RENAME_FOLDER }; +`,yV$={"how-to-rename-episode-video-files":uV$,"how-to-recognize-episode-video-files":_V$,readme:CV$},fV$={"how-to-rename-episode-video-files":"Returns step-by-step guidance for renaming episode video files. Call this before starting a rename task.","how-to-recognize-episode-video-files":"Returns step-by-step guidance for recognising which local video files correspond to which episodes. Call this before starting a recognise task.",readme:"Returns the SMM overview: core concepts, common operations, and terminology."},kV$=K.object({}),nV$=K.object({text:K.string().describe("Markdown content")});function $h($,X){for(let[Q,J]of Object.entries(yV$)){let v=Q,Y=X.toolDescriptions?.[v]??fV$[v];$.registerTool(v,{description:Y,inputSchema:kV$,outputSchema:nV$},async()=>{return t({text:J})})}}async function wI($){let X=$.fs??J4(),Q=vw($.appDataDir),J=new KI({name:"Simple Media Manager (SMM)",version:"1.0.0",description:"Simple Media Manager (SMM) exposes tools to query media from TMDB and manage media folder in SMM"},{capabilities:{tools:{}}});if(await nm(J,$),hm(J,$),pm(J,$),om(J,$),lm(J,$),em(J,$),$h(J,$),!$.disabledTools?.includes(J1))am(J,$);if(!$.disabledTools?.includes(Y1))rm(J,$);if(!$.disabledTools?.includes(Z6))tm(J,$);if(!$.disabledTools?.includes(q6))sm(J,$);return ym(J,$,Q),um(J,$,Q),km(J,$,Q),_m(J,$),Tm(J,$),fm(J,$),cm(J,$),mm(J,$),await J.connect(new jV({})),async(v)=>{await J.close();let Y=new jV({});return await J.connect(Y),Y.handleRequest(v)}}function t($){return{content:[{type:"text",text:JSON.stringify($,null,2)}],structuredContent:$}}function n($){return{content:[{type:"text",text:$}],isError:!0}}var Xh={RENAME_FOLDER:J1,RENAME_EPISODE_FILE:Y1,SCRAPE:Z6,GET_JOB:q6,TMDB_SEARCH:X1,TMDB_GET_MOVIE:Q1,TMDB_GET_TV_SHOW:v1}; diff --git a/packages/core-routes/src/index.ts b/packages/core-routes/src/index.ts index 4abc53ab..f3b4f310 100644 --- a/packages/core-routes/src/index.ts +++ b/packages/core-routes/src/index.ts @@ -194,11 +194,26 @@ export { doMcpGetStatus, doMcpStart, doMcpStop, + parseStartOptionsFromBody, type McpLifecycleResult, type McpStartRequestBody, } from "./mcp/index.ts"; +export { + getMcpServerStatusWithUserConfig, + startMcpServerWithUserConfig, + stopMcpServerWithUserConfig, + type McpServerStateResponse, + type McpServerOperationOptions, + DEFAULT_MCP_HOST, + DEFAULT_MCP_PORT, +} from "./mcp/mcpServerConfig.ts"; export { handleMcpStartPut, handleMcpStopPut, handleMcpStatusGet, } from "./routes/mcpLifecycleRoute.ts"; +export { + handleMcpGetServerStatusGet, + handleMcpStartPost, + handleMcpStopPost, +} from "./routes/mcpServerRpcRoute.ts"; diff --git a/packages/core-routes/src/mcp/index.ts b/packages/core-routes/src/mcp/index.ts index 237b5225..525e7b9a 100644 --- a/packages/core-routes/src/mcp/index.ts +++ b/packages/core-routes/src/mcp/index.ts @@ -25,9 +25,19 @@ export { doMcpGetStatus, doMcpStart, doMcpStop, + parseStartOptionsFromBody, type McpLifecycleResult, type McpStartRequestBody, } from "./lifecycle.ts"; +export { + getMcpServerStatusWithUserConfig, + startMcpServerWithUserConfig, + stopMcpServerWithUserConfig, + type McpServerStateResponse, + type McpServerOperationOptions, + DEFAULT_MCP_HOST, + DEFAULT_MCP_PORT, +} from "./mcpServerConfig.ts"; /** * Re-exported tool-name constants so consumers that already depend diff --git a/packages/core-routes/src/mcp/lifecycle.ts b/packages/core-routes/src/mcp/lifecycle.ts index 0bf77881..d3190181 100644 --- a/packages/core-routes/src/mcp/lifecycle.ts +++ b/packages/core-routes/src/mcp/lifecycle.ts @@ -17,6 +17,11 @@ export interface McpLifecycleResult { } function parseStartOptions(body: unknown): StartMcpOptions | undefined { + return parseStartOptionsFromBody(body); +} + +/** Exported for MCP RPC config persistence. */ +export function parseStartOptionsFromBody(body: unknown): StartMcpOptions | undefined { if (!body || typeof body !== "object") { return undefined; } diff --git a/packages/core-routes/src/mcp/mcpServerConfig.test.ts b/packages/core-routes/src/mcp/mcpServerConfig.test.ts new file mode 100644 index 00000000..45e47b91 --- /dev/null +++ b/packages/core-routes/src/mcp/mcpServerConfig.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import type { McpLifecycleManager } from "./lifecycleTypes.ts"; +import type { CoreRoutesConfig } from "../types.ts"; +import { + getMcpServerStatusWithUserConfig, + startMcpServerWithUserConfig, + stopMcpServerWithUserConfig, +} from "./mcpServerConfig.ts"; + +function createGateManager(mainOrigin = "http://127.0.0.1:18081"): McpLifecycleManager { + let enabled = false; + const mcpUrl = `${mainOrigin.replace(/\/$/, "")}/mcp`; + + return { + async start() { + enabled = true; + }, + async stop() { + enabled = false; + }, + getState() { + if (enabled) { + const url = new URL(mcpUrl); + return { + status: "running" as const, + host: url.hostname, + port: url.port ? Number(url.port) : 80, + url: mcpUrl, + }; + } + return { status: "stopped" as const, url: mcpUrl }; + }, + }; +} + +async function readConfigFile(userDataDir: string) { + const raw = await readFile(path.join(userDataDir, "smm.json"), "utf-8"); + return JSON.parse(raw) as Record; +} + +describe("mcpServerConfig persistence", () => { + let tmpDir: string; + let routesConfig: CoreRoutesConfig; + let manager: McpLifecycleManager; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "mcp-config-")); + await writeFile( + path.join(tmpDir, "smm.json"), + JSON.stringify({ folders: [], enableMcpServer: false }), + "utf-8", + ); + routesConfig = { + hello: { userDataDir: tmpDir }, + appDataDir: tmpDir, + }; + manager = createGateManager(); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("startMcpServerWithUserConfig persists enableMcpServer and host/port", async () => { + const result = await startMcpServerWithUserConfig( + manager, + routesConfig, + { host: "127.0.0.2", port: 30021 }, + ); + + expect(result.error).toBeNull(); + expect(result.data?.status).toBe("running"); + expect(result.data?.url).toBe("http://127.0.0.1:18081/mcp"); + + const config = await readConfigFile(tmpDir); + expect(config.enableMcpServer).toBe(true); + expect(config.mcpHost).toBe("127.0.0.2"); + expect(config.mcpPort).toBe(30021); + }); + + it("stopMcpServerWithUserConfig sets enableMcpServer to false", async () => { + await startMcpServerWithUserConfig(manager, routesConfig, {}); + + const result = await stopMcpServerWithUserConfig(manager, routesConfig); + expect(result.error).toBeNull(); + expect(result.data?.status).toBe("stopped"); + + const config = await readConfigFile(tmpDir); + expect(config.enableMcpServer).toBe(false); + }); + + it("getMcpServerStatusWithUserConfig reconciles stale enableMcpServer", async () => { + await writeFile( + path.join(tmpDir, "smm.json"), + JSON.stringify({ folders: [], enableMcpServer: true }), + "utf-8", + ); + + const result = await getMcpServerStatusWithUserConfig(manager, routesConfig); + expect(result.data?.status).toBe("stopped"); + + const config = await readConfigFile(tmpDir); + expect(config.enableMcpServer).toBe(false); + }); + + it("startMcpServerWithUserConfig skips config write when persistUserConfig is false", async () => { + await startMcpServerWithUserConfig(manager, routesConfig, {}, { + persistUserConfig: false, + }); + + const config = await readConfigFile(tmpDir); + expect(config.enableMcpServer).toBe(false); + }); +}); diff --git a/packages/core-routes/src/mcp/mcpServerConfig.ts b/packages/core-routes/src/mcp/mcpServerConfig.ts new file mode 100644 index 00000000..7e2b9339 --- /dev/null +++ b/packages/core-routes/src/mcp/mcpServerConfig.ts @@ -0,0 +1,124 @@ +import type { UserConfig } from "@smm/core/types"; +import type { CoreRoutesConfig } from "../types.ts"; +import { readUserConfig, writeUserConfigToDisk } from "../userConfig.ts"; +import type { McpLifecycleManager, McpServerState, StartMcpOptions } from "./lifecycleTypes.ts"; +import { parseStartOptionsFromBody } from "./lifecycle.ts"; + +export const DEFAULT_MCP_HOST = "127.0.0.1"; +export const DEFAULT_MCP_PORT = 30001; + +export interface McpServerStateResponse { + data?: McpServerState; + error?: string | null; +} + +export interface McpServerOperationOptions { + /** When false, runtime is updated but smm.json MCP fields are not written (boot path). */ + persistUserConfig?: boolean; +} + +function mcpErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function resolveMcpStartOptions( + config: UserConfig, + options?: StartMcpOptions, +): { hostname: string; port: number } { + return { + hostname: options?.hostname ?? config.mcpHost ?? DEFAULT_MCP_HOST, + port: options?.port ?? config.mcpPort ?? DEFAULT_MCP_PORT, + }; +} + +export async function startMcpServerWithUserConfig( + manager: McpLifecycleManager, + routesConfig: CoreRoutesConfig, + body: unknown, + operation?: McpServerOperationOptions, +): Promise { + const options = parseStartOptionsFromBody(body); + const userConfig = await readUserConfig(routesConfig); + const { hostname, port } = resolveMcpStartOptions(userConfig, options); + + try { + await manager.start({ hostname, port }); + const state = manager.getState(); + if (state.status === "error") { + return { + data: state, + error: `Error Reason: ${state.error ?? "Failed to start MCP server"}`, + }; + } + + if (operation?.persistUserConfig !== false) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: true, + mcpHost: hostname, + mcpPort: port, + }); + } + + return { data: state, error: null }; + } catch (error) { + const message = mcpErrorMessage(error); + const state = manager.getState(); + return { + data: { ...state, status: "error", error: message }, + error: `Error Reason: ${message}`, + }; + } +} + +export async function stopMcpServerWithUserConfig( + manager: McpLifecycleManager, + routesConfig: CoreRoutesConfig, + operation?: McpServerOperationOptions, +): Promise { + const userConfig = await readUserConfig(routesConfig); + + try { + await manager.stop(); + const state = manager.getState(); + if (state.status === "error") { + return { + data: state, + error: `Error Reason: ${state.error ?? "Failed to stop MCP server"}`, + }; + } + return { data: state, error: null }; + } catch (error) { + const message = mcpErrorMessage(error); + return { + data: { status: "error", error: message }, + error: `Error Reason: ${message}`, + }; + } finally { + if (operation?.persistUserConfig !== false) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: false, + }); + } + } +} + +export async function getMcpServerStatusWithUserConfig( + manager: McpLifecycleManager, + routesConfig: CoreRoutesConfig, +): Promise { + const state = manager.getState(); + + if (state.status !== "running") { + const userConfig = await readUserConfig(routesConfig); + if (userConfig.enableMcpServer) { + await writeUserConfigToDisk(routesConfig, { + ...userConfig, + enableMcpServer: false, + }); + } + } + + return { data: state, error: null }; +} diff --git a/packages/core-routes/src/register.ts b/packages/core-routes/src/register.ts index 4efab2f4..28b225f8 100644 --- a/packages/core-routes/src/register.ts +++ b/packages/core-routes/src/register.ts @@ -22,6 +22,11 @@ import { handleMcpStatusGet, handleMcpStopPut, } from "./routes/mcpLifecycleRoute.ts"; +import { + handleMcpGetServerStatusGet, + handleMcpStartPost, + handleMcpStopPost, +} from "./routes/mcpServerRpcRoute.ts"; import { handleCreatePlanPost, handleGetPlanByIdPost, @@ -49,6 +54,9 @@ export const coreRouteHandlers: RouteHandler[] = [ handleReadImagePost, handleDiscoverGet, handleChatPost, + handleMcpGetServerStatusGet, + handleMcpStartPost, + handleMcpStopPost, handleMcpStartPut, handleMcpStopPut, handleMcpStatusGet, diff --git a/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts b/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts new file mode 100644 index 00000000..65e6bf5b --- /dev/null +++ b/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import type { McpLifecycleManager } from "../mcp/lifecycleTypes.ts"; +import type { CoreRoutesConfig } from "../types.ts"; +import { + handleMcpGetServerStatusGet, + handleMcpStartPost, + handleMcpStopPost, +} from "./mcpServerRpcRoute.ts"; + +function createGateManager(): McpLifecycleManager { + let enabled = false; + const mcpUrl = "http://127.0.0.1:18081/mcp"; + + return { + async start() { + enabled = true; + }, + async stop() { + enabled = false; + }, + getState() { + if (enabled) { + return { + status: "running" as const, + host: "127.0.0.1", + port: 18081, + url: mcpUrl, + }; + } + return { status: "stopped" as const, url: mcpUrl }; + }, + }; +} + +function mockRequest(method: string, pathname: string, body?: unknown): IncomingMessage { + const payload = body === undefined ? "" : JSON.stringify(body); + return { + method, + url: pathname, + async *[Symbol.asyncIterator]() { + if (payload) { + yield Buffer.from(payload); + } + }, + } as IncomingMessage; +} + +function mockResponse(): ServerResponse & { + statusCode?: number; + body?: string; +} { + const res = { + statusCode: undefined as number | undefined, + body: undefined as string | undefined, + writeHead(status: number, _headers: Record) { + this.statusCode = status; + }, + end(payload?: string) { + this.body = payload; + }, + }; + return res as ServerResponse & { statusCode?: number; body?: string }; +} + +describe("MCP RPC route handlers", () => { + let tmpDir: string; + let config: CoreRoutesConfig; + let manager: McpLifecycleManager; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "mcp-rpc-")); + await writeFile( + path.join(tmpDir, "smm.json"), + JSON.stringify({ folders: [], enableMcpServer: false }), + "utf-8", + ); + manager = createGateManager(); + config = { + hello: { userDataDir: tmpDir }, + appDataDir: tmpDir, + mcp: { manager }, + }; + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("handleMcpStartPost returns { data, error: null } and persists config", async () => { + const req = mockRequest("POST", "/api/start-mcp-server", { + host: "127.0.0.1", + port: 30001, + }); + const res = mockResponse(); + const ctx = { config, url: new URL("http://127.0.0.1:18081/api/start-mcp-server") }; + + const handled = await handleMcpStartPost(req, res, ctx); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body ?? "{}") as { + data?: { status: string; url?: string }; + error?: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.status).toBe("running"); + expect(body.data?.url).toBe("http://127.0.0.1:18081/mcp"); + }); + + it("handleMcpGetServerStatusGet returns stopped state", async () => { + const req = mockRequest("GET", "/api/get-mcp-server-status"); + const res = mockResponse(); + const ctx = { config, url: new URL("http://127.0.0.1:18081/api/get-mcp-server-status") }; + + const handled = await handleMcpGetServerStatusGet(req, res, ctx); + expect(handled).toBe(true); + + const body = JSON.parse(res.body ?? "{}") as { + data?: { status: string }; + error?: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.status).toBe("stopped"); + }); + + it("handleMcpStopPost returns stopped after start", async () => { + await manager.start(); + const req = mockRequest("POST", "/api/stop-mcp-server"); + const res = mockResponse(); + const ctx = { config, url: new URL("http://127.0.0.1:18081/api/stop-mcp-server") }; + + const handled = await handleMcpStopPost(req, res, ctx); + expect(handled).toBe(true); + + const body = JSON.parse(res.body ?? "{}") as { + data?: { status: string }; + error?: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.status).toBe("stopped"); + }); +}); diff --git a/packages/core-routes/src/routes/mcpServerRpcRoute.ts b/packages/core-routes/src/routes/mcpServerRpcRoute.ts new file mode 100644 index 00000000..4d595fdc --- /dev/null +++ b/packages/core-routes/src/routes/mcpServerRpcRoute.ts @@ -0,0 +1,86 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + getMcpServerStatusWithUserConfig, + startMcpServerWithUserConfig, + stopMcpServerWithUserConfig, +} from "../mcp/mcpServerConfig.ts"; +import { readJsonBody, sendJson } from "../http.ts"; +import type { RouteContext } from "../types.ts"; + +/** + * Web UI RPC routes (docs/dev/mcp.md): + * GET /api/get-mcp-server-status + * POST /api/start-mcp-server + * POST /api/stop-mcp-server + * + * Response shape: `{ data?: McpServerState, error?: string | null }` + */ +export async function handleMcpGetServerStatusGet( + req: IncomingMessage, + res: ServerResponse, + ctx: RouteContext, +): Promise { + if (req.method !== "GET" || ctx.url.pathname !== "/api/get-mcp-server-status") { + return false; + } + + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + + try { + const result = await getMcpServerStatusWithUserConfig(manager, ctx.config); + sendJson(res, 200, result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendJson(res, 200, { error: `Error Reason: ${message}` }); + } + return true; +} + +export async function handleMcpStartPost( + req: IncomingMessage, + res: ServerResponse, + ctx: RouteContext, +): Promise { + if (req.method !== "POST" || ctx.url.pathname !== "/api/start-mcp-server") { + return false; + } + + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + + const body = await readJsonBody(req); + const result = await startMcpServerWithUserConfig(manager, ctx.config, body, { + persistUserConfig: true, + }); + sendJson(res, 200, result); + return true; +} + +export async function handleMcpStopPost( + req: IncomingMessage, + res: ServerResponse, + ctx: RouteContext, +): Promise { + if (req.method !== "POST" || ctx.url.pathname !== "/api/stop-mcp-server") { + return false; + } + + const manager = ctx.config.mcp?.manager; + if (!manager) { + sendJson(res, 200, { error: "Error Reason: MCP lifecycle not configured" }); + return true; + } + + const result = await stopMcpServerWithUserConfig(manager, ctx.config, { + persistUserConfig: true, + }); + sendJson(res, 200, result); + return true; +} From 06e841ed4ca201b1bd48366276c4caeb76fd0029 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 23 Aug 2026 17:44:04 +0800 Subject: [PATCH 105/214] docs(mcp): update status from draft to done in MCP server documentation --- docs/dev/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/mcp.md b/docs/dev/mcp.md index ce33bee4..ee270e82 100644 --- a/docs/dev/mcp.md +++ b/docs/dev/mcp.md @@ -1,7 +1,7 @@ # MCP Server **Supported Platform** MCP tools, Web UI, ohos -**Status** draft +**Status** done This document describes the **future architecture** for turning the MCP server on and off (not the current implementation). From 14b52a783f134d1031db7d2ea85f719522f1e296 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 14:15:37 +0800 Subject: [PATCH 106/214] feat(tmdb): route Web UI and AI tools through Core HTTP APIs Expose search/get-movie/get-tvshow as 1:1 Internal HTTP endpoints and wire v3 UI search plus frontend AI tools to them, with test and alias fixes so CLI vitest and builds stay green under parallel load. Co-authored-by: Cursor --- apps/cli/server.ts | 2 + apps/cli/src/cli/add.test.ts | 1 + apps/cli/src/route/Tmdb.test.ts | 138 ++++++++++++++++++ apps/cli/src/route/Tmdb.ts | 128 ++++++++++++++++ apps/cli/test/tmdb.e2e.ts | 11 +- apps/cli/vitest.config.ts | 30 +++- apps/core/vitest.config.ts | 28 +++- apps/ui/src/ai/Assistant.tsx | 6 + apps/ui/src/ai/tools/TmdbGetMovie.tsx | 37 +++++ apps/ui/src/ai/tools/TmdbGetTvShow.tsx | 37 +++++ apps/ui/src/ai/tools/TmdbSearch.tsx | 51 +++++++ apps/ui/src/ai/tools/index.ts | 3 + apps/ui/src/api/tmdb.test.ts | 60 ++++++++ apps/ui/src/api/tmdb.ts | 50 +++++++ apps/ui/src/api/tmdbV3.test.ts | 69 +++++++++ apps/ui/src/api/tmdbV3.ts | 99 +++++++++++++ .../MediaDatabaseSearchbox.test.tsx | 29 ++-- .../src/components/MediaDatabaseSearchbox.tsx | 36 ++--- .../ui/settings/GeneralSettings.test.tsx | 11 ++ apps/ui/src/core/BrowserNetworkPort.ts | 7 +- docs/api/index.md | 9 ++ docs/dev/tmdb.md | 85 ++++++----- packages/core-routes/src/core-routes.test.ts | 11 +- packages/core/ai-tool/registry.ts | 8 +- 24 files changed, 850 insertions(+), 96 deletions(-) create mode 100644 apps/cli/src/route/Tmdb.test.ts create mode 100644 apps/cli/src/route/Tmdb.ts create mode 100644 apps/ui/src/ai/tools/TmdbGetMovie.tsx create mode 100644 apps/ui/src/ai/tools/TmdbGetTvShow.tsx create mode 100644 apps/ui/src/ai/tools/TmdbSearch.tsx create mode 100644 apps/ui/src/api/tmdbV3.test.ts create mode 100644 apps/ui/src/api/tmdbV3.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 35203888..34612b2f 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -51,6 +51,7 @@ import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleImportFolder } from './src/route/ImportFolder'; import { handleGetJob } from './src/route/GetJob'; import { handleScrape } from './src/route/Scrape'; +import { handleTmdb } from './src/route/Tmdb'; import { handleCoreFetch } from './src/route/CoreFetch'; import { handleShowFolder } from './src/route/ShowFolder'; import { handleFolderMetadata } from './src/route/FolderMetadata'; @@ -296,6 +297,7 @@ export class Server { handleImportFolder(this.app); handleGetJob(this.app); handleScrape(this.app); + handleTmdb(this.app); handleCoreFetch(this.app); handleShowFolder(this.app); handleFolderMetadata(this.app); diff --git a/apps/cli/src/cli/add.test.ts b/apps/cli/src/cli/add.test.ts index 3b379287..bdc3a7e0 100644 --- a/apps/cli/src/cli/add.test.ts +++ b/apps/cli/src/cli/add.test.ts @@ -67,6 +67,7 @@ describe('smm add', () => { const { Core } = await import('core-app') const importFolder = vi.spyOn(Core.prototype, 'importFolder').mockReturnValue({ id: 'job-1' }) vi.spyOn(Core.prototype, 'getJob').mockReturnValue({ + kind: 'import', id: 'job-1', folderPath: mediaFolder, type: 'tvshow', diff --git a/apps/cli/src/route/Tmdb.test.ts b/apps/cli/src/route/Tmdb.test.ts new file mode 100644 index 00000000..17b4c270 --- /dev/null +++ b/apps/cli/src/route/Tmdb.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + searchInTmdb: vi.fn(), + getMovieInTmdb: vi.fn(), + getTvShowInTmdb: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +import { handleTmdb } from './Tmdb' + +describe('TMDB Internal HTTP APIs', () => { + let app: Hono + + beforeEach(() => { + mocks.searchInTmdb.mockReset() + mocks.getMovieInTmdb.mockReset() + mocks.getTvShowInTmdb.mockReset() + app = new Hono() + handleTmdb(app) + }) + + async function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + describe('POST /api/search-in-tmdb', () => { + it('returns Error Reason when keyword is missing', async () => { + const res = await post('/api/search-in-tmdb', { type: 'tv' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: keyword is required/) + expect(mocks.searchInTmdb).not.toHaveBeenCalled() + }) + + it('returns Error Reason when type is invalid', async () => { + const res = await post('/api/search-in-tmdb', { keyword: 'naruto', type: 'anime' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: type must be tv or movie/) + }) + + it('calls Core.searchInTmdb and returns data', async () => { + mocks.searchInTmdb.mockResolvedValue({ + results: [{ id: 1, name: 'Naruto' }], + page: 1, + total_pages: 1, + total_results: 1, + }) + const res = await post('/api/search-in-tmdb', { + keyword: 'naruto', + type: 'tv', + language: 'zh-CN', + host: 'https://tmdb.example/3', + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { + results: [{ id: 1, name: 'Naruto' }], + page: 1, + total_pages: 1, + total_results: 1, + }, + }) + expect(mocks.searchInTmdb).toHaveBeenCalledWith('naruto', { + type: 'tv', + language: 'zh-CN', + host: 'https://tmdb.example/3', + password: undefined, + proxy: undefined, + }) + }) + + it('maps Core throw to Error Reason', async () => { + mocks.searchInTmdb.mockRejectedValue(new Error('Unsupported language "cn"')) + const res = await post('/api/search-in-tmdb', { keyword: 'x', type: 'movie', language: 'cn' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toBe('Error Reason: Unsupported language "cn"') + }) + }) + + describe('POST /api/get-movie-in-tmdb', () => { + it('returns Error Reason when id is missing', async () => { + const res = await post('/api/get-movie-in-tmdb', {}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: id is required/) + }) + + it('calls Core.getMovieInTmdb and returns data', async () => { + mocks.getMovieInTmdb.mockResolvedValue({ id: 550, title: 'Fight Club' }) + const res = await post('/api/get-movie-in-tmdb', { id: 550, language: 'en-US' }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { id: 550, title: 'Fight Club' }, + }) + expect(mocks.getMovieInTmdb).toHaveBeenCalledWith(550, { + language: 'en-US', + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) + + describe('POST /api/get-tvshow-in-tmdb', () => { + it('returns Error Reason when id is not a positive integer', async () => { + const res = await post('/api/get-tvshow-in-tmdb', { id: 0 }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: id is required/) + }) + + it('calls Core.getTvShowInTmdb and returns data', async () => { + mocks.getTvShowInTmdb.mockResolvedValue({ id: 1396, name: 'Breaking Bad' }) + const res = await post('/api/get-tvshow-in-tmdb', { id: 1396 }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { id: 1396, name: 'Breaking Bad' }, + }) + expect(mocks.getTvShowInTmdb).toHaveBeenCalledWith(1396, { + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) +}) diff --git a/apps/cli/src/route/Tmdb.ts b/apps/cli/src/route/Tmdb.ts new file mode 100644 index 00000000..6b407295 --- /dev/null +++ b/apps/cli/src/route/Tmdb.ts @@ -0,0 +1,128 @@ +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface TmdbSearchHttpResponseBody { + data?: unknown + error?: string +} + +export interface TmdbDetailsHttpResponseBody { + data?: unknown + error?: string +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined +} + +function parsePositiveInt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value + } + if (typeof value === 'string' && /^\d+$/.test(value)) { + const parsed = Number(value) + if (parsed > 0) return parsed + } + return undefined +} + +function tmdbRequestOptions(rec: Record): { + language?: string + host?: string + password?: string + proxy?: string +} { + return { + language: optionalString(rec.language), + host: optionalString(rec.host), + password: optionalString(rec.password), + proxy: optionalString(rec.proxy), + } +} + +async function readJsonObject(c: { req: { json: () => Promise } }): Promise> { + try { + const body = await c.req.json() + if (typeof body === 'object' && body !== null) { + return body as Record + } + } catch { + /* empty / invalid JSON */ + } + return {} +} + +function errorBody(message: string): { error: string } { + return { error: `Error Reason: ${message}` } +} + +export function handleTmdb(app: Hono): void { + app.post('/api/search-in-tmdb', async (c) => { + try { + const rec = await readJsonObject(c) + const keyword = optionalString(rec.keyword) + if (!keyword) { + const err: TmdbSearchHttpResponseBody = errorBody('keyword is required') + return c.json(err, 200) + } + const type = rec.type + if (type !== 'tv' && type !== 'movie') { + const err: TmdbSearchHttpResponseBody = errorBody('type must be tv or movie') + return c.json(err, 200) + } + const data = await getCore().searchInTmdb(keyword, { + type, + ...tmdbRequestOptions(rec), + }) + const ok: TmdbSearchHttpResponseBody = { data } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/search-in-tmdb] route error') + const err: TmdbSearchHttpResponseBody = errorBody( + error instanceof Error ? error.message : 'Unknown error', + ) + return c.json(err, 200) + } + }) + + app.post('/api/get-movie-in-tmdb', async (c) => { + try { + const rec = await readJsonObject(c) + const id = parsePositiveInt(rec.id) + if (id === undefined) { + const err: TmdbDetailsHttpResponseBody = errorBody('id is required') + return c.json(err, 200) + } + const data = await getCore().getMovieInTmdb(id, tmdbRequestOptions(rec)) + const ok: TmdbDetailsHttpResponseBody = { data } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-movie-in-tmdb] route error') + const err: TmdbDetailsHttpResponseBody = errorBody( + error instanceof Error ? error.message : 'Unknown error', + ) + return c.json(err, 200) + } + }) + + app.post('/api/get-tvshow-in-tmdb', async (c) => { + try { + const rec = await readJsonObject(c) + const id = parsePositiveInt(rec.id) + if (id === undefined) { + const err: TmdbDetailsHttpResponseBody = errorBody('id is required') + return c.json(err, 200) + } + const data = await getCore().getTvShowInTmdb(id, tmdbRequestOptions(rec)) + const ok: TmdbDetailsHttpResponseBody = { data } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-tvshow-in-tmdb] route error') + const err: TmdbDetailsHttpResponseBody = errorBody( + error instanceof Error ? error.message : 'Unknown error', + ) + return c.json(err, 200) + } + }) +} diff --git a/apps/cli/test/tmdb.e2e.ts b/apps/cli/test/tmdb.e2e.ts index 37763750..b63f4785 100644 --- a/apps/cli/test/tmdb.e2e.ts +++ b/apps/cli/test/tmdb.e2e.ts @@ -73,8 +73,9 @@ describe('smm tmdb search CLI e2e (docs/dev/tmdb.md scenarios)', () => { '3) Search through custom TMDB host', { timeout: TMDB_SEARCH_TIMEOUT_MS }, async () => { - // Official TMDB rejects unauthenticated search; --password is required for this host. - const { host, password } = officialTmdb() + // Official TMDB is often unreachable without a proxy in restricted networks. + // --password is required; --proxy matches docs/dev/tmdb.md custom-host example. + const { host, password, proxy } = officialTmdb() const result = await smm([ 'tmdb', 'search', @@ -85,6 +86,8 @@ describe('smm tmdb search CLI e2e (docs/dev/tmdb.md scenarios)', () => { host, '--password', password, + '--proxy', + proxy, ]) expect(result.code, result.stderr || result.stdout).toBe(0) expect(result.stdout).toMatch(RESULT_HEADER) @@ -96,7 +99,7 @@ describe('smm tmdb search CLI e2e (docs/dev/tmdb.md scenarios)', () => { '4) Search through custom TMDB host and password', { timeout: TMDB_SEARCH_TIMEOUT_MS }, async () => { - const { host, password } = officialTmdb() + const { host, password, proxy } = officialTmdb() const result = await smm([ 'tmdb', 'search', @@ -107,6 +110,8 @@ describe('smm tmdb search CLI e2e (docs/dev/tmdb.md scenarios)', () => { host, '--password', password, + '--proxy', + proxy, ]) expect(result.code, result.stderr || result.stdout).toBe(0) expect(result.stdout).toMatch(RESULT_HEADER) diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 8435d09f..babe4a3c 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from 'vitest/config' import { resolve } from 'path' +const coreRoot = resolve(__dirname, '../../packages/core') + export default defineConfig({ test: { environment: 'node', @@ -8,12 +10,26 @@ export default defineConfig({ exclude: ['test/test-mcp.e2e.ts'], }, resolve: { - alias: { - '@': resolve(__dirname, './src'), - '@core': resolve(__dirname, '../../packages/core'), - '@smm/core': resolve(__dirname, '../../packages/core/types.ts'), - '@smm/test': resolve(__dirname, '../../packages/test/src/index.ts'), - 'core-app': resolve(__dirname, '../core/src/index.ts'), - }, + alias: [ + { find: '@', replacement: resolve(__dirname, './src') }, + { find: '@core', replacement: coreRoot }, + // Subpaths (`@smm/core/path`, …) must win over bare `@smm/core` → types.ts. + { + find: /^@smm\/core\/(.+)$/, + replacement: `${coreRoot}/$1`, + }, + { + find: '@smm/core', + replacement: resolve(coreRoot, 'types.ts'), + }, + { + find: '@smm/test', + replacement: resolve(__dirname, '../../packages/test/src/index.ts'), + }, + { + find: 'core-app', + replacement: resolve(__dirname, '../core/src/index.ts'), + }, + ], }, }) diff --git a/apps/core/vitest.config.ts b/apps/core/vitest.config.ts index 73f29301..2017cad7 100644 --- a/apps/core/vitest.config.ts +++ b/apps/core/vitest.config.ts @@ -1,17 +1,33 @@ import { defineConfig } from "vitest/config"; import { resolve } from "path"; +const coreRoot = resolve(__dirname, "../../packages/core"); + export default defineConfig({ test: { environment: "node", include: ["src/**/*.test.ts"], }, resolve: { - alias: { - "@core": resolve(__dirname, "../../packages/core"), - "@smm/core": resolve(__dirname, "../../packages/core/types.ts"), - "@smm/tvdb4/types": resolve(__dirname, "../../packages/tvdb4/src/types.ts"), - "@smm/tvdb4": resolve(__dirname, "../../packages/tvdb4/src/index.ts"), - }, + alias: [ + { find: "@core", replacement: coreRoot }, + // Subpaths (`@smm/core/path`, …) must win over bare `@smm/core` → types.ts. + { + find: /^@smm\/core\/(.+)$/, + replacement: `${coreRoot}/$1`, + }, + { + find: "@smm/core", + replacement: resolve(coreRoot, "types.ts"), + }, + { + find: "@smm/tvdb4/types", + replacement: resolve(__dirname, "../../packages/tvdb4/src/types.ts"), + }, + { + find: "@smm/tvdb4", + replacement: resolve(__dirname, "../../packages/tvdb4/src/index.ts"), + }, + ], }, }); diff --git a/apps/ui/src/ai/Assistant.tsx b/apps/ui/src/ai/Assistant.tsx index cc1779d6..4c41c3aa 100644 --- a/apps/ui/src/ai/Assistant.tsx +++ b/apps/ui/src/ai/Assistant.tsx @@ -22,6 +22,9 @@ import { RenameEpisodeFileTool, ScrapeTool, GetJobTool, + TmdbSearchTool, + TmdbGetMovieTool, + TmdbGetTvShowTool, BeginRecognizeTaskTool, AddRecognizedMediaFileTool, EndRecognizeTaskTool, @@ -320,6 +323,9 @@ function AssistantImpl() { + + + {/* Rename/recognize task tools run HTTP plan APIs in the renderer. On desktop `AssistantChatTransport`, the same tools are executed server-side in `doChat` — mounting them here would duplicate diff --git a/apps/ui/src/ai/tools/TmdbGetMovie.tsx b/apps/ui/src/ai/tools/TmdbGetMovie.tsx new file mode 100644 index 00000000..3990bae8 --- /dev/null +++ b/apps/ui/src/ai/tools/TmdbGetMovie.tsx @@ -0,0 +1,37 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + TMDB_GET_MOVIE, + TMDB_GET_MOVIE_DESCRIPTION, + tmdbGetMovieInputSchema, + type TmdbGetMovieOutput, +} from '@core/types/ai-tools/tmdbGetMovie' +import { formatToolError } from '@core/ai-tool/toolResult' +import { getMovieInTmdb } from '@/api/tmdbV3' + +const tmdbGetMovieTool = tool({ + description: TMDB_GET_MOVIE_DESCRIPTION, + parameters: tmdbGetMovieInputSchema, + execute: async ({ id, language, baseURL }): Promise => { + if (!Number.isInteger(id) || id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" } + } + + try { + const body = await getMovieInTmdb({ id, language, host: baseURL }) + if (body.error) { + return { error: body.error } + } + if (!body.data) { + return { error: 'Error Reason: empty movie result' } + } + return body.data as unknown as TmdbGetMovieOutput + } catch (error) { + return { error: formatToolError(error).error } + } + }, +}) + +export const TmdbGetMovieTool = makeAssistantTool({ + ...tmdbGetMovieTool, + toolName: TMDB_GET_MOVIE, +}) diff --git a/apps/ui/src/ai/tools/TmdbGetTvShow.tsx b/apps/ui/src/ai/tools/TmdbGetTvShow.tsx new file mode 100644 index 00000000..59d7a307 --- /dev/null +++ b/apps/ui/src/ai/tools/TmdbGetTvShow.tsx @@ -0,0 +1,37 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + TMDB_GET_TV_SHOW, + TMDB_GET_TV_SHOW_DESCRIPTION, + tmdbGetTvShowInputSchema, + type TmdbGetTvShowOutput, +} from '@core/types/ai-tools/tmdbGetTvShow' +import { formatToolError } from '@core/ai-tool/toolResult' +import { getTvShowInTmdb } from '@/api/tmdbV3' + +const tmdbGetTvShowTool = tool({ + description: TMDB_GET_TV_SHOW_DESCRIPTION, + parameters: tmdbGetTvShowInputSchema, + execute: async ({ id, language, baseURL }): Promise => { + if (!Number.isInteger(id) || id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" } + } + + try { + const body = await getTvShowInTmdb({ id, language, host: baseURL }) + if (body.error) { + return { error: body.error } + } + if (!body.data) { + return { error: 'Error Reason: empty TV show result' } + } + return body.data as unknown as TmdbGetTvShowOutput + } catch (error) { + return { error: formatToolError(error).error } + } + }, +}) + +export const TmdbGetTvShowTool = makeAssistantTool({ + ...tmdbGetTvShowTool, + toolName: TMDB_GET_TV_SHOW, +}) diff --git a/apps/ui/src/ai/tools/TmdbSearch.tsx b/apps/ui/src/ai/tools/TmdbSearch.tsx new file mode 100644 index 00000000..f2eaa153 --- /dev/null +++ b/apps/ui/src/ai/tools/TmdbSearch.tsx @@ -0,0 +1,51 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + TMDB_SEARCH, + TMDB_SEARCH_DESCRIPTION, + tmdbSearchInputSchema, + type TmdbSearchOutput, +} from '@core/types/ai-tools/tmdbSearch' +import { formatToolError, requireNonEmptyString } from '@core/ai-tool/toolResult' +import { searchInTmdb } from '@/api/tmdbV3' + +const tmdbSearchTool = tool({ + description: TMDB_SEARCH_DESCRIPTION, + parameters: tmdbSearchInputSchema, + execute: async ({ keyword, type, language, baseURL }): Promise => { + const keywordCheck = requireNonEmptyString(keyword, 'keyword') + if (typeof keywordCheck !== 'string') { + return { error: keywordCheck.error } + } + + try { + const body = await searchInTmdb({ + keyword: keywordCheck, + type, + language, + host: baseURL, + }) + if (body.error) { + return { error: body.error } + } + if (!body.data) { + return { error: 'Error Reason: empty search result' } + } + if (body.data.error) { + return { error: body.data.error } + } + return { + results: body.data.results as unknown as Record[], + page: body.data.page, + total_pages: body.data.total_pages, + total_results: body.data.total_results, + } + } catch (error) { + return { error: formatToolError(error).error } + } + }, +}) + +export const TmdbSearchTool = makeAssistantTool({ + ...tmdbSearchTool, + toolName: TMDB_SEARCH, +}) diff --git a/apps/ui/src/ai/tools/index.ts b/apps/ui/src/ai/tools/index.ts index 2898550c..93877539 100644 --- a/apps/ui/src/ai/tools/index.ts +++ b/apps/ui/src/ai/tools/index.ts @@ -8,6 +8,9 @@ export { RenameFolderTool } from './RenameFolder'; export { RenameEpisodeFileTool } from './RenameEpisodeFile'; export { ScrapeTool } from './Scrape'; export { GetJobTool } from './GetJob'; +export { TmdbSearchTool } from './TmdbSearch'; +export { TmdbGetMovieTool } from './TmdbGetMovie'; +export { TmdbGetTvShowTool } from './TmdbGetTvShow'; export { BeginRecognizeTaskTool } from './BeginRecognizeTask'; export { AddRecognizedMediaFileTool } from './AddRecognizedMediaFile'; export { EndRecognizeTaskTool, cleanupRecognizePlan } from './EndRecognizeTask'; diff --git a/apps/ui/src/api/tmdb.test.ts b/apps/ui/src/api/tmdb.test.ts index 63451421..c3cb4886 100644 --- a/apps/ui/src/api/tmdb.test.ts +++ b/apps/ui/src/api/tmdb.test.ts @@ -694,3 +694,63 @@ describe('getTmdbLanguages', () => { ) }) }) + +describe('v3 Core Internal HTTP', () => { + beforeEach(() => { + localStorage.setItem('smm.v3.enabled', 'true') + }) + + afterEach(() => { + localStorage.removeItem('smm.v3.enabled') + }) + + it('searchTmdb POSTs to /api/search-in-tmdb', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: { + results: [{ id: 1, name: 'Naruto' }], + page: 1, + total_pages: 1, + total_results: 1, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const result = await searchTmdb('naruto', 'tv', 'en-US') + + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/search-in-tmdb') + expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: 'POST' }) + expect(result.results).toEqual([{ id: 1, name: 'Naruto' }]) + expect(result.total_results).toBe(1) + }) + + it('getMovieById POSTs to /api/get-movie-in-tmdb', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ data: { id: 550, title: 'Fight Club' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const result = await getMovieById(550, 'en-US') + expect(result.title).toBe('Fight Club') + const fetchSpy = vi.mocked(globalThis.fetch) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('/api/get-movie-in-tmdb') + }) + + it('getTvShowById POSTs to /api/get-tvshow-in-tmdb', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ data: { id: 1396, name: 'Breaking Bad' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const result = await getTvShowById(1396) + expect(result.name).toBe('Breaking Bad') + expect(vi.mocked(globalThis.fetch).mock.calls[0]?.[0]).toBe('/api/get-tvshow-in-tmdb') + }) +}) diff --git a/apps/ui/src/api/tmdb.ts b/apps/ui/src/api/tmdb.ts index c7b47224..dbc9fc5c 100644 --- a/apps/ui/src/api/tmdb.ts +++ b/apps/ui/src/api/tmdb.ts @@ -12,6 +12,8 @@ import { fetchWithFailover, HttpFailoverExhaustedError } from '@/lib/http' import staticConfig from './staticConfig' import { fetchByInternalReverseProxy } from './fetchByInternalReverseProxy' import { buildTmdbErrorFromResponse } from './tmdbErrors' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbV3' export const SMM_TMDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tmdb' @@ -129,6 +131,32 @@ export async function searchTmdb( language: string, options?: TmdbRequestOptions, ): Promise { + if (isSmmV3Enabled()) { + const body = await searchInTmdb( + { keyword, type, language }, + options?.signal, + ) + if (body.error) { + return { + error: body.error, + results: [], + page: 0, + total_pages: 0, + total_results: 0, + } + } + if (!body.data) { + return { + error: 'Error Reason: empty search result', + results: [], + page: 0, + total_pages: 0, + total_results: 0, + } + } + return body.data + } + const queryParams = new URLSearchParams() queryParams.append('query', keyword) queryParams.append('language', language) @@ -150,6 +178,17 @@ export async function getTvShowById( language?: string, options?: TmdbRequestOptions, ): Promise { + if (isSmmV3Enabled()) { + const body = await getTvShowInTmdb({ id, language }, options?.signal) + if (body.error) { + throw new Error(body.error) + } + if (!body.data) { + throw new Error('Error Reason: empty TV show result') + } + return body.data + } + const queryParams = new URLSearchParams() if (language) queryParams.append('language', language) const resp = await fetchTmdb( @@ -170,6 +209,17 @@ export async function getMovieById( language?: string, options?: TmdbRequestOptions, ): Promise { + if (isSmmV3Enabled()) { + const body = await getMovieInTmdb({ id, language }, options?.signal) + if (body.error) { + throw new Error(body.error) + } + if (!body.data) { + throw new Error('Error Reason: empty movie result') + } + return body.data + } + const queryParams = new URLSearchParams() if (language) queryParams.append('language', language) const resp = await fetchTmdb( diff --git a/apps/ui/src/api/tmdbV3.test.ts b/apps/ui/src/api/tmdbV3.test.ts new file mode 100644 index 00000000..59be34fb --- /dev/null +++ b/apps/ui/src/api/tmdbV3.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getMovieInTmdb, getTvShowInTmdb, searchInTmdb } from './tmdbV3' + +vi.mock('@/lib/apiFetch', () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from '@/lib/apiFetch' + +const mockApiFetch = vi.mocked(apiFetch) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('tmdbV3 Internal HTTP clients', () => { + beforeEach(() => { + mockApiFetch.mockReset() + }) + + afterEach(() => { + mockApiFetch.mockReset() + }) + + it('searchInTmdb POSTs keyword and type', async () => { + mockApiFetch.mockResolvedValue( + jsonResponse({ + data: { results: [{ id: 1 }], page: 1, total_pages: 1, total_results: 1 }, + }), + ) + const body = await searchInTmdb({ keyword: 'naruto', type: 'tv', language: 'zh-CN' }) + expect(mockApiFetch).toHaveBeenCalledWith('/api/search-in-tmdb', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keyword: 'naruto', type: 'tv', language: 'zh-CN' }), + signal: undefined, + }) + expect(body.data?.total_results).toBe(1) + }) + + it('getMovieInTmdb POSTs id', async () => { + mockApiFetch.mockResolvedValue(jsonResponse({ data: { id: 550, title: 'Fight Club' } })) + const body = await getMovieInTmdb({ id: 550 }) + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/get-movie-in-tmdb', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ id: 550 }), + }), + ) + expect(body.data?.title).toBe('Fight Club') + }) + + it('getTvShowInTmdb POSTs id', async () => { + mockApiFetch.mockResolvedValue(jsonResponse({ data: { id: 1396, name: 'Breaking Bad' } })) + const body = await getTvShowInTmdb({ id: 1396, language: 'en-US' }) + expect(mockApiFetch.mock.calls[0]?.[0]).toBe('/api/get-tvshow-in-tmdb') + expect(body.data?.name).toBe('Breaking Bad') + }) + + it('throws on HTTP layer failure', async () => { + mockApiFetch.mockResolvedValue(jsonResponse({ error: 'nope' }, 500)) + await expect(searchInTmdb({ keyword: 'x', type: 'movie' })).rejects.toThrow(/HTTP Layer Error/) + }) +}) diff --git a/apps/ui/src/api/tmdbV3.ts b/apps/ui/src/api/tmdbV3.ts new file mode 100644 index 00000000..79229e57 --- /dev/null +++ b/apps/ui/src/api/tmdbV3.ts @@ -0,0 +1,99 @@ +import { apiFetch } from '@/lib/apiFetch' +import type { + TmdbMovieDetails, + TmdbSearchResponseBody, + TmdbSeriesDetails, +} from '@core/types' + +export interface TmdbCoreRequestOptions { + language?: string + host?: string + password?: string + proxy?: string +} + +export interface SearchInTmdbParams extends TmdbCoreRequestOptions { + keyword: string + type: 'tv' | 'movie' +} + +export interface GetTmdbByIdParams extends TmdbCoreRequestOptions { + id: number +} + +export interface SearchInTmdbResponseBody { + data?: TmdbSearchResponseBody + error?: string +} + +export interface GetMovieInTmdbResponseBody { + data?: TmdbMovieDetails + error?: string +} + +export interface GetTvShowInTmdbResponseBody { + data?: TmdbSeriesDetails + error?: string +} + +function optionalFields(options?: TmdbCoreRequestOptions): Record { + const body: Record = {} + if (options?.language) body.language = options.language + if (options?.host) body.host = options.host + if (options?.password) body.password = options.password + if (options?.proxy) body.proxy = options.proxy + return body +} + +async function postTmdb(path: string, body: unknown, signal?: AbortSignal): Promise { + const resp = await apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + return (await resp.json()) as T +} + +/** `POST /api/search-in-tmdb` → `Core.searchInTmdb`. */ +export async function searchInTmdb( + params: SearchInTmdbParams, + signal?: AbortSignal, +): Promise { + return postTmdb( + '/api/search-in-tmdb', + { + keyword: params.keyword, + type: params.type, + ...optionalFields(params), + }, + signal, + ) +} + +/** `POST /api/get-movie-in-tmdb` → `Core.getMovieInTmdb`. */ +export async function getMovieInTmdb( + params: GetTmdbByIdParams, + signal?: AbortSignal, +): Promise { + return postTmdb( + '/api/get-movie-in-tmdb', + { id: params.id, ...optionalFields(params) }, + signal, + ) +} + +/** `POST /api/get-tvshow-in-tmdb` → `Core.getTvShowInTmdb`. */ +export async function getTvShowInTmdb( + params: GetTmdbByIdParams, + signal?: AbortSignal, +): Promise { + return postTmdb( + '/api/get-tvshow-in-tmdb', + { id: params.id, ...optionalFields(params) }, + signal, + ) +} diff --git a/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx b/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx index fd8cf6d4..9a4e4fd0 100644 --- a/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx +++ b/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx @@ -75,16 +75,16 @@ vi.mock('./ImmersiveSearchbox', () => ({ }), })) -const { mockFetchTmdb, mockGetTVDBv4Client, mockTvdbSearch } = vi.hoisted(() => { - const mockFetchTmdb = vi.fn() +const { mockSearchTmdb, mockGetTVDBv4Client, mockTvdbSearch } = vi.hoisted(() => { + const mockSearchTmdb = vi.fn() const mockTvdbSearch = vi.fn() const mockGetTVDBv4Client = vi.fn(() => ({ search: mockTvdbSearch })) - return { mockFetchTmdb, mockGetTVDBv4Client, mockTvdbSearch } + return { mockSearchTmdb, mockGetTVDBv4Client, mockTvdbSearch } }) vi.mock('@/api/tmdb', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, fetchTmdbOrUndefined: mockFetchTmdb } + return { ...actual, searchTmdb: mockSearchTmdb } }) vi.mock('@/lib/TvdbUtils', () => ({ @@ -486,10 +486,8 @@ describe('MediaDatabaseSearchbox', () => { ]) }) - it('handleSearch calls fetchTmdbOrUndefined with /search/tv? when database is TMDB', async () => { - mockFetchTmdb.mockResolvedValue( - new Response(JSON.stringify({ results: [] }), { status: 200 }), - ) + it('handleSearch calls searchTmdb with keyword, type, and language when database is TMDB', async () => { + mockSearchTmdb.mockResolvedValue({ results: [] }) render(, { wrapper: createWrapper(), }) @@ -498,10 +496,8 @@ describe('MediaDatabaseSearchbox', () => { await mockImmersiveSearchboxProps.current.onSearch?.() }) - await waitFor(() => expect(mockFetchTmdb).toHaveBeenCalledTimes(1)) - const [urlPath] = mockFetchTmdb.mock.calls[0] as [string] - expect(urlPath.startsWith('/search/tv?')).toBe(true) - expect(urlPath).toContain('query=naruto') + await waitFor(() => expect(mockSearchTmdb).toHaveBeenCalledTimes(1)) + expect(mockSearchTmdb).toHaveBeenCalledWith('naruto', 'tv', 'en-US') expect(mockGetTVDBv4Client).not.toHaveBeenCalled() }) @@ -534,13 +530,14 @@ describe('MediaDatabaseSearchbox', () => { expect(mockGetTVDBv4Client).toHaveBeenCalledTimes(1) const [searchParams] = mockTvdbSearch.mock.calls[0] as [{ query: string; type: string; language?: string }] expect(searchParams).toMatchObject({ query: 'inception', type: 'movie' }) - expect(mockFetchTmdb).not.toHaveBeenCalled() + expect(mockSearchTmdb).not.toHaveBeenCalled() }) it('shows database-specific unauthorized message when TMDB search returns 401', async () => { - mockFetchTmdb.mockResolvedValue( - new Response(JSON.stringify({ status_code: 7 }), { status: 401 }), - ) + mockSearchTmdb.mockResolvedValue({ + results: [], + error: 'Error Reason: Invalid API key: 401 Unauthorized', + }) render(, { wrapper: createWrapper(), }) diff --git a/apps/ui/src/components/MediaDatabaseSearchbox.tsx b/apps/ui/src/components/MediaDatabaseSearchbox.tsx index be466481..2dcdf3c5 100644 --- a/apps/ui/src/components/MediaDatabaseSearchbox.tsx +++ b/apps/ui/src/components/MediaDatabaseSearchbox.tsx @@ -1,13 +1,13 @@ import { useState, useCallback, useEffect, useMemo } from "react" import { ImmersiveSearchbox, type ImmersiveSearchResultItem } from "./ImmersiveSearchbox" import { - fetchTmdbOrUndefined, + searchTmdb, getTMDBImageUrl, SMM_TMDB_DEFAULT_UPSTREAM, - buildTmdbErrorFromResponse, classifyTmdbError, formatTmdbErrorForDisplay, } from "@/api/tmdb" +import { TmdbFetchError } from "@/api/tmdbErrors" import { useConfig } from "@/hooks/userConfig" import { useResolvedLanguages } from "@/hooks/useResolvedLanguages" import { useTranslation } from "@/lib/i18n" @@ -16,7 +16,6 @@ import type { TMDBMovie, PrimaryDatabase, PreferMediaLanguage, - TmdbSearchResponseBody, } from "@core/types" import { TVDBv4Error } from "@smm/tvdb4" import { getTVDBv4Client } from "@/lib/TvdbUtils" @@ -255,21 +254,18 @@ export function MediaDatabaseSearchbox({ return } - const params = new URLSearchParams() - params.set("query", searchQuery.trim()) - params.set("language", searchLanguage) - const resp = await fetchTmdbOrUndefined(`/search/${mediaType}?${params.toString()}`) - if (!resp || !resp.ok) { - const tmdbUrl = userConfig?.tmdb?.host?.trim() || SMM_TMDB_DEFAULT_UPSTREAM - const tmdbError = await buildTmdbErrorFromResponse(resp) - const display = classifyTmdbError(tmdbError, tmdbUrl) - setSearchError(formatTmdbErrorForDisplay(display, t)) - return - } - - const response = (await resp.json()) as TmdbSearchResponseBody + const response = await searchTmdb( + searchQuery.trim(), + mediaType, + searchLanguage, + ) if (response.error) { - setSearchError(t("errors:searchFailed")) + const errText = response.error + if (/401|API key|unauthorized/i.test(errText)) { + setSearchError(t("errors:searchFailedUnauthorizedTmdb")) + } else { + setSearchError(errText.replace(/^Error Reason:\s*/i, "")) + } return } @@ -285,6 +281,10 @@ export function MediaDatabaseSearchbox({ console.error("Search failed:", error) if (error instanceof TVDBv4Error && error.status === 401) { setSearchError(t("errors:searchFailedUnauthorizedTvdb")) + } else if (error instanceof TmdbFetchError) { + const tmdbUrl = userConfig?.tmdb?.host?.trim() || SMM_TMDB_DEFAULT_UPSTREAM + const display = classifyTmdbError(error, tmdbUrl) + setSearchError(formatTmdbErrorForDisplay(display, t)) } else { setSearchError( error instanceof Error ? error.message : t("errors:searchFailed"), @@ -295,7 +295,7 @@ export function MediaDatabaseSearchbox({ } finally { setIsSearching(false) } - }, [searchQuery, searchLanguage, searchDatabase, mediaType, t]) + }, [searchQuery, searchLanguage, searchDatabase, mediaType, t, userConfig]) const handleSelect = useCallback( (item: ImmersiveSearchResultItem) => { diff --git a/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx b/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx index 4aab3aef..52fda7aa 100644 --- a/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx +++ b/apps/ui/src/components/ui/settings/GeneralSettings.test.tsx @@ -44,6 +44,17 @@ vi.mock("@/lib/i18n", () => ({ }), })); +vi.mock("@/hooks/useMcpServerStatus", () => ({ + useStartMcpServerMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), + useStopMcpServerMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + describe("GeneralSettings", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/ui/src/core/BrowserNetworkPort.ts b/apps/ui/src/core/BrowserNetworkPort.ts index 9c98f68c..478c8e2b 100644 --- a/apps/ui/src/core/BrowserNetworkPort.ts +++ b/apps/ui/src/core/BrowserNetworkPort.ts @@ -57,8 +57,11 @@ function dataToHttpResponse(data: CoreFetchResponseData): HttpResponse { } return jsonCache as T }, - async arrayBuffer() { - return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + async arrayBuffer(): Promise { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer }, } } diff --git a/docs/api/index.md b/docs/api/index.md index 58688f77..e5741cc3 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -58,6 +58,15 @@ HTTP: `POST /api/import-folder` — starts Layer 2 `Core.importFolder(path, type Source Code: apps/cli/src/route/GetJob.ts HTTP: `POST /api/get-job` — returns an in-memory import job from `Core.getJob(id)`. Request body: `{ id: string }`. Response: `{ data: ImportJob }` or `{ error }` (`Job not found`). CLI `smm add` polls this internally until the job settles. +## SearchInTmdb / GetMovieInTmdb / GetTvShowInTmdb +Source Code: apps/cli/src/route/Tmdb.ts + +1:1 Internal HTTP for Core TMDB methods. Response `{ data }` or `{ error }` (HTTP 200). Optional `language` / `host` / `password` / `proxy` override `userConfig.tmdb`. Used by Web UI when `localStorage["smm.v3.enabled"] === "true"` and by in-app AI tools. MCP / server-side chat inject the same Core methods in-process. + +- `POST /api/search-in-tmdb` → `Core.searchInTmdb`. Body: `{ keyword: string, type: "tv" | "movie", language?, host?, password?, proxy? }`. +- `POST /api/get-movie-in-tmdb` → `Core.getMovieInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +- `POST /api/get-tvshow-in-tmdb` → `Core.getTvShowInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. + ## ShowFolder Source Code: apps/cli/src/route/ShowFolder.ts HTTP: `POST /api/show-folder` — UI-aligned folder snapshot via the same helper as `smm show`. Request body: `{ path: string }`. Response: `{ data: { path, status, type?, title? } }` (`status`: `ok` | `folder_not_found` | `error_loading_metadata`) or `{ error }` (not imported / path missing). diff --git a/docs/dev/tmdb.md b/docs/dev/tmdb.md index 08e80500..ec3a5c84 100644 --- a/docs/dev/tmdb.md +++ b/docs/dev/tmdb.md @@ -4,10 +4,18 @@ SMM 通过 `apps/core` 统一访问 TMDB:**全部出站流量**经 `Core` + `N | 入口 | 状态 | 说明 | |------|------|------| -| CLI | ✅ | `smm tmdb search` → 进程内 `Core` | -| AI Tool | ✅ | Chat 工具 → `core-routes` → `Core` | -| MCP Tool | ✅ | MCP 工具 → `core-routes` → `Core` | -| Web UI | ⏳ | 目标:`BrowserNetworkPort` → `POST /api/core/fetch` → `Core`(迁移中) | +| CLI | ✅ | `smm tmdb search` → 进程内 `Core.searchInTmdb` | +| AI Tool | ✅ | 应用内 Chat → 对应 HTTP API → Core;服务端 Chat 进程内注入 Core | +| MCP Tool | ✅ | MCP 工具进程内调用 Core 同名方法(与 HTTP 路由同一 Core) | +| Web UI | ⏳ | `smm.v3.enabled`:对应 HTTP API → Core 同名方法 | + +Core 方法与 Internal HTTP **一对一**暴露。Web UI(v3)与应用内 AI 走这些 API 再进入 Core;MCP / 服务端 Chat 在进程内调用同一套 Core 方法。均不走 `POST /api/core/fetch`(那是 `BrowserNetworkPort` 的通用出站中继,见 [network-core.md](./network-core.md))。 + +| Core 方法 | HTTP | +|-----------|------| +| `searchInTmdb` | `POST /api/search-in-tmdb` | +| `getMovieInTmdb` | `POST /api/get-movie-in-tmdb` | +| `getTvShowInTmdb` | `POST /api/get-tvshow-in-tmdb` | ```mermaid flowchart TB @@ -16,16 +24,16 @@ flowchart TB AI["AI Tool ✅"] MCP["MCP Tool ✅"] - CR["packages/core-routes
/api/core/fetch · chat · MCP"] + API["POST /api/search-in-tmdb
POST /api/get-movie-in-tmdb
POST /api/get-tvshow-in-tmdb"] CORE["apps/core
searchInTmdb · getMovieInTmdb · getTvShowInTmdb"] - NET["NetworkPort
NodejsNetworkPort / BrowserNetworkPort"] + NET["NetworkPort
NodejsNetworkPort"] TMDB["TMDB 上游"] CLI -->|进程内| CORE - WEB --> CR - AI --> CR - MCP --> CR - CR --> CORE + WEB --> API + AI --> API + MCP --> API + API -->|一对一| CORE CORE --> NET --> TMDB ``` @@ -35,13 +43,13 @@ flowchart TB ## apps/core API -三个公开方法,共用 `createTmdbClient` 与 `NetworkPort` 出站。 +三个公开方法,共用 `createTmdbClient` 与 `NetworkPort` 出站。每个方法一对一暴露为 HTTP: -| 方法 | 用途 | -|------|------| -| `searchInTmdb(keyword, options)` | 按关键词搜索 TV / movie | -| `getMovieInTmdb(id, options)` | 按 TMDB id 拉电影详情 | -| `getTvShowInTmdb(id, options)` | 按 TMDB id 拉剧集详情 | +| 方法 | HTTP | 用途 | +|------|------|------| +| `searchInTmdb(keyword, options)` | `POST /api/search-in-tmdb` | 按关键词搜索 TV / movie | +| `getMovieInTmdb(id, options)` | `POST /api/get-movie-in-tmdb` | 按 TMDB id 拉电影详情 | +| `getTvShowInTmdb(id, options)` | `POST /api/get-tvshow-in-tmdb` | 按 TMDB id 拉剧集详情 | ```ts core.searchInTmdb(keyword, { @@ -118,13 +126,13 @@ smm tmdb search "keyword" --type tv \ ## AI Tool -Chat 后端工具,**仅服务端**(`packages/core/ai-tool/registry.ts` 中 `frontend: false`)。 +Chat 后端工具,**仅服务端也可执行**(registry 中 `backend: true`)。应用内 AI(`frontend: true`)经对应 HTTP API 进入 Core,不走 `/api/core/fetch`。MCP / 服务端 chat 在进程内注入 `Core.searchInTmdb` 等同名方法(与 HTTP 路由同一 Core)。 -| 工具名 | Core 方法 | 说明 | -|--------|-----------|------| -| `tmdb-search` | `searchInTmdb` | 关键词搜索 | -| `tmdb-get-movie` | `getMovieInTmdb` | 电影详情 | -| `tmdb-get-tv-show` | `getTvShowInTmdb` | 剧集详情 | +| 工具名 | HTTP | Core 方法 | 说明 | +|--------|------|-----------|------| +| `tmdb-search` | `POST /api/search-in-tmdb` | `searchInTmdb` | 关键词搜索 | +| `tmdb-get-movie` | `POST /api/get-movie-in-tmdb` | `getMovieInTmdb` | 电影详情 | +| `tmdb-get-tv-show` | `POST /api/get-tvshow-in-tmdb` | `getTvShowInTmdb` | 剧集详情 | ### 参数(与 CLI 对照) @@ -147,13 +155,13 @@ System prompt 指引见 `packages/core/ai-tool/systemPrompt.ts`(先 `tmdb-sear ## MCP Tool -与 AI Tool **同名、同 schema、同 Core 路径**,经 MCP HTTP 暴露。 +与 AI Tool **同名、同 schema**,经 MCP HTTP 暴露。工具 handler 在进程内调用 `Core.searchInTmdb` 等同名方法(与 `POST /api/search-in-tmdb` 等路由同一 Core),不走 `/api/core/fetch`。 -| MCP 工具名 | Core 方法 | -|------------|-----------| -| `tmdb-search` | `searchInTmdb` | -| `tmdb-get-movie` | `getMovieInTmdb` | -| `tmdb-get-tv-show` | `getTvShowInTmdb` | +| MCP 工具名 | HTTP | Core 方法 | +|------------|------|-----------| +| `tmdb-search` | `POST /api/search-in-tmdb` | `searchInTmdb` | +| `tmdb-get-movie` | `POST /api/get-movie-in-tmdb` | `getMovieInTmdb` | +| `tmdb-get-tv-show` | `POST /api/get-tvshow-in-tmdb` | `getTvShowInTmdb` | 注册:`packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts` CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTmdb` / `getMovieInTmdb` / `getTvShowInTmdb`)。 @@ -164,27 +172,28 @@ CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTmdb` / `getMovieInTmdb` / `ge ## Web UI(⏳ v3 迁移) +Web UI 的改动需要使用 localStorage 开关 `smm.v3.enabled` 控制. + 搜索入口:`MediaDatabaseSearchbox`(`TvShowPanelHeader` / `MovieHeaderV2`)。 -**目标路径**(与 Core v3 一致): +**目标路径**(与 Core v3 一致):UI 调用一对一 Internal HTTP,服务端再调对应 Core 方法。不经 `BrowserNetworkPort` / `POST /api/core/fetch`。 ```mermaid flowchart LR - A[MediaDatabaseSearchbox] --> B[Core.searchInTmdb] - B --> C[BrowserNetworkPort] - C --> D[POST /api/core/fetch] - D --> E[NodejsNetworkPort] - E --> F[TMDB] + A[MediaDatabaseSearchbox] --> B[POST /api/search-in-tmdb] + B --> C[Core.searchInTmdb] + C --> D[NodejsNetworkPort] + D --> E[TMDB] ``` -| 层 | 文件 | +| 层 | 文件 / 接口 | |----|------| | UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` | -| 浏览器 Port | `apps/ui/src/core/BrowserNetworkPort.ts` | -| RPC | `apps/cli/src/route/CoreFetch.ts` | +| HTTP | `POST /api/search-in-tmdb` · `POST /api/get-movie-in-tmdb` · `POST /api/get-tvshow-in-tmdb` | +| Core | `searchInTmdb` · `getMovieInTmdb` · `getTvShowInTmdb` | | 出站 | `apps/cli/src/core/NodejsNetworkPort.ts` | -Web 不调用 `smm tmdb search`,但与 CLI 共用 `Core` 与 `userConfig.tmdb` 语义。 +Web 不调用 `smm tmdb search`,但与 CLI 共用 Core 方法与 `userConfig.tmdb` 语义。 --- diff --git a/packages/core-routes/src/core-routes.test.ts b/packages/core-routes/src/core-routes.test.ts index 3566ce13..77b41b89 100644 --- a/packages/core-routes/src/core-routes.test.ts +++ b/packages/core-routes/src/core-routes.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, writeFile, rm, stat, readFile } from "node:fs/promises" import os from "node:os"; import path from "node:path"; import { Buffer } from "node:buffer"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { validatePathIsInAllowlist } from "../src/allowlist.ts"; import type { CoreRoutesConfig } from "../src/types.ts"; @@ -86,6 +86,12 @@ describe("doWriteFile", () => { }); describe("handleCoreRoutesRequest", () => { + // register.ts pulls AI SDK / chat deps; under parallel `pnpm test` the first + // dynamic import can exceed vitest's default 5s and flake as a timeout. + beforeAll(async () => { + await import("../src/register.ts"); + }, 30_000); + async function requestCoreRoute( method: string, url: string, @@ -104,8 +110,9 @@ describe("handleCoreRoutesRequest", () => { if (rawBody !== undefined) { req.push(Buffer.from(rawBody)); - req.push(null); } + // Always end the stream so handlers that read the body cannot hang. + req.push(null); let status = 0; let headers: Record = {}; diff --git a/packages/core/ai-tool/registry.ts b/packages/core/ai-tool/registry.ts index 8b2eadbb..54b0e6c3 100644 --- a/packages/core/ai-tool/registry.ts +++ b/packages/core/ai-tool/registry.ts @@ -108,10 +108,10 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ { name: SCRAPE, backend: true, frontend: true }, { name: GET_JOB, backend: true, frontend: true }, - // TMDB query (Core-backed; backend chat / MCP on hosts with Core) - { name: TMDB_SEARCH, backend: true, frontend: false }, - { name: TMDB_GET_MOVIE, backend: true, frontend: false }, - { name: TMDB_GET_TV_SHOW, backend: true, frontend: false }, + // TMDB query (Internal HTTP for Web UI / in-app AI; MCP and server chat inject Core runners) + { name: TMDB_SEARCH, backend: true, frontend: true }, + { name: TMDB_GET_MOVIE, backend: true, frontend: true }, + { name: TMDB_GET_TV_SHOW, backend: true, frontend: true }, // Rename files task { name: BEGIN_RENAME_FILES_TASK, backend: true, frontend: true }, From 4f615d78f0a69cab74905627bcf12a09b18fabd2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 16:44:27 +0800 Subject: [PATCH 107/214] feat(core): add TVDB supported languages snapshot and offline validation --- packages/core/locale.ts | 26 +++++++++++ packages/core/tvdbSupportedLanguages.test.ts | 44 +++++++++++++++++++ packages/core/tvdbSupportedLanguages.ts | 45 ++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 packages/core/tvdbSupportedLanguages.test.ts create mode 100644 packages/core/tvdbSupportedLanguages.ts diff --git a/packages/core/locale.ts b/packages/core/locale.ts index 1a5f7338..0c49cb6e 100644 --- a/packages/core/locale.ts +++ b/packages/core/locale.ts @@ -220,3 +220,29 @@ export function detectOsLocale(): string { return '' } + +/** IETF BCP 47 media language → TVDB ISO 639-3 code (kept in @smm/core for offline resolution). */ +export function mediaLanguageToTvdbCode(lang: PreferMediaLanguage): string { + switch (lang) { + case 'zh-CN': + return 'zho' + case 'ja-JP': + return 'jpn' + default: + return 'eng' + } +} + +/** + * Resolve the TVDB search/metadata language (ISO 639-3) with priority: + * 1. preferMediaLanguage (explicit smm.json config) → mapped to ISO 639-3 + * 2. Resolved media language chain (applicationLanguage → OS → en) + * 3. eng + */ +export function resolveTvdbSearchLanguage(opts: ResolveMediaLanguageOptions): string { + if (opts.preferMediaLanguage) { + return mediaLanguageToTvdbCode(opts.preferMediaLanguage) + } + const mediaLang = resolveMediaLanguage(opts) + return mediaLanguageToTvdbCode(mediaLang) +} diff --git a/packages/core/tvdbSupportedLanguages.test.ts b/packages/core/tvdbSupportedLanguages.test.ts new file mode 100644 index 00000000..d063535e --- /dev/null +++ b/packages/core/tvdbSupportedLanguages.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + isTvdbLanguageCode, + parseTvdbSearchLanguage, + TVDB_SUPPORTED_LANGUAGES, +} from "./tvdbSupportedLanguages"; +import { resolveTvdbSearchLanguage } from "./locale"; + +describe("tvdbSupportedLanguages", () => { + it("contains 185 codes including eng/zho/yue", () => { + expect(TVDB_SUPPORTED_LANGUAGES).toHaveLength(185); + for (const code of ["eng", "zho", "yue", "jpn", "fra"]) { + expect(TVDB_SUPPORTED_LANGUAGES).toContain(code); + } + }); + + it("isTvdbLanguageCode matches known codes and rejects unknown", () => { + expect(isTvdbLanguageCode("zho")).toBe(true); + expect(isTvdbLanguageCode("zh-CN")).toBe(false); + expect(isTvdbLanguageCode("xx")).toBe(false); + }); + + it("parseTvdbSearchLanguage returns the canonical code", () => { + expect(parseTvdbSearchLanguage("zho")).toBe("zho"); + expect(parseTvdbSearchLanguage(" ENG ")).toBe("eng"); + }); + + it("parseTvdbSearchLanguage throws for unknown codes", () => { + expect(() => parseTvdbSearchLanguage("zh-CN")).toThrow(/ISO 639-3/); + expect(() => parseTvdbSearchLanguage("zzz")).toThrow(/ISO 639-3/); + }); +}); + +describe("resolveTvdbSearchLanguage", () => { + it("maps preferMediaLanguage IETF to ISO 639-3", () => { + expect(resolveTvdbSearchLanguage({ preferMediaLanguage: "zh-CN" })).toBe("zho"); + expect(resolveTvdbSearchLanguage({ preferMediaLanguage: "en-US" })).toBe("eng"); + expect(resolveTvdbSearchLanguage({ preferMediaLanguage: "ja-JP" })).toBe("jpn"); + }); + + it("falls back to eng when nothing is configured", () => { + expect(resolveTvdbSearchLanguage({})).toBe("eng"); + }); +}); diff --git a/packages/core/tvdbSupportedLanguages.ts b/packages/core/tvdbSupportedLanguages.ts new file mode 100644 index 00000000..6e19c73d --- /dev/null +++ b/packages/core/tvdbSupportedLanguages.ts @@ -0,0 +1,45 @@ +/** + * Snapshot of TVDB supported languages (ISO 639-3 codes). + * Source: GET /v4/languages — see docs/superpowers/reference/tvdb_supported_languages.md + * Update this list when TVDB adds/removes languages. + */ +export const TVDB_SUPPORTED_LANGUAGES = [ + "aar", "abk", "afr", "aka", "amh", "ara", "arg", "asm", "ava", "ave", + "aym", "aze", "bak", "bam", "bel", "ben", "bih", "bis", "bod", "bos", + "bre", "bul", "cat", "ces", "cha", "che", "chu", "chv", "cor", "cos", + "cre", "cym", "dan", "deu", "div", "dzo", "ell", "eng", "epo", "est", + "eus", "ewe", "fao", "fas", "fij", "fin", "fra", "fry", "ful", "gla", + "gle", "glg", "glv", "grn", "guj", "hat", "hau", "heb", "her", "hin", "hmo", + "hrv", "hun", "hye", "ibo", "ido", "iii", "iku", "ile", "ina", "ind", + "ipk", "isl", "ita", "jav", "jpn", "kal", "kan", "kas", "kat", "kau", + "kaz", "khm", "kik", "kin", "kir", "kom", "kon", "kor", "kua", "kur", + "lao", "lat", "lav", "lim", "lin", "lit", "ltz", "lub", "lug", "mah", + "mal", "mar", "mkd", "mlg", "mlt", "mon", "mri", "msa", "mya", "nau", + "nav", "nbl", "nde", "ndo", "nep", "nld", "nor", "nya", "oci", "oji", + "ori", "orm", "oss", "pan", "pli", "pol", "por", "pt", "pus", "que", + "roh", "ron", "run", "rus", "sag", "san", "sin", "slk", "slv", "sme", + "smo", "sna", "snd", "som", "sot", "spa", "sqi", "srd", "srp", "ssw", + "sun", "swa", "swe", "tah", "tam", "tat", "tel", "tgk", "tgl", "tha", + "tir", "ton", "tsn", "tso", "tuk", "tur", "twi", "uig", "ukr", "urd", + "uzb", "ven", "vie", "vol", "wln", "wol", "xho", "yid", "yor", "yue", + "zha", "zho", "zhtw", "zul", +] as const; + +export function isTvdbLanguageCode(value: string): boolean { + const code = value.trim().toLowerCase(); + return (TVDB_SUPPORTED_LANGUAGES as readonly string[]).includes(code); +} + +export function parseTvdbSearchLanguage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error( + "Missing language value. Use a TVDB ISO 639-3 code such as eng, zho, or yue (example: --lang zho).", + ); + } + const lower = trimmed.toLowerCase(); + if (isTvdbLanguageCode(lower)) return lower; + throw new Error( + `Unsupported language "${trimmed}". Use a TVDB ISO 639-3 code such as eng, zho, or yue (example: --lang zho).`, + ); +} From 26fd0accf7fb9cf647c2f509417dbd557df1cb90 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 16:50:38 +0800 Subject: [PATCH 108/214] refactor(core): TvdbClient language contract to ISO 639-3 + getLanguages --- apps/core/src/clients/TvdbClient.test.ts | 30 +++++++++++++------ apps/core/src/clients/TvdbClient.ts | 18 ++++++----- .../src/pipeline/recognizeMediaFolder.test.ts | 2 +- .../core/src/pipeline/recognizeMediaFolder.ts | 25 +++++++++------- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/apps/core/src/clients/TvdbClient.test.ts b/apps/core/src/clients/TvdbClient.test.ts index 899e1823..dbf418c3 100644 --- a/apps/core/src/clients/TvdbClient.test.ts +++ b/apps/core/src/clients/TvdbClient.test.ts @@ -67,22 +67,22 @@ function tvdbNetwork(searchUrls: string[] = []): NetworkPort { describe("TvdbClient", () => { it("searches series", async () => { const client = new TvdbClient(tvdbNetwork(), {}); - const items = await client.searchSeries("My Show", "en-US"); + const items = await client.searchSeries("My Show", "eng"); expect(items?.[0]?.tvdb_id).toBe("1"); }); it("searches movies", async () => { const client = new TvdbClient(tvdbNetwork(), {}); - const items = await client.searchMovie("My Film", "en-US"); + const items = await client.searchMovie("My Film", "eng"); expect(items?.[0]?.tvdb_id).toBe("2"); }); - it("maps the preferred language to TVDB ISO 639-3 in search", async () => { + it("passes ISO 639-3 language straight through to search", async () => { const searchUrls: string[] = []; const client = new TvdbClient(tvdbNetwork(searchUrls), {}); - await client.searchSeries("My Show", "en-US"); - await client.searchMovie("My Film", "ja-JP"); - await client.searchSeries("My Show", "zh-CN"); + await client.searchSeries("My Show", "eng"); + await client.searchMovie("My Film", "jpn"); + await client.searchSeries("My Show", "zho"); expect(searchUrls[0]).toContain("language=eng"); expect(searchUrls[1]).toContain("language=jpn"); expect(searchUrls[2]).toContain("language=zho"); @@ -90,7 +90,7 @@ describe("TvdbClient", () => { it("getTvShowMediaMetadata builds seasons + episodes", async () => { const client = new TvdbClient(tvdbNetwork(), {}); - const tvShow = await client.getTvShowMediaMetadata(1, "en-US"); + const tvShow = await client.getTvShowMediaMetadata(1, "eng"); expect(tvShow).toEqual({ id: "1", name: "My Show", @@ -104,11 +104,23 @@ describe("TvdbClient", () => { it("getMovieMediaMetadata maps a movie", async () => { const client = new TvdbClient(tvdbNetwork(), {}); - const movie = await client.getMovieMediaMetadata(2, "en-US"); + const movie = await client.getMovieMediaMetadata(2, "eng"); expect(movie).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TVDB" }); }); - it("maps IETF media language to TVDB ISO 639-3", () => { + it("getLanguages returns language records", async () => { + const network: NetworkPort = { + fetch: async (url) => { + expect(url).toContain("/languages"); + return jsonResponse(envelope([{ id: "zho", name: "Chinese" }])); + }, + }; + const client = new TvdbClient(network, {}); + const langs = await client.getLanguages(); + expect(langs?.[0]?.id).toBe("zho"); + }); + + it("keeps mapToTvdbLangCode helper", () => { expect(mapToTvdbLangCode("zh-CN")).toBe("zho"); expect(mapToTvdbLangCode("en-US")).toBe("eng"); expect(mapToTvdbLangCode("ja-JP")).toBe("jpn"); diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts index 63d5f0cd..a4e4ef18 100644 --- a/apps/core/src/clients/TvdbClient.ts +++ b/apps/core/src/clients/TvdbClient.ts @@ -1,11 +1,12 @@ import { TVDBv4, type TVDBv4SearchResult } from "@smm/tvdb4"; import type { TVDBv4ArtworkTypeRecord, + TVDBv4LanguageRecord, TVDBv4Season, TVDBv4SeriesExtendedResponse, TVDBv4SeriesSeasonsExtendedResponse, } from "@smm/tvdb4/types"; -import type { MovieMediaMetadata, PreferMediaLanguage, TvShowMediaMetadata } from "@smm/core"; +import type { MovieMediaMetadata, TvShowMediaMetadata } from "@smm/core"; import type { DiscoverPort } from "../ports/DiscoverPort"; import type { NetworkPort } from "../ports/NetworkPort"; import { @@ -75,7 +76,7 @@ export class TvdbClient { const resp = await this.client.search({ query, type: "series", - language: mapToTvdbLangCode(language as PreferMediaLanguage), + language, }); return resp.status === "success" ? resp.data : undefined; } @@ -84,16 +85,15 @@ export class TvdbClient { const resp = await this.client.search({ query, type: "movie", - language: mapToTvdbLangCode(language as PreferMediaLanguage), + language, }); return resp.status === "success" ? resp.data : undefined; } async getTvShowMediaMetadata(seriesId: number, language: string): Promise { - const langCode = mapToTvdbLangCode(language as PreferMediaLanguage); const m: TvShowMediaMetadata = { id: seriesId.toString(), name: "", database: "TVDB", seasons: [] }; - const translation = await this.client.seriesTranslationByLangCode(seriesId, langCode); + const translation = await this.client.seriesTranslationByLangCode(seriesId, language); if (translation.status === "success") m.name = translation.data?.name ?? ""; const seriesResp = await this.client.seriesExtendedById(seriesId); @@ -118,10 +118,9 @@ export class TvdbClient { } async getMovieMediaMetadata(movieId: number, language: string): Promise { - const langCode = mapToTvdbLangCode(language as PreferMediaLanguage); const m: MovieMediaMetadata = { id: movieId.toString(), name: "", database: "TVDB" }; - const translation = await this.client.movieTranslationByLangCode(movieId, langCode); + const translation = await this.client.movieTranslationByLangCode(movieId, language); if (translation.status === "success") m.name = translation.data?.name ?? ""; const movieResp = await this.client.movieExtendedById(movieId); @@ -135,6 +134,11 @@ export class TvdbClient { return m; } + async getLanguages(): Promise { + const resp = await this.client.languages(); + return resp.status === "success" ? resp.data : undefined; + } + async getSeriesExtended(seriesId: number): Promise { const resp = await this.client.seriesExtendedById(seriesId); return resp.status === "success" ? (resp.data as TVDBv4SeriesExtendedResponse) : undefined; diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index eb3eff28..07138395 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -109,7 +109,7 @@ describe("recognizeMediaFolder", () => { const result = await recognizeMediaFolder(mm, d); expect(result.tvShow?.id).toBe("1"); - expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(5, "en-US"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(5, "eng"); expect(d.tmdb.search).not.toHaveBeenCalled(); }); diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index 3085b59e..11b407ab 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -1,6 +1,7 @@ import type { MediaMetadata, MovieMediaMetadata, + PreferMediaLanguage, PrimaryDatabase, TMDBMovie, TMDBTVShow, @@ -9,6 +10,7 @@ import type { } from "@smm/core"; import type { TVDBv4SearchResult } from "@smm/tvdb4"; import { movieMediaMetadataFromTmdbSearch } from "../clients/TmdbClient"; +import { mapToTvdbLangCode } from "../clients/TvdbClient"; import type { FsPort } from "../ports/FsPort"; import { parseNfo } from "./nfo"; import { basename } from "./paths"; @@ -91,6 +93,7 @@ async function recognizeByNfo( deps: RecognitionDeps, result: RecognitionResult, isTvShow: boolean, + tvdbLang: string, ): Promise { const nfoName = isTvShow ? "tvshow.nfo" : "movie.nfo"; const nfoPath = (mm.files ?? []).find((f) => f.endsWith(`/${nfoName}`)); @@ -127,10 +130,10 @@ async function recognizeByNfo( if (n > 0) { try { if (isTvShow) { - const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, tvdbLang); if (tvShow !== undefined) result.tvShow = tvShow; } else { - const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + const movie = await deps.tvdb.getMovieMediaMetadata(n, tvdbLang); if (movie !== undefined) result.movie = movie; } } catch { @@ -174,15 +177,16 @@ async function searchInTvdb( isTvShow: boolean, deps: RecognitionDeps, result: RecognitionResult, + tvdbLang: string, ): Promise { try { if (isTvShow) { - const items = await deps.tvdb.searchSeries(folderName, deps.language); + const items = await deps.tvdb.searchSeries(folderName, tvdbLang); for (const item of items ?? []) { try { const id = resolveTvdbSeriesId(item); if (id === undefined) continue; - const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, deps.language); + const tvShow = await deps.tvdb.getTvShowMediaMetadata(id, tvdbLang); if (tvShow !== undefined) { result.tvShow = tvShow; return; @@ -192,13 +196,13 @@ async function searchInTvdb( } } } else { - const items = await deps.tvdb.searchMovie(folderName, deps.language); + const items = await deps.tvdb.searchMovie(folderName, tvdbLang); for (const item of items ?? []) { try { if (item.name === folderName) { const id = resolveTvdbMovieId(item); if (id === undefined) continue; - const movie = await deps.tvdb.getMovieMediaMetadata(id, deps.language); + const movie = await deps.tvdb.getMovieMediaMetadata(id, tvdbLang); if (movie !== undefined) { result.movie = movie; return; @@ -223,8 +227,9 @@ export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionD const result: RecognitionResult = {}; const folderName = folderNameOf(mm); const isTvShow = mm.type === "tvshow-folder"; + const tvdbLang = mapToTvdbLangCode(deps.language as PreferMediaLanguage); - await recognizeByNfo(mm, deps, result, isTvShow); + await recognizeByNfo(mm, deps, result, isTvShow, tvdbLang); const tmdbId = getTmdbIdFromFolderName(folderName); if (tmdbId !== null && result.tvShow === undefined && result.movie === undefined) { @@ -250,10 +255,10 @@ export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionD if (n > 0) { try { if (isTvShow) { - const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, deps.language); + const tvShow = await deps.tvdb.getTvShowMediaMetadata(n, tvdbLang); if (tvShow !== undefined) result.tvShow = tvShow; } else { - const movie = await deps.tvdb.getMovieMediaMetadata(n, deps.language); + const movie = await deps.tvdb.getMovieMediaMetadata(n, tvdbLang); if (movie !== undefined) result.movie = movie; } } catch { @@ -266,7 +271,7 @@ export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionD for (const db of order) { if (result.tvShow !== undefined || result.movie !== undefined) break; if (db === "TMDB") await searchInTmdb(folderName, isTvShow, deps, result); - else await searchInTvdb(folderName, isTvShow, deps, result); + else await searchInTvdb(folderName, isTvShow, deps, result, tvdbLang); } return result; From 466c7d15f39f201aedc785062a871016434b99b0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 16:55:41 +0800 Subject: [PATCH 109/214] feat(core): add searchInTvdb/getTvShowInTvdb/getMovieInTvdb/getTvdbLanguages --- apps/core/src/Core.test.ts | 166 +++++++++++++++++++++++++++++++++++-- apps/core/src/Core.ts | 109 +++++++++++++++++++++++- 2 files changed, 266 insertions(+), 9 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index ddc42751..dbf1cac8 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -47,6 +47,18 @@ function inMemoryFs(seed: Record = {}): FsPort { }; } +function jsonResponse(body: unknown): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }; +} + /** Network that satisfies the empty-seed recognition path (returns no results). */ function emptyNetwork(): NetworkPort { return { @@ -55,14 +67,7 @@ function emptyNetwork(): NetworkPort { url.includes("/api/tmdb/") || url.includes("tmdb") ? { results: [], page: 1, total_pages: 1, total_results: 0 } : { status: "success", data: [] }; - return { - ok: true, - status: 200, - statusText: "OK", - headers: {}, - text: () => Promise.resolve(JSON.stringify(body)), - json: () => Promise.resolve(body as T), - }; + return jsonResponse(body); }) as never, }; } @@ -1158,3 +1163,148 @@ describe("Core.searchInTmdb", () => { await expect(core.searchInTmdb(" ", { type: "movie" })).rejects.toThrow(/keyword/i); }); }); + +function tvdbResponse(url: string): HttpResponse { + if (url.includes("/search")) { + const type = url.includes("type=series") ? "series" : "movie"; + return jsonResponse({ + status: "success", + data: type === "series" + ? [{ id: "series-1", objectID: "series-1", name: "My Show", tvdb_id: "1", type: "series" }] + : [{ id: "movie-2", objectID: "movie-2", name: "My Film", tvdb_id: "2", type: "movie" }], + }); + } + if (url.includes("/series/1/translations/eng")) return jsonResponse({ status: "success", data: { name: "My Show" } }); + if (url.includes("/series/1/extended")) { + return jsonResponse({ status: "success", data: { id: 1, name: "My Show", firstAired: "2020-01-01", seasons: [{ id: 11, number: 1, type: { name: "Aired Order" } }] } }); + } + if (url.includes("/seasons/11/extended")) { + return jsonResponse({ status: "success", data: { id: 11, episodes: [{ id: 1, number: 1, seasonNumber: 1, name: "Pilot" }] } }); + } + if (url.includes("/movies/2/translations/eng")) return jsonResponse({ status: "success", data: { name: "My Film" } }); + if (url.includes("/movies/2/extended")) return jsonResponse({ status: "success", data: { id: 2, name: "My Film", first_release: { first: "2019-05-01" } } }); + if (url.includes("/languages")) return jsonResponse({ status: "success", data: [{ id: "zho", name: "Chinese" }] }); + throw new Error("unexpected url: " + url); +} + +describe("Core.searchInTvdb", () => { + it("searches via NetworkPort with host, password and proxy", async () => { + const calls: Array<{ url: string; proxy?: string; auth?: string }> = []; + const network: NetworkPort = { + fetch: async (url, init) => { + calls.push({ url, proxy: init?.proxy, auth: init?.headers?.Authorization }); + return tvdbResponse(url); + }, + }; + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network, + appDataDir: "/data/smm", + }); + + const results = await core.searchInTvdb("keyword", { + type: "series", + host: "https://tvdb.example.com/v4", + password: "secret", + proxy: "socks5://127.0.0.1:1080", + }); + + expect(results[0]?.tvdb_id).toBe("1"); + expect(calls[0]?.url).toContain("https://tvdb.example.com/v4/search"); + expect(calls[0]?.url).toContain("query=keyword"); + expect(calls[0]?.url).toContain("language=eng"); + expect(calls[0]?.proxy).toBe("socks5://127.0.0.1:1080"); + expect(calls[0]?.auth).toBe("Bearer secret"); + }); + + it("maps explicit ISO 639-3 language through", async () => { + const calls: string[] = []; + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => { calls.push(url); return tvdbResponse(url); } }, + appDataDir: "/data/smm", + }); + await core.searchInTvdb("keyword", { type: "movie", language: "zho" }); + expect(calls[0]).toContain("language=zho"); + }); + + it("rejects language not in the static TVDB supported list", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.searchInTvdb("keyword", { type: "series", language: "zh-CN" })).rejects.toThrow(/ISO 639-3/); + await expect(core.searchInTvdb("keyword", { type: "series", language: "zzz" })).rejects.toThrow(/ISO 639-3/); + }); + + it("rejects empty keyword", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.searchInTvdb(" ", { type: "movie" })).rejects.toThrow(/keyword/i); + }); + + it("resolves preferMediaLanguage zh-CN to zho when language omitted", async () => { + const calls: string[] = []; + const core = new Core({ + fs: inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], preferMediaLanguage: "zh-CN", tmdb: {}, tvdb: {} }), + }), + network: { fetch: async (url) => { calls.push(url); return tvdbResponse(url); } }, + appDataDir: "/data/smm", + }); + await core.searchInTvdb("keyword", { type: "series" }); + expect(calls[0]).toContain("language=zho"); + }); +}); + +describe("Core.getTvShowInTvdb / getMovieInTvdb / getTvdbLanguages", () => { + it("getTvShowInTvdb builds TvShowMediaMetadata", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const tvShow = await core.getTvShowInTvdb(1, { language: "eng" }); + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TVDB", + airDate: "2020-01-01", + seasons: [{ season: 1, name: "", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }], + }); + }); + + it("getMovieInTvdb builds MovieMediaMetadata", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const movie = await core.getMovieInTvdb(2, { language: "eng" }); + expect(movie).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TVDB" }); + }); + + it("getTvdbLanguages returns language records", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const langs = await core.getTvdbLanguages(); + expect(langs[0]?.id).toBe("zho"); + }); + + it("validates id as a positive integer", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.getTvShowInTvdb(0)).rejects.toThrow(/positive integer/); + await expect(core.getMovieInTvdb(-1)).rejects.toThrow(/positive integer/); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index aacfb4a6..ad226e54 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -3,12 +3,21 @@ import type { AppConfig, FolderType, MediaMetadata, + MovieMediaMetadata, TmdbMovieDetails, TmdbSearchResponseBody, TmdbSeriesDetails, + TvShowMediaMetadata, UserConfig as UserConfigData, } from "@smm/core"; -import { detectOsLocale, parseTmdbSearchLanguage, resolveMediaLanguage } from "@core/locale"; +import { + detectOsLocale, + parseTmdbSearchLanguage, + resolveMediaLanguage, + resolveTvdbSearchLanguage, +} from "@core/locale"; +import { parseTvdbSearchLanguage } from "@core/tvdbSupportedLanguages"; +import type { TVDBv4LanguageRecord, TVDBv4SearchResult } from "@smm/tvdb4"; import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "./ports/FsPort"; @@ -25,6 +34,7 @@ import { } from "./pipeline/mcpServer"; import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; +import { TvdbClient } from "./clients/TvdbClient"; import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; @@ -65,6 +75,21 @@ export interface SearchInTmdbOptions extends TmdbRequestOptions { type: "tv" | "movie"; } +export interface TvdbRequestOptions { + /** TVDB language (CLI `--lang`). ISO 639-3 code, validated offline against the static list. */ + language?: string; + /** Override userConfig.tvdb.host */ + host?: string; + /** Override userConfig.tvdb.apiKey (CLI `--password`) */ + password?: string; + /** Override userConfig.tvdb.httpProxy (CLI `--proxy`) */ + proxy?: string; +} + +export interface SearchInTvdbOptions extends TvdbRequestOptions { + type: "series" | "movie"; +} + export type { RenameFolderArgs, RenameEpisodeFileInput, @@ -361,6 +386,62 @@ export class Core { return client.getTvShowById(id, language); } + /** + * Search TVDB via {@link NetworkPort} (CLI uses NodejsNetworkPort). + * Explicit `language` is ISO 639-3, validated offline against the static + * supported-languages snapshot. When omitted it resolves preferMediaLanguage → OS → eng. + */ + async searchInTvdb(keyword: string, options: SearchInTvdbOptions): Promise { + const trimmed = keyword.trim(); + if (!trimmed) { + throw new Error("keyword is required"); + } + const { client, language } = await this.createTvdbClient(options); + const results = options.type === "series" + ? await client.searchSeries(trimmed, language) + : await client.searchMovie(trimmed, language); + if (!results) { + throw new Error("TVDB search failed"); + } + return results; + } + + /** Fetch TVDB series metadata (seasons + episodes + translations) by id via {@link NetworkPort}. */ + async getTvShowInTvdb(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const metadata = await client.getTvShowMediaMetadata(id, language); + if (!metadata) { + throw new Error(`Failed to get TVDB series ${id}`); + } + return metadata; + } + + /** Fetch TVDB movie metadata by id via {@link NetworkPort}. */ + async getMovieInTvdb(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const metadata = await client.getMovieMediaMetadata(id, language); + if (!metadata) { + throw new Error(`Failed to get TVDB movie ${id}`); + } + return metadata; + } + + /** Fetch the TVDB supported language list via {@link NetworkPort}. */ + async getTvdbLanguages(options: TvdbRequestOptions = {}): Promise { + const { client } = await this.createTvdbClient(options); + const languages = await client.getLanguages(); + if (!languages) { + throw new Error("Failed to get TVDB languages"); + } + return languages; + } + private async createTmdbClient( options: TmdbRequestOptions, ): Promise<{ client: TmdbClient; language: string }> { @@ -387,6 +468,32 @@ export class Core { return { client, language }; } + private async createTvdbClient( + options: TvdbRequestOptions, + ): Promise<{ client: TvdbClient; language: string }> { + const config = await this.userConfig.read(); + const language = options.language?.trim() + ? parseTvdbSearchLanguage(options.language) + : resolveTvdbSearchLanguage({ + preferMediaLanguage: config.preferMediaLanguage, + configured: config.applicationLanguage, + osLocale: detectOsLocale(), + }); + const host = options.host?.trim() || config.tvdb?.host; + const apiKey = options.password?.trim() || config.tvdb?.apiKey; + const httpProxy = options.proxy?.trim() || config.tvdb?.httpProxy; + + const client = new TvdbClient(this.network, { + host, + apiKey, + httpProxy, + reverseProxyUrl: null, + discover: this.discover, + }); + + return { client, language }; + } + private normalizePosix(path: string): string { try { return Path.posix(path); From a5ce7611076b0fde6c9694bb26241183bcf8be5b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:00:06 +0800 Subject: [PATCH 110/214] feat(cli): add TVDB internal HTTP routes --- apps/cli/server.ts | 2 + apps/cli/src/route/Tvdb.test.ts | 134 ++++++++++++++++++++++++++++++++ apps/cli/src/route/Tvdb.ts | 117 ++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 apps/cli/src/route/Tvdb.test.ts create mode 100644 apps/cli/src/route/Tvdb.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 34612b2f..4389932c 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -52,6 +52,7 @@ import { handleImportFolder } from './src/route/ImportFolder'; import { handleGetJob } from './src/route/GetJob'; import { handleScrape } from './src/route/Scrape'; import { handleTmdb } from './src/route/Tmdb'; +import { handleTvdb } from './src/route/Tvdb'; import { handleCoreFetch } from './src/route/CoreFetch'; import { handleShowFolder } from './src/route/ShowFolder'; import { handleFolderMetadata } from './src/route/FolderMetadata'; @@ -298,6 +299,7 @@ export class Server { handleGetJob(this.app); handleScrape(this.app); handleTmdb(this.app); + handleTvdb(this.app); handleCoreFetch(this.app); handleShowFolder(this.app); handleFolderMetadata(this.app); diff --git a/apps/cli/src/route/Tvdb.test.ts b/apps/cli/src/route/Tvdb.test.ts new file mode 100644 index 00000000..b71a20b0 --- /dev/null +++ b/apps/cli/src/route/Tvdb.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + searchInTvdb: vi.fn(), + getTvShowInTvdb: vi.fn(), + getMovieInTvdb: vi.fn(), + getTvdbLanguages: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +import { handleTvdb } from './Tvdb' + +describe('TVDB Internal HTTP APIs', () => { + let app: Hono + + beforeEach(() => { + mocks.searchInTvdb.mockReset() + mocks.getTvShowInTvdb.mockReset() + mocks.getMovieInTvdb.mockReset() + mocks.getTvdbLanguages.mockReset() + app = new Hono() + handleTvdb(app) + }) + + async function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + describe('POST /api/search-in-tvdb', () => { + it('returns Error Reason when keyword is missing', async () => { + const res = await post('/api/search-in-tvdb', { type: 'series' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: keyword is required/) + expect(mocks.searchInTvdb).not.toHaveBeenCalled() + }) + + it('returns Error Reason when type is invalid', async () => { + const res = await post('/api/search-in-tvdb', { keyword: 'naruto', type: 'anime' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: type must be series or movie/) + }) + + it('calls Core.searchInTvdb and returns data', async () => { + mocks.searchInTvdb.mockResolvedValue([{ tvdb_id: '1', name: 'Naruto' }]) + const res = await post('/api/search-in-tvdb', { + keyword: 'naruto', + type: 'series', + language: 'zho', + host: 'https://tvdb.example.com/v4', + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: [{ tvdb_id: '1', name: 'Naruto' }] }) + expect(mocks.searchInTvdb).toHaveBeenCalledWith('naruto', { + type: 'series', + language: 'zho', + host: 'https://tvdb.example.com/v4', + password: undefined, + proxy: undefined, + }) + }) + + it('maps Core throw to Error Reason', async () => { + mocks.searchInTvdb.mockRejectedValue(new Error('Unsupported language "zh-CN"')) + const res = await post('/api/search-in-tvdb', { keyword: 'x', type: 'movie', language: 'zh-CN' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toBe('Error Reason: Unsupported language "zh-CN"') + }) + }) + + describe('POST /api/get-tvshow-in-tvdb', () => { + it('returns Error Reason when id is missing', async () => { + const res = await post('/api/get-tvshow-in-tvdb', {}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: id is required/) + }) + + it('calls Core.getTvShowInTvdb and returns data', async () => { + mocks.getTvShowInTvdb.mockResolvedValue({ id: '1', name: 'My Show', database: 'TVDB', seasons: [] }) + const res = await post('/api/get-tvshow-in-tvdb', { id: 1, language: 'eng' }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { id: '1', name: 'My Show', database: 'TVDB', seasons: [] }, + }) + expect(mocks.getTvShowInTvdb).toHaveBeenCalledWith(1, { + language: 'eng', + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) + + describe('POST /api/get-movie-in-tvdb', () => { + it('calls Core.getMovieInTvdb and returns data', async () => { + mocks.getMovieInTvdb.mockResolvedValue({ id: '2', name: 'My Film', database: 'TVDB' }) + const res = await post('/api/get-movie-in-tvdb', { id: 2 }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { id: '2', name: 'My Film', database: 'TVDB' } }) + expect(mocks.getMovieInTvdb).toHaveBeenCalledWith(2, { + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) + + describe('POST /api/get-tvdb-languages', () => { + it('calls Core.getTvdbLanguages and returns data', async () => { + mocks.getTvdbLanguages.mockResolvedValue([{ id: 'zho', name: 'Chinese' }]) + const res = await post('/api/get-tvdb-languages', {}) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: [{ id: 'zho', name: 'Chinese' }] }) + expect(mocks.getTvdbLanguages).toHaveBeenCalledWith({ + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) +}) diff --git a/apps/cli/src/route/Tvdb.ts b/apps/cli/src/route/Tvdb.ts new file mode 100644 index 00000000..95ed78fa --- /dev/null +++ b/apps/cli/src/route/Tvdb.ts @@ -0,0 +1,117 @@ +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface TvdbHttpResponseBody { + data?: unknown + error?: string +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined +} + +function parsePositiveInt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value + } + if (typeof value === 'string' && /^\d+$/.test(value)) { + const parsed = Number(value) + if (parsed > 0) return parsed + } + return undefined +} + +function tvdbRequestOptions(rec: Record): { + language?: string + host?: string + password?: string + proxy?: string +} { + return { + language: optionalString(rec.language), + host: optionalString(rec.host), + password: optionalString(rec.password), + proxy: optionalString(rec.proxy), + } +} + +async function readJsonObject(c: { req: { json: () => Promise } }): Promise> { + try { + const body = await c.req.json() + if (typeof body === 'object' && body !== null) { + return body as Record + } + } catch { + /* empty / invalid JSON */ + } + return {} +} + +function errorBody(message: string): { error: string } { + return { error: `Error Reason: ${message}` } +} + +export function handleTvdb(app: Hono): void { + app.post('/api/search-in-tvdb', async (c) => { + try { + const rec = await readJsonObject(c) + const keyword = optionalString(rec.keyword) + if (!keyword) { + return c.json(errorBody('keyword is required'), 200) + } + const type = rec.type + if (type !== 'series' && type !== 'movie') { + return c.json(errorBody('type must be series or movie'), 200) + } + const data = await getCore().searchInTvdb(keyword, { + type, + ...tvdbRequestOptions(rec), + }) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/search-in-tvdb] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) + + app.post('/api/get-tvshow-in-tvdb', async (c) => { + try { + const rec = await readJsonObject(c) + const id = parsePositiveInt(rec.id) + if (id === undefined) { + return c.json(errorBody('id is required'), 200) + } + const data = await getCore().getTvShowInTvdb(id, tvdbRequestOptions(rec)) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-tvshow-in-tvdb] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) + + app.post('/api/get-movie-in-tvdb', async (c) => { + try { + const rec = await readJsonObject(c) + const id = parsePositiveInt(rec.id) + if (id === undefined) { + return c.json(errorBody('id is required'), 200) + } + const data = await getCore().getMovieInTvdb(id, tvdbRequestOptions(rec)) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-movie-in-tvdb] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) + + app.post('/api/get-tvdb-languages', async (c) => { + try { + const data = await getCore().getTvdbLanguages(tvdbRequestOptions({})) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-tvdb-languages] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) +} From 6e9a417f417c78c1299cf3fd682290ba6c109387 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:03:35 +0800 Subject: [PATCH 111/214] feat(cli): add smm tvdb search command --- apps/cli/src/cli/runCli.ts | 47 ++++++++++ apps/cli/src/cli/tvdb.test.ts | 102 ++++++++++++++++++++++ apps/cli/src/cli/tvdbSearchFormat.test.ts | 36 ++++++++ apps/cli/src/cli/tvdbSearchFormat.ts | 29 ++++++ 4 files changed, 214 insertions(+) create mode 100644 apps/cli/src/cli/tvdb.test.ts create mode 100644 apps/cli/src/cli/tvdbSearchFormat.test.ts create mode 100644 apps/cli/src/cli/tvdbSearchFormat.ts diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 5a153ec0..1580d112 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -20,6 +20,7 @@ import { printEpisodeRenameResult, } from './renameDispatch' import { formatTmdbSearchResults } from './tmdbSearchFormat' +import { formatTvdbSearchResults } from './tvdbSearchFormat' import { Path } from '@core/path' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] @@ -440,6 +441,52 @@ export async function runCli(argv: string[] = process.argv): Promise { }, ) + const tvdbCmd = program.command('tvdb').description('TVDB helpers') + + tvdbCmd + .command('search') + .description('Search TVDB for TV series or movies') + .argument('', 'Search keyword') + .addOption( + new Option('--type ', 'Media type') + .choices(['series', 'movie']) + .makeOptionMandatory(), + ) + .option('--host ', 'TVDB API base URL (overrides userConfig.tvdb.host)') + .option('--password ', 'TVDB API key (overrides userConfig.tvdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tvdb.httpProxy)') + .option( + '--lang ', + 'TVDB ISO 639-3 language code (static list, e.g. eng, zho, yue); defaults from userConfig then OS locale', + ) + .action( + async ( + keyword: string, + opts: { + type: 'series' | 'movie' + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const results = await getCore().searchInTvdb(keyword, { + type: opts.type, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + language: opts.lang, + }) + const text = formatTvdbSearchResults(results, opts.type) + if (text) console.log(text) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) + const configCmd = program.command('config').description('Read or write user config (smm.json)') configCmd diff --git a/apps/cli/src/cli/tvdb.test.ts b/apps/cli/src/cli/tvdb.test.ts new file mode 100644 index 00000000..e830b608 --- /dev/null +++ b/apps/cli/src/cli/tvdb.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm tvdb search', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-tvdb-cli-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + vi.doUnmock('../core/getCore') + vi.resetModules() + }) + + it('prints formatted results and exits 0', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTvdb: async () => [ + { id: 'series-42', objectID: 'series-42', tvdb_id: '42', name: 'My Show', overview: 'A show', first_air_time: '2020-01-01' }, + ], + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tvdb', 'search', 'keyword', '--type', 'series']) + + expect(code).toBe(0) + expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) + }) + + it('passes host, password, proxy and lang to Core.searchInTvdb', async () => { + const calls: unknown[] = [] + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTvdb: async (keyword: string, options: unknown) => { + calls.push({ keyword, options }) + return [] + }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', 'smm', 'tvdb', 'search', 'hello', '--type', 'movie', + '--host', 'https://tvdb.example.com/v4', '--password', 'secret', + '--proxy', 'socks5://127.0.0.1:1080', '--lang', 'zho', + ]) + + expect(code).toBe(0) + expect(calls).toEqual([ + { + keyword: 'hello', + options: { type: 'movie', host: 'https://tvdb.example.com/v4', password: 'secret', proxy: 'socks5://127.0.0.1:1080', language: 'zho' }, + }, + ]) + }) + + it('exits non-zero when --lang is invalid', async () => { + vi.resetModules() + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tvdb', 'search', 'keyword', '--type', 'series', '--lang', 'zh-CN']) + expect(code).not.toBe(0) + expect(String(errorSpy.mock.calls.flat().join(' '))).toMatch(/ISO 639-3/) + }) + + it('writes to stderr and exits 1 when search throws', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTvdb: async () => { throw new Error('network down') }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tvdb', 'search', 'x', '--type', 'series']) + expect(code).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0] ?? '')).toContain('network down') + }) +}) diff --git a/apps/cli/src/cli/tvdbSearchFormat.test.ts b/apps/cli/src/cli/tvdbSearchFormat.test.ts new file mode 100644 index 00000000..fee23e0f --- /dev/null +++ b/apps/cli/src/cli/tvdbSearchFormat.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { formatTvdbSearchResults } from './tvdbSearchFormat' + +const seriesItem = { + id: 'series-42', + objectID: 'series-42', + tvdb_id: '42', + name: 'My Show', + overview: 'A show', + first_air_time: '2020-01-01', +} as never + +const movieItem = { + id: 'movie-7', + objectID: 'movie-7', + tvdb_id: '7', + name: 'My Film', + overview: 'A film', + release_date: '2019-05-01', +} as never + +describe('formatTvdbSearchResults', () => { + it('formats series results', () => { + const text = formatTvdbSearchResults([seriesItem], 'series') + expect(text).toBe('#1 42 My Show (2020-01-01)\nA show') + }) + + it('formats movie results with release date', () => { + const text = formatTvdbSearchResults([movieItem], 'movie') + expect(text).toBe('#1 7 My Film (2019-05-01)\nA film') + }) + + it('returns empty string for empty results', () => { + expect(formatTvdbSearchResults([], 'series')).toBe('') + }) +}) diff --git a/apps/cli/src/cli/tvdbSearchFormat.ts b/apps/cli/src/cli/tvdbSearchFormat.ts new file mode 100644 index 00000000..66f066ae --- /dev/null +++ b/apps/cli/src/cli/tvdbSearchFormat.ts @@ -0,0 +1,29 @@ +import type { TVDBv4SearchResult } from '@smm/tvdb4' + +export type TvdbSearchMediaType = 'series' | 'movie' + +function releaseYear(item: TVDBv4SearchResult): string { + const firstAir = typeof item.first_air_time === 'string' ? item.first_air_time : undefined + const release = typeof item.release_date === 'string' ? item.release_date : undefined + const year = typeof item.year === 'string' ? item.year : undefined + return firstAir || release || year || '' +} + +/** + * Format TVDB search results for CLI stdout: + * `#{index} {tvdb_id} {name} ({release date})` then overview on the next line. + */ +export function formatTvdbSearchResults( + items: TVDBv4SearchResult[], + _type: TvdbSearchMediaType, +): string { + const lines: string[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i]! + const index = i + 1 + const id = item.tvdb_id || item.id || '' + lines.push(`#${index} ${id} ${item.name} (${releaseYear(item)})`) + lines.push(item.overview ?? '') + } + return lines.join('\n') +} From 90b16ed530aca06ecd8d1535dd35ecc7379e5b73 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:07:28 +0800 Subject: [PATCH 112/214] feat(core-routes): add TVDB AI tool types and execution --- packages/core-routes/src/tools/tvdb.test.ts | 68 +++++ packages/core-routes/src/tools/tvdb.ts | 234 ++++++++++++++++++ packages/core/types/ai-tools/tvdbCommon.ts | 36 +++ .../core/types/ai-tools/tvdbGetLanguages.ts | 19 ++ packages/core/types/ai-tools/tvdbGetMovie.ts | 21 ++ packages/core/types/ai-tools/tvdbGetTvShow.ts | 21 ++ packages/core/types/ai-tools/tvdbSearch.ts | 24 ++ 7 files changed, 423 insertions(+) create mode 100644 packages/core-routes/src/tools/tvdb.test.ts create mode 100644 packages/core-routes/src/tools/tvdb.ts create mode 100644 packages/core/types/ai-tools/tvdbCommon.ts create mode 100644 packages/core/types/ai-tools/tvdbGetLanguages.ts create mode 100644 packages/core/types/ai-tools/tvdbGetMovie.ts create mode 100644 packages/core/types/ai-tools/tvdbGetTvShow.ts create mode 100644 packages/core/types/ai-tools/tvdbSearch.ts diff --git a/packages/core-routes/src/tools/tvdb.test.ts b/packages/core-routes/src/tools/tvdb.test.ts new file mode 100644 index 00000000..addd0f4c --- /dev/null +++ b/packages/core-routes/src/tools/tvdb.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { + executeTvdbGetLanguages, + executeTvdbGetMovie, + executeTvdbGetTvShow, + executeTvdbSearch, +} from "./tvdb.ts"; + +describe("executeTvdbSearch", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeTvdbSearch({ keyword: "naruto", type: "series" }, undefined); + expect(result.error).toMatch(/not available/i); + }); + + it("maps keyword/type/language to Core runner", async () => { + const runner = vi.fn(async () => [{ tvdb_id: "1", name: "Show" }]); + const result = await executeTvdbSearch( + { keyword: "naruto", type: "series", language: "zho", baseURL: "https://tvdb.example/v4" }, + runner, + ); + expect(result.results).toHaveLength(1); + expect(runner).toHaveBeenCalledWith("naruto", { + type: "series", + language: "zho", + host: "https://tvdb.example/v4", + }); + }); + + it("returns error when runner throws", async () => { + const runner = vi.fn(async () => { throw new Error("upstream failed"); }); + const result = await executeTvdbSearch({ keyword: "x", type: "movie" }, runner); + expect(result.error).toMatch(/upstream failed/); + }); +}); + +describe("executeTvdbGetMovie / GetTvShow", () => { + it("returns unavailable when runner is missing", async () => { + const movie = await executeTvdbGetMovie({ id: 2 }, undefined); + expect(movie.error).toMatch(/not available/i); + const show = await executeTvdbGetTvShow({ id: 1 }, undefined); + expect(show.error).toMatch(/not available/i); + }); + + it("validates id", async () => { + const movie = await executeTvdbGetMovie({ id: 0 }, vi.fn()); + expect(movie.error).toMatch(/positive integer/i); + }); + + it("returns metadata on success", async () => { + const runner = vi.fn(async () => ({ id: "2", name: "My Film", database: "TVDB" })); + const result = await executeTvdbGetMovie({ id: 2, language: "eng" }, runner); + expect(result.name).toBe("My Film"); + expect(runner).toHaveBeenCalledWith(2, { language: "eng", host: undefined }); + }); +}); + +describe("executeTvdbGetLanguages", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeTvdbGetLanguages(undefined); + expect(result.error).toMatch(/not available/i); + }); + + it("returns languages on success", async () => { + const runner = vi.fn(async () => [{ id: "zho", name: "Chinese" }]); + const result = await executeTvdbGetLanguages(runner); + expect(result.languages).toEqual([{ id: "zho", name: "Chinese" }]); + }); +}); diff --git a/packages/core-routes/src/tools/tvdb.ts b/packages/core-routes/src/tools/tvdb.ts new file mode 100644 index 00000000..d9fc31d7 --- /dev/null +++ b/packages/core-routes/src/tools/tvdb.ts @@ -0,0 +1,234 @@ +import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; +import { + formatTvdbToolError, + toTvdbCoreOptions, + type TvdbToolHostOptions, +} from "@smm/core/types/ai-tools/tvdbCommon"; +import { + TVDB_SEARCH, + TVDB_SEARCH_DESCRIPTION, + tvdbSearchInputSchema, + tvdbSearchOutputSchema, + type TvdbSearchInput, + type TvdbSearchOutput, +} from "@smm/core/types/ai-tools/tvdbSearch"; +import { + TVDB_GET_MOVIE, + TVDB_GET_MOVIE_DESCRIPTION, + tvdbGetMovieInputSchema, + tvdbGetMovieOutputSchema, + type TvdbGetMovieInput, + type TvdbGetMovieOutput, +} from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { + TVDB_GET_TV_SHOW, + TVDB_GET_TV_SHOW_DESCRIPTION, + tvdbGetTvShowInputSchema, + tvdbGetTvShowOutputSchema, + type TvdbGetTvShowInput, + type TvdbGetTvShowOutput, +} from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { + TVDB_GET_LANGUAGES, + TVDB_GET_LANGUAGES_DESCRIPTION, + tvdbGetLanguagesInputSchema, + tvdbGetLanguagesOutputSchema, + type TvdbGetLanguagesInput, + type TvdbGetLanguagesOutput, +} from "@smm/core/types/ai-tools/tvdbGetLanguages"; + +export type SearchInTvdbRunner = ( + keyword: string, + options: { + type: "series" | "movie"; + language?: string; + host?: string; + password?: string; + proxy?: string; + }, +) => Promise>>; + +export type GetTvShowInTvdbRunner = ( + id: number, + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise>; + +export type GetMovieInTvdbRunner = ( + id: number, + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise>; + +export type GetTvdbLanguagesRunner = ( + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise>>; + +export interface TvdbToolRunners { + searchInTvdb?: SearchInTvdbRunner; + getTvShowInTvdb?: GetTvShowInTvdbRunner; + getMovieInTvdb?: GetMovieInTvdbRunner; + getTvdbLanguages?: GetTvdbLanguagesRunner; +} + +function unavailable(message: string): { error: string } { + return { error: message }; +} + +function assertNotAborted(abortSignal?: AbortSignal): void { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} + +export async function executeTvdbSearch( + params: TvdbSearchInput, + runner: SearchInTvdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + + if (!runner) { + return unavailable("tvdb-search is not available on this host"); + } + + try { + const results = await runner(keywordCheck, { + type: params.type, + ...toTvdbCoreOptions(params), + }); + return { results: results as TvdbSearchOutput["results"] }; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export async function executeTvdbGetMovie( + params: TvdbGetMovieInput, + runner: GetMovieInTvdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + + if (!runner) { + return unavailable("tvdb-get-movie is not available on this host"); + } + + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details as TvdbGetMovieOutput; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export async function executeTvdbGetTvShow( + params: TvdbGetTvShowInput, + runner: GetTvShowInTvdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + + if (!runner) { + return unavailable("tvdb-get-tv-show is not available on this host"); + } + + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details as TvdbGetTvShowOutput; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export async function executeTvdbGetLanguages( + runner: GetTvdbLanguagesRunner | undefined, + _params: TvdbGetLanguagesInput = {}, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!runner) { + return unavailable("tvdb-get-languages is not available on this host"); + } + + try { + const languages = await runner(toTvdbCoreOptions(_params)); + return { languages: languages as TvdbGetLanguagesOutput["languages"] }; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export function buildTvdbSearchTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_SEARCH_DESCRIPTION, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbSearch((args ?? {}) as TvdbSearchInput, runners?.searchInTvdb, abortSignal); + }, + }; +} + +export function buildTvdbGetMovieTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_GET_MOVIE_DESCRIPTION, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbGetMovie((args ?? {}) as TvdbGetMovieInput, runners?.getMovieInTvdb, abortSignal); + }, + }; +} + +export function buildTvdbGetTvShowTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbGetTvShow( + (args ?? {}) as TvdbGetTvShowInput, + runners?.getTvShowInTvdb, + abortSignal, + ); + }, + }; +} + +export function buildTvdbGetLanguagesTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_GET_LANGUAGES_DESCRIPTION, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbGetLanguages(runners?.getTvdbLanguages, (args ?? {}) as TvdbGetLanguagesInput, abortSignal); + }, + }; +} + +export { TVDB_SEARCH, TVDB_GET_MOVIE, TVDB_GET_TV_SHOW, TVDB_GET_LANGUAGES }; diff --git a/packages/core/types/ai-tools/tvdbCommon.ts b/packages/core/types/ai-tools/tvdbCommon.ts new file mode 100644 index 00000000..414d9c80 --- /dev/null +++ b/packages/core/types/ai-tools/tvdbCommon.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +/** Optional TVDB ISO 639-3 language code (e.g. eng, zho, yue). */ +export const tvdbLanguageSchema = z + .string() + .optional() + .describe( + 'TVDB ISO 639-3 language code (e.g. eng, zho, yue). Defaults from userConfig.preferMediaLanguage.', + ) + +/** Override TVDB API base URL (MCP/AI tool name: baseURL). */ +export const tvdbBaseUrlSchema = z + .string() + .optional() + .describe('Optional TVDB API base URL override (defaults from userConfig.tvdb.host)') + +export interface TvdbToolHostOptions { + language?: string + baseURL?: string +} + +export function toTvdbCoreOptions(params: TvdbToolHostOptions): { + language?: string + host?: string +} { + const host = params.baseURL?.trim() + return { + language: params.language, + host: host || undefined, + } +} + +export function formatTvdbToolError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.startsWith('Error Reason:') ? message : `Error Reason: ${message}` +} diff --git a/packages/core/types/ai-tools/tvdbGetLanguages.ts b/packages/core/types/ai-tools/tvdbGetLanguages.ts new file mode 100644 index 00000000..309a573e --- /dev/null +++ b/packages/core/types/ai-tools/tvdbGetLanguages.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' +import { tvdbBaseUrlSchema } from './tvdbCommon' + +export const TVDB_GET_LANGUAGES = 'tvdb-get-languages' as const + +export const TVDB_GET_LANGUAGES_DESCRIPTION = + 'Retrieve the list of TVDB supported languages (ISO 639-3 codes). Useful for picking a search language.' + +export const tvdbGetLanguagesInputSchema = z.object({ + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbGetLanguagesOutputSchema = z.object({ + languages: z.array(z.record(z.string(), z.unknown())).optional(), + error: z.string().optional(), +}) + +export type TvdbGetLanguagesInput = z.infer +export type TvdbGetLanguagesOutput = z.infer diff --git a/packages/core/types/ai-tools/tvdbGetMovie.ts b/packages/core/types/ai-tools/tvdbGetMovie.ts new file mode 100644 index 00000000..60f0cbe9 --- /dev/null +++ b/packages/core/types/ai-tools/tvdbGetMovie.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { tvdbBaseUrlSchema, tvdbLanguageSchema } from './tvdbCommon' + +export const TVDB_GET_MOVIE = 'tvdb-get-movie' as const + +export const TVDB_GET_MOVIE_DESCRIPTION = + 'Retrieve movie metadata from TVDB by TVDB ID, including the localized title.\n\n' + + 'Example: Get movie metadata for TVDB id 7.' + +export const tvdbGetMovieInputSchema = z.object({ + id: z.number().int().positive().describe('TVDB movie id'), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbGetMovieOutputSchema = z + .object({ error: z.string().optional() }) + .passthrough() + +export type TvdbGetMovieInput = z.infer +export type TvdbGetMovieOutput = z.infer diff --git a/packages/core/types/ai-tools/tvdbGetTvShow.ts b/packages/core/types/ai-tools/tvdbGetTvShow.ts new file mode 100644 index 00000000..e486f7b3 --- /dev/null +++ b/packages/core/types/ai-tools/tvdbGetTvShow.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { tvdbBaseUrlSchema, tvdbLanguageSchema } from './tvdbCommon' + +export const TVDB_GET_TV_SHOW = 'tvdb-get-tv-show' as const + +export const TVDB_GET_TV_SHOW_DESCRIPTION = + 'Retrieve TV series metadata from TVDB by TVDB ID, including seasons and episodes with localized titles.\n\n' + + 'Example: Get TV series metadata for TVDB id 42.' + +export const tvdbGetTvShowInputSchema = z.object({ + id: z.number().int().positive().describe('TVDB series id'), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbGetTvShowOutputSchema = z + .object({ error: z.string().optional() }) + .passthrough() + +export type TvdbGetTvShowInput = z.infer +export type TvdbGetTvShowOutput = z.infer diff --git a/packages/core/types/ai-tools/tvdbSearch.ts b/packages/core/types/ai-tools/tvdbSearch.ts new file mode 100644 index 00000000..aa8a25fc --- /dev/null +++ b/packages/core/types/ai-tools/tvdbSearch.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { tvdbBaseUrlSchema, tvdbLanguageSchema } from './tvdbCommon' + +export const TVDB_SEARCH = 'tvdb-search' as const + +export const TVDB_SEARCH_DESCRIPTION = + 'Search TVDB (TheTVDB) for TV series or movies by keyword. ' + + 'Returns matching results with title, overview, and TVDB ID.\n\n' + + 'Example: Search series matching "naruto".' + +export const tvdbSearchInputSchema = z.object({ + keyword: z.string().describe('Search keyword'), + type: z.enum(['series', 'movie']).describe('Media type to search'), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbSearchOutputSchema = z.object({ + results: z.array(z.record(z.string(), z.unknown())).optional(), + error: z.string().optional(), +}) + +export type TvdbSearchInput = z.infer +export type TvdbSearchOutput = z.infer From f8e7bd8904d39827a9334fa2cdc6a2b8033f01c4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:10:42 +0800 Subject: [PATCH 113/214] feat(core-routes): register TVDB AI tools in chat toolset --- packages/core-routes/src/tools/index.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index e54812a0..f82d107e 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -19,6 +19,17 @@ import { buildTmdbSearchTool, type TmdbToolRunners, } from "./tmdb.ts"; +import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; +import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; +import { + buildTvdbGetLanguagesTool, + buildTvdbGetMovieTool, + buildTvdbGetTvShowTool, + buildTvdbSearchTool, + type TvdbToolRunners, +} from "./tvdb.ts"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -86,6 +97,10 @@ export interface ChatTools { [TMDB_SEARCH]: ReturnType; [TMDB_GET_MOVIE]: ReturnType; [TMDB_GET_TV_SHOW]: ReturnType; + [TVDB_SEARCH]: ReturnType; + [TVDB_GET_MOVIE]: ReturnType; + [TVDB_GET_TV_SHOW]: ReturnType; + [TVDB_GET_LANGUAGES]: ReturnType; [BEGIN_RENAME_FILES_TASK]: ReturnType; [ADD_RENAME_FILE_TO_TASK]: ReturnType; [END_RENAME_FILES_TASK]: ReturnType; @@ -110,6 +125,8 @@ export interface ChatToolsExtraDeps { getJob?: GetJobRunner; /** Host Core runners for TMDB query tools. */ tmdb?: TmdbToolRunners; + /** Host Core runners for TVDB query tools. */ + tvdb?: TvdbToolRunners; } export interface CreateChatToolsArgs { @@ -160,6 +177,7 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { extra?.renameFilesTask ?? defaultRenameFilesTaskDeps(config.appDataDir); const tmdbRunners: TmdbToolRunners | undefined = extra?.tmdb; + const tvdbRunners: TvdbToolRunners | undefined = extra?.tvdb; return { [GET_APPLICATION_CONTEXT]: buildGetApplicationContextTool( @@ -201,6 +219,10 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { [TMDB_SEARCH]: buildTmdbSearchTool(tmdbRunners, abortSignal), [TMDB_GET_MOVIE]: buildTmdbGetMovieTool(tmdbRunners, abortSignal), [TMDB_GET_TV_SHOW]: buildTmdbGetTvShowTool(tmdbRunners, abortSignal), + [TVDB_SEARCH]: buildTvdbSearchTool(tvdbRunners, abortSignal), + [TVDB_GET_MOVIE]: buildTvdbGetMovieTool(tvdbRunners, abortSignal), + [TVDB_GET_TV_SHOW]: buildTvdbGetTvShowTool(tvdbRunners, abortSignal), + [TVDB_GET_LANGUAGES]: buildTvdbGetLanguagesTool(tvdbRunners, abortSignal), [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool( clientId, config.appDataDir, From 9289f4c33063aeac8bb6419427a338d8dd8a4dc2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:25:10 +0800 Subject: [PATCH 114/214] feat(core-routes): register TVDB MCP tools --- packages/core-routes/src/mcp/createServer.ts | 2 + packages/core-routes/src/mcp/mcp.test.ts | 49 ++++++ .../src/mcp/toolHandlers/tvdbTools.ts | 143 ++++++++++++++++++ packages/core-routes/src/mcp/types.ts | 44 ++++++ packages/core-routes/src/tools/tvdb.ts | 8 +- 5 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts diff --git a/packages/core-routes/src/mcp/createServer.ts b/packages/core-routes/src/mcp/createServer.ts index 1da8e0cd..fa5d8d73 100644 --- a/packages/core-routes/src/mcp/createServer.ts +++ b/packages/core-routes/src/mcp/createServer.ts @@ -22,6 +22,7 @@ import { registerRenameEpisodeFileTool } from "./toolHandlers/renameEpisodeFile. import { registerScrapeTool } from "./toolHandlers/scrape.ts"; import { registerGetJobTool } from "./toolHandlers/getJob.ts"; import { registerTmdbTools } from "./toolHandlers/tmdbTools.ts"; +import { registerTvdbTools } from "./toolHandlers/tvdbTools.ts"; import { registerStaticTextTools } from "./toolHandlers/staticText.ts"; import type { McpConfig } from "./types.ts"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; @@ -94,6 +95,7 @@ export async function createMcpStreamableHttpHandler( registerListFilesTool(server, config); registerGetMediaMetadataTool(server, config); registerTmdbTools(server, config); + registerTvdbTools(server, config); // Read-only documentation tools. registerStaticTextTools(server, config); diff --git a/packages/core-routes/src/mcp/mcp.test.ts b/packages/core-routes/src/mcp/mcp.test.ts index 78803970..7c79f7dc 100644 --- a/packages/core-routes/src/mcp/mcp.test.ts +++ b/packages/core-routes/src/mcp/mcp.test.ts @@ -582,4 +582,53 @@ describe("createMcpStreamableHttpHandler", () => { await cleanup() } }) + + it("registers TVDB query tools when runners are provided", async () => { + const { appDataDir, userDataDir, userConfig, cleanup } = await makeTempConfig() + try { + const handler = await createMcpStreamableHttpHandler({ + getUserConfig: async () => userConfig, + appDataDir, + userDataDir, + fs: defaultChatFs(), + logger: { + debug() {}, + info() {}, + warn() {}, + error() {}, + }, + searchInTvdb: async () => [], + getMovieInTvdb: async () => ({}), + getTvShowInTvdb: async () => ({}), + getTvdbLanguages: async () => [], + }) + + await callMcp(handler, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.1" }, + }, + }) + + const listResponse = await callMcp(handler, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }) + const tools = (listResponse.result as { tools: Array<{ name: string }> }) + .tools + const names = tools.map((t) => t.name) + expect(names).toContain("tvdb-search") + expect(names).toContain("tvdb-get-movie") + expect(names).toContain("tvdb-get-tv-show") + expect(names).toContain("tvdb-get-languages") + } finally { + await cleanup() + } + }) }) diff --git a/packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts b/packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts new file mode 100644 index 00000000..d26ba5d2 --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts @@ -0,0 +1,143 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + TVDB_SEARCH, + TVDB_SEARCH_DESCRIPTION, + tvdbSearchInputSchema, + tvdbSearchOutputSchema, +} from "@smm/core/types/ai-tools/tvdbSearch"; +import { + TVDB_GET_MOVIE, + TVDB_GET_MOVIE_DESCRIPTION, + tvdbGetMovieInputSchema, + tvdbGetMovieOutputSchema, +} from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { + TVDB_GET_TV_SHOW, + TVDB_GET_TV_SHOW_DESCRIPTION, + tvdbGetTvShowInputSchema, + tvdbGetTvShowOutputSchema, +} from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { + TVDB_GET_LANGUAGES, + TVDB_GET_LANGUAGES_DESCRIPTION, + tvdbGetLanguagesInputSchema, + tvdbGetLanguagesOutputSchema, +} from "@smm/core/types/ai-tools/tvdbGetLanguages"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; +import { + executeTvdbGetLanguages, + executeTvdbGetMovie, + executeTvdbGetTvShow, + executeTvdbSearch, +} from "../../tools/tvdb.ts"; + +/** + * Register TVDB query MCP tools (`tvdb-search`, `tvdb-get-movie`, `tvdb-get-tv-show`, `tvdb-get-languages`). + */ +export function registerTvdbTools(server: McpServer, config: McpConfig): void { + const searchDescription = + config.toolDescriptions?.[TVDB_SEARCH] ?? TVDB_SEARCH_DESCRIPTION; + const movieDescription = + config.toolDescriptions?.[TVDB_GET_MOVIE] ?? TVDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = + config.toolDescriptions?.[TVDB_GET_TV_SHOW] ?? TVDB_GET_TV_SHOW_DESCRIPTION; + const languagesDescription = + config.toolDescriptions?.[TVDB_GET_LANGUAGES] ?? TVDB_GET_LANGUAGES_DESCRIPTION; + + server.registerTool( + TVDB_SEARCH, + { + description: searchDescription, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbSearch( + (args ?? {}) as Parameters[0], + config.searchInTvdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); + + server.registerTool( + TVDB_GET_MOVIE, + { + description: movieDescription, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbGetMovie( + (args ?? {}) as Parameters[0], + config.getMovieInTvdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); + + server.registerTool( + TVDB_GET_TV_SHOW, + { + description: tvShowDescription, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbGetTvShow( + (args ?? {}) as Parameters[0], + config.getTvShowInTvdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); + + server.registerTool( + TVDB_GET_LANGUAGES, + { + description: languagesDescription, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbGetLanguages( + config.getTvdbLanguages, + (args ?? {}) as Parameters[1], + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); +} diff --git a/packages/core-routes/src/mcp/types.ts b/packages/core-routes/src/mcp/types.ts index bce62ab7..53474084 100644 --- a/packages/core-routes/src/mcp/types.ts +++ b/packages/core-routes/src/mcp/types.ts @@ -143,6 +143,50 @@ export interface McpConfig { }, ) => Promise; + /** Optional runner for `tvdb-search` → `Core.searchInTvdb`. */ + searchInTvdb?: ( + keyword: string, + options: { + type: "series" | "movie"; + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tvdb-get-movie` → `Core.getMovieInTvdb`. */ + getMovieInTvdb?: ( + id: number, + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tvdb-get-tv-show` → `Core.getTvShowInTvdb`. */ + getTvShowInTvdb?: ( + id: number, + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tvdb-get-languages` → `Core.getTvdbLanguages`. */ + getTvdbLanguages?: ( + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + /** * Optional localized tool descriptions, keyed by tool name * (e.g. `"get-media-folders"`, `"is-folder-exist"`). When diff --git a/packages/core-routes/src/tools/tvdb.ts b/packages/core-routes/src/tools/tvdb.ts index d9fc31d7..2533f35d 100644 --- a/packages/core-routes/src/tools/tvdb.ts +++ b/packages/core-routes/src/tools/tvdb.ts @@ -46,21 +46,21 @@ export type SearchInTvdbRunner = ( password?: string; proxy?: string; }, -) => Promise>>; +) => Promise; export type GetTvShowInTvdbRunner = ( id: number, options?: { language?: string; host?: string; password?: string; proxy?: string }, -) => Promise>; +) => Promise; export type GetMovieInTvdbRunner = ( id: number, options?: { language?: string; host?: string; password?: string; proxy?: string }, -) => Promise>; +) => Promise; export type GetTvdbLanguagesRunner = ( options?: { language?: string; host?: string; password?: string; proxy?: string }, -) => Promise>>; +) => Promise; export interface TvdbToolRunners { searchInTvdb?: SearchInTvdbRunner; From 743c2558f5cc7a8115925d9c1e7e01494925ddf5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:43:00 +0800 Subject: [PATCH 115/214] feat(cli): wire TVDB Core runners into MCP and chat hosts --- apps/cli/src/mcp/mcp.ts | 12 ++++++++++++ apps/cli/src/route/chatRoute.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 69d14fbc..87a02ade 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -14,6 +14,10 @@ import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; import { TMDB_GET_MOVIE } from "@smm/core/types/ai-tools/tmdbGetMovie"; import { TMDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tmdbGetTvShow"; +import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; +import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -67,6 +71,10 @@ const TOOL_NAME_KEYS = [ TMDB_SEARCH, TMDB_GET_MOVIE, TMDB_GET_TV_SHOW, + TVDB_SEARCH, + TVDB_GET_MOVIE, + TVDB_GET_TV_SHOW, + TVDB_GET_LANGUAGES, ] as const; let handlerPromise: Promise | null = null; @@ -151,6 +159,10 @@ async function buildMcpConfig(): Promise { searchInTmdb: (keyword, options) => getCore().searchInTmdb(keyword, options), getMovieInTmdb: (id, options) => getCore().getMovieInTmdb(id, options), getTvShowInTmdb: (id, options) => getCore().getTvShowInTmdb(id, options), + searchInTvdb: (keyword, options) => getCore().searchInTvdb(keyword, options), + getMovieInTvdb: (id, options) => getCore().getMovieInTvdb(id, options), + getTvShowInTvdb: (id, options) => getCore().getTvShowInTvdb(id, options), + getTvdbLanguages: (options) => getCore().getTvdbLanguages(options), logger: { debug: (obj, msg) => logger.debug(obj, msg), info: (obj, msg) => logger.info(obj, msg), diff --git a/apps/cli/src/route/chatRoute.ts b/apps/cli/src/route/chatRoute.ts index 4148680b..6ce42d7b 100644 --- a/apps/cli/src/route/chatRoute.ts +++ b/apps/cli/src/route/chatRoute.ts @@ -24,6 +24,12 @@ export function handleChatRequest(app: Hono, chatConfig: ChatConfig) { getMovieInTmdb: (id, options) => getCore().getMovieInTmdb(id, options), getTvShowInTmdb: (id, options) => getCore().getTvShowInTmdb(id, options), }, + tvdb: { + searchInTvdb: (keyword, options) => getCore().searchInTvdb(keyword, options), + getMovieInTvdb: (id, options) => getCore().getMovieInTvdb(id, options), + getTvShowInTvdb: (id, options) => getCore().getTvShowInTvdb(id, options), + getTvdbLanguages: (options) => getCore().getTvdbLanguages(options), + }, }) return response } catch (error) { From e6e97027d96092373f38414edab44263ad3e88aa Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:54:22 +0800 Subject: [PATCH 116/214] test(cli): add smm tvdb search e2e --- apps/cli/test/tvdb.e2e.ts | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/cli/test/tvdb.e2e.ts diff --git a/apps/cli/test/tvdb.e2e.ts b/apps/cli/test/tvdb.e2e.ts new file mode 100644 index 00000000..dacebb44 --- /dev/null +++ b/apps/cli/test/tvdb.e2e.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './helpers/smm' +import { loadEnvLocal, requiredEnv } from './helpers/loadEnvLocal' + +loadEnvLocal(import.meta.dirname) + +const TVDB_SEARCH_TIMEOUT_MS = 60_000 +const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m +const SERIES_TITLE = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i + +function officialTvdb(): { host: string; password: string; proxy: string } { + return { + host: requiredEnv('TVDB_HOST'), + password: requiredEnv('TVDB_API_KEY'), + proxy: requiredEnv('TVDB_HTTP_PROXY'), + } +} + +describe('smm tvdb search CLI e2e (docs/dev/tvdb.md scenarios)', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tvdb-e2e-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it( + '1) Search through SMM-provided TVDB host', + { timeout: TVDB_SEARCH_TIMEOUT_MS }, + async () => { + const result = await smm(['tvdb', 'search', '天使降临到我身边', '--type', 'series']) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(SERIES_TITLE) + }, + ) + + it( + '2) Search through SMM-provided TVDB host and HTTP/SOCKS proxy', + { timeout: TVDB_SEARCH_TIMEOUT_MS }, + async () => { + const { proxy } = officialTvdb() + const result = await smm([ + 'tvdb', 'search', '天使降临到我身边', '--type', 'series', '--proxy', proxy, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(SERIES_TITLE) + }, + ) + + it( + '3) Search through custom TVDB host with password and proxy', + { timeout: TVDB_SEARCH_TIMEOUT_MS }, + async () => { + const { host, password, proxy } = officialTvdb() + const result = await smm([ + 'tvdb', 'search', '天使降临到我身边', '--type', 'series', + '--host', host, '--password', password, '--proxy', proxy, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(SERIES_TITLE) + }, + ) +}) From e8531f96461bb727dc6188715c004d7b7dff50bb Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 17:55:35 +0800 Subject: [PATCH 117/214] test(cli): fix tvdb e2e series title regex --- apps/cli/test/tvdb.e2e.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/test/tvdb.e2e.ts b/apps/cli/test/tvdb.e2e.ts index dacebb44..33d7a5c6 100644 --- a/apps/cli/test/tvdb.e2e.ts +++ b/apps/cli/test/tvdb.e2e.ts @@ -9,7 +9,9 @@ loadEnvLocal(import.meta.dirname) const TVDB_SEARCH_TIMEOUT_MS = 60_000 const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m -const SERIES_TITLE = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i +// TVDB search returns the primary (often Japanese) title and the CLI prints +// only `item.name` — WATATEN's TVDB primary title is "私に天使が舞い降りた!". +const SERIES_TITLE = /天使|WATATEN|Angel Flew Down|Wataten/i function officialTvdb(): { host: string; password: string; proxy: string } { return { From f9ea728472c1e5866d732e40410f71c2e3f3d5d8 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:08:44 +0800 Subject: [PATCH 118/214] feat(core): TVDB JWT login for custom hosts in media-database transport --- apps/core/src/Core.test.ts | 17 ++-- .../clients/mediaDatabaseTransport.test.ts | 88 ++++++++++++++++++- .../src/clients/mediaDatabaseTransport.ts | 66 ++++++++++++-- 3 files changed, 156 insertions(+), 15 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index dbf1cac8..730e1f4d 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -1165,6 +1165,9 @@ describe("Core.searchInTmdb", () => { }); function tvdbResponse(url: string): HttpResponse { + if (url.endsWith("/login")) { + return jsonResponse({ status: "success", data: { token: "jwt-123" } }); + } if (url.includes("/search")) { const type = url.includes("type=series") ? "series" : "movie"; return jsonResponse({ @@ -1210,11 +1213,15 @@ describe("Core.searchInTvdb", () => { }); expect(results[0]?.tvdb_id).toBe("1"); - expect(calls[0]?.url).toContain("https://tvdb.example.com/v4/search"); - expect(calls[0]?.url).toContain("query=keyword"); - expect(calls[0]?.url).toContain("language=eng"); - expect(calls[0]?.proxy).toBe("socks5://127.0.0.1:1080"); - expect(calls[0]?.auth).toBe("Bearer secret"); + // First call is the JWT login exchange. + expect(calls[0]?.url).toContain("https://tvdb.example.com/v4/login"); + expect(calls[0]?.auth).toBeUndefined(); + // The search request uses the JWT obtained from login. + expect(calls[1]?.url).toContain("https://tvdb.example.com/v4/search"); + expect(calls[1]?.url).toContain("query=keyword"); + expect(calls[1]?.url).toContain("language=eng"); + expect(calls[1]?.proxy).toBe("socks5://127.0.0.1:1080"); + expect(calls[1]?.auth).toBe("Bearer jwt-123"); }); it("maps explicit ISO 639-3 language through", async () => { diff --git a/apps/core/src/clients/mediaDatabaseTransport.test.ts b/apps/core/src/clients/mediaDatabaseTransport.test.ts index 17ba49c0..c54e3811 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.test.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { DiscoverPort } from "../ports/DiscoverPort"; import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; -import { fetchMediaDatabase, MediaDatabaseFailoverExhaustedError } from "./mediaDatabaseTransport"; +import { + _resetTvdbTokenCacheForTests, + fetchMediaDatabase, + MediaDatabaseFailoverExhaustedError, +} from "./mediaDatabaseTransport"; function jsonOk(body: unknown = {}): HttpResponse { return { @@ -158,3 +162,85 @@ describe("fetchMediaDatabase", () => { expect(proxies[0]).toBe("socks5://127.0.0.1:1080"); }); }); + +describe("TVDB custom-host JWT login", () => { + it("logs in first and uses the JWT as Bearer for a custom TVDB host", async () => { + const urls: string[] = [] + const headers: Array> = [] + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url) + headers.push(init?.headers ?? {}) + if (url.endsWith("/login")) { + expect(init?.method).toBe("POST") + expect(JSON.parse(init?.body ?? "{}")).toEqual({ apikey: "secret" }) + return jsonOk({ status: "success", data: { token: "jwt-123" } }) + } + return jsonOk({ status: "success", data: [{ id: "series-1", name: "My Show" }] }) + }, + } + + const resp = await fetchMediaDatabase(network, { + kind: "tvdb", + path: "/search?query=x&type=series", + configuredHost: "https://api4.thetvdb.com/v4", + apiKey: "secret", + }) + + expect(resp.ok).toBe(true) + expect(urls[0]).toContain("/login") + expect(urls[1]).toContain("/search") + // The login request must NOT carry an Authorization header. + expect(headers[0]?.Authorization).toBeUndefined() + // The actual request uses the JWT. + expect(headers[1]?.Authorization).toBe("Bearer jwt-123") + }) + + it("caches the JWT and skips re-login for the same host", async () => { + let loginCount = 0 + const network: NetworkPort = { + fetch: async (url) => { + if (url.endsWith("/login")) { + loginCount += 1 + return jsonOk({ status: "success", data: { token: "jwt-123" } }) + } + return jsonOk({ status: "success", data: [] }) + }, + } + _resetTvdbTokenCacheForTests() + + const opts = { + kind: "tvdb" as const, + path: "/search?query=x&type=series", + configuredHost: "https://api4.thetvdb.com/v4", + apiKey: "secret", + } + await fetchMediaDatabase(network, opts) + await fetchMediaDatabase(network, opts) + + expect(loginCount).toBe(1) + _resetTvdbTokenCacheForTests() + }) + + it("still sends the raw API key as Bearer for a custom TMDB host (no login)", async () => { + const urls: string[] = [] + const headers: Array> = [] + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url) + headers.push(init?.headers ?? {}) + return jsonOk({ results: [] }) + }, + } + + await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + configuredHost: "https://api.themoviedb.org/3", + apiKey: "secret", + }) + + expect(urls[0]).not.toContain("/login") + expect(headers[0]?.Authorization).toBe("Bearer secret") + }) +}) diff --git a/apps/core/src/clients/mediaDatabaseTransport.ts b/apps/core/src/clients/mediaDatabaseTransport.ts index 73a03f29..53ac1799 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.ts @@ -64,6 +64,55 @@ function authHeaders(apiKey?: string): Record { return { Authorization: `Bearer ${key}` }; } +/** In-process TVDB JWT cache keyed by normalized custom host. */ +const tvdbTokenCache = new Map< + string, + { token: string; expiresAt: number } +>(); +const TVDB_TOKEN_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000; // ~1 month per docs +const TVDB_TOKEN_REFRESH_BUFFER_MS = 60 * 60 * 1000; // refresh 1h early + +/** Test-only: clear the TVDB JWT cache. */ +export function _resetTvdbTokenCacheForTests(): void { + tvdbTokenCache.clear(); +} + +/** + * Obtain a TVDB bearer JWT for a custom host via `POST /login`, caching it in + * process memory and refreshing near expiry. The raw API key is NOT a bearer + * token for TVDB v4; a login exchange is required. + */ +async function ensureTvdbToken( + network: NetworkPort, + host: string, + apiKey: string | undefined, + proxy: string | undefined, +): Promise { + const key = normalizeBase(host); + const cached = tvdbTokenCache.get(key); + if (cached && Date.now() < cached.expiresAt - TVDB_TOKEN_REFRESH_BUFFER_MS) { + return cached.token; + } + + const url = joinUrl(key, "/login"); + const resp = await network.fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ apikey: apiKey?.trim() || "" }), + ...(proxy?.trim() ? { proxy: proxy.trim() } : {}), + }); + if (!resp.ok) { + throw new Error(`TVDB login failed: ${resp.status} ${resp.statusText}`); + } + const body = await resp.json<{ data?: { token?: string }; token?: string }>(); + const token = body?.data?.token ?? body?.token; + if (typeof token !== "string" || !token) { + throw new Error("TVDB login response missing token"); + } + tvdbTokenCache.set(key, { token, expiresAt: Date.now() + TVDB_TOKEN_VALIDITY_MS }); + return token; +} + function generalProxyHeaders( upstreamBaseURL: string, authorizationMethod: MediaDatabaseAuthorizationMethod, @@ -145,15 +194,14 @@ export async function fetchMediaDatabase( // (httpProxy is passed as NetworkPort `proxy`, not X-Http-Proxy). const url = joinUrl(upstream, path); attempted.push(url); - const resp = await tryFetch( - network, - url, - { - Accept: "application/json", - ...authHeaders(options.apiKey), - }, - options.httpProxy, - ); + let headers: Record = { Accept: "application/json" }; + if (options.kind === "tvdb") { + const token = await ensureTvdbToken(network, upstream, options.apiKey, options.httpProxy); + headers.Authorization = `Bearer ${token}`; + } else { + Object.assign(headers, authHeaders(options.apiKey)); + } + const resp = await tryFetch(network, url, headers, options.httpProxy); if (resp !== undefined) return resp; throw new MediaDatabaseFailoverExhaustedError(attempted); } From 3a898729383f4ffdc2c14b224b17fd91035c552f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:15:38 +0800 Subject: [PATCH 119/214] test(cli): add end-to-end tests for importing folders in CLI --- apps/cli/test/import-folder.e2e.ts | 127 +++++++++++++++++++++++++ docs/dev/import-folder.md | 139 ++++++++++++++++++++++++++++ docs/dev/supported-platform.md | 13 +++ docs/dev/test/import-folder-test.md | 70 ++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 apps/cli/test/import-folder.e2e.ts create mode 100644 docs/dev/import-folder.md create mode 100644 docs/dev/test/import-folder-test.md diff --git a/apps/cli/test/import-folder.e2e.ts b/apps/cli/test/import-folder.e2e.ts new file mode 100644 index 00000000..8f89e7a7 --- /dev/null +++ b/apps/cli/test/import-folder.e2e.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './helpers/smm' +import { + createFolderInTestFolder, + movieFolder, + musicFolder, + tvShowFolder, + type TestFolder, +} from './helpers/testFolders' + +describe('smm import folder CLI e2e', () => { + let userDataDir: string + let mediaDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-import-folder-e2e-ud-')) + mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-import-folder-e2e-media-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(mediaDir, { recursive: true, force: true }) + }) + + it('add --skip-init, list, then rm', async () => { + const folder = createFolderInTestFolder(mediaDir, tvShowFolder) + const path = folder.path! + + const added = await smm(['add', path, '--type', folder.type, '--skip-init']) + expect(added.code).toBe(0) + expect(added.stdout).toBe(`imported folder ${path}`) + expect(added.stdout).not.toContain('recognizing') + expect(added.stdout).not.toContain('succeeded') + + const listed = await smm(['list']) + expect(listed.code).toBe(0) + expect(listed.stdout.split('\n').filter(Boolean)).toEqual([path]) + + const removed = await smm(['rm', path]) + expect(removed.code).toBe(0) + expect(removed.stdout).toContain(`Removed ${path}`) + + const listedAfter = await smm(['list']) + expect(listedAfter.code).toBe(0) + expect(listedAfter.stdout.trim()).toBe('') + }) + + it( + 'add tvshow, movie, and music folders, then show and metadata', + { timeout: 10 * 60 * 1000 }, + async () => { + const tv = createFolderInTestFolder(mediaDir, tvShowFolder) + const movie = createFolderInTestFolder(mediaDir, movieFolder) + const music = createFolderInTestFolder(mediaDir, musicFolder) + + await addFolder(tv) + await addFolder(movie) + await addFolder(music) + + const listed = await smm(['list']) + expect(listed.code).toBe(0) + const listedPaths = listed.stdout.split('\n').filter(Boolean) + expect(listedPaths).toEqual(expect.arrayContaining([tv.path, movie.path, music.path])) + + const tvShow = await smm(['show', tv.path!]) + expect(tvShow.code).toBe(0) + expect(tvShow.stdout).toContain('Status: ok') + expect(tvShow.stdout).toContain('Type: tvshow-folder') + expect(tvShow.stdout).toMatch(/Title:\s+.+/) + expect(tvShow.stdout).toMatch(/天使降临到我身边|WATATEN|Angel Flew Down/i) + expect(tvShow.stdout).toMatch(/Season \d+:/) + expect(tvShow.stdout).toMatch(/S\d{2}E\d{2}/) + + const tvMeta = await smm(['metadata', tv.path!]) + expect(tvMeta.code).toBe(0) + expect(tvMeta.stdout).toContain('type: tvshow-folder') + expect(tvMeta.stdout).toContain('tvShow:') + expect(tvMeta.stdout).toContain('mediaFiles:') + expect(tvMeta.stdout).not.toContain('(empty)') + expect(tvMeta.stdout).toMatch(/seasonNumber:\s*1/) + + const movieShow = await smm(['show', movie.path!]) + expect(movieShow.code).toBe(0) + expect(movieShow.stdout).toContain('Status: ok') + expect(movieShow.stdout).toContain('Type: movie-folder') + expect(movieShow.stdout).toMatch(/Title:\s+.+/) + expect(movieShow.stdout).toMatch(/Dark Knight|黑暗骑士/i) + expect(movieShow.stdout).toMatch(/\n {4}\S+/) + + const movieMeta = await smm(['metadata', movie.path!]) + expect(movieMeta.code).toBe(0) + expect(movieMeta.stdout).toContain('type: movie-folder') + expect(movieMeta.stdout).toContain('movie:') + expect(movieMeta.stdout).toContain('mediaFiles:') + expect(movieMeta.stdout).not.toContain('(empty)') + + const musicShow = await smm(['show', music.path!]) + expect(musicShow.code).toBe(0) + expect(musicShow.stdout).toContain('Status: ok') + expect(musicShow.stdout).toContain('Type: music-folder') + expect(musicShow.stdout).not.toMatch(/Title:/) + + const musicMeta = await smm(['metadata', music.path!]) + expect(musicMeta.code).toBe(0) + expect(musicMeta.stdout).toContain('type: music-folder') + expect(musicMeta.stdout).not.toContain('tvShow:') + expect(musicMeta.stdout).not.toContain('movie:') + }, + ) +}) + +async function addFolder(folder: TestFolder): Promise { + const result = await smm(['add', folder.path!, '--type', folder.type]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toContain(`imported folder ${folder.path}`) + expect(result.stdout).toMatch(/succeeded/) +} diff --git a/docs/dev/import-folder.md b/docs/dev/import-folder.md new file mode 100644 index 00000000..465ef0ad --- /dev/null +++ b/docs/dev/import-folder.md @@ -0,0 +1,139 @@ +# Import Folder + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** wip + +## apps/core + +Layer 2 entry points used by all frontends: + +| Method | Role | +|--------|------| +| `importFolder(path, type, { skipInit? })` | Start import; returns `{ id }` immediately; runs pipeline in background | +| `getJob(id)` | Poll in-memory import job (`status`, `stage`, `progress`, `error`) | +| `getFolders()` | List imported folder paths from `smm.json` | +| `getMediaMetadata(path)` | Read persisted metadata cache; used by `resolveShowFolder` for folder status | + +`importFolder` pipeline (`ImportFolderPipeline`): + +``` +config → metadata → listFiles → recognize → episodes → persist +``` + +- **config** — add path to `userConfig.folders`, write `smm.json` +- **metadata** — create blank `MediaMetadata` with folder type +- **listFiles** — list directory files via `FsPort` +- **recognize** — tvshow/movie only (NFO → id in folder name → search); music skips +- **episodes** — match video files to episodes (tvshow) or pick first video (movie) +- **persist** — write metadata cache under `/metadata/` + +```mermaid +sequenceDiagram + participant Caller + participant Core + participant Pipeline as ImportFolderPipeline + participant Fs as FsPort + participant Net as NetworkPort + + Caller->>Core: importFolder(path, type) + Core->>Core: create job (running) + Core-->>Caller: { id } + Core->>Pipeline: run (background) + Pipeline->>Fs: read/write smm.json + Pipeline->>Fs: listFiles(path) + Pipeline->>Net: TMDB/TVDB (recognize) + Pipeline->>Fs: write metadata cache + Pipeline-->>Core: job.status = succeeded | failed + + loop poll + Caller->>Core: getJob(id) + Core-->>Caller: ImportJob + end + + Caller->>Core: getFolders() + Core->>Fs: read smm.json + Core-->>Caller: folders[] + + Caller->>Core: getMediaMetadata(path) + Core->>Fs: read metadata cache + Core-->>Caller: MediaMetadata | null +``` + +Folder status values (`ok` | `folder_not_found` | `error_loading_metadata`) are derived in the CLI helper `resolveShowFolder` from `getFolders()`, on-disk path check, and `getMediaMetadata()`. + +## Web UI, Electron and ohos + +Web UI talks to Core via Internal HTTP (`apps/cli`). Electron and ohos reuse the same UI bundle. + +```mermaid +sequenceDiagram + participant User + participant Browser + participant CLI + participant Core + + User->>Browser: click import button + Browser->>CLI: POST /api/import-folder { path, type } + CLI->>Core: importFolder(path, type) + Core-->>CLI: { id } + CLI-->>Browser: { data: { id } } + Note over Browser: upsertFolder status=initializing (Zustand) + loop poll ~1s + Browser->>CLI: POST /api/get-job { id } + CLI->>Core: getJob(id) + Core-->>CLI: ImportJob + CLI-->>Browser: { data: ImportJob } + end + Browser->>CLI: POST /api/get-folders + CLI->>Core: getFolders() + Core-->>CLI: folders[] + CLI-->>Browser: { data: { folders } } + Browser->>CLI: POST /api/show-folder { path } + CLI->>Core: getFolders() + getMediaMetadata(path) + Core-->>CLI: folders[] + MediaMetadata | null + CLI-->>Browser: { data: { path, status, ... } } + Note over Browser: upsertFolder status from show-folder (Zustand) +``` + +See [apps/core](#appscore) for pipeline stages and persistence details. + +## CLI + +``` +smm add --type tvshow|movie|music|anime [--verbose] [--skip-init] +smm list +smm show +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + + User->>CLI: smm add --type tvshow + activate CLI + CLI->>Core: importFolder(path, type) + Core-->>CLI: { id } + loop poll internally (~1s, not shown to user) + CLI->>Core: getJob(id) + Core-->>CLI: ImportJob + end + CLI-->>User: progress lines + exit 0 + deactivate CLI + + User->>CLI: smm show + CLI->>Core: getFolders() + getMediaMetadata(path) + Core-->>CLI: folders[] + MediaMetadata | null + CLI-->>User: Path / Status / Type / Title +``` + +See [apps/core](#appscore) for pipeline stages and persistence details. + +## Test + +See [test cases](./test/import-folder-test.md) + +## References + +[Supported Platform](./supported-platform.md) diff --git a/docs/dev/supported-platform.md b/docs/dev/supported-platform.md index bf2186e6..95477ad6 100644 --- a/docs/dev/supported-platform.md +++ b/docs/dev/supported-platform.md @@ -26,6 +26,19 @@ smm Server is listening at http://localhost:8080 ``` +**Electron** + +Electron desktop app + +``` +Electron + |-- Main Process (node.js side) + |-- core (built from apps/core) + |-- core-routes (built from packages/core-routes) + |-- UI (browser side, built from apps/ui) +``` + + **HarmonyOS/ohos** User operates in HarmonyOS app, which is a Electron desktop application ported to HarmonyOS. diff --git a/docs/dev/test/import-folder-test.md b/docs/dev/test/import-folder-test.md new file mode 100644 index 00000000..30c54c85 --- /dev/null +++ b/docs/dev/test/import-folder-test.md @@ -0,0 +1,70 @@ +# Tests for Importing Folder + +This page describes **e2e** test cases for [Import Folder](../import-folder.md). + +Fixtures: `packages/test` (`folder1`, `folder2`, `tvShowFolder`, `movieFolder`, `musicFolder`, …). + +Web UI and ohos share `apps/e2e/common/**`; ohos runs them with `--platform ohos`. + +Directory recognition scenarios (TMDB/TVDB ID, NFO, search by name, unknown) are covered by unit tests in `apps/core/src/pipeline/recognizeMediaFolder.test.ts`, not listed here. + +| Test Case | Platform | Test File | +|--|--|--| +| Import TV show — full pipeline | CLI | apps/cli/test/import-folder.e2e.ts | +| | Web UI | apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts | +| | ohos | apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts · apps/e2e/ohos/tv/TVShow-Import.e2e.ts | +| Import TV show — TMDB ID in folder name | CLI | apps/cli/test/import-folder.e2e.ts | +| | Web UI | apps/e2e/common/tv/TVShow-Import.e2e.ts | +| | ohos | apps/e2e/common/tv/TVShow-Import.e2e.ts · apps/e2e/ohos/tv/TVShow-Import.e2e.ts | +| Import TV show — TVDB ID in folder name | Web UI | apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts | +| | ohos | apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts | +| Import TV show — search by folder name (TVDB) | Web UI | apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts | +| | ohos | apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts | +| Import TV show — recognize from NFO | Web UI | apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts · InitializeTvShowByTvdb.e2e.ts | +| | ohos | apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts · InitializeTvShowByTvdb.e2e.ts | +| Import TV show — unknown / no match | Web UI | apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts | +| | ohos | apps/e2e/common/tv/InitializeTvShowByTmdb.e2e.ts | +| Import TV show — batch media library | Web UI | apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts | +| | ohos | apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts | +| Import movie — full pipeline | CLI | apps/cli/test/import-folder.e2e.ts | +| | Web UI | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| Import movie — folder name search (TMDB) | Web UI | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| Import movie — TMDB ID in folder name | Web UI | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| Import movie — TVDB ID in folder name | Web UI | apps/e2e/common/movie/InitializeMovieByTvdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTvdb.e2e.ts | +| Import movie — search by folder name (TVDB) | Web UI | apps/e2e/common/movie/InitializeMovieByTvdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTvdb.e2e.ts | +| Import movie — recognize from NFO | Web UI | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| Import movie — unknown / no match | Web UI | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | +| Import movie — batch media library | Web UI | apps/e2e/common/movie/ImportMovieLibrary.e2e.ts | +| | ohos | apps/e2e/common/movie/ImportMovieLibrary.e2e.ts | +| Import music | CLI | apps/cli/test/import-folder.e2e.ts | +| | Web UI | apps/e2e/common/other/App.e2e.ts | +| | ohos | apps/e2e/common/other/App.e2e.ts | +| Import music — tutorial fixture (manual) | Web UI | apps/e2e/common/manual/MusicPanel-Transcribe.e2e.ts | +| | ohos | apps/e2e/common/manual/MusicPanel-Transcribe.e2e.ts | +| Import with `--skip-init` | CLI | apps/cli/test/import-folder.e2e.ts | +| List imported folders | CLI | apps/cli/test/import-folder.e2e.ts | +| Remove imported folder (`smm rm`) | CLI | apps/cli/test/import-folder.e2e.ts | +| `smm show` / `smm metadata` after import | CLI | apps/cli/test/import-folder.e2e.ts | +| Switch UI among TV / movie / music folders | Web UI | apps/e2e/common/other/App.e2e.ts | +| | ohos | apps/e2e/common/other/App.e2e.ts | +| Import behind HTTP proxy | Web UI | apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts · InitTvShowByTvdbBehindHttpProxy.e2e.ts | +| | ohos | apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts · InitTvShowByTvdbBehindHttpProxy.e2e.ts | +| Import with custom TMDB / TVDB host | Web UI | apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts · InitTvShowByCustomTvdbHost.e2e.ts | +| | ohos | apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts · InitTvShowByCustomTvdbHost.e2e.ts | + +## Coverage gaps + +**CLI 有、Web UI / ohos 无 e2e:** `--skip-init`、`smm list`、`smm rm`、`smm show` / `metadata`。 + +**Web UI / ohos 有、CLI 无 e2e:** TVDB 识别、NFO 识别、Unknown 文件夹、批量导入媒体库、HTTP 代理与自定义 TMDB·TVDB host、UI 文件夹切换(`App.e2e.ts`)。其中识别路径已在 `recognizeMediaFolder.test.ts` 有单元覆盖;e2e 侧重 UI 与 pipeline 集成。 + +**Music:** CLI 有完整 e2e 断言;Web UI / ohos 主要在 `App.e2e.ts` 做 smoke。`RenameFolder.e2e.ts` 注明 `createAndImportFolderViaBrowser` 尚无法导入 music(TODO)。 + +**ohos:** movie / music / library 无单独 `apps/e2e/ohos/**` spec,依赖 `--platform ohos` 跑 `common/**`;设备侧导入步骤见 `apps/e2e/test/steps/import-folder-in-harmonyos.ts`。 From e365158d1a58c52d79979eef3fd1d5df7960719e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:21:00 +0800 Subject: [PATCH 120/214] docs: document TVDB internal HTTP routes --- docs/api/index.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/api/index.md b/docs/api/index.md index e5741cc3..9b6c4053 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -67,6 +67,16 @@ Source Code: apps/cli/src/route/Tmdb.ts - `POST /api/get-movie-in-tmdb` → `Core.getMovieInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. - `POST /api/get-tvshow-in-tmdb` → `Core.getTvShowInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +## SearchInTvdb / GetMovieInTvdb / GetTvShowInTvdb / GetTvdbLanguages +Source Code: apps/cli/src/route/Tvdb.ts + +1:1 Internal HTTP for Core TVDB methods. Response `{ data }` or `{ error }` (HTTP 200). Optional `language` (ISO 639-3) / `host` / `password` / `proxy` override `userConfig.tvdb`. TVDB custom hosts authenticate via an in-process JWT login exchange (`POST /login`). Used by Web UI when `localStorage["smm.v3.enabled"] === "true"` and by in-app AI tools. MCP / server-side chat inject the same Core methods in-process. + +- `POST /api/search-in-tvdb` → `Core.searchInTvdb`. Body: `{ keyword: string, type: "series" | "movie", language?, host?, password?, proxy? }`. +- `POST /api/get-movie-in-tvdb` → `Core.getMovieInTvdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +- `POST /api/get-tvshow-in-tvdb` → `Core.getTvShowInTvdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +- `POST /api/get-tvdb-languages` → `Core.getTvdbLanguages`. Body: `{}`. + ## ShowFolder Source Code: apps/cli/src/route/ShowFolder.ts HTTP: `POST /api/show-folder` — UI-aligned folder snapshot via the same helper as `smm show`. Request body: `{ path: string }`. Response: `{ data: { path, status, type?, title? } }` (`status`: `ok` | `folder_not_found` | `error_loading_metadata`) or `{ error }` (not imported / path missing). From b619c2ab97cb00e4765dfb34bf1f2d254849e073 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:28:52 +0800 Subject: [PATCH 121/214] fix(core-routes): wire TVDB AI tools into chat streamText and address review findings --- apps/cli/src/cli/tmdb.test.ts | 2 +- apps/cli/src/cli/tvdb.test.ts | 2 +- apps/core/src/Core.ts | 19 ++- docs/dev/tvdb.md | 223 ++++++++++++++++++++++++++ packages/core-routes/src/chat.ts | 8 + packages/core/ai-tool/systemPrompt.ts | 12 ++ 6 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 docs/dev/tvdb.md diff --git a/apps/cli/src/cli/tmdb.test.ts b/apps/cli/src/cli/tmdb.test.ts index 1824f6a6..f1c419df 100644 --- a/apps/cli/src/cli/tmdb.test.ts +++ b/apps/cli/src/cli/tmdb.test.ts @@ -63,7 +63,7 @@ describe('smm tmdb search', () => { const code = await runCli(['node', 'smm', 'tmdb', 'search', 'keyword', '--type', 'tv']) expect(code).toBe(0) - expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) + expect(logSpy.mock.calls.map((c: unknown[]) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) }) it('passes host, password, proxy and lang to Core.searchInTmdb', async () => { diff --git a/apps/cli/src/cli/tvdb.test.ts b/apps/cli/src/cli/tvdb.test.ts index e830b608..c123e974 100644 --- a/apps/cli/src/cli/tvdb.test.ts +++ b/apps/cli/src/cli/tvdb.test.ts @@ -45,7 +45,7 @@ describe('smm tvdb search', () => { const code = await runCli(['node', 'smm', 'tvdb', 'search', 'keyword', '--type', 'series']) expect(code).toBe(0) - expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) + expect(logSpy.mock.calls.map((c: unknown[]) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) }) it('passes host, password, proxy and lang to Core.searchInTvdb', async () => { diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index ad226e54..108bfe20 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -434,7 +434,7 @@ export class Core { /** Fetch the TVDB supported language list via {@link NetworkPort}. */ async getTvdbLanguages(options: TvdbRequestOptions = {}): Promise { - const { client } = await this.createTvdbClient(options); + const { client } = await this.createTvdbClient(options, false); const languages = await client.getLanguages(); if (!languages) { throw new Error("Failed to get TVDB languages"); @@ -470,15 +470,18 @@ export class Core { private async createTvdbClient( options: TvdbRequestOptions, + resolveLanguage = true, ): Promise<{ client: TvdbClient; language: string }> { const config = await this.userConfig.read(); - const language = options.language?.trim() - ? parseTvdbSearchLanguage(options.language) - : resolveTvdbSearchLanguage({ - preferMediaLanguage: config.preferMediaLanguage, - configured: config.applicationLanguage, - osLocale: detectOsLocale(), - }); + const language = resolveLanguage + ? (options.language?.trim() + ? parseTvdbSearchLanguage(options.language) + : resolveTvdbSearchLanguage({ + preferMediaLanguage: config.preferMediaLanguage, + configured: config.applicationLanguage, + osLocale: detectOsLocale(), + })) + : ""; const host = options.host?.trim() || config.tvdb?.host; const apiKey = options.password?.trim() || config.tvdb?.apiKey; const httpProxy = options.proxy?.trim() || config.tvdb?.httpProxy; diff --git a/docs/dev/tvdb.md b/docs/dev/tvdb.md new file mode 100644 index 00000000..73c7fa4e --- /dev/null +++ b/docs/dev/tvdb.md @@ -0,0 +1,223 @@ +# TVDB 集成(v3) + +SMM 通过 `apps/core` 统一访问 TVDB:**全部出站流量**经 `Core` + `NetworkPort`,不直连上游(浏览器侧见 [network-core.md](./network-core.md))。 + +| 入口 | 状态 | 说明 | +|------|------|------| +| CLI | ⏳ | `smm tvdb search` → 进程内 `Core.searchInTvdb` | +| AI Tool | ⏳ | 应用内 Chat → 对应 HTTP API → Core;服务端 Chat 进程内注入 Core | +| MCP Tool | ⏳ | MCP 工具进程内调用 Core 同名方法(与 HTTP 路由同一 Core) | +| Web UI | ⏳ | `smm.v3.enabled`:对应 HTTP API → Core 同名方法 | + +Core 方法与 Internal HTTP **一对一**暴露。Web UI(v3)与应用内 AI 走这些 API 再进入 Core;MCP / 服务端 Chat 在进程内调用同一套 Core 方法。均不走 `POST /api/core/fetch`(那是 `BrowserNetworkPort` 的通用出站中继,见 [network-core.md](./network-core.md))。 + +| Core 方法 | HTTP | +|-----------|------| +| `searchInTvdb` | `POST /api/search-in-tvdb` | +| `getTvShowInTvdb` | `POST /api/get-tvshow-in-tvdb` | +| `getMovieInTvdb` | `POST /api/get-movie-in-tvdb` | +| `getTvdbLanguages` | `POST /api/get-tvdb-languages` | + +```mermaid +flowchart TB + CLI["apps/cli"] + WEB["apps/ui ⏳"] + AI["AI Tool ⏳"] + MCP["MCP Tool ⏳"] + + API["POST /api/search-in-tvdb
POST /api/get-tvshow-in-tvdb
POST /api/get-movie-in-tvdb
POST /api/get-tvdb-languages"] + CORE["apps/core
searchInTvdb · getTvShowInTvdb · getMovieInTvdb · getTvdbLanguages"] + NET["NetworkPort
NodejsNetworkPort"] + TVDB["TVDB v4 上游"] + + CLI -->|进程内| CORE + WEB --> API + AI --> API + MCP --> API + API -->|一对一| CORE + CORE --> NET --> TVDB +``` + +**配置**:host / apiKey / httpProxy 来自 `userConfig.tvdb`;各入口可临时覆盖(CLI flags、工具参数 `host` 等)。TVDB 的 JWT(`POST /login`,约 30 天有效)由 Core 内的 `TvdbClient` 获取并进程内缓存、临近过期自动刷新,对调用方透明。 + +--- + +## apps/core API + +四个公开方法,共用 `TvdbClient` 与 `NetworkPort` 出站。每个方法一对一暴露为 HTTP: + +| 方法 | HTTP | 用途 | +|------|------|------| +| `searchInTvdb(keyword, options)` | `POST /api/search-in-tvdb` | 按关键词搜索 TV series / movie | +| `getTvShowInTvdb(id, options)` | `POST /api/get-tvshow-in-tvdb` | 按 TVDB series id 拉剧集元数据(季/集 + 翻译) | +| `getMovieInTvdb(id, options)` | `POST /api/get-movie-in-tvdb` | 按 TVDB movie id 拉电影元数据(含翻译) | +| `getTvdbLanguages(options)` | `POST /api/get-tvdb-languages` | 支持的语言列表(ISO 639-3,供搜索语言下拉) | + +```ts +core.searchInTvdb(keyword, { + type: "series" | "movie", + language?: string, // ISO 639-3 主语言过滤(可选);离线校验 packages/core/tvdbSupportedLanguages.ts + host?: string, // 默认 userConfig.tvdb.host + password?: string, // 默认 userConfig.tvdb.apiKey + proxy?: string, // 默认 userConfig.tvdb.httpProxy +}) + +core.getTvShowInTvdb(id, { language?, host?, password?, proxy? }) +core.getMovieInTvdb(id, { language?, host?, password?, proxy? }) +core.getTvdbLanguages({ host?, password?, proxy? }) +``` + +- `TvdbClient` 经 `NetworkPort` 发请求;内部完成 `POST /login` 并缓存 JWT,`reverseProxyUrl` 语义与 TMDB 一致。 +- `language` 为 ISO 639-3 代码(如 `eng`、`zho`、`yue`),须在静态 supported languages 列表中;省略时按 `preferMediaLanguage` → OS → `eng` 映射(`zh-CN`→`zho`、`en-US`→`eng`、`ja-JP`→`jpn`)。 +- 搜索接口的 `language` 是**主语言过滤**(primary language filter),不返回该语言的本地化标题;本地化标题取搜索结果中的 `translations` / `name_translated` 字段(按搜索语言挑选)。 +- `getTvShowInTvdb` / `getMovieInTvdb` 的 `language` 决定返回标题与剧集名的翻译语言。 + +实现:`apps/core/src/Core.ts`(`TvdbClient` 见 `apps/core/src/clients/TvdbClient.ts`)。 + +--- + +## CLI + +### 命令 + +仅暴露搜索子命令: + +```bash +smm tvdb search "" --type series|movie [options] +``` + +### 输出格式 + +```bash +$ smm tvdb search "keyword" --type series +#1 {tvdbid} {title} ({release date}) +{overview} +#2 {tvdbid2} {title2} ({release date}) +{overview} +``` + +### 常用场景 + +```bash +# 默认 host(userConfig / 内置) +smm tvdb search "keyword" --type series + +# 指定语言(主语言过滤,ISO 639-3) +smm tvdb search "keyword" --type series --lang zho + +# 经 HTTP/SOCKS 代理 +smm tvdb search "keyword" --type series --proxy "socks5://proxy.example.com:7079" + +# 自定义 host + API key +smm tvdb search "keyword" --type series \ + --host "https://api4.thetvdb.com/v4" \ + --password "your-api-key" \ + --proxy "socks5://proxy.example.com:7079" +``` + +### 参数 + +| 参数 | 说明 | +|------|------| +| `--type` | `series` \| `movie`(必填) | +| `--lang` | ISO 639-3 主语言过滤(如 `zho`);无效值离线报错 | +| `--host` | 覆盖 `userConfig.tvdb.host` | +| `--password` | 覆盖 `userConfig.tvdb.apiKey` | +| `--proxy` | 覆盖 `userConfig.tvdb.httpProxy` | + +实现:`apps/cli/src/cli/runCli.ts`(格式化:`tvdbSearchFormat.ts`)。 + +--- + +## AI Tool + +Chat 后端工具,**仅服务端也可执行**(registry 中 `backend: true`)。应用内 AI(`frontend: true`)经对应 HTTP API 进入 Core,不走 `/api/core/fetch`。MCP / 服务端 chat 在进程内注入 `Core.searchInTvdb` 等同名方法(与 HTTP 路由同一 Core)。 + +| 工具名 | HTTP | Core 方法 | 说明 | +|--------|------|-----------|------| +| `tvdb-search` | `POST /api/search-in-tvdb` | `searchInTvdb` | 关键词搜索 | +| `tvdb-get-movie` | `POST /api/get-movie-in-tvdb` | `getMovieInTvdb` | 电影详情 | +| `tvdb-get-tv-show` | `POST /api/get-tvshow-in-tvdb` | `getTvShowInTvdb` | 剧集详情 | +| `tvdb-get-languages` | `POST /api/get-tvdb-languages` | `getTvdbLanguages` | 支持的语言列表 | + +### 参数(与 CLI 对照) + +| 工具字段 | 对应 Core | +|----------|-----------| +| `keyword`, `type` | `searchInTvdb` | +| `id` | `getMovieInTvdb` / `getTvShowInTvdb` | +| `language` | `language`(同 CLI `--lang`) | +| `baseURL` | `host`(同 CLI `--host`) | + +host / apiKey / proxy 未在工具参数中指定时,仍走 `userConfig.tvdb`。 + +类型定义:`packages/core/types/ai-tools/tvdb*.ts` +执行与构建:`packages/core-routes/src/tools/tvdb.ts` +注册:`packages/core-routes/src/tools/index.ts`、`chat.ts`;CLI 注入 runner:`apps/cli/src/route/chatRoute.ts`。 + +System prompt 指引见 `packages/core/ai-tool/systemPrompt.ts`(先 `tvdb-search`,再按 id 拉详情)。 + +--- + +## MCP Tool + +与 AI Tool **同名、同 schema**,经 MCP HTTP 暴露。工具 handler 在进程内调用 `Core.searchInTvdb` 等同名方法(与 `POST /api/search-in-tvdb` 等路由同一 Core),不走 `/api/core/fetch`。 + +| MCP 工具名 | HTTP | Core 方法 | +|------------|------|-----------| +| `tvdb-search` | `POST /api/search-in-tvdb` | `searchInTvdb` | +| `tvdb-get-movie` | `POST /api/get-movie-in-tvdb` | `getMovieInTvdb` | +| `tvdb-get-tv-show` | `POST /api/get-tvshow-in-tvdb` | `getTvShowInTvdb` | +| `tvdb-get-languages` | `POST /api/get-tvdb-languages` | `getTvdbLanguages` | + +注册:`packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts` +CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTvdb` / `getMovieInTvdb` / `getTvShowInTvdb` / `getTvdbLanguages`)。 + +本地调试 MCP 客户端:`test/mcp-test-client/index.ts`(`SMM_MCP_URL` + `--tool tvdb-search`)。 + +--- + +## Web UI(⏳ v3 迁移) + +Web UI 的改动需要使用 localStorage 开关 `smm.v3.enabled` 控制. + +搜索入口:`MediaDatabaseSearchbox`(`TvShowPanelHeader` / `MovieHeaderV2`)。 + +**目标路径**(与 Core v3 一致):UI 调用一对一 Internal HTTP,服务端再调对应 Core 方法。不经 `BrowserNetworkPort` / `POST /api/core/fetch`。 + +```mermaid +flowchart LR + A[MediaDatabaseSearchbox] --> B[POST /api/search-in-tvdb] + B --> C[Core.searchInTvdb] + C --> D[NodejsNetworkPort] + D --> E[TVDB] +``` + +| 层 | 文件 / 接口 | +|----|------| +| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` | +| HTTP | `POST /api/search-in-tvdb` · `POST /api/get-movie-in-tvdb` · `POST /api/get-tvshow-in-tvdb` · `POST /api/get-tvdb-languages` | +| Core | `searchInTvdb` · `getMovieInTvdb` · `getTvShowInTvdb` · `getTvdbLanguages` | +| 出站 | `apps/cli/src/core/NodejsNetworkPort.ts` | + +Web 不调用 `smm tvdb search`,但与 CLI 共用 Core 方法与 `userConfig.tvdb` 语义。 + +--- + +## 测试 + +| 范围 | 文件 | +|------|------| +| CLI e2e | `apps/cli/test/tvdb.e2e.ts` — 文档「常用场景」四类搜索 | +| core-routes 单测 | `packages/core-routes/src/tools/tvdb.test.ts` | +| MCP e2e (⏳) | `apps/e2e/common/mcp/McpOther-TvdbTools.e2e.ts` — 四工具 + live TVDB(待添加) | +| MCP 客户端 | `apps/e2e/test/lib/McpClient.ts` | + +MCP / 需直连 TVDB 的 e2e 在 `apps/e2e/.env.local` 配置 `TVDB_HOST`、`TVDB_API_KEY`、`TVDB_HTTP_PROXY`(网络受限环境)。 + +运行 MCP spec: + +```bash +cd apps/e2e +pnpm run wdio --spec ./common/mcp/McpOther-TvdbTools.e2e.ts +``` diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index 02ef3adf..652adc2f 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -15,6 +15,10 @@ import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; import { TMDB_GET_MOVIE } from "@smm/core/types/ai-tools/tmdbGetMovie"; import { TMDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tmdbGetTvShow"; +import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; +import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -145,6 +149,10 @@ export async function doChat( [TMDB_SEARCH]: tools[TMDB_SEARCH], [TMDB_GET_MOVIE]: tools[TMDB_GET_MOVIE], [TMDB_GET_TV_SHOW]: tools[TMDB_GET_TV_SHOW], + [TVDB_SEARCH]: tools[TVDB_SEARCH], + [TVDB_GET_MOVIE]: tools[TVDB_GET_MOVIE], + [TVDB_GET_TV_SHOW]: tools[TVDB_GET_TV_SHOW], + [TVDB_GET_LANGUAGES]: tools[TVDB_GET_LANGUAGES], [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], diff --git a/packages/core/ai-tool/systemPrompt.ts b/packages/core/ai-tool/systemPrompt.ts index a4013d5c..74f94f19 100644 --- a/packages/core/ai-tool/systemPrompt.ts +++ b/packages/core/ai-tool/systemPrompt.ts @@ -17,6 +17,10 @@ import { GET_JOB } from '../types/ai-tools/getJob' import { TMDB_SEARCH } from '../types/ai-tools/tmdbSearch' import { TMDB_GET_MOVIE } from '../types/ai-tools/tmdbGetMovie' import { TMDB_GET_TV_SHOW } from '../types/ai-tools/tmdbGetTvShow' +import { TVDB_SEARCH } from '../types/ai-tools/tvdbSearch' +import { TVDB_GET_MOVIE } from '../types/ai-tools/tvdbGetMovie' +import { TVDB_GET_TV_SHOW } from '../types/ai-tools/tvdbGetTvShow' +import { TVDB_GET_LANGUAGES } from '../types/ai-tools/tvdbGetLanguages' /** * The system prompt for the AI Assistant, shared between the @@ -90,6 +94,14 @@ When user asks to search TMDB, find a TV show or movie on TMDB, or look up TMDB 1. Call "${TMDB_SEARCH}" with keyword and type (\`tv\` or \`movie\`) to find candidates. 2. Call "${TMDB_GET_TV_SHOW}" or "${TMDB_GET_MOVIE}" with the chosen TMDB id for full details (seasons/episodes for TV). +### TVDB Search and Details + +When user asks to search TVDB, find a TV show or movie on TVDB, or look up TVDB metadata by id: + +1. Call "${TVDB_SEARCH}" with keyword and type (\`series\` or \`movie\`) to find candidates. +2. Call "${TVDB_GET_TV_SHOW}" or "${TVDB_GET_MOVIE}" with the chosen TVDB id for full metadata (seasons/episodes for TV). +3. Use "${TVDB_GET_LANGUAGES}" to discover supported ISO 639-3 language codes when needed. + ## User Preferences Language: zh-CN (you should answer in user's language) From 74799607d5e943c97f17844611c19d8ed0234886 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:31:39 +0800 Subject: [PATCH 122/214] docs(tvdb): mark implemented entry status and gate pending MCP e2e command --- docs/dev/tvdb.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/dev/tvdb.md b/docs/dev/tvdb.md index 73c7fa4e..3087960f 100644 --- a/docs/dev/tvdb.md +++ b/docs/dev/tvdb.md @@ -4,9 +4,9 @@ SMM 通过 `apps/core` 统一访问 TVDB:**全部出站流量**经 `Core` + `N | 入口 | 状态 | 说明 | |------|------|------| -| CLI | ⏳ | `smm tvdb search` → 进程内 `Core.searchInTvdb` | -| AI Tool | ⏳ | 应用内 Chat → 对应 HTTP API → Core;服务端 Chat 进程内注入 Core | -| MCP Tool | ⏳ | MCP 工具进程内调用 Core 同名方法(与 HTTP 路由同一 Core) | +| CLI | ✅ | `smm tvdb search` → 进程内 `Core.searchInTvdb` | +| AI Tool | ✅ | 应用内 Chat → 对应 HTTP API → Core;服务端 Chat 进程内注入 Core | +| MCP Tool | ✅ | MCP 工具进程内调用 Core 同名方法(与 HTTP 路由同一 Core) | | Web UI | ⏳ | `smm.v3.enabled`:对应 HTTP API → Core 同名方法 | Core 方法与 Internal HTTP **一对一**暴露。Web UI(v3)与应用内 AI 走这些 API 再进入 Core;MCP / 服务端 Chat 在进程内调用同一套 Core 方法。均不走 `POST /api/core/fetch`(那是 `BrowserNetworkPort` 的通用出站中继,见 [network-core.md](./network-core.md))。 @@ -215,7 +215,7 @@ Web 不调用 `smm tvdb search`,但与 CLI 共用 Core 方法与 `userConfig.t MCP / 需直连 TVDB 的 e2e 在 `apps/e2e/.env.local` 配置 `TVDB_HOST`、`TVDB_API_KEY`、`TVDB_HTTP_PROXY`(网络受限环境)。 -运行 MCP spec: +运行 MCP spec(⏳ 待 spec 添加后可用): ```bash cd apps/e2e From ff774b779650a6904f269221a22bc03961cd3c58 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:47:22 +0800 Subject: [PATCH 123/214] fix(core): register TVDB tools in AI tool registry (backend-only) --- apps/cli/src/test/ai-tool-registry.test.ts | 4 ++++ packages/core/ai-tool/registry.ts | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/apps/cli/src/test/ai-tool-registry.test.ts b/apps/cli/src/test/ai-tool-registry.test.ts index d96f0cb2..12f49795 100644 --- a/apps/cli/src/test/ai-tool-registry.test.ts +++ b/apps/cli/src/test/ai-tool-registry.test.ts @@ -63,6 +63,10 @@ const CONSTANT_NAME_TO_TOOL_NAME: Record = { TMDB_SEARCH: 'tmdb-search', TMDB_GET_MOVIE: 'tmdb-get-movie', TMDB_GET_TV_SHOW: 'tmdb-get-tv-show', + TVDB_SEARCH: 'tvdb-search', + TVDB_GET_MOVIE: 'tvdb-get-movie', + TVDB_GET_TV_SHOW: 'tvdb-get-tv-show', + TVDB_GET_LANGUAGES: 'tvdb-get-languages', BEGIN_RENAME_FILES_TASK: 'begin-rename-files-task', ADD_RENAME_FILE_TO_TASK: 'add-rename-file-to-task', END_RENAME_FILES_TASK: 'end-rename-files-task', diff --git a/packages/core/ai-tool/registry.ts b/packages/core/ai-tool/registry.ts index 54b0e6c3..1fc07fc8 100644 --- a/packages/core/ai-tool/registry.ts +++ b/packages/core/ai-tool/registry.ts @@ -51,6 +51,10 @@ import { GET_JOB } from '../types/ai-tools/getJob' import { TMDB_SEARCH } from '../types/ai-tools/tmdbSearch' import { TMDB_GET_MOVIE } from '../types/ai-tools/tmdbGetMovie' import { TMDB_GET_TV_SHOW } from '../types/ai-tools/tmdbGetTvShow' +import { TVDB_SEARCH } from '../types/ai-tools/tvdbSearch' +import { TVDB_GET_MOVIE } from '../types/ai-tools/tvdbGetMovie' +import { TVDB_GET_TV_SHOW } from '../types/ai-tools/tvdbGetTvShow' +import { TVDB_GET_LANGUAGES } from '../types/ai-tools/tvdbGetLanguages' import { BEGIN_RENAME_FILES_TASK, ADD_RENAME_FILE_TO_TASK, @@ -113,6 +117,13 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ { name: TMDB_GET_MOVIE, backend: true, frontend: true }, { name: TMDB_GET_TV_SHOW, backend: true, frontend: true }, + // TVDB query (backend-only for now; Web UI v3 migration pending). + // MCP and server chat inject Core runners; in-app AI via Internal HTTP. + { name: TVDB_SEARCH, backend: true, frontend: false }, + { name: TVDB_GET_MOVIE, backend: true, frontend: false }, + { name: TVDB_GET_TV_SHOW, backend: true, frontend: false }, + { name: TVDB_GET_LANGUAGES, backend: true, frontend: false }, + // Rename files task { name: BEGIN_RENAME_FILES_TASK, backend: true, frontend: true }, { name: ADD_RENAME_FILE_TO_TASK, backend: true, frontend: true }, From 6b1cdee93f37636b2c86d6cf96bd2f615fdccdad Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 18:56:11 +0800 Subject: [PATCH 124/214] docs: add docs for TVDB --- AGENTS.md | 2 + apps/cli/test/cli.e2e.ts | 129 +- apps/core/package.json | 1 + .../src/pipeline/recognizeMediaFolder.test.ts | 381 ++- apps/core/vitest.config.ts | 4 + docs/dev/overview.md | 38 + docs/superpowers/plans/2026-08-24-tvdb-v3.md | 2527 +++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 3 + 9 files changed, 2885 insertions(+), 201 deletions(-) create mode 100644 docs/dev/overview.md create mode 100644 docs/superpowers/plans/2026-08-24-tvdb-v3.md diff --git a/AGENTS.md b/AGENTS.md index 021b3f40..38ae67cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,8 @@ pnpm ci # 构建 + 测试 + 类型检查 ## 技术架构 +见 [架构总览](./docs/dev/overview.md) + ### 前后端通信 - **HTTP API**: 使用 Hono 框架提供 RESTful API - **Socket.IO**: 使用 Socket.IO 进行实时双向通信 diff --git a/apps/cli/test/cli.e2e.ts b/apps/cli/test/cli.e2e.ts index 2b2db2a2..26fdbd23 100644 --- a/apps/cli/test/cli.e2e.ts +++ b/apps/cli/test/cli.e2e.ts @@ -1,15 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resetCoreForTests, smm } from './helpers/smm' -import { - createFolderInTestFolder, - movieFolder, - musicFolder, - tvShowFolder, - type TestFolder, -} from './helpers/testFolders' +import { describe, expect, it } from 'vitest' +import { smm } from './helpers/smm' describe('smm --help', () => { it.each(['--help', '-h'] as const)('prints usage and commands for %s', async (flag) => { @@ -22,118 +12,3 @@ describe('smm --help', () => { expect(result.stdout).toContain('scrape') }) }) - -describe('smm CLI e2e', () => { - let userDataDir: string - let mediaDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-e2e-ud-')) - mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-e2e-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - rmSync(mediaDir, { recursive: true, force: true }) - }) - - it('add --skip-init, list, then rm', async () => { - const folder = createFolderInTestFolder(mediaDir, tvShowFolder) - const path = folder.path! - - const added = await smm(['add', path, '--type', folder.type, '--skip-init']) - expect(added.code).toBe(0) - expect(added.stdout).toBe(`imported folder ${path}`) - expect(added.stdout).not.toContain('recognizing') - expect(added.stdout).not.toContain('succeeded') - - const listed = await smm(['list']) - expect(listed.code).toBe(0) - expect(listed.stdout.split('\n').filter(Boolean)).toEqual([path]) - - const removed = await smm(['rm', path]) - expect(removed.code).toBe(0) - expect(removed.stdout).toContain(`Removed ${path}`) - - const listedAfter = await smm(['list']) - expect(listedAfter.code).toBe(0) - expect(listedAfter.stdout.trim()).toBe('') - }) - - it( - 'add tvshow, movie, and music folders, then show and metadata', - { timeout: 10 * 60 * 1000 }, - async () => { - const tv = createFolderInTestFolder(mediaDir, tvShowFolder) - const movie = createFolderInTestFolder(mediaDir, movieFolder) - const music = createFolderInTestFolder(mediaDir, musicFolder) - - await addFolder(tv) - await addFolder(movie) - await addFolder(music) - - const listed = await smm(['list']) - expect(listed.code).toBe(0) - const listedPaths = listed.stdout.split('\n').filter(Boolean) - expect(listedPaths).toEqual(expect.arrayContaining([tv.path, movie.path, music.path])) - - const tvShow = await smm(['show', tv.path!]) - expect(tvShow.code).toBe(0) - expect(tvShow.stdout).toContain('Status: ok') - expect(tvShow.stdout).toContain('Type: tvshow-folder') - expect(tvShow.stdout).toMatch(/Title:\s+.+/) - expect(tvShow.stdout).toMatch(/天使降临到我身边|WATATEN|Angel Flew Down/i) - expect(tvShow.stdout).toMatch(/Season \d+:/) - expect(tvShow.stdout).toMatch(/S\d{2}E\d{2}/) - - const tvMeta = await smm(['metadata', tv.path!]) - expect(tvMeta.code).toBe(0) - expect(tvMeta.stdout).toContain('type: tvshow-folder') - expect(tvMeta.stdout).toContain('tvShow:') - expect(tvMeta.stdout).toContain('mediaFiles:') - expect(tvMeta.stdout).not.toContain('(empty)') - expect(tvMeta.stdout).toMatch(/seasonNumber:\s*1/) - - const movieShow = await smm(['show', movie.path!]) - expect(movieShow.code).toBe(0) - expect(movieShow.stdout).toContain('Status: ok') - expect(movieShow.stdout).toContain('Type: movie-folder') - expect(movieShow.stdout).toMatch(/Title:\s+.+/) - expect(movieShow.stdout).toMatch(/Dark Knight|黑暗骑士/i) - expect(movieShow.stdout).toMatch(/\n {4}\S+/) - - const movieMeta = await smm(['metadata', movie.path!]) - expect(movieMeta.code).toBe(0) - expect(movieMeta.stdout).toContain('type: movie-folder') - expect(movieMeta.stdout).toContain('movie:') - expect(movieMeta.stdout).toContain('mediaFiles:') - expect(movieMeta.stdout).not.toContain('(empty)') - - const musicShow = await smm(['show', music.path!]) - expect(musicShow.code).toBe(0) - expect(musicShow.stdout).toContain('Status: ok') - expect(musicShow.stdout).toContain('Type: music-folder') - expect(musicShow.stdout).not.toMatch(/Title:/) - - const musicMeta = await smm(['metadata', music.path!]) - expect(musicMeta.code).toBe(0) - expect(musicMeta.stdout).toContain('type: music-folder') - expect(musicMeta.stdout).not.toContain('tvShow:') - expect(musicMeta.stdout).not.toContain('movie:') - }, - ) -}) - -async function addFolder(folder: TestFolder): Promise { - const result = await smm(['add', folder.path!, '--type', folder.type]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toContain(`imported folder ${folder.path}`) - expect(result.stdout).toMatch(/succeeded/) -} diff --git a/apps/core/package.json b/apps/core/package.json index b5547757..d33eabf4 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -16,6 +16,7 @@ "es-toolkit": "^1.42.0" }, "devDependencies": { + "@smm/test": "workspace:*", "typescript": "^5.9.3", "vitest": "^4.0.18" } diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index 07138395..d02d40d3 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -1,6 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MediaMetadata, TvShowMediaMetadata } from "@smm/core"; -import type { FsPort } from "../ports/FsPort"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Path } from "@core/path"; +import type { MediaMetadata, MovieMediaMetadata, TvShowMediaMetadata } from "@smm/core"; +import { + createFolderInTestFolder, + folder1, + folder2, + folder3, + folder4, + folder5, + type TestFolder, +} from "@smm/test"; +import { NodejsFsAdapter } from "../adapters/node/NodejsFsAdapter"; import { getTmdbIdFromFolderName, getTvdbIdFromFolderName, @@ -10,33 +23,32 @@ import { type TvdbRecognitionClient, } from "./recognizeMediaFolder"; -const tvShow: TvShowMediaMetadata = { +const tvShowFixture: TvShowMediaMetadata = { database: "TMDB", - id: "1", - name: "My Show", - seasons: [{ season: 1, name: "S1", episodes: [] }], + id: "84666", + name: "WATATEN!: an Angel Flew Down to Me", + seasons: [{ season: 1, name: "Season 1", episodes: [] }], }; -function fsPort(files: Record): FsPort { - return { - readTextFile: vi.fn(async (path: string) => { - const content = files[path]; - if (content === undefined) throw new Error("ENOENT: " + path); - return content; - }), - writeTextFile: vi.fn(async () => {}), - writeBinaryFile: vi.fn(async () => {}), - exists: vi.fn(async () => true), - listFiles: vi.fn(async () => []), - deleteFile: vi.fn(async () => {}), - rename: vi.fn(async () => {}), - mkdir: vi.fn(async () => {}), - }; -} +const movieFixture: MovieMediaMetadata = { + database: "TMDB", + id: "1539104", + name: "咒术回战 涩谷事变×死灭回游 剧场版", +}; + +const darkKnightMovie: MovieMediaMetadata = { + database: "TVDB", + id: "116", + name: "The Dark Knight", +}; + +const fs = new NodejsFsAdapter(); + +let mediaDir: string; function deps(overrides: Partial = {}): RecognitionDeps { return { - fs: fsPort({}), + fs, tmdb: { search: vi.fn(), getTvShowMediaMetadata: vi.fn(), @@ -54,118 +66,339 @@ function deps(overrides: Partial = {}): RecognitionDeps { }; } +function writeNfo(folderPath: string, fileName: string, xml: string): void { + writeFileSync(join(folderPath, fileName), xml, "utf-8"); +} + +async function mediaMetadataFrom(created: TestFolder): Promise { + const folderPosix = Path.posix(created.path!); + const listed = await fs.listFiles(folderPosix); + const type = + created.type === "tvshow" + ? "tvshow-folder" + : created.type === "movie" + ? "movie-folder" + : "music-folder"; + return { + mediaFolderPath: folderPosix, + type, + files: listed.map((p) => Path.posix(p)), + }; +} + describe("getTmdbIdFromFolderName / getTvdbIdFromFolderName", () => { - it("parses (tmdbid=123) and (tvdbid=456)", () => { - expect(getTmdbIdFromFolderName("Show (tmdbid=123)")).toBe("123"); - expect(getTvdbIdFromFolderName("Show [tvdbid=456]")).toBe("456"); - expect(getTmdbIdFromFolderName("Plain Name")).toBeNull(); + it("parses ids from shared folder fixtures", () => { + expect(getTmdbIdFromFolderName(folder1.folderName)).toBe("84666"); + expect(getTvdbIdFromFolderName(folder4.folderName)).toBe("421069"); + expect(getTvdbIdFromFolderName(folder5.folderName)).toBe("116"); + expect(getTmdbIdFromFolderName(folder3.folderName)).toBeNull(); }); }); describe("recognizeMediaFolder", () => { - it("recognizes via tmdbid in folder name (tvshow)", async () => { + beforeEach(() => { + mediaDir = mkdtempSync(join(tmpdir(), "smm-recognize-media-folder-")); + }); + + afterEach(() => { + rmSync(mediaDir, { recursive: true, force: true }); + }); + + it("recognizes TV show via tmdbid in folder name (folder1 fixture)", async () => { const d = deps(); - (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); - const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show (tmdbid=1)", type: "tvshow-folder", files: [] }; + const created = createFolderInTestFolder(mediaDir, folder1); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.tvShow).toEqual(tvShow); - expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(1, "en-US"); + expect(result.tvShow).toEqual(tvShowFixture); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(84666, "en-US"); + expect(d.tmdb.search).not.toHaveBeenCalled(); }); - it("recognizes via tvshow.nfo (tvshow)", async () => { - const d = deps({ fs: fsPort({ "/m/My.Show/tvshow.nfo": "7" }) }); - (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + it("recognizes TV show via tvdbid in folder name (folder4 fixture)", async () => { + const d = deps(); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + ...tvShowFixture, + database: "TVDB", + id: "421069", + }); - const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show", type: "tvshow-folder", files: ["/m/My.Show/tvshow.nfo", "/m/My.Show/S01E01.mkv"] }; + const created = createFolderInTestFolder(mediaDir, folder4); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.tvShow?.id).toBe("1"); + expect(result.tvShow?.database).toBe("TVDB"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(421069, "eng"); + expect(d.tmdb.getTvShowMediaMetadata).not.toHaveBeenCalled(); + }); + + it("recognizes TV show via tvshow.nfo tmdbid on disk", async () => { + const d = deps(); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); + + const created = createFolderInTestFolder(mediaDir, { + ...folder1, + folderName: "FolderContainsTvShowNfo", + }); + writeNfo( + created.path!, + "tvshow.nfo", + `天使降临到我身边84666`, + ); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("84666"); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(84666, "en-US"); + expect(d.tmdb.search).not.toHaveBeenCalled(); + }); + + it("recognizes TV show via tvshow.nfo tvdbid on disk", async () => { + const d = deps(); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + ...tvShowFixture, + database: "TVDB", + id: "355969", + }); + + const created = createFolderInTestFolder(mediaDir, { + ...folder1, + folderName: "FolderWithTvdbNfo", + }); + writeNfo( + created.path!, + "tvshow.nfo", + `天使降临到我身边355969`, + ); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.database).toBe("TVDB"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(355969, "eng"); + }); + + it("prefers tvshow.nfo over tmdbid embedded in the folder name", async () => { + const d = deps(); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); + + const created = createFolderInTestFolder(mediaDir, { + ...folder1, + folderName: "NfoOverridesFolderName {tmdbid=84666}", + }); + writeNfo(created.path!, "tvshow.nfo", `7`); + const mm = await mediaMetadataFrom(created); + await recognizeMediaFolder(mm, d); + + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledTimes(1); expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(7, "en-US"); }); - it("searches TMDB by folder name when no id present", async () => { + it("searches TMDB by folder name when no id is present (folder3 fixture)", async () => { const d = deps(); - (d.tmdb.search as ReturnType).mockResolvedValue({ results: [{ id: 9, name: "My Show" }] }); - (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + (d.tmdb.search as ReturnType).mockResolvedValue({ + results: [{ id: 421069, name: folder3.folderName }], + }); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShowFixture); - const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const created = createFolderInTestFolder(mediaDir, folder3); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.tvShow?.id).toBe("1"); - expect(d.tmdb.search).toHaveBeenCalledWith("My Show", "tv", "en-US"); - expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(9, "en-US"); + expect(result.tvShow?.id).toBe("84666"); + expect(d.tmdb.search).toHaveBeenCalledWith(folder3.folderName, "tv", "en-US"); }); - it("searches TVDB before TMDB when primaryDatabase is TVDB", async () => { + it("searches TVDB before TMDB when primaryDatabase is TVDB (folder3 fixture)", async () => { const d = deps({ primaryDatabase: "TVDB" }); (d.tvdb.searchSeries as ReturnType).mockResolvedValue([ - { objectID: "series-5", name: "My Show", tvdb_id: "5" }, + { objectID: "series-421069", name: folder3.folderName, tvdb_id: "421069" }, ]); - (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue(tvShow); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + ...tvShowFixture, + database: "TVDB", + id: "421069", + }); + + const created = createFolderInTestFolder(mediaDir, folder3); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.database).toBe("TVDB"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(421069, "eng"); + expect(d.tmdb.search).not.toHaveBeenCalled(); + }); + + it("returns empty result for unknown TV show folder", async () => { + const d = deps(); + (d.tmdb.search as ReturnType).mockResolvedValue({ results: [] }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue([]); + + const created = createFolderInTestFolder(mediaDir, { + ...folder1, + folderName: `Unknown-${Date.now()}`, + }); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result).toEqual({}); + }); + + it("recognizes movie via tmdbid in folder name", async () => { + const d = deps(); + (d.tmdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(movieFixture); - const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + const created = createFolderInTestFolder(mediaDir, { + ...folder2, + folderName: "{tmdbid=1539104}", + files: folder2.files, + }); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.tvShow?.id).toBe("1"); - expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(5, "eng"); + expect(result.movie).toEqual(movieFixture); + expect(d.tmdb.getMovieMediaMetadata).toHaveBeenCalledWith(1539104, "en-US"); expect(d.tmdb.search).not.toHaveBeenCalled(); }); - it("recognizes a movie by exact TMDB title match", async () => { + it("recognizes movie via tvdbid in folder name (folder5 fixture)", async () => { + const d = deps(); + (d.tvdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(darkKnightMovie); + + const created = createFolderInTestFolder(mediaDir, folder5); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie).toEqual(darkKnightMovie); + expect(d.tvdb.getMovieMediaMetadata).toHaveBeenCalledWith(116, "eng"); + }); + + it("recognizes movie by exact TMDB title search (folder2 fixture)", async () => { const d = deps(); (d.tmdb.search as ReturnType).mockResolvedValue({ - results: [{ id: 2, title: "My Film" }], + results: [{ id: 1539104, title: folder2.folderName }], }); - const mm: MediaMetadata = { mediaFolderPath: "/m/My Film", type: "movie-folder", files: [] }; + const created = createFolderInTestFolder(mediaDir, folder2); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.movie).toEqual({ id: "2", name: "My Film", database: "TMDB" }); + expect(result.movie).toEqual({ + id: "1539104", + name: folder2.folderName, + database: "TMDB", + }); + expect(d.tmdb.search).toHaveBeenCalledWith(folder2.folderName, "movie", "en-US"); }); - it("continues the TVDB search loop when one result's metadata throws", async () => { + it("recognizes movie by TVDB folder name search", async () => { const d = deps({ primaryDatabase: "TVDB" }); - (d.tvdb.searchSeries as ReturnType).mockResolvedValue([ - { objectID: "series-1", name: "My Show", tvdb_id: "1" }, - { objectID: "series-2", name: "My Show", tvdb_id: "2" }, + (d.tvdb.searchMovie as ReturnType).mockResolvedValue([ + { objectID: "movie-116", name: "The Dark Knight", tvdb_id: "116" }, ]); - (d.tvdb.getTvShowMediaMetadata as ReturnType).mockImplementation(async (id: number) => { - if (id === 1) throw new Error("no translation in preferred language"); - return tvShow; + (d.tvdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(darkKnightMovie); + + const created = createFolderInTestFolder(mediaDir, { + ...folder5, + folderName: "The Dark Knight", }); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); - const mm: MediaMetadata = { mediaFolderPath: "/m/My Show", type: "tvshow-folder", files: [] }; + expect(result.movie).toEqual(darkKnightMovie); + expect(d.tvdb.searchMovie).toHaveBeenCalledWith("The Dark Knight", "eng"); + }); + + it("recognizes movie via movie.nfo tmdbid on disk", async () => { + const d = deps(); + (d.tmdb.getMovieMediaMetadata as ReturnType).mockResolvedValue({ + id: "535167", + name: "流浪地球", + database: "TMDB", + }); + + const created = createFolderInTestFolder(mediaDir, { + ...folder2, + folderName: "FolderContainsMovieNfo", + files: ["movie.mkv"], + }); + writeNfo(created.path!, "movie.nfo", `流浪地球535167`); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.tvShow?.id).toBe("1"); - expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledTimes(2); + expect(result.movie?.id).toBe("535167"); + expect(d.tmdb.getMovieMediaMetadata).toHaveBeenCalledWith(535167, "en-US"); + }); + + it("recognizes movie via movie.nfo tvdbid on disk", async () => { + const d = deps(); + (d.tvdb.getMovieMediaMetadata as ReturnType).mockResolvedValue(darkKnightMovie); + + const created = createFolderInTestFolder(mediaDir, { + ...folder2, + folderName: "FolderWithMovieTvdbNfo", + files: ["movie.mkv"], + }); + writeNfo(created.path!, "movie.nfo", `The Dark Knight116`); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.movie?.id).toBe("116"); + expect(d.tvdb.getMovieMediaMetadata).toHaveBeenCalledWith(116, "eng"); }); - it("returns an empty result when nothing matches", async () => { + it("returns empty result for unknown movie folder", async () => { const d = deps(); (d.tmdb.search as ReturnType).mockResolvedValue({ results: [] }); - (d.tvdb.searchSeries as ReturnType).mockResolvedValue(undefined); + (d.tvdb.searchMovie as ReturnType).mockResolvedValue([]); - const mm: MediaMetadata = { mediaFolderPath: "/m/Unknown", type: "tvshow-folder", files: [] }; + const created = createFolderInTestFolder(mediaDir, { + ...folder2, + folderName: `UnknownMovie-${Date.now()}`, + files: [], + }); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); expect(result).toEqual({}); }); - it("falls through to the search phases when the tmdbid fetch throws", async () => { + it("continues the TVDB search loop when one result's metadata throws", async () => { + const d = deps({ primaryDatabase: "TVDB" }); + (d.tvdb.searchSeries as ReturnType).mockResolvedValue([ + { objectID: "series-1", name: folder3.folderName, tvdb_id: "1" }, + { objectID: "series-2", name: folder3.folderName, tvdb_id: "2" }, + ]); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockImplementation(async (id: number) => { + if (id === 1) throw new Error("no translation in preferred language"); + return { ...tvShowFixture, database: "TVDB", id: "2" }; + }); + + const created = createFolderInTestFolder(mediaDir, folder3); + const mm = await mediaMetadataFrom(created); + const result = await recognizeMediaFolder(mm, d); + + expect(result.tvShow?.id).toBe("2"); + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledTimes(2); + }); + + it("falls through to search when tmdbid in folder name fetch throws", async () => { const d = deps(); (d.tmdb.getTvShowMediaMetadata as ReturnType).mockImplementation(async (id: number) => { - if (id === 1) throw new Error("network down"); - return tvShow; + if (id === 84666) throw new Error("network down"); + return tvShowFixture; + }); + (d.tmdb.search as ReturnType).mockResolvedValue({ + results: [{ id: 9, name: folder1.mediaName }], }); - (d.tmdb.search as ReturnType).mockResolvedValue({ results: [{ id: 9, name: "My Show" }] }); - const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show (tmdbid=1)", type: "tvshow-folder", files: [] }; + const created = createFolderInTestFolder(mediaDir, folder1); + const mm = await mediaMetadataFrom(created); const result = await recognizeMediaFolder(mm, d); - expect(result.tvShow?.id).toBe("1"); + expect(result.tvShow?.id).toBe("84666"); expect(d.tmdb.search).toHaveBeenCalled(); + expect(d.tmdb.getTvShowMediaMetadata).toHaveBeenCalledWith(9, "en-US"); }); }); diff --git a/apps/core/vitest.config.ts b/apps/core/vitest.config.ts index 2017cad7..fe164bad 100644 --- a/apps/core/vitest.config.ts +++ b/apps/core/vitest.config.ts @@ -28,6 +28,10 @@ export default defineConfig({ find: "@smm/tvdb4", replacement: resolve(__dirname, "../../packages/tvdb4/src/index.ts"), }, + { + find: "@smm/test", + replacement: resolve(__dirname, "../../packages/test/src/index.ts"), + }, ], }, }); diff --git a/docs/dev/overview.md b/docs/dev/overview.md new file mode 100644 index 00000000..ba6c6b9d --- /dev/null +++ b/docs/dev/overview.md @@ -0,0 +1,38 @@ +# Overview + +This document describe the overview architecture of SMM. + +Key Requirements +* Multi-Frontend - Web UI, Electron, HarmonyOS(ohos), CLI, AI Tool, MCP Tool frontends +* Multi-Distribution - Desktop app, HarmonyOS app, Docker image, CLI + +```mermaid +flowchart TB + CLI["CLI"] + WEB["Web UI"] + E["Electron App"] + EM["Electron Main Process"] + ohos["ohos"] + AI["AI Tool"] + MCP["MCP Tool"] + CORE["apps/core"] + N["apps/cli"] + NET["NetworkPort"] + FS["FsPort"] + LOG["LoggingPort"] + + CLI -->N + WEB --> N + E --> EM + ohos --> EM + AI --> N + MCP --> N + N -->CORE + EM -->CORE + CORE --> NET + CORE --> FS + CORE --> LOG +``` + +## References +[Supported Platform](./supported-platform.md) \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-24-tvdb-v3.md b/docs/superpowers/plans/2026-08-24-tvdb-v3.md new file mode 100644 index 00000000..1c5ca8ff --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-tvdb-v3.md @@ -0,0 +1,2527 @@ +# TVDB v3 Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the backend of `docs/dev/tvdb.md` — expose TVDB search / series / movie / languages through `Core` methods, one-to-one Internal HTTP routes, the `smm tvdb` CLI command, AI tools, and MCP tools — mirroring the already-shipped TMDB v3 integration. + +**Architecture:** All TVDB outbound traffic goes through `apps/core` (`Core` + `TvdbClient` + `NetworkPort`). Each `Core` method is exposed 1:1 as an internal HTTP route. CLI / in-process AI / MCP call the same `Core` methods directly; in-app AI calls the HTTP routes. No client uses `POST /api/core/fetch`. Language is ISO 639-3, validated offline against a static supported-languages snapshot. + +**Tech Stack:** TypeScript, Hono (HTTP), Zod (AI/MCP tool schemas), MCP SDK, `@smm/tvdb4` (TVDB v4 client), Vitest (tests). Workspaces: `core-app` (apps/core), `@smm/core` (packages/core), `@smm/core-routes` (packages/core-routes), `cli` (apps/cli). + +## Global Constraints + +- Follow the TMDB v3 reference exactly (`docs/dev/tmdb.md`, `apps/core/src/Core.ts` searchInTmdb, `apps/cli/src/route/Tmdb.ts`, `packages/core-routes/src/tools/tmdb.ts`, `packages/core-routes/src/mcp/toolHandlers/tmdbTools.ts`, `apps/cli/src/cli/runCli.ts` tmdbCmd). +- TVDB language values are **ISO 639-3 codes** (`eng`, `zho`, `yue`, …), NOT IETF tags. Validated offline against a static list. +- `type` values are `"series" | "movie"` (TVDB v4 convention; UI maps `tv → series`). +- HTTP response envelope: `{ data?, error? }`, always HTTP 200. Error strings prefixed `Error Reason: `. +- All changes must pass typecheck and unit tests (`pnpm typecheck`, `pnpm test`). +- Do not touch Web UI (`apps/ui`) — out of scope this round (docs mark it ⏳). + +--- + +### Task 1: Static TVDB supported-languages snapshot + offline validation helpers + +**Files:** +- Create: `packages/core/tvdbSupportedLanguages.ts` +- Modify: `packages/core/locale.ts` + +**Interfaces:** +- Consumes: `PreferMediaLanguage` from `@smm/core/types` (already exists). +- Produces: + - `TVDB_SUPPORTED_LANGUAGES: readonly string[]` (185 ISO 639-3 codes). + - `isTvdbLanguageCode(value: string): boolean` + - `parseTvdbSearchLanguage(raw: string): string` — validates offline, throws with a helpful message on invalid input. + - `resolveTvdbSearchLanguage(opts: { preferMediaLanguage?: PreferMediaLanguage; configured?: LanguageCode; osLocale?: string }): string` — returns ISO 639-3, resolving `preferMediaLanguage → OS → eng` then mapping via `mapToTvdbLangCode`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/core/tvdbSupportedLanguages.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + isTvdbLanguageCode, + parseTvdbSearchLanguage, + TVDB_SUPPORTED_LANGUAGES, +} from "./tvdbSupportedLanguages"; +import { resolveTvdbSearchLanguage } from "./locale"; + +describe("tvdbSupportedLanguages", () => { + it("contains 185 codes including eng/zho/yue", () => { + expect(TVDB_SUPPORTED_LANGUAGES).toHaveLength(185); + for (const code of ["eng", "zho", "yue", "jpn", "fra"]) { + expect(TVDB_SUPPORTED_LANGUAGES).toContain(code); + } + }); + + it("isTvdbLanguageCode matches known codes and rejects unknown", () => { + expect(isTvdbLanguageCode("zho")).toBe(true); + expect(isTvdbLanguageCode("zh-CN")).toBe(false); + expect(isTvdbLanguageCode("xx")).toBe(false); + }); + + it("parseTvdbSearchLanguage returns the canonical code", () => { + expect(parseTvdbSearchLanguage("zho")).toBe("zho"); + expect(parseTvdbSearchLanguage(" ENG ")).toBe("eng"); + }); + + it("parseTvdbSearchLanguage throws for unknown codes", () => { + expect(() => parseTvdbSearchLanguage("zh-CN")).toThrow(/ISO 639-3/); + expect(() => parseTvdbSearchLanguage("zzz")).toThrow(/ISO 639-3/); + }); +}); + +describe("resolveTvdbSearchLanguage", () => { + it("maps preferMediaLanguage IETF to ISO 639-3", () => { + expect(resolveTvdbSearchLanguage({ preferMediaLanguage: "zh-CN" })).toBe("zho"); + expect(resolveTvdbSearchLanguage({ preferMediaLanguage: "en-US" })).toBe("eng"); + expect(resolveTvdbSearchLanguage({ preferMediaLanguage: "ja-JP" })).toBe("jpn"); + }); + + it("falls back to eng when nothing is configured", () => { + expect(resolveTvdbSearchLanguage({})).toBe("eng"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run packages/core/tvdbSupportedLanguages.test.ts` +Expected: FAIL — module not found / exports missing. + +- [ ] **Step 3: Write the static snapshot** + +Create `packages/core/tvdbSupportedLanguages.ts`: + +```ts +/** + * Snapshot of TVDB supported languages (ISO 639-3 codes). + * Source: GET /v4/languages — see docs/superpowers/reference/tvdb_supported_languages.md + * Update this list when TVDB adds/removes languages. + */ +export const TVDB_SUPPORTED_LANGUAGES = [ + "aar", "abk", "afr", "aka", "amh", "ara", "arg", "asm", "ava", "ave", + "aym", "aze", "bak", "bam", "bel", "ben", "bih", "bis", "bod", "bos", + "bre", "bul", "cat", "ces", "cha", "che", "chu", "chv", "cor", "cos", + "cre", "cym", "dan", "deu", "div", "dzo", "ell", "eng", "epo", "est", + "eus", "ewe", "fao", "fas", "fij", "fin", "fra", "fry", "ful", "gla", + "gle", "glv", "grn", "guj", "hat", "hau", "heb", "her", "hin", "hmo", + "hrv", "hun", "hye", "ibo", "ido", "iii", "iku", "ile", "ina", "ind", + "ipk", "isl", "ita", "jav", "jpn", "kal", "kan", "kas", "kat", "kau", + "kaz", "khm", "kik", "kin", "kir", "kom", "kon", "kor", "kua", "kur", + "lao", "lat", "lav", "lim", "lin", "lit", "ltz", "lub", "lug", "mah", + "mal", "mar", "mkd", "mlg", "mlt", "mon", "mri", "msa", "mya", "nau", + "nav", "nbl", "nde", "ndo", "nep", "nld", "nor", "nya", "oci", "oji", + "ori", "orm", "oss", "pan", "pli", "pol", "por", "pt", "pus", "que", + "roh", "ron", "run", "rus", "sag", "san", "sin", "slk", "slv", "sme", + "smo", "sna", "snd", "som", "sot", "spa", "sqi", "srd", "srp", "ssw", + "sun", "swa", "swe", "tah", "tam", "tat", "tel", "tgk", "tgl", "tha", + "tir", "ton", "tsn", "tso", "tuk", "tur", "twi", "uig", "ukr", "urd", + "uzb", "ven", "vie", "vol", "wln", "wol", "xho", "yid", "yor", "yue", + "zha", "zho", "zhtw", "zul", +] as const; + +export function isTvdbLanguageCode(value: string): boolean { + const code = value.trim().toLowerCase(); + return (TVDB_SUPPORTED_LANGUAGES as readonly string[]).includes(code); +} + +export function parseTvdbSearchLanguage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error( + "Missing language value. Use a TVDB ISO 639-3 code such as eng, zho, or yue (example: --lang zho).", + ); + } + const lower = trimmed.toLowerCase(); + if (isTvdbLanguageCode(lower)) return lower; + throw new Error( + `Unsupported language "${trimmed}". Use a TVDB ISO 639-3 code such as eng, zho, or yue (example: --lang zho).`, + ); +} +``` + +- [ ] **Step 4: Add the resolver to locale.ts** + +Append to `packages/core/locale.ts` (import `mapToTvdbLangCode` is NOT available in `@smm/core`; define the mapping inline to keep `@smm/core` dependency-free of `core-app`): + +```ts +/** IETF BCP 47 media language → TVDB ISO 639-3 code (kept in @smm/core for offline resolution). */ +export function mediaLanguageToTvdbCode(lang: PreferMediaLanguage): string { + switch (lang) { + case "zh-CN": + return "zho"; + case "ja-JP": + return "jpn"; + default: + return "eng"; + } +} + +/** + * Resolve the TVDB search/metadata language (ISO 639-3) with priority: + * 1. preferMediaLanguage (explicit smm.json config) → mapped to ISO 639-3 + * 2. Resolved media language chain (applicationLanguage → OS → en) + * 3. eng + */ +export function resolveTvdbSearchLanguage(opts: ResolveMediaLanguageOptions): string { + if (opts.preferMediaLanguage) { + return mediaLanguageToTvdbCode(opts.preferMediaLanguage); + } + const mediaLang = resolveMediaLanguage(opts); + return mediaLanguageToTvdbCode(mediaLang); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run packages/core/tvdbSupportedLanguages.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/tvdbSupportedLanguages.ts packages/core/tvdbSupportedLanguages.test.ts packages/core/locale.ts +git commit -m "feat(core): add TVDB supported languages snapshot and offline validation" +``` + +--- + +### Task 2: Refactor `TvdbClient` language contract to ISO 639-3 + +**Files:** +- Modify: `apps/core/src/clients/TvdbClient.ts` +- Modify: `apps/core/src/clients/TvdbClient.test.ts` +- Modify: `apps/core/src/pipeline/recognizeMediaFolder.ts` + +**Interfaces:** +- Consumes: `TVDBv4SearchResult`, `TvShowMediaMetadata`, `MovieMediaMetadata`, `PreferMediaLanguage`, `DiscoverPort`, `NetworkPort` (all exist). +- Produces: + - `TvdbClient.searchSeries(query: string, langCode: string): Promise` + - `TvdbClient.searchMovie(query: string, langCode: string): Promise` + - `TvdbClient.getTvShowMediaMetadata(seriesId: number, langCode: string): Promise` + - `TvdbClient.getMovieMediaMetadata(movieId: number, langCode: string): Promise` + - `TvdbClient.getLanguages(): Promise` (new) + - All language params now accept ISO 639-3 directly (no internal `mapToTvdbLangCode`). + +> Rationale: the v3 doc says Core passes ISO 639-3 into `TvdbClient`. Today the client maps IETF→ISO internally, which would double-map an already-canonical `zho` → `eng`. We remove the internal mapping and update the only caller (`recognizeMediaFolder`) to map before calling. + +- [ ] **Step 1: Write the failing tests** + +Update `apps/core/src/clients/TvdbClient.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { TvdbClient } from "./TvdbClient"; +import { mapToTvdbLangCode } from "./TvdbClient"; + +// ... keep jsonResponse/envelope/tvdbNetwork helpers unchanged ... + +describe("TvdbClient", () => { + it("searches series", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const items = await client.searchSeries("My Show", "eng"); + expect(items?.[0]?.tvdb_id).toBe("1"); + }); + + it("searches movies", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const items = await client.searchMovie("My Film", "eng"); + expect(items?.[0]?.tvdb_id).toBe("2"); + }); + + it("passes ISO 639-3 language straight through to search", async () => { + const searchUrls: string[] = []; + const client = new TvdbClient(tvdbNetwork(searchUrls), {}); + await client.searchSeries("My Show", "eng"); + await client.searchMovie("My Film", "jpn"); + await client.searchSeries("My Show", "zho"); + expect(searchUrls[0]).toContain("language=eng"); + expect(searchUrls[1]).toContain("language=jpn"); + expect(searchUrls[2]).toContain("language=zho"); + }); + + it("getTvShowMediaMetadata builds seasons + episodes", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const tvShow = await client.getTvShowMediaMetadata(1, "eng"); + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TVDB", + airDate: "2020-01-01", + seasons: [ + { season: 1, name: "", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }, + ], + }); + }); + + it("getMovieMediaMetadata maps a movie", async () => { + const client = new TvdbClient(tvdbNetwork(), {}); + const movie = await client.getMovieMediaMetadata(2, "eng"); + expect(movie).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TVDB" }); + }); + + it("getLanguages returns language records", async () => { + const network: NetworkPort = { + fetch: async (url) => { + expect(url).toContain("/languages"); + return jsonResponse(envelope([{ id: "zho", name: "Chinese" }])); + }, + }; + const client = new TvdbClient(network, {}); + const langs = await client.getLanguages(); + expect(langs?.[0]?.id).toBe("zho"); + }); + + it("keeps mapToTvdbLangCode helper", () => { + expect(mapToTvdbLangCode("zh-CN")).toBe("zho"); + expect(mapToTvdbLangCode("en-US")).toBe("eng"); + expect(mapToTvdbLangCode("ja-JP")).toBe("jpn"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/core/src/clients/TvdbClient.test.ts` +Expected: FAIL — `getLanguages` missing; search tests fail on double-mapped language. + +- [ ] **Step 3: Refactor `TvdbClient.ts`** + +In `apps/core/src/clients/TvdbClient.ts`: +- Remove `mapToTvdbLangCode` calls inside `searchSeries`, `searchMovie`, `getTvShowMediaMetadata`, `getMovieMediaMetadata` — pass the `language` argument straight to the underlying `this.client` calls. +- Keep exporting `mapToTvdbLangCode` (it is still used by `scrapeNfoTmdb.ts`). +- Add `getLanguages`: + +```ts +async getLanguages(): Promise { + const resp = await this.client.languages(); + return resp.status === "success" ? resp.data : undefined; +} +``` + +Add `TVDBv4LanguageRecord` to the type imports from `@smm/tvdb4/types`. + +- [ ] **Step 4: Update `recognizeMediaFolder.ts`** + +In `apps/core/src/pipeline/recognizeMediaFolder.ts`, import `mapToTvdbLangCode` from `../clients/TvdbClient` and wrap each tvdb call's language argument: + +```ts +const tvdbLang = mapToTvdbLangCode(deps.language as PreferMediaLanguage); +``` + +Replace the four `deps.tvdb.*` call sites to pass `tvdbLang` instead of `deps.language` (lines 180, 185, 195, 201). + +> Note: `deps.language` is IETF (`"zh-CN"` etc.) flowing from `importFolderPipeline.ts`. The mapping belongs here since the recognize pipeline is the only legacy consumer. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm vitest run apps/core/src/clients/TvdbClient.test.ts apps/core/src/pipeline/recognizeMediaFolder.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/src/clients/TvdbClient.ts apps/core/src/clients/TvdbClient.test.ts apps/core/src/pipeline/recognizeMediaFolder.ts +git commit -m "refactor(core): TvdbClient language contract to ISO 639-3 + getLanguages" +``` + +--- + +### Task 3: Add `Core` v3 TVDB methods + +**Files:** +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` + +**Interfaces:** +- Consumes: `TvdbClient` (Task 2), `TVDB_SUPPORTED_LANGUAGES`/`parseTvdbSearchLanguage`/`resolveTvdbSearchLanguage` from `@core` (Task 1), `userConfig.tvdb`, `NetworkPort`. +- Produces: + - `interface TvdbRequestOptions { language?: string; host?: string; password?: string; proxy?: string }` + - `interface SearchInTvdbOptions extends TvdbRequestOptions { type: "series" | "movie" }` + - `Core.searchInTvdb(keyword: string, options: SearchInTvdbOptions): Promise` + - `Core.getTvShowInTvdb(id: number, options?: TvdbRequestOptions): Promise` + - `Core.getMovieInTvdb(id: number, options?: TvdbRequestOptions): Promise` + - `Core.getTvdbLanguages(options?: TvdbRequestOptions): Promise` + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/core/src/Core.test.ts`. Add a `tvdbNetwork()` helper mirroring `TvdbClient.test.ts` (returns TVDB envelopes) and add `describe("Core TVDB methods", () => { ... })`: + +```ts +function tvdbResponse(url: string): HttpResponse { + if (url.includes("/search")) { + const type = url.includes("type=series") ? "series" : "movie"; + return jsonResponse({ + status: "success", + data: type === "series" + ? [{ id: "series-1", objectID: "series-1", name: "My Show", tvdb_id: "1", type: "series" }] + : [{ id: "movie-2", objectID: "movie-2", name: "My Film", tvdb_id: "2", type: "movie" }], + }); + } + if (url.includes("/series/1/translations/eng")) return jsonResponse({ status: "success", data: { name: "My Show" } }); + if (url.includes("/series/1/extended")) { + return jsonResponse({ status: "success", data: { id: 1, name: "My Show", firstAired: "2020-01-01", seasons: [{ id: 11, number: 1, type: { name: "Aired Order" } }] } }); + } + if (url.includes("/seasons/11/extended")) { + return jsonResponse({ status: "success", data: { id: 11, episodes: [{ id: 1, number: 1, seasonNumber: 1, name: "Pilot" }] } }); + } + if (url.includes("/movies/2/translations/eng")) return jsonResponse({ status: "success", data: { name: "My Film" } }); + if (url.includes("/movies/2/extended")) return jsonResponse({ status: "success", data: { id: 2, name: "My Film", first_release: { first: "2019-05-01" } } }); + if (url.includes("/languages")) return jsonResponse({ status: "success", data: [{ id: "zho", name: "Chinese" }] }); + throw new Error("unexpected url: " + url); +} + +describe("Core.searchInTvdb", () => { + it("searches via NetworkPort with host, password and proxy", async () => { + const calls: Array<{ url: string; proxy?: string; auth?: string }> = []; + const network: NetworkPort = { + fetch: async (url, init) => { + calls.push({ url, proxy: init?.proxy, auth: init?.headers?.Authorization }); + return tvdbResponse(url); + }, + }; + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network, + appDataDir: "/data/smm", + }); + + const results = await core.searchInTvdb("keyword", { + type: "series", + host: "https://tvdb.example.com/v4", + password: "secret", + proxy: "socks5://127.0.0.1:1080", + }); + + expect(results[0]?.tvdb_id).toBe("1"); + expect(calls[0]?.url).toContain("https://tvdb.example.com/v4/search"); + expect(calls[0]?.url).toContain("query=keyword"); + expect(calls[0]?.url).toContain("language=eng"); + expect(calls[0]?.proxy).toBe("socks5://127.0.0.1:1080"); + expect(calls[0]?.auth).toBe("Bearer secret"); + }); + + it("maps explicit ISO 639-3 language through", async () => { + const calls: string[] = []; + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => { calls.push(url); return tvdbResponse(url); } }, + appDataDir: "/data/smm", + }); + await core.searchInTvdb("keyword", { type: "movie", language: "zho" }); + expect(calls[0]).toContain("language=zho"); + }); + + it("rejects language not in the static TVDB supported list", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.searchInTvdb("keyword", { type: "series", language: "zh-CN" })).rejects.toThrow(/ISO 639-3/); + await expect(core.searchInTvdb("keyword", { type: "series", language: "zzz" })).rejects.toThrow(/ISO 639-3/); + }); + + it("rejects empty keyword", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.searchInTvdb(" ", { type: "movie" })).rejects.toThrow(/keyword/i); + }); + + it("resolves preferMediaLanguage zh-CN to zho when language omitted", async () => { + const calls: string[] = []; + const core = new Core({ + fs: inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], preferMediaLanguage: "zh-CN", tmdb: {}, tvdb: {} }), + }), + network: { fetch: async (url) => { calls.push(url); return tvdbResponse(url); } }, + appDataDir: "/data/smm", + }); + await core.searchInTvdb("keyword", { type: "series" }); + expect(calls[0]).toContain("language=zho"); + }); +}); + +describe("Core.getTvShowInTvdb / getMovieInTvdb / getTvdbLanguages", () => { + it("getTvShowInTvdb builds TvShowMediaMetadata", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const tvShow = await core.getTvShowInTvdb(1, { language: "eng" }); + expect(tvShow).toEqual({ + id: "1", + name: "My Show", + database: "TVDB", + airDate: "2020-01-01", + seasons: [{ season: 1, name: "", episodes: [{ season: 1, episode: 1, name: "Pilot" }] }], + }); + }); + + it("getMovieInTvdb builds MovieMediaMetadata", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const movie = await core.getMovieInTvdb(2, { language: "eng" }); + expect(movie).toEqual({ id: "2", name: "My Film", airDate: "2019-05-01", database: "TVDB" }); + }); + + it("getTvdbLanguages returns language records", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const langs = await core.getTvdbLanguages(); + expect(langs[0]?.id).toBe("zho"); + }); + + it("validates id as a positive integer", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.getTvShowInTvdb(0)).rejects.toThrow(/positive integer/); + await expect(core.getMovieInTvdb(-1)).rejects.toThrow(/positive integer/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/core/src/Core.test.ts` +Expected: FAIL — methods not found on `Core`. + +- [ ] **Step 3: Implement the Core methods** + +In `apps/core/src/Core.ts`: + +Add imports: + +```ts +import { resolveTvdbSearchLanguage } from "@core/locale"; +import { parseTvdbSearchLanguage } from "@core/tvdbSupportedLanguages"; +import type { TVDBv4LanguageRecord, TVDBv4SearchResult } from "@smm/tvdb4"; +import { TvdbClient } from "./clients/TvdbClient"; +``` + +Add option interfaces next to `TmdbRequestOptions`: + +```ts +export interface TvdbRequestOptions { + /** TVDB language (CLI `--lang`). ISO 639-3 code, validated offline against the static list. */ + language?: string; + /** Override userConfig.tvdb.host */ + host?: string; + /** Override userConfig.tvdb.apiKey (CLI `--password`) */ + password?: string; + /** Override userConfig.tvdb.httpProxy (CLI `--proxy`) */ + proxy?: string; +} + +export interface SearchInTvdbOptions extends TvdbRequestOptions { + type: "series" | "movie"; +} +``` + +Add the public methods after `getTvShowInTmdb`: + +```ts +/** + * Search TVDB via {@link NetworkPort} (CLI uses NodejsNetworkPort). + * Explicit `language` is ISO 639-3, validated offline against the static + * supported-languages snapshot. When omitted it resolves preferMediaLanguage → OS → eng. + */ +async searchInTvdb(keyword: string, options: SearchInTvdbOptions): Promise { + const trimmed = keyword.trim(); + if (!trimmed) { + throw new Error("keyword is required"); + } + const { client, language } = await this.createTvdbClient(options); + const results = options.type === "series" + ? await client.searchSeries(trimmed, language) + : await client.searchMovie(trimmed, language); + if (!results) { + throw new Error("TVDB search failed"); + } + return results; +} + +/** Fetch TVDB series metadata (seasons + episodes + translations) by id via {@link NetworkPort}. */ +async getTvShowInTvdb(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const metadata = await client.getTvShowMediaMetadata(id, language); + if (!metadata) { + throw new Error(`Failed to get TVDB series ${id}`); + } + return metadata; +} + +/** Fetch TVDB movie metadata by id via {@link NetworkPort}. */ +async getMovieInTvdb(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const metadata = await client.getMovieMediaMetadata(id, language); + if (!metadata) { + throw new Error(`Failed to get TVDB movie ${id}`); + } + return metadata; +} + +/** Fetch the TVDB supported language list via {@link NetworkPort}. */ +async getTvdbLanguages(options: TvdbRequestOptions = {}): Promise { + const { client } = await this.createTvdbClient(options); + const languages = await client.getLanguages(); + if (!languages) { + throw new Error("Failed to get TVDB languages"); + } + return languages; +} +``` + +Add the private helper after `createTmdbClient`: + +```ts +private async createTvdbClient( + options: TvdbRequestOptions, +): Promise<{ client: TvdbClient; language: string }> { + const config = await this.userConfig.read(); + const language = options.language?.trim() + ? parseTvdbSearchLanguage(options.language) + : resolveTvdbSearchLanguage({ + preferMediaLanguage: config.preferMediaLanguage, + configured: config.applicationLanguage, + osLocale: detectOsLocale(), + }); + const host = options.host?.trim() || config.tvdb?.host; + const apiKey = options.password?.trim() || config.tvdb?.apiKey; + const httpProxy = options.proxy?.trim() || config.tvdb?.httpProxy; + + const client = new TvdbClient(this.network, { + host, + apiKey, + httpProxy, + reverseProxyUrl: null, + discover: this.discover, + }); + + return { client, language }; +} +``` + +Add `TvShowMediaMetadata` and `MovieMediaMetadata` to the `@smm/core` type imports (they already exist in the import list used elsewhere in Core.ts — verify; if not, add). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run apps/core/src/Core.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/Core.ts apps/core/src/Core.test.ts +git commit -m "feat(core): add searchInTvdb/getTvShowInTvdb/getMovieInTvdb/getTvdbLanguages" +``` + +--- + +### Task 4: Internal HTTP routes for TVDB + +**Files:** +- Create: `apps/cli/src/route/Tvdb.ts` +- Create: `apps/cli/src/route/Tvdb.test.ts` +- Modify: `apps/cli/server.ts` + +**Interfaces:** +- Consumes: `Core.searchInTvdb`, `Core.getTvShowInTvdb`, `Core.getMovieInTvdb`, `Core.getTvdbLanguages` (Task 3). +- Produces Hono routes: + - `POST /api/search-in-tvdb` body `{ keyword, type: "series"|"movie", language?, host?, password?, proxy? }` + - `POST /api/get-tvshow-in-tvdb` body `{ id, language?, host?, password?, proxy? }` + - `POST /api/get-movie-in-tvdb` body `{ id, language?, host?, password?, proxy? }` + - `POST /api/get-tvdb-languages` body `{}` + +- [ ] **Step 1: Write the failing test** + +Create `apps/cli/src/route/Tvdb.test.ts` (mirror `Tmdb.test.ts`): + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + searchInTvdb: vi.fn(), + getTvShowInTvdb: vi.fn(), + getMovieInTvdb: vi.fn(), + getTvdbLanguages: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +import { handleTvdb } from './Tvdb' + +describe('TVDB Internal HTTP APIs', () => { + let app: Hono + + beforeEach(() => { + mocks.searchInTvdb.mockReset() + mocks.getTvShowInTvdb.mockReset() + mocks.getMovieInTvdb.mockReset() + mocks.getTvdbLanguages.mockReset() + app = new Hono() + handleTvdb(app) + }) + + async function post(path: string, body: unknown) { + return app.request(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + describe('POST /api/search-in-tvdb', () => { + it('returns Error Reason when keyword is missing', async () => { + const res = await post('/api/search-in-tvdb', { type: 'series' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: keyword is required/) + expect(mocks.searchInTvdb).not.toHaveBeenCalled() + }) + + it('returns Error Reason when type is invalid', async () => { + const res = await post('/api/search-in-tvdb', { keyword: 'naruto', type: 'anime' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: type must be series or movie/) + }) + + it('calls Core.searchInTvdb and returns data', async () => { + mocks.searchInTvdb.mockResolvedValue([{ tvdb_id: '1', name: 'Naruto' }]) + const res = await post('/api/search-in-tvdb', { + keyword: 'naruto', + type: 'series', + language: 'zho', + host: 'https://tvdb.example.com/v4', + }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: [{ tvdb_id: '1', name: 'Naruto' }] }) + expect(mocks.searchInTvdb).toHaveBeenCalledWith('naruto', { + type: 'series', + language: 'zho', + host: 'https://tvdb.example.com/v4', + password: undefined, + proxy: undefined, + }) + }) + + it('maps Core throw to Error Reason', async () => { + mocks.searchInTvdb.mockRejectedValue(new Error('Unsupported language "zh-CN"')) + const res = await post('/api/search-in-tvdb', { keyword: 'x', type: 'movie', language: 'zh-CN' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toBe('Error Reason: Unsupported language "zh-CN"') + }) + }) + + describe('POST /api/get-tvshow-in-tvdb', () => { + it('returns Error Reason when id is missing', async () => { + const res = await post('/api/get-tvshow-in-tvdb', {}) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: id is required/) + }) + + it('calls Core.getTvShowInTvdb and returns data', async () => { + mocks.getTvShowInTvdb.mockResolvedValue({ id: '1', name: 'My Show', database: 'TVDB', seasons: [] }) + const res = await post('/api/get-tvshow-in-tvdb', { id: 1, language: 'eng' }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: { id: '1', name: 'My Show', database: 'TVDB', seasons: [] }, + }) + expect(mocks.getTvShowInTvdb).toHaveBeenCalledWith(1, { + language: 'eng', + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) + + describe('POST /api/get-movie-in-tvdb', () => { + it('calls Core.getMovieInTvdb and returns data', async () => { + mocks.getMovieInTvdb.mockResolvedValue({ id: '2', name: 'My Film', database: 'TVDB' }) + const res = await post('/api/get-movie-in-tvdb', { id: 2 }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: { id: '2', name: 'My Film', database: 'TVDB' } }) + expect(mocks.getMovieInTvdb).toHaveBeenCalledWith(2, { + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) + + describe('POST /api/get-tvdb-languages', () => { + it('calls Core.getTvdbLanguages and returns data', async () => { + mocks.getTvdbLanguages.mockResolvedValue([{ id: 'zho', name: 'Chinese' }]) + const res = await post('/api/get-tvdb-languages', {}) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ data: [{ id: 'zho', name: 'Chinese' }] }) + expect(mocks.getTvdbLanguages).toHaveBeenCalledWith({ + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + }) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/cli/src/route/Tvdb.test.ts` +Expected: FAIL — module `./Tvdb` not found. + +- [ ] **Step 3: Implement the route** + +Create `apps/cli/src/route/Tvdb.ts` (mirror `Tmdb.ts`): + +```ts +import type { Hono } from 'hono' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface TvdbHttpResponseBody { + data?: unknown + error?: string +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined +} + +function parsePositiveInt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value + } + if (typeof value === 'string' && /^\d+$/.test(value)) { + const parsed = Number(value) + if (parsed > 0) return parsed + } + return undefined +} + +function tvdbRequestOptions(rec: Record): { + language?: string + host?: string + password?: string + proxy?: string +} { + return { + language: optionalString(rec.language), + host: optionalString(rec.host), + password: optionalString(rec.password), + proxy: optionalString(rec.proxy), + } +} + +async function readJsonObject(c: { req: { json: () => Promise } }): Promise> { + try { + const body = await c.req.json() + if (typeof body === 'object' && body !== null) { + return body as Record + } + } catch { + /* empty / invalid JSON */ + } + return {} +} + +function errorBody(message: string): { error: string } { + return { error: `Error Reason: ${message}` } +} + +export function handleTvdb(app: Hono): void { + app.post('/api/search-in-tvdb', async (c) => { + try { + const rec = await readJsonObject(c) + const keyword = optionalString(rec.keyword) + if (!keyword) { + return c.json(errorBody('keyword is required'), 200) + } + const type = rec.type + if (type !== 'series' && type !== 'movie') { + return c.json(errorBody('type must be series or movie'), 200) + } + const data = await getCore().searchInTvdb(keyword, { + type, + ...tvdbRequestOptions(rec), + }) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/search-in-tvdb] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) + + app.post('/api/get-tvshow-in-tvdb', async (c) => { + try { + const rec = await readJsonObject(c) + const id = parsePositiveInt(rec.id) + if (id === undefined) { + return c.json(errorBody('id is required'), 200) + } + const data = await getCore().getTvShowInTvdb(id, tvdbRequestOptions(rec)) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-tvshow-in-tvdb] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) + + app.post('/api/get-movie-in-tvdb', async (c) => { + try { + const rec = await readJsonObject(c) + const id = parsePositiveInt(rec.id) + if (id === undefined) { + return c.json(errorBody('id is required'), 200) + } + const data = await getCore().getMovieInTvdb(id, tvdbRequestOptions(rec)) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-movie-in-tvdb] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) + + app.post('/api/get-tvdb-languages', async (c) => { + try { + const data = await getCore().getTvdbLanguages(tvdbRequestOptions({})) + return c.json({ data }, 200) + } catch (error) { + logger.error({ error }, '[POST /api/get-tvdb-languages] route error') + return c.json(errorBody(error instanceof Error ? error.message : 'Unknown error'), 200) + } + }) +} +``` + +- [ ] **Step 4: Register the route in server.ts** + +In `apps/cli/server.ts`: +- Add import: `import { handleTvdb } from './src/route/Tvdb';` +- After `handleTmdb(this.app);` add `handleTvdb(this.app);` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run apps/cli/src/route/Tvdb.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/cli/src/route/Tvdb.ts apps/cli/src/route/Tvdb.test.ts apps/cli/server.ts +git commit -m "feat(cli): add TVDB internal HTTP routes" +``` + +--- + +### Task 5: CLI `smm tvdb search` command + +**Files:** +- Create: `apps/cli/src/cli/tvdbSearchFormat.ts` +- Create: `apps/cli/src/cli/tvdbSearchFormat.test.ts` +- Modify: `apps/cli/src/cli/runCli.ts` +- Create: `apps/cli/src/cli/tvdb.test.ts` + +**Interfaces:** +- Consumes: `Core.searchInTvdb` (Task 3), `TVDBv4SearchResult` from `@smm/tvdb4`. +- Produces: + - `formatTvdbSearchResults(items: TVDBv4SearchResult[], type: "series" | "movie"): string` + - CLI subcommand `smm tvdb search --type series|movie [--lang ] [--host ] [--password ] [--proxy ]` + +- [ ] **Step 1: Write the failing formatter test** + +Create `apps/cli/src/cli/tvdbSearchFormat.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { formatTvdbSearchResults } from './tvdbSearchFormat' + +const seriesItem = { + id: 'series-42', + objectID: 'series-42', + tvdb_id: '42', + name: 'My Show', + overview: 'A show', + first_air_time: '2020-01-01', +} as never + +const movieItem = { + id: 'movie-7', + objectID: 'movie-7', + tvdb_id: '7', + name: 'My Film', + overview: 'A film', + release_date: '2019-05-01', +} as never + +describe('formatTvdbSearchResults', () => { + it('formats series results', () => { + const text = formatTvdbSearchResults([seriesItem], 'series') + expect(text).toBe('#1 42 My Show (2020-01-01)\nA show') + }) + + it('formats movie results with release date', () => { + const text = formatTvdbSearchResults([movieItem], 'movie') + expect(text).toBe('#1 7 My Film (2019-05-01)\nA film') + }) + + it('returns empty string for empty results', () => { + expect(formatTvdbSearchResults([], 'series')).toBe('') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/cli/src/cli/tvdbSearchFormat.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the formatter** + +Create `apps/cli/src/cli/tvdbSearchFormat.ts`: + +```ts +import type { TVDBv4SearchResult } from '@smm/tvdb4' + +export type TvdbSearchMediaType = 'series' | 'movie' + +function releaseYear(item: TVDBv4SearchResult): string { + const firstAir = typeof item.first_air_time === 'string' ? item.first_air_time : undefined + const release = typeof item.release_date === 'string' ? item.release_date : undefined + const year = typeof item.year === 'string' ? item.year : undefined + return firstAir || release || year || '' +} + +/** + * Format TVDB search results for CLI stdout: + * `#{index} {tvdb_id} {name} ({release date})` then overview on the next line. + */ +export function formatTvdbSearchResults( + items: TVDBv4SearchResult[], + _type: TvdbSearchMediaType, +): string { + const lines: string[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i]! + const index = i + 1 + const id = item.tvdb_id || item.id || '' + lines.push(`#${index} ${id} ${item.name} (${releaseYear(item)})`) + lines.push(item.overview ?? '') + } + return lines.join('\n') +} +``` + +> `first_air_time`, `release_date`, `year` are not in the `TVDBv4SearchResult` type (it is `Record & {...}`), so the `typeof` guards are required. + +- [ ] **Step 4: Run formatter test to verify it passes** + +Run: `pnpm vitest run apps/cli/src/cli/tvdbSearchFormat.test.ts` +Expected: PASS + +- [ ] **Step 5: Add the CLI command to runCli.ts** + +Create `apps/cli/src/cli/tvdb.test.ts` (mirror `tmdb.test.ts`): + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { resetCoreForTests } from '../core/getCore' + +describe('smm tvdb search', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-tvdb-cli-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + vi.doUnmock('../core/getCore') + vi.resetModules() + }) + + it('prints formatted results and exits 0', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTvdb: async () => [ + { id: 'series-42', objectID: 'series-42', tvdb_id: '42', name: 'My Show', overview: 'A show', first_air_time: '2020-01-01' }, + ], + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tvdb', 'search', 'keyword', '--type', 'series']) + + expect(code).toBe(0) + expect(logSpy.mock.calls.map((c) => c[0])).toEqual(['#1 42 My Show (2020-01-01)\nA show']) + }) + + it('passes host, password, proxy and lang to Core.searchInTvdb', async () => { + const calls: unknown[] = [] + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTvdb: async (keyword: string, options: unknown) => { + calls.push({ keyword, options }) + return [] + }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', 'smm', 'tvdb', 'search', 'hello', '--type', 'movie', + '--host', 'https://tvdb.example.com/v4', '--password', 'secret', + '--proxy', 'socks5://127.0.0.1:1080', '--lang', 'zho', + ]) + + expect(code).toBe(0) + expect(calls).toEqual([ + { + keyword: 'hello', + options: { type: 'movie', host: 'https://tvdb.example.com/v4', password: 'secret', proxy: 'socks5://127.0.0.1:1080', language: 'zho' }, + }, + ]) + }) + + it('exits non-zero when --lang is invalid', async () => { + vi.resetModules() + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tvdb', 'search', 'keyword', '--type', 'series', '--lang', 'zh-CN']) + expect(code).not.toBe(0) + expect(String(errorSpy.mock.calls.flat().join(' '))).toMatch(/ISO 639-3/) + }) + + it('writes to stderr and exits 1 when search throws', async () => { + vi.resetModules() + vi.doMock('../core/getCore', () => ({ + getCore: () => ({ + searchInTvdb: async () => { throw new Error('network down') }, + }), + resetCoreForTests: () => {}, + })) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'tvdb', 'search', 'x', '--type', 'series']) + expect(code).toBe(1) + expect(String(errorSpy.mock.calls[0]?.[0] ?? '')).toContain('network down') + }) +}) +``` + +- [ ] **Step 6: Add the `tvdb` command to runCli.ts** + +In `apps/cli/src/cli/runCli.ts`: +- Import: `import { formatTvdbSearchResults } from './tvdbSearchFormat'` +- After the `tmdbCmd` block, add: + +```ts +const tvdbCmd = program.command('tvdb').description('TVDB helpers') + +tvdbCmd + .command('search') + .description('Search TVDB for TV series or movies') + .argument('', 'Search keyword') + .addOption( + new Option('--type ', 'Media type') + .choices(['series', 'movie']) + .makeOptionMandatory(), + ) + .option('--host ', 'TVDB API base URL (overrides userConfig.tvdb.host)') + .option('--password ', 'TVDB API key (overrides userConfig.tvdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tvdb.httpProxy)') + .option( + '--lang ', + 'TVDB ISO 639-3 language code (static list, e.g. eng, zho, yue); defaults from userConfig then OS locale', + ) + .action( + async ( + keyword: string, + opts: { + type: 'series' | 'movie' + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const results = await getCore().searchInTvdb(keyword, { + type: opts.type, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + language: opts.lang, + }) + const text = formatTvdbSearchResults(results, opts.type) + if (text) console.log(text) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) +``` + +- [ ] **Step 7: Run CLI test to verify it passes** + +Run: `pnpm vitest run apps/cli/src/cli/tvdb.test.ts apps/cli/src/cli/tvdbSearchFormat.test.ts` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/cli/src/cli/tvdbSearchFormat.ts apps/cli/src/cli/tvdbSearchFormat.test.ts apps/cli/src/cli/runCli.ts apps/cli/src/cli/tvdb.test.ts +git commit -m "feat(cli): add smm tvdb search command" +``` + +--- + +### Task 6: AI tool type definitions + execution for TVDB + +**Files:** +- Create: `packages/core/types/ai-tools/tvdbCommon.ts` +- Create: `packages/core/types/ai-tools/tvdbSearch.ts` +- Create: `packages/core/types/ai-tools/tvdbGetMovie.ts` +- Create: `packages/core/types/ai-tools/tvdbGetTvShow.ts` +- Create: `packages/core/types/ai-tools/tvdbGetLanguages.ts` +- Create: `packages/core-routes/src/tools/tvdb.ts` +- Create: `packages/core-routes/src/tools/tvdb.test.ts` + +**Interfaces:** +- Consumes: `TVDBv4SearchResult`, `TVDBv4LanguageRecord`, `TvShowMediaMetadata`, `MovieMediaMetadata`. +- Produces: + - Constants: `TVDB_SEARCH = "tvdb-search"`, `TVDB_GET_MOVIE = "tvdb-get-movie"`, `TVDB_GET_TV_SHOW = "tvdb-get-tv-show"`, `TVDB_GET_LANGUAGES = "tvdb-get-languages"`. + - Schemas + types: `tvdbSearchInputSchema/TvdbSearchInput`, `tvdbSearchOutputSchema/TvdbSearchOutput`, and same for movie/tv-show/languages. + - Runners + builders: `TvdbToolRunners`, `executeTvdbSearch`, `executeTvdbGetMovie`, `executeTvdbGetTvShow`, `executeTvdbGetLanguages`, `buildTvdbSearchTool`, `buildTvdbGetMovieTool`, `buildTvdbGetTvShowTool`, `buildTvdbGetLanguagesTool`. + +- [ ] **Step 1: Write the failing tests** + +Create `packages/core-routes/src/tools/tvdb.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { + executeTvdbGetLanguages, + executeTvdbGetMovie, + executeTvdbGetTvShow, + executeTvdbSearch, +} from "./tvdb.ts"; + +describe("executeTvdbSearch", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeTvdbSearch({ keyword: "naruto", type: "series" }, undefined); + expect(result.error).toMatch(/not available/i); + }); + + it("maps keyword/type/language to Core runner", async () => { + const runner = vi.fn(async () => [{ tvdb_id: "1", name: "Show" }]); + const result = await executeTvdbSearch( + { keyword: "naruto", type: "series", language: "zho", baseURL: "https://tvdb.example/v4" }, + runner, + ); + expect(result.results).toHaveLength(1); + expect(runner).toHaveBeenCalledWith("naruto", { + type: "series", + language: "zho", + host: "https://tvdb.example/v4", + }); + }); + + it("returns error when runner throws", async () => { + const runner = vi.fn(async () => { throw new Error("upstream failed"); }); + const result = await executeTvdbSearch({ keyword: "x", type: "movie" }, runner); + expect(result.error).toMatch(/upstream failed/); + }); +}); + +describe("executeTvdbGetMovie / GetTvShow", () => { + it("returns unavailable when runner is missing", async () => { + const movie = await executeTvdbGetMovie({ id: 2 }, undefined); + expect(movie.error).toMatch(/not available/i); + const show = await executeTvdbGetTvShow({ id: 1 }, undefined); + expect(show.error).toMatch(/not available/i); + }); + + it("validates id", async () => { + const movie = await executeTvdbGetMovie({ id: 0 }, vi.fn()); + expect(movie.error).toMatch(/positive integer/i); + }); + + it("returns metadata on success", async () => { + const runner = vi.fn(async () => ({ id: "2", name: "My Film", database: "TVDB" })); + const result = await executeTvdbGetMovie({ id: 2, language: "eng" }, runner); + expect(result.name).toBe("My Film"); + expect(runner).toHaveBeenCalledWith(2, { language: "eng", host: undefined }); + }); +}); + +describe("executeTvdbGetLanguages", () => { + it("returns unavailable when runner is missing", async () => { + const result = await executeTvdbGetLanguages(undefined); + expect(result.error).toMatch(/not available/i); + }); + + it("returns languages on success", async () => { + const runner = vi.fn(async () => [{ id: "zho", name: "Chinese" }]); + const result = await executeTvdbGetLanguages(runner); + expect(result.languages).toEqual([{ id: "zho", name: "Chinese" }]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run packages/core-routes/src/tools/tvdb.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create the type files** + +Create `packages/core/types/ai-tools/tvdbCommon.ts`: + +```ts +import { z } from 'zod' + +/** Optional TVDB ISO 639-3 language code (e.g. eng, zho, yue). */ +export const tvdbLanguageSchema = z + .string() + .optional() + .describe( + 'TVDB ISO 639-3 language code (e.g. eng, zho, yue). Defaults from userConfig.preferMediaLanguage.', + ) + +/** Override TVDB API base URL (MCP/AI tool name: baseURL). */ +export const tvdbBaseUrlSchema = z + .string() + .optional() + .describe('Optional TVDB API base URL override (defaults from userConfig.tvdb.host)') + +export interface TvdbToolHostOptions { + language?: string + baseURL?: string +} + +export function toTvdbCoreOptions(params: TvdbToolHostOptions): { + language?: string + host?: string +} { + const host = params.baseURL?.trim() + return { + language: params.language, + host: host || undefined, + } +} + +export function formatTvdbToolError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.startsWith('Error Reason:') ? message : `Error Reason: ${message}` +} +``` + +Create `packages/core/types/ai-tools/tvdbSearch.ts`: + +```ts +import { z } from 'zod' +import { tvdbBaseUrlSchema, tvdbLanguageSchema } from './tvdbCommon' + +export const TVDB_SEARCH = 'tvdb-search' as const + +export const TVDB_SEARCH_DESCRIPTION = + 'Search TVDB (TheTVDB) for TV series or movies by keyword. ' + + 'Returns matching results with title, overview, and TVDB ID.\n\n' + + 'Example: Search series matching "naruto".' + +export const tvdbSearchInputSchema = z.object({ + keyword: z.string().describe('Search keyword'), + type: z.enum(['series', 'movie']).describe('Media type to search'), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbSearchOutputSchema = z.object({ + results: z.array(z.record(z.string(), z.unknown())).optional(), + error: z.string().optional(), +}) + +export type TvdbSearchInput = z.infer +export type TvdbSearchOutput = z.infer +``` + +Create `packages/core/types/ai-tools/tvdbGetMovie.ts`: + +```ts +import { z } from 'zod' +import { tvdbBaseUrlSchema, tvdbLanguageSchema } from './tvdbCommon' + +export const TVDB_GET_MOVIE = 'tvdb-get-movie' as const + +export const TVDB_GET_MOVIE_DESCRIPTION = + 'Retrieve movie metadata from TVDB by TVDB ID, including the localized title.\n\n' + + 'Example: Get movie metadata for TVDB id 7.' + +export const tvdbGetMovieInputSchema = z.object({ + id: z.number().int().positive().describe('TVDB movie id'), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbGetMovieOutputSchema = z + .object({ error: z.string().optional() }) + .passthrough() + +export type TvdbGetMovieInput = z.infer +export type TvdbGetMovieOutput = z.infer +``` + +Create `packages/core/types/ai-tools/tvdbGetTvShow.ts`: + +```ts +import { z } from 'zod' +import { tvdbBaseUrlSchema, tvdbLanguageSchema } from './tvdbCommon' + +export const TVDB_GET_TV_SHOW = 'tvdb-get-tv-show' as const + +export const TVDB_GET_TV_SHOW_DESCRIPTION = + 'Retrieve TV series metadata from TVDB by TVDB ID, including seasons and episodes with localized titles.\n\n' + + 'Example: Get TV series metadata for TVDB id 42.' + +export const tvdbGetTvShowInputSchema = z.object({ + id: z.number().int().positive().describe('TVDB series id'), + language: tvdbLanguageSchema, + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbGetTvShowOutputSchema = z + .object({ error: z.string().optional() }) + .passthrough() + +export type TvdbGetTvShowInput = z.infer +export type TvdbGetTvShowOutput = z.infer +``` + +Create `packages/core/types/ai-tools/tvdbGetLanguages.ts`: + +```ts +import { z } from 'zod' +import { tvdbBaseUrlSchema } from './tvdbCommon' + +export const TVDB_GET_LANGUAGES = 'tvdb-get-languages' as const + +export const TVDB_GET_LANGUAGES_DESCRIPTION = + 'Retrieve the list of TVDB supported languages (ISO 639-3 codes). Useful for picking a search language.' + +export const tvdbGetLanguagesInputSchema = z.object({ + baseURL: tvdbBaseUrlSchema, +}) + +export const tvdbGetLanguagesOutputSchema = z.object({ + languages: z.array(z.record(z.string(), z.unknown())).optional(), + error: z.string().optional(), +}) + +export type TvdbGetLanguagesInput = z.infer +export type TvdbGetLanguagesOutput = z.infer +``` + +- [ ] **Step 4: Implement the execution + builders** + +Create `packages/core-routes/src/tools/tvdb.ts`: + +```ts +import { requireNonEmptyString } from "@smm/core/ai-tool/toolResult"; +import { + formatTvdbToolError, + toTvdbCoreOptions, + type TvdbToolHostOptions, +} from "@smm/core/types/ai-tools/tvdbCommon"; +import { + TVDB_SEARCH, + TVDB_SEARCH_DESCRIPTION, + tvdbSearchInputSchema, + tvdbSearchOutputSchema, + type TvdbSearchInput, + type TvdbSearchOutput, +} from "@smm/core/types/ai-tools/tvdbSearch"; +import { + TVDB_GET_MOVIE, + TVDB_GET_MOVIE_DESCRIPTION, + tvdbGetMovieInputSchema, + tvdbGetMovieOutputSchema, + type TvdbGetMovieInput, + type TvdbGetMovieOutput, +} from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { + TVDB_GET_TV_SHOW, + TVDB_GET_TV_SHOW_DESCRIPTION, + tvdbGetTvShowInputSchema, + tvdbGetTvShowOutputSchema, + type TvdbGetTvShowInput, + type TvdbGetTvShowOutput, +} from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { + TVDB_GET_LANGUAGES, + TVDB_GET_LANGUAGES_DESCRIPTION, + tvdbGetLanguagesInputSchema, + tvdbGetLanguagesOutputSchema, + type TvdbGetLanguagesInput, + type TvdbGetLanguagesOutput, +} from "@smm/core/types/ai-tools/tvdbGetLanguages"; + +export type SearchInTvdbRunner = ( + keyword: string, + options: { + type: "series" | "movie"; + language?: string; + host?: string; + password?: string; + proxy?: string; + }, +) => Promise>>; + +export type GetTvShowInTvdbRunner = ( + id: number, + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise>; + +export type GetMovieInTvdbRunner = ( + id: number, + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise>; + +export type GetTvdbLanguagesRunner = ( + options?: { language?: string; host?: string; password?: string; proxy?: string }, +) => Promise>>; + +export interface TvdbToolRunners { + searchInTvdb?: SearchInTvdbRunner; + getTvShowInTvdb?: GetTvShowInTvdbRunner; + getMovieInTvdb?: GetMovieInTvdbRunner; + getTvdbLanguages?: GetTvdbLanguagesRunner; +} + +function unavailable(message: string): { error: string } { + return { error: message }; +} + +function assertNotAborted(abortSignal?: AbortSignal): void { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } +} + +export async function executeTvdbSearch( + params: TvdbSearchInput, + runner: SearchInTvdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + const keywordCheck = requireNonEmptyString(params.keyword, "keyword"); + if (typeof keywordCheck !== "string") { + return { error: keywordCheck.error }; + } + + if (!runner) { + return unavailable("tvdb-search is not available on this host"); + } + + try { + const results = await runner(keywordCheck, { + type: params.type, + ...toTvdbCoreOptions(params), + }); + return { results: results as TvdbSearchOutput["results"] }; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export async function executeTvdbGetMovie( + params: TvdbGetMovieInput, + runner: GetMovieInTvdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + + if (!runner) { + return unavailable("tvdb-get-movie is not available on this host"); + } + + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details as TvdbGetMovieOutput; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export async function executeTvdbGetTvShow( + params: TvdbGetTvShowInput, + runner: GetTvShowInTvdbRunner | undefined, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!Number.isInteger(params.id) || params.id <= 0) { + return { error: "Invalid id: 'id' must be a positive integer" }; + } + + if (!runner) { + return unavailable("tvdb-get-tv-show is not available on this host"); + } + + try { + const details = await runner(params.id, toTvdbCoreOptions(params)); + return details as TvdbGetTvShowOutput; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export async function executeTvdbGetLanguages( + runner: GetTvdbLanguagesRunner | undefined, + _params: TvdbGetLanguagesInput = {}, + abortSignal?: AbortSignal, +): Promise { + assertNotAborted(abortSignal); + + if (!runner) { + return unavailable("tvdb-get-languages is not available on this host"); + } + + try { + const languages = await runner(toTvdbCoreOptions(_params)); + return { languages: languages as TvdbGetLanguagesOutput["languages"] }; + } catch (error) { + return { error: formatTvdbToolError(error) }; + } +} + +export function buildTvdbSearchTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_SEARCH_DESCRIPTION, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbSearch((args ?? {}) as TvdbSearchInput, runners?.searchInTvdb, abortSignal); + }, + }; +} + +export function buildTvdbGetMovieTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_GET_MOVIE_DESCRIPTION, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbGetMovie((args ?? {}) as TvdbGetMovieInput, runners?.getMovieInTvdb, abortSignal); + }, + }; +} + +export function buildTvdbGetTvShowTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_GET_TV_SHOW_DESCRIPTION, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbGetTvShow( + (args ?? {}) as TvdbGetTvShowInput, + runners?.getTvShowInTvdb, + abortSignal, + ); + }, + }; +} + +export function buildTvdbGetLanguagesTool( + runners: TvdbToolRunners | undefined, + abortSignal?: AbortSignal, +) { + return { + description: TVDB_GET_LANGUAGES_DESCRIPTION, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema, + execute: async (args: unknown): Promise => { + return executeTvdbGetLanguages(runners?.getTvdbLanguages, (args ?? {}) as TvdbGetLanguagesInput, abortSignal); + }, + }; +} + +export { TVDB_SEARCH, TVDB_GET_MOVIE, TVDB_GET_TV_SHOW, TVDB_GET_LANGUAGES }; +``` + +> `@smm/tvdb4` is NOT a dependency of `@smm/core-routes` and its tsconfig has no path for it — do NOT import from `@smm/tvdb4` in `tools/tvdb.ts`. Runner types use loose `Record` shapes (assignable from `Core.searchInTvdb`'s `TVDBv4SearchResult[]` returns) so no new dependency is introduced. The Core runner return types (`TVDBv4SearchResult[]` / `TvShowMediaMetadata`) are structurally assignable to these runner signatures. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm vitest run packages/core-routes/src/tools/tvdb.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/types/ai-tools/tvdbCommon.ts packages/core/types/ai-tools/tvdbSearch.ts packages/core/types/ai-tools/tvdbGetMovie.ts packages/core/types/ai-tools/tvdbGetTvShow.ts packages/core/types/ai-tools/tvdbGetLanguages.ts packages/core-routes/src/tools/tvdb.ts packages/core-routes/src/tools/tvdb.test.ts +git commit -m "feat(core-routes): add TVDB AI tool types and execution" +``` + +--- + +### Task 7: Register TVDB AI tools in the chat toolset + +**Files:** +- Modify: `packages/core-routes/src/tools/index.ts` + +**Interfaces:** +- Consumes: `TvdbToolRunners` + builders (Task 6). +- Produces: `ChatToolsExtraDeps.tvdb?: TvdbToolRunners`; four keys in `ChatTools` interface and `createChatTools` return. + +- [ ] **Step 1: Edit `tools/index.ts`** + +Add imports: + +```ts +import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; +import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; +import { + buildTvdbGetLanguagesTool, + buildTvdbGetMovieTool, + buildTvdbGetTvShowTool, + buildTvdbSearchTool, + type TvdbToolRunners, +} from "./tvdb.ts"; +``` + +Add to `ChatTools` interface: + +```ts + [TVDB_SEARCH]: ReturnType; + [TVDB_GET_MOVIE]: ReturnType; + [TVDB_GET_TV_SHOW]: ReturnType; + [TVDB_GET_LANGUAGES]: ReturnType; +``` + +Add to `ChatToolsExtraDeps`: + +```ts + /** Host Core runners for TVDB query tools. */ + tvdb?: TvdbToolRunners; +``` + +In `createChatTools`, after `const tmdbRunners = extra?.tmdb;`: + +```ts +const tvdbRunners: TvdbToolRunners | undefined = extra?.tvdb; +``` + +Add to the returned object: + +```ts + [TVDB_SEARCH]: buildTvdbSearchTool(tvdbRunners, abortSignal), + [TVDB_GET_MOVIE]: buildTvdbGetMovieTool(tvdbRunners, abortSignal), + [TVDB_GET_TV_SHOW]: buildTvdbGetTvShowTool(tvdbRunners, abortSignal), + [TVDB_GET_LANGUAGES]: buildTvdbGetLanguagesTool(tvdbRunners, abortSignal), +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm typecheck` (or `pnpm --filter @smm/core-routes exec tsc --noEmit`) +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add packages/core-routes/src/tools/index.ts +git commit -m "feat(core-routes): register TVDB AI tools in chat toolset" +``` + +--- + +### Task 8: MCP tool handlers for TVDB + +**Files:** +- Create: `packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts` +- Modify: `packages/core-routes/src/mcp/createServer.ts` +- Modify: `packages/core-routes/src/mcp/types.ts` + +**Interfaces:** +- Consumes: `executeTvdb*` from `packages/core-routes/src/tools/tvdb.ts` (Task 6), `McpServer`, `McpConfig`, `createErrorResponse`/`createSuccessResponse`. +- Produces: `registerTvdbTools(server: McpServer, config: McpConfig): void`; new optional `McpConfig` fields `searchInTvdb`, `getMovieInTvdb`, `getTvShowInTvdb`, `getTvdbLanguages`. + +- [ ] **Step 1: Write the MCP registration test** (append to `packages/core-routes/src/mcp/mcp.test.ts` — follow the existing `callMcp`/`makeTempConfig`/`tools/list` patterns in that file; there is NO `listTools` helper) + +```ts +it("registers TVDB query tools when runners are provided", async () => { + const { appDataDir, userDataDir, userConfig, cleanup } = await makeTempConfig() + try { + const handler = await createMcpStreamableHttpHandler({ + getUserConfig: async () => userConfig, + appDataDir, + userDataDir, + fs: defaultChatFs(), + logger: { + debug() {}, + info() {}, + warn() {}, + error() {}, + }, + searchInTvdb: async () => [], + getMovieInTvdb: async () => ({}), + getTvShowInTvdb: async () => ({}), + getTvdbLanguages: async () => [], + }) + + await callMcp(handler, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.1" }, + }, + }) + + const listResponse = await callMcp(handler, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }) + const tools = (listResponse.result as { tools: Array<{ name: string }> }) + .tools + const names = tools.map((t) => t.name) + expect(names).toContain("tvdb-search") + expect(names).toContain("tvdb-get-movie") + expect(names).toContain("tvdb-get-tv-show") + expect(names).toContain("tvdb-get-languages") + } finally { + await cleanup() + } +}) +``` + +> Reuse the existing `callMcp`, `makeTempConfig`, and `defaultChatFs` helpers already in `mcp.test.ts` — import them as needed (they are defined in that file). No new helper named `listTools` is needed. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run packages/core-routes/src/mcp/mcp.test.ts` +Expected: FAIL — tools not present in the list. + +- [ ] **Step 3: Implement the handler** + +Create `packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts`: + +```ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + TVDB_SEARCH, + TVDB_SEARCH_DESCRIPTION, + tvdbSearchInputSchema, + tvdbSearchOutputSchema, +} from "@smm/core/types/ai-tools/tvdbSearch"; +import { + TVDB_GET_MOVIE, + TVDB_GET_MOVIE_DESCRIPTION, + tvdbGetMovieInputSchema, + tvdbGetMovieOutputSchema, +} from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { + TVDB_GET_TV_SHOW, + TVDB_GET_TV_SHOW_DESCRIPTION, + tvdbGetTvShowInputSchema, + tvdbGetTvShowOutputSchema, +} from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { + TVDB_GET_LANGUAGES, + TVDB_GET_LANGUAGES_DESCRIPTION, + tvdbGetLanguagesInputSchema, + tvdbGetLanguagesOutputSchema, +} from "@smm/core/types/ai-tools/tvdbGetLanguages"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; +import { + executeTvdbGetLanguages, + executeTvdbGetMovie, + executeTvdbGetTvShow, + executeTvdbSearch, +} from "../../tools/tvdb.ts"; + +/** + * Register TVDB query MCP tools (`tvdb-search`, `tvdb-get-movie`, `tvdb-get-tv-show`, `tvdb-get-languages`). + */ +export function registerTvdbTools(server: McpServer, config: McpConfig): void { + const searchDescription = + config.toolDescriptions?.[TVDB_SEARCH] ?? TVDB_SEARCH_DESCRIPTION; + const movieDescription = + config.toolDescriptions?.[TVDB_GET_MOVIE] ?? TVDB_GET_MOVIE_DESCRIPTION; + const tvShowDescription = + config.toolDescriptions?.[TVDB_GET_TV_SHOW] ?? TVDB_GET_TV_SHOW_DESCRIPTION; + const languagesDescription = + config.toolDescriptions?.[TVDB_GET_LANGUAGES] ?? TVDB_GET_LANGUAGES_DESCRIPTION; + + server.registerTool( + TVDB_SEARCH, + { + description: searchDescription, + inputSchema: tvdbSearchInputSchema, + outputSchema: tvdbSearchOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbSearch( + (args ?? {}) as Parameters[0], + config.searchInTvdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); + + server.registerTool( + TVDB_GET_MOVIE, + { + description: movieDescription, + inputSchema: tvdbGetMovieInputSchema, + outputSchema: tvdbGetMovieOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbGetMovie( + (args ?? {}) as Parameters[0], + config.getMovieInTvdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); + + server.registerTool( + TVDB_GET_TV_SHOW, + { + description: tvShowDescription, + inputSchema: tvdbGetTvShowInputSchema, + outputSchema: tvdbGetTvShowOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbGetTvShow( + (args ?? {}) as Parameters[0], + config.getTvShowInTvdb, + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); + + server.registerTool( + TVDB_GET_LANGUAGES, + { + description: languagesDescription, + inputSchema: tvdbGetLanguagesInputSchema, + outputSchema: tvdbGetLanguagesOutputSchema, + }, + async (args: unknown): Promise => { + try { + const result = await executeTvdbGetLanguages( + config.getTvdbLanguages, + (args ?? {}) as Parameters[1], + ); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result as { [x: string]: unknown }); + } catch (error) { + return createErrorResponse(error instanceof Error ? error.message : String(error)); + } + }, + ); +} +``` + +- [ ] **Step 4: Add McpConfig fields + register in createServer.ts** + +In `packages/core-routes/src/mcp/types.ts`, after the `getTvShowInTmdb` field (around line 144), add: + +```ts + /** Optional runner for `tvdb-search` → `Core.searchInTvdb`. */ + searchInTvdb?: ( + keyword: string, + options: { + type: "series" | "movie"; + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tvdb-get-movie` → `Core.getMovieInTvdb`. */ + getMovieInTvdb?: ( + id: number, + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tvdb-get-tv-show` → `Core.getTvShowInTvdb`. */ + getTvShowInTvdb?: ( + id: number, + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; + + /** Optional runner for `tvdb-get-languages` → `Core.getTvdbLanguages`. */ + getTvdbLanguages?: ( + options?: { + language?: string; + host?: string; + password?: string; + proxy?: string; + }, + ) => Promise; +``` + +In `packages/core-routes/src/mcp/createServer.ts`: +- Add import: `import { registerTvdbTools } from "./toolHandlers/tvdbTools.ts";` +- After `registerTmdbTools(server, config);` add `registerTvdbTools(server, config);` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm vitest run packages/core-routes/src/mcp/mcp.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/core-routes/src/mcp/toolHandlers/tvdbTools.ts packages/core-routes/src/mcp/createServer.ts packages/core-routes/src/mcp/types.ts packages/core-routes/src/mcp/mcp.test.ts +git commit -m "feat(core-routes): register TVDB MCP tools" +``` + +--- + +### Task 9: Wire Core TVDB runners into CLI hosts + +**Files:** +- Modify: `apps/cli/src/mcp/mcp.ts` +- Modify: `apps/cli/src/route/chatRoute.ts` + +**Interfaces:** +- Consumes: `Core.searchInTvdb` / `getMovieInTvdb` / `getTvShowInTvdb` / `getTvdbLanguages` (Task 3), `McpConfig` fields (Task 8), `ChatToolsExtraDeps.tvdb` (Task 7). +- Produces: CLI MCP config injects the four TVDB runners; chat route injects `tvdb` into `doChat` extra. + +- [ ] **Step 1: Edit `apps/cli/src/mcp/mcp.ts`** + +Add to `TOOL_NAME_KEYS` array (after the TMDB entries): + +```ts + TVDB_SEARCH, + TVDB_GET_MOVIE, + TVDB_GET_TV_SHOW, + TVDB_GET_LANGUAGES, +``` + +Add the imports for the constants: + +```ts +import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; +import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; +import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; +import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; +``` + +In `buildMcpConfig()`, after the TMDB runner lines, add: + +```ts + searchInTvdb: (keyword, options) => getCore().searchInTvdb(keyword, options), + getMovieInTvdb: (id, options) => getCore().getMovieInTvdb(id, options), + getTvShowInTvdb: (id, options) => getCore().getTvShowInTvdb(id, options), + getTvdbLanguages: (options) => getCore().getTvdbLanguages(options), +``` + +- [ ] **Step 2: Edit `apps/cli/src/route/chatRoute.ts`** + +In the `doChat(chatConfig, c.req.raw, { ... })` extra object, after the `tmdb` block add: + +```ts + tvdb: { + searchInTvdb: (keyword, options) => getCore().searchInTvdb(keyword, options), + getMovieInTvdb: (id, options) => getCore().getMovieInTvdb(id, options), + getTvShowInTvdb: (id, options) => getCore().getTvShowInTvdb(id, options), + getTvdbLanguages: (options) => getCore().getTvdbLanguages(options), + }, +``` + +- [ ] **Step 3: Verify typecheck** + +Run: `pnpm typecheck` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/cli/src/mcp/mcp.ts apps/cli/src/route/chatRoute.ts +git commit -m "feat(cli): wire TVDB Core runners into MCP and chat hosts" +``` + +--- + +### Task 10: CLI e2e test for `smm tvdb search` + +**Files:** +- Create: `apps/cli/test/tvdb.e2e.ts` + +**Interfaces:** +- Consumes: the `smm` helper and `loadEnvLocal`/`requiredEnv` from `apps/cli/test/helpers`. +- Produces: a live e2e spec covering the four doc scenarios (default host, language, proxy, custom host) — mirror `tmdb.e2e.ts`. + +- [ ] **Step 1: Create the e2e test** + +Create `apps/cli/test/tvdb.e2e.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests, smm } from './helpers/smm' +import { loadEnvLocal, requiredEnv } from './helpers/loadEnvLocal' + +loadEnvLocal(import.meta.dirname) + +const TVDB_SEARCH_TIMEOUT_MS = 60_000 +const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m +// TVDB search returns the primary (often Japanese) title and the CLI prints +// only `item.name` — WATATEN's TVDB primary title is "私に天使が舞い降りた!". +const SERIES_TITLE = /天使|WATATEN|Angel Flew Down|Wataten/i + +function officialTvdb(): { host: string; password: string; proxy: string } { + return { + host: requiredEnv('TVDB_HOST'), + password: requiredEnv('TVDB_API_KEY'), + proxy: requiredEnv('TVDB_HTTP_PROXY'), + } +} + +describe('smm tvdb search CLI e2e (docs/dev/tvdb.md scenarios)', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tvdb-e2e-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it( + '1) Search through SMM-provided TVDB host', + { timeout: TVDB_SEARCH_TIMEOUT_MS }, + async () => { + const result = await smm(['tvdb', 'search', '天使降临到我身边', '--type', 'series']) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(SERIES_TITLE) + }, + ) + + it( + '2) Search through SMM-provided TVDB host and HTTP/SOCKS proxy', + { timeout: TVDB_SEARCH_TIMEOUT_MS }, + async () => { + const { proxy } = officialTvdb() + const result = await smm([ + 'tvdb', 'search', '天使降临到我身边', '--type', 'series', '--proxy', proxy, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(SERIES_TITLE) + }, + ) + + it( + '3) Search through custom TVDB host with password and proxy', + { timeout: TVDB_SEARCH_TIMEOUT_MS }, + async () => { + const { host, password, proxy } = officialTvdb() + const result = await smm([ + 'tvdb', 'search', '天使降临到我身边', '--type', 'series', + '--host', host, '--password', password, '--proxy', proxy, + ]) + expect(result.code, result.stderr || result.stdout).toBe(0) + expect(result.stdout).toMatch(RESULT_HEADER) + expect(result.stdout).toMatch(SERIES_TITLE) + }, + ) +}) +``` + +- [ ] **Step 2: Run e2e (requires live TVDB env)** + +Run: `cd apps/cli && pnpm vitest run test/tvdb.e2e.ts` (with `TVDB_HOST`, `TVDB_API_KEY`, `TVDB_HTTP_PROXY` set in `apps/e2e/.env.local` or env) +Expected: PASS when network available; otherwise the same gating as `tmdb.e2e.ts` applies. + +- [ ] **Step 3: Commit** + +```bash +git add apps/cli/test/tvdb.e2e.ts +git commit -m "test(cli): add smm tvdb search e2e" +``` + +--- + +--- + +### Task 11a: TVDB JWT login for custom hosts (discovered during Task 11 verification) + +**Files:** +- Modify: `apps/core/src/clients/mediaDatabaseTransport.ts` +- Modify: `apps/core/src/clients/mediaDatabaseTransport.test.ts` + +**Interfaces:** +- Consumes: `NetworkPort.fetch` (supports POST + body + proxy), `HttpResponse.json`. +- Produces: + - `ensureTvdbToken(network, host, apiKey, proxy): Promise` — POST `{host}/login` with `{ apikey }`, parse `data.token` (or top-level `token`), cache in-process (~30 days validity, 1h refresh buffer). + - In the custom-host **direct** path of `fetchMediaDatabase`, when `kind === "tvdb"`, use `Authorization: Bearer ` (from login) instead of `Authorization: Bearer `. + - `_resetTvdbTokenCacheForTests(): void` — clears the module-level token cache (test isolation). + +> Background: `docs/dev/tvdb.md` states the Core `TvdbClient` transparently handles the TVDB JWT (`POST /login`, ~30 days). The CLI custom-host scenario (`smm tvdb search --host --password `) currently fails because `mediaDatabaseTransport` sends the raw API key as Bearer, which TVDB rejects (401). The default SMM upstream (`mediadb.vercel.app`) works because the reverse proxy handles auth; the UI handles custom hosts with its own login flow (`apps/ui/src/lib/TvdbUtils.ts:111-137`). This task brings the Core transport in line with the doc. + +- [ ] **Step 1: Write the failing tests** + +Append to `apps/core/src/clients/mediaDatabaseTransport.test.ts`: + +```ts +describe("TVDB custom-host JWT login", () => { + it("logs in first and uses the JWT as Bearer for a custom TVDB host", async () => { + const urls: string[] = [] + const headers: Array> = [] + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url) + headers.push(init?.headers ?? {}) + if (url.endsWith("/login")) { + expect(init?.method).toBe("POST") + expect(JSON.parse(init?.body ?? "{}")).toEqual({ apikey: "secret" }) + return jsonOk({ status: "success", data: { token: "jwt-123" } }) + } + return jsonOk({ status: "success", data: [{ id: "series-1", name: "My Show" }] }) + }, + } + + const resp = await fetchMediaDatabase(network, { + kind: "tvdb", + path: "/search?query=x&type=series", + configuredHost: "https://api4.thetvdb.com/v4", + apiKey: "secret", + }) + + expect(resp.ok).toBe(true) + expect(urls[0]).toContain("/login") + expect(urls[1]).toContain("/search") + // The login request must NOT carry an Authorization header. + expect(headers[0]?.Authorization).toBeUndefined() + // The actual request uses the JWT. + expect(headers[1]?.Authorization).toBe("Bearer jwt-123") + }) + + it("caches the JWT and skips re-login for the same host", async () => { + let loginCount = 0 + const network: NetworkPort = { + fetch: async (url) => { + if (url.endsWith("/login")) { + loginCount += 1 + return jsonOk({ status: "success", data: { token: "jwt-123" } }) + } + return jsonOk({ status: "success", data: [] }) + }, + } + _resetTvdbTokenCacheForTests() + + const opts = { + kind: "tvdb" as const, + path: "/search?query=x&type=series", + configuredHost: "https://api4.thetvdb.com/v4", + apiKey: "secret", + } + await fetchMediaDatabase(network, opts) + await fetchMediaDatabase(network, opts) + + expect(loginCount).toBe(1) + _resetTvdbTokenCacheForTests() + }) + + it("still sends the raw API key as Bearer for a custom TMDB host (no login)", async () => { + const urls: string[] = [] + const headers: Array> = [] + const network: NetworkPort = { + fetch: async (url, init) => { + urls.push(url) + headers.push(init?.headers ?? {}) + return jsonOk({ results: [] }) + }, + } + + await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + configuredHost: "https://api.themoviedb.org/3", + apiKey: "secret", + }) + + expect(urls[0]).not.toContain("/login") + expect(headers[0]?.Authorization).toBe("Bearer secret") + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/core/src/clients/mediaDatabaseTransport.test.ts` +Expected: FAIL — TVDB custom-host path sends `Bearer secret` (raw apiKey), no login call, so `urls[0]` contains `/search` not `/login`; token not cached. + +- [ ] **Step 3: Implement the JWT login in the transport** + +In `apps/core/src/clients/mediaDatabaseTransport.ts`: + +Add a module-level cache + helper after `authHeaders`: + +```ts +/** In-process TVDB JWT cache keyed by normalized custom host. */ +const tvdbTokenCache = new Map< + string, + { token: string; expiresAt: number } +>(); +const TVDB_TOKEN_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000; // ~1 month per docs +const TVDB_TOKEN_REFRESH_BUFFER_MS = 60 * 60 * 1000; // refresh 1h early + +/** Test-only: clear the TVDB JWT cache. */ +export function _resetTvdbTokenCacheForTests(): void { + tvdbTokenCache.clear(); +} + +/** + * Obtain a TVDB bearer JWT for a custom host via `POST /login`, caching it in + * process memory and refreshing near expiry. The raw API key is NOT a bearer + * token for TVDB v4; a login exchange is required. + */ +async function ensureTvdbToken( + network: NetworkPort, + host: string, + apiKey: string | undefined, + proxy: string | undefined, +): Promise { + const key = normalizeBase(host); + const cached = tvdbTokenCache.get(key); + if (cached && Date.now() < cached.expiresAt - TVDB_TOKEN_REFRESH_BUFFER_MS) { + return cached.token; + } + + const url = joinUrl(key, "/login"); + const resp = await network.fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ apikey: apiKey?.trim() || "" }), + ...(proxy?.trim() ? { proxy: proxy.trim() } : {}), + }); + if (!resp.ok) { + throw new Error(`TVDB login failed: ${resp.status} ${resp.statusText}`); + } + const body = await resp.json<{ data?: { token?: string }; token?: string }>(); + const token = body?.data?.token ?? body?.token; + if (typeof token !== "string" || !token) { + throw new Error("TVDB login response missing token"); + } + tvdbTokenCache.set(key, { token, expiresAt: Date.now() + TVDB_TOKEN_VALIDITY_MS }); + return token; +} +``` + +In the custom-host branch, replace the direct-fetch header construction (the `// Unit-test / CLI / no-proxy fallback` block) so TVDB uses the JWT: + +```ts + const url = joinUrl(upstream, path); + attempted.push(url); + let headers: Record = { Accept: "application/json" }; + if (options.kind === "tvdb") { + const token = await ensureTvdbToken(network, upstream, options.apiKey, options.httpProxy); + headers.Authorization = `Bearer ${token}`; + } else { + Object.assign(headers, authHeaders(options.apiKey)); + } + const resp = await tryFetch(network, url, headers, options.httpProxy); + if (resp !== undefined) return resp; + throw new MediaDatabaseFailoverExhaustedError(attempted); +``` + +> The local-reverse-proxy path (`reverseProxyUrl` set) is unchanged — the CLI Core always passes `reverseProxyUrl: null` for TVDB (`Core.createTvdbClient`), so the direct path is what the CLI e2e exercises. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run apps/core/src/clients/mediaDatabaseTransport.test.ts` +Expected: PASS (3 new + existing pass). + +- [ ] **Step 5: Re-run the CLI e2e** + +Run (from `apps/cli`): `pnpm vitest run test/tvdb.e2e.ts` +Expected: all 3 tests pass (live TVDB via proxy). + +- [ ] **Step 6: Commit** + +```bash +git add apps/core/src/clients/mediaDatabaseTransport.ts apps/core/src/clients/mediaDatabaseTransport.test.ts +git commit -m "feat(core): TVDB JWT login for custom hosts in media-database transport" +``` + +--- + +### Task 11: Full verification + docs touch-up + +**Files:** +- Modify: `docs/api/index.md` (optional, to document the four new routes) + +- [ ] **Step 1: Run all unit tests** + +Run: `pnpm test` +Expected: all pass (including new Core/route/tools/MCP/CLI tests). + +- [ ] **Step 2: Run typecheck** + +Run: `pnpm typecheck` +Expected: PASS. + +- [ ] **Step 3: Document the new routes in docs/api/index.md** + +After the TMDB route bullet, add: + +```markdown +- `POST /api/search-in-tvdb` → `Core.searchInTvdb`. Body: `{ keyword: string, type: "series" | "movie", language?, host?, password?, proxy? }` (`language` is ISO 639-3). +- `POST /api/get-tvshow-in-tvdb` → `Core.getTvShowInTvdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +- `POST /api/get-movie-in-tvdb` → `Core.getMovieInTvdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +- `POST /api/get-tvdb-languages` → `Core.getTvdbLanguages`. Body: `{}`. +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/api/index.md +git commit -m "docs: document TVDB internal HTTP routes" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Core methods (search/tvshow/movie/languages) → Task 3 ✅ +- Offline language validation + static snapshot → Task 1 ✅ +- HTTP routes 1:1 → Task 4 ✅ +- CLI `smm tvdb search` + formatter → Task 5 ✅ +- AI tools (types + execution + registration) → Tasks 6, 7 ✅ +- MCP tools (handler + registration + config) → Task 8 ✅ +- CLI host wiring (MCP + chat) → Task 9 ✅ +- Tests (unit + CLI e2e) → Tasks 1-6, 8, 10 ✅ +- Web UI → explicitly out of scope (doc marks ⏳) ✅ +- `smm.tv3.enabled` localStorage guard → not needed (no UI changes) ✅ + +**Placeholder scan:** No TODOs/TBD. All code shown. Where a helper name might differ (`requireNonEmptyString`, MCP test `listTools`), the plan names the reference file to check. + +**Type consistency:** `TvdbRequestOptions`/`SearchInTvdbOptions` (Core) match the runner option shapes in `tools/tvdb.ts` and `McpConfig`. `type` is consistently `"series" | "movie"`. Language is consistently ISO 639-3 from Core → TvdbClient → HTTP → tools. diff --git a/package.json b/package.json index 6cfd768c..4caab10f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "e2e:config": "bun ci/run-e2e-test.ts --spec \"./common/config/*.e2e.ts\" --spec \"./common/httpproxy/*.e2e.ts\"", "e2e:mcp": "bun test test/mcp/", "e2e:mcp:ui": "bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", + "e2e:mcp:ui:v3": "cross-env E2E_SMM_V3=true bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", "e2e:media-folder-initialization": "bun ci/run-e2e-test.ts --spec \"./common/tv/ImportTvShowLibrary.e2e.ts\" --spec \"./common/tv/Initialize*.e2e.ts\" --spec \"./common/movie/ImportMovieLibrary.e2e.ts\" --spec \"./common/movie/Initialize*.e2e.ts\"", "e2e:movie": "bun ci/run-e2e-test.ts --spec \"./common/movie/SearchMovie.e2e.ts\" --spec \"./common/movie/Movie-RenameVideoFile.e2e.ts\"", "e2e:music": "bun ci/run-e2e-test.ts --spec \"./common/music/*.e2e.ts\"", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6690777d..91a284d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: specifier: ^1.42.0 version: 1.44.0 devDependencies: + '@smm/test': + specifier: workspace:* + version: link:../../packages/test typescript: specifier: ^5.9.3 version: 5.9.3 From f8b07d78fdf7a7bb3a92d992f37320bc4a5c5201 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Mon, 24 Aug 2026 21:31:02 +0800 Subject: [PATCH 125/214] feat(hello): add smm hello CLI and migrate bootstrap to GET /api/hello Introduce Core.hello() as the shared bootstrap source, add smm hello with line and JSON output, switch HTTP clients to GET /api/hello, and register hello in the CLI entry dispatch so it no longer starts the HTTP server. Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/src/cli/hello.test.ts | 64 ++++++ apps/cli/src/cli/helloFormat.test.ts | 28 +++ apps/cli/src/cli/helloFormat.ts | 14 ++ apps/cli/src/cli/helloHttp.ts | 9 + apps/cli/src/cli/runCli.ts | 22 +++ apps/cli/src/core/getCore.ts | 10 +- apps/cli/src/coreRoutesServer.ts | 5 +- apps/cli/src/route/execute.test.ts | 53 +++-- apps/cli/src/route/execute.ts | 18 +- apps/cli/tasks/HelloTask.ts | 23 +-- apps/core/src/Core.test.ts | 37 ++++ apps/core/src/Core.ts | 35 ++++ apps/core/src/index.ts | 2 +- apps/e2e/test/lib/browser-fs.ts | 6 +- apps/e2e/test/lib/testbed.ts | 33 ++-- apps/ui/src/api/hello.ts | 2 +- apps/ui/src/api/verifyHelloWithToken.ts | 4 +- ci/wait-for-docker-e2e-ready.ts | 2 +- ci/wait-for-e2e-ready.ts | 2 +- docs/dev/hello.md | 187 ++++++++++++++++++ packages/core-routes/src/core-routes.test.ts | 6 +- packages/core-routes/src/index.ts | 1 + packages/core-routes/src/register.ts | 6 +- packages/core-routes/src/routes/helloRoute.ts | 15 +- packages/core-routes/src/types.ts | 5 +- packages/core/path.ts | 2 +- packages/core/types.ts | 51 ++--- packages/test/src/index.ts | 2 +- 29 files changed, 511 insertions(+), 135 deletions(-) create mode 100644 apps/cli/src/cli/hello.test.ts create mode 100644 apps/cli/src/cli/helloFormat.test.ts create mode 100644 apps/cli/src/cli/helloFormat.ts create mode 100644 apps/cli/src/cli/helloHttp.ts create mode 100644 docs/dev/hello.md diff --git a/apps/cli/index.ts b/apps/cli/index.ts index bff0bb2b..edd9b665 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file', 'tmdb', 'mcp']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) const firstArg = process.argv[2] const isCliHelp = firstArg === '--help' || firstArg === '-h' if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { diff --git a/apps/cli/src/cli/hello.test.ts b/apps/cli/src/cli/hello.test.ts new file mode 100644 index 00000000..d83e9784 --- /dev/null +++ b/apps/cli/src/cli/hello.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { runCli } from './runCli' + +const mockHello = vi.fn(() => ({ + uptime: 12, + version: '9.9.9', + platform: 'linux', + userDataDir: '/data/ud', + appDataDir: '/data/ad', + tmpDir: '/tmp/smm', + logDir: '/data/ad/logs', + osLocale: 'en-US', +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => ({ hello: mockHello }), +})) + +describe('smm hello', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prints human-readable lines by default', async () => { + const logs: string[] = [] + const errors: string[] = [] + const origLog = console.log + const origError = console.error + console.log = (msg: string) => logs.push(msg) + console.error = (msg: string) => errors.push(msg) + try { + const code = await runCli(['node', 'smm', 'hello']) + expect(code).toBe(0) + expect(mockHello).toHaveBeenCalledOnce() + expect(logs).toEqual([ + 'Version: 9.9.9', + 'Platform: linux', + 'Uptime: 12s', + 'User data dir: /data/ud', + 'App data dir: /data/ad', + 'Tmp dir: /tmp/smm', + 'Log dir: /data/ad/logs', + 'OS locale: en-US', + ]) + expect(errors).toEqual([]) + } finally { + console.log = origLog + console.error = origError + } + }) + + it('prints JSON with -f json', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli(['node', 'smm', 'hello', '-f', 'json']) + expect(code).toBe(0) + expect(JSON.parse(logs[0]!)).toEqual(mockHello()) + } finally { + console.log = origLog + } + }) +}) diff --git a/apps/cli/src/cli/helloFormat.test.ts b/apps/cli/src/cli/helloFormat.test.ts new file mode 100644 index 00000000..bc434730 --- /dev/null +++ b/apps/cli/src/cli/helloFormat.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { formatHelloLines } from './helloFormat' + +describe('formatHelloLines', () => { + it('formats bootstrap fields one per line', () => { + expect( + formatHelloLines({ + uptime: 42.5, + version: '1.3.8', + platform: 'win32', + userDataDir: 'C:\\Users\\me\\AppData\\Roaming\\SMM', + appDataDir: 'C:\\Users\\me\\AppData\\Local\\SMM', + tmpDir: 'C:\\Temp\\smm', + logDir: 'C:\\Users\\me\\AppData\\Local\\SMM\\logs', + osLocale: 'zh-CN', + }), + ).toEqual([ + 'Version: 1.3.8', + 'Platform: win32', + 'Uptime: 42.5s', + 'User data dir: C:\\Users\\me\\AppData\\Roaming\\SMM', + 'App data dir: C:\\Users\\me\\AppData\\Local\\SMM', + 'Tmp dir: C:\\Temp\\smm', + 'Log dir: C:\\Users\\me\\AppData\\Local\\SMM\\logs', + 'OS locale: zh-CN', + ]) + }) +}) diff --git a/apps/cli/src/cli/helloFormat.ts b/apps/cli/src/cli/helloFormat.ts new file mode 100644 index 00000000..c8fafaa0 --- /dev/null +++ b/apps/cli/src/cli/helloFormat.ts @@ -0,0 +1,14 @@ +import type { HelloCliBody } from '@core/types' + +export function formatHelloLines(body: HelloCliBody): string[] { + return [ + `Version: ${body.version}`, + `Platform: ${body.platform}`, + `Uptime: ${body.uptime}s`, + `User data dir: ${body.userDataDir}`, + `App data dir: ${body.appDataDir}`, + `Tmp dir: ${body.tmpDir}`, + `Log dir: ${body.logDir}`, + `OS locale: ${body.osLocale}`, + ] +} diff --git a/apps/cli/src/cli/helloHttp.ts b/apps/cli/src/cli/helloHttp.ts new file mode 100644 index 00000000..87a51c1b --- /dev/null +++ b/apps/cli/src/cli/helloHttp.ts @@ -0,0 +1,9 @@ +import type { HelloHttpResponseBody } from '@core/types' +import { getCore } from '../core/getCore' + +export function buildHelloHttpResponse( + reverseProxyUrl: string | null, + coreRoutesPort: number, +): HelloHttpResponseBody { + return { ...getCore().hello(), reverseProxyUrl, coreRoutesPort } +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 1580d112..1371f25c 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -4,6 +4,7 @@ import type { FolderType, RenameRuleName } from 'core-app' import type { MediaMetadata } from '@smm/core' import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' +import { formatHelloLines } from './helloFormat' import { waitUntilImportSettled } from './addProgress' import { CliLoggerAdapter } from './cliLogger' import { formatScrapeJobTaskLines } from './scrapeJobFormat' @@ -70,6 +71,27 @@ export async function runCli(argv: string[] = process.argv): Promise { const program = new Command() program.name('smm').exitOverride() + program + .command('hello') + .description('Print application bootstrap info') + .option('-f, --format ', 'Output format (json)') + .action(async (opts: { format?: string }) => { + try { + const body = getCore().hello() + if (opts.format === 'json') { + printJson(body) + return + } + for (const line of formatHelloLines(body)) { + console.log(line) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + program .command('list') .description('List imported media folder paths') diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index b25d88d2..df8e1057 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -5,7 +5,9 @@ import { StaticDiscoverAdapter, type LoggerPort, } from 'core-app' -import { getUserDataDir } from '@/utils/config' +import { detectOsLocale } from '@core/locale' +import { getUserDataDir, getAppDataDir, getTmpDir, getLogDir } from '@/utils/config' +import { APP_VERSION } from '@/version' import { getBunMcpServerPort } from '@/mcp/BunMcpServerPort' import { NodejsNetworkPort } from './NodejsNetworkPort' @@ -25,6 +27,12 @@ export function getCore(options?: GetCoreOptions): Core { logger: options?.logger ?? new NoopLoggerAdapter(), appDataDir: userDataDir, userDataDir, + version: APP_VERSION, + reportedAppDataDir: getAppDataDir(), + tmpDir: getTmpDir(), + logDir: getLogDir(), + platform: process.platform, + osLocale: detectOsLocale(), discover: new StaticDiscoverAdapter(), mcpServer: getBunMcpServerPort(), }) diff --git a/apps/cli/src/coreRoutesServer.ts b/apps/cli/src/coreRoutesServer.ts index eb2c346b..66628093 100644 --- a/apps/cli/src/coreRoutesServer.ts +++ b/apps/cli/src/coreRoutesServer.ts @@ -6,7 +6,7 @@ import { } from "@smm/core-routes"; import { buildAllowlist } from "@/utils/buildAllowlist"; import { getAppDataDir } from "@/utils/config"; -import { buildHelloOptions } from "../tasks/HelloTask"; +import { buildHelloHttpResponse } from "@/cli/helloHttp"; import { logger } from "../lib/logger"; import { broadcast } from "@/utils/socketIO"; import { resolveCoreRoutesPort } from "@/coreRoutesPort"; @@ -26,13 +26,12 @@ export async function startCoreRoutesServer( const port = resolveCoreRoutesPort(); const allowlist = await buildAllowlist(); const appDataDir = getAppDataDir(); - const helloOptions = { ...buildHelloOptions(null), coreRoutesPort: port }; const handler = createCoreRoutesRequestHandler( { allowlist, resolveAllowlist: buildAllowlist, logger: createCoreRoutesLogger(), - hello: helloOptions, + resolveHello: () => buildHelloHttpResponse(null, port), appDataDir, broadcast: (message) => broadcast(message), auth, diff --git a/apps/cli/src/route/execute.test.ts b/apps/cli/src/route/execute.test.ts index 07648e47..879931bf 100644 --- a/apps/cli/src/route/execute.test.ts +++ b/apps/cli/src/route/execute.test.ts @@ -3,23 +3,25 @@ import { Hono } from 'hono'; import { registerExecuteRoutes } from './execute'; import { readJson } from '../test/readJson'; -vi.mock('@smm/core-routes', () => ({ - doHello: vi.fn((options: Record) => ({ - uptime: 1.5, - ...options, - })), +vi.mock('../cli/helloHttp', () => ({ + buildHelloHttpResponse: vi.fn( + (reverseProxyUrl: string | null, coreRoutesPort: number) => ({ + uptime: 1.5, + version: '1.2.3-test', + platform: 'win32', + userDataDir: '/tmp/userData', + appDataDir: '/tmp/appData', + logDir: '/tmp/logs', + tmpDir: '/tmp', + osLocale: 'en-US', + reverseProxyUrl, + coreRoutesPort, + }), + ), })); -vi.mock('../../tasks/HelloTask', () => ({ - buildHelloOptions: vi.fn((reverseProxyUrl: string | null) => ({ - version: '1.2.3-test', - userDataDir: '/tmp/userData', - appDataDir: '/tmp/appData', - logDir: '/tmp/logs', - tmpDir: '/tmp', - reverseProxyUrl, - osLocale: 'en-US', - })), +vi.mock('@/coreRoutesPort', () => ({ + resolveCoreRoutesPort: vi.fn(() => 3001), })); vi.mock('../../tasks/GetSelectedMediaMetadataTask', () => ({ @@ -41,10 +43,10 @@ describe('/api/hello — bootstrap handshake', () => { vi.clearAllMocks(); }); - it('returns the HelloResponseBody with the current proxyManager.url', async () => { + it('returns the HelloHttpResponseBody with the current proxyManager.url', async () => { const app = new Hono(); registerExecuteRoutes(app, makeProxyManager('http://127.0.0.1:30001')); - const res = await app.request('/api/hello', { method: 'POST' }); + const res = await app.request('/api/hello', { method: 'GET' }); expect(res.status).toBe(200); const body = await readJson>(res); expect(body).toMatchObject({ @@ -55,6 +57,7 @@ describe('/api/hello — bootstrap handshake', () => { logDir: '/tmp/logs', tmpDir: '/tmp', reverseProxyUrl: 'http://127.0.0.1:30001', + coreRoutesPort: 3001, osLocale: 'en-US', }); }); @@ -62,23 +65,17 @@ describe('/api/hello — bootstrap handshake', () => { it('forwards null reverseProxyUrl when the proxy is not yet available', async () => { const app = new Hono(); registerExecuteRoutes(app, makeProxyManager(null)); - const res = await app.request('/api/hello', { method: 'POST' }); + const res = await app.request('/api/hello', { method: 'GET' }); expect(res.status).toBe(200); const body = await readJson>(res); expect(body.reverseProxyUrl).toBeNull(); }); - it('ignores any request body', async () => { + it('returns 404 for POST (method not allowed via notFound)', async () => { const app = new Hono(); - registerExecuteRoutes(app, makeProxyManager('http://127.0.0.1:30001')); - const res = await app.request('/api/hello', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ignored: true }), - }); - expect(res.status).toBe(200); - const body = await readJson>(res); - expect(body.version).toBe('1.2.3-test'); + registerExecuteRoutes(app, makeProxyManager(null)); + const res = await app.request('/api/hello', { method: 'POST' }); + expect(res.status).toBe(404); }); }); diff --git a/apps/cli/src/route/execute.ts b/apps/cli/src/route/execute.ts index c6a80675..5c916791 100644 --- a/apps/cli/src/route/execute.ts +++ b/apps/cli/src/route/execute.ts @@ -1,14 +1,14 @@ import { Hono } from 'hono'; import { z } from 'zod'; -import { doHello } from '@smm/core-routes'; -import { buildHelloOptions } from '../../tasks/HelloTask'; +import { buildHelloHttpResponse } from '../cli/helloHttp'; +import { resolveCoreRoutesPort } from '@/coreRoutesPort'; import { executeGetSelectedMediaMetadataTask } from '../../tasks/GetSelectedMediaMetadataTask'; import type { ReverseProxyManager } from '@smm/core-routes'; /** * Zod schema for /api/execute request body validation. * - * Note: the bootstrap handshake ("hello") is exposed at POST /api/hello, + * Note: the bootstrap handshake ("hello") is exposed at GET /api/hello, * not through this /api/execute orchestration endpoint. */ const executeRequestSchema = z.object({ @@ -21,20 +21,16 @@ const executeRequestSchema = z.object({ /** * Register /api/hello and /api/execute routes on the given Hono app. * - * POST /api/hello — Application bootstrap handshake. Returns environment - * paths, version, reverse-proxy URL and OS locale. Body is ignored; an - * empty body is acceptable. + * GET /api/hello — Application bootstrap handshake. Returns environment + * paths, version, reverse-proxy URL and OS locale. * * POST /api/execute — Special orchestration route for multiple user tasks. * Currently dispatches `name: "GetSelectedMediaMetadata"`. The previous * `name: "hello"` task has been moved to /api/hello. */ export function registerExecuteRoutes(app: Hono, proxyManager: ReverseProxyManager): void { - // POST /api/hello - Application bootstrap handshake. - // Returns environment paths, version, reverse-proxy URL and OS locale. - // Body is ignored; an empty body is acceptable. - app.post('/api/hello', async (c) => { - const result = doHello(buildHelloOptions(proxyManager.url)); + app.get('/api/hello', async (c) => { + const result = buildHelloHttpResponse(proxyManager.url, resolveCoreRoutesPort()); return c.json(result); }); diff --git a/apps/cli/tasks/HelloTask.ts b/apps/cli/tasks/HelloTask.ts index 0d79965b..ecab73e3 100644 --- a/apps/cli/tasks/HelloTask.ts +++ b/apps/cli/tasks/HelloTask.ts @@ -1,30 +1,15 @@ -import { APP_VERSION } from '../src/version'; -import type { HelloResponseBody } from '@core/types'; -import { detectOsLocale } from '@core/locale'; -import { doHello, type HelloOptions } from '@smm/core-routes'; -import { getLogDir, getUserDataDir, getAppDataDir, getTmpDir } from '@/utils/config'; -import { logger } from '../lib/logger'; +import type { HelloOptions } from '@smm/core-routes'; +import { getCore } from '@/core/getCore'; import { resolveCoreRoutesPort } from '@/coreRoutesPort'; +/** Static hello options for core-routes fallback (ohos) and legacy callers. */ export function buildHelloOptions( reverseProxyUrl: string | null = null, coreRoutesPort = resolveCoreRoutesPort(), ): HelloOptions { return { - version: APP_VERSION, - userDataDir: getUserDataDir(), - appDataDir: getAppDataDir(), - logDir: getLogDir(), - tmpDir: getTmpDir(), + ...getCore().hello(), reverseProxyUrl, - osLocale: detectOsLocale(), coreRoutesPort, }; } - -export async function executeHelloTask(reverseProxyUrl: string | null = null): Promise { - if (reverseProxyUrl === null) { - logger.warn('Reverse proxy is not available — metadata API requests that depend on the local proxy may fail.'); - } - return doHello(buildHelloOptions(reverseProxyUrl)); -} diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 730e1f4d..bfa211c3 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -193,6 +193,43 @@ describe("getAppConfig", () => { }); }); +describe("hello", () => { + it("returns injected bootstrap fields and uptime >= 0", () => { + const core = new Core({ + fs: inMemoryFs(), + network: emptyNetwork(), + appDataDir: "/data/smm", + userDataDir: "/data/ud", + reportedAppDataDir: "/data/ad", + version: "1.3.8", + tmpDir: "/tmp/smm", + logDir: "/data/ad/logs", + platform: "linux", + osLocale: "zh-CN", + }); + const result = core.hello(); + expect(result.uptime).toBeGreaterThanOrEqual(0); + expect(result).toMatchObject({ + version: "1.3.8", + platform: "linux", + userDataDir: "/data/ud", + appDataDir: "/data/ad", + tmpDir: "/tmp/smm", + logDir: "/data/ad/logs", + osLocale: "zh-CN", + }); + }); + + it("falls back appDataDir to appDataDir and empty tmp/log when omitted", () => { + const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); + const result = core.hello(); + expect(result.appDataDir).toBe("/data/smm"); + expect(result.tmpDir).toBe(""); + expect(result.logDir).toBe(""); + expect(result.platform).toBe(process.platform); + }); +}); + describe("getUserConfig", () => { it("returns the default config when no smm.json exists", async () => { const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 108bfe20..1f2cef43 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -2,6 +2,7 @@ import { Path } from "@core/path"; import type { AppConfig, FolderType, + HelloCliBody, MediaMetadata, MovieMediaMetadata, TmdbMovieDetails, @@ -110,6 +111,16 @@ export interface CoreOptions { reverseProxyUrl?: string | null; /** userDataDir reported by getAppConfig(); falls back to appDataDir. */ userDataDir?: string; + /** Hello appDataDir; may differ from smm.json root on Linux. Falls back to appDataDir. */ + reportedAppDataDir?: string; + /** Tmp dir for hello bootstrap. */ + tmpDir?: string; + /** Log dir for hello bootstrap. */ + logDir?: string; + /** CLI process platform for hello bootstrap. Falls back to process.platform. */ + platform?: string; + /** OS locale for hello bootstrap. Falls back to detectOsLocale(). */ + osLocale?: string; /** Discover hosts for TMDB/TVDB failover. */ discover?: DiscoverPort; /** MCP HTTP runtime (injected by CLI / OHOS host). */ @@ -138,6 +149,11 @@ export class Core { private readonly version: string; private readonly reverseProxyUrl: string | null; private readonly userDataDir: string; + private readonly reportedAppDataDir: string | undefined; + private readonly tmpDir: string | undefined; + private readonly logDir: string | undefined; + private readonly platform: string | undefined; + private readonly osLocale: string | undefined; private readonly userConfig: UserConfig; private readonly discover?: DiscoverPort; private readonly mcpServer?: McpServerPort; @@ -150,6 +166,11 @@ export class Core { this.version = options.version ?? ""; this.reverseProxyUrl = options.reverseProxyUrl ?? null; this.userDataDir = options.userDataDir ?? options.appDataDir; + this.reportedAppDataDir = options.reportedAppDataDir; + this.tmpDir = options.tmpDir; + this.logDir = options.logDir; + this.platform = options.platform; + this.osLocale = options.osLocale; this.userConfig = new UserConfig(this.fs, this.appDataDir); this.discover = options.discover; this.mcpServer = options.mcpServer; @@ -228,6 +249,20 @@ export class Core { }; } + /** Bootstrap info for CLI and HTTP adapters; never touches fs. */ + hello(): HelloCliBody { + return { + uptime: process.uptime(), + version: this.version, + platform: this.platform ?? process.platform, + userDataDir: this.userDataDir, + appDataDir: this.reportedAppDataDir ?? this.appDataDir, + tmpDir: this.tmpDir ?? "", + logDir: this.logDir ?? "", + osLocale: this.osLocale ?? detectOsLocale(), + }; + } + getUserConfig(): Promise { return this.userConfig.read(); } diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 6f2c4b29..dc390f4c 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -67,4 +67,4 @@ export type { ScrapeJobTask, ScrapeTaskRuntimeStatus, } from "./jobs/types"; -export type { FolderType } from "@smm/core"; +export type { FolderType, HelloCliBody } from "@smm/core"; diff --git a/apps/e2e/test/lib/browser-fs.ts b/apps/e2e/test/lib/browser-fs.ts index 32d3f485..caca62dc 100644 --- a/apps/e2e/test/lib/browser-fs.ts +++ b/apps/e2e/test/lib/browser-fs.ts @@ -98,7 +98,7 @@ export async function fetchHelloPathsViaBrowser(): Promise { if (token) { headers['Authorization'] = `Bearer ${token}` } - const helloRes = await fetch('/api/hello', { method: 'POST', headers }) + const helloRes = await fetch('/api/hello', { method: 'GET', headers }) const helloBody = await helloRes.json() as { appDataDir?: string userDataDir?: string @@ -440,12 +440,12 @@ export async function getFileSizeViaBrowser(filePath: string): Promise { } /** - * `{tmpDir}/smm-test-folder` from `POST /api/hello` — shared fixture root for common specs. + * `{tmpDir}/smm-test-folder` from `GET /api/hello` — shared fixture root for common specs. */ export async function resolveSmmTestFolderViaBrowser(): Promise { const { tmpDir } = await fetchHelloPathsViaBrowser() if (!tmpDir) { - throw new Error('POST /api/hello did not return tmpDir') + throw new Error('GET /api/hello did not return tmpDir') } return joinPlatformPath(tmpDir, 'smm-test-folder') } diff --git a/apps/e2e/test/lib/testbed.ts b/apps/e2e/test/lib/testbed.ts index 466329b8..ff71b85b 100644 --- a/apps/e2e/test/lib/testbed.ts +++ b/apps/e2e/test/lib/testbed.ts @@ -30,6 +30,12 @@ import type { TestbedOs } from './ui-page-url' import { isOhosE2e, testbedOs as defaultTestbedOs } from './e2e-platform' import { browser } from '@wdio/globals' import { localStorageEntriesAfterClear } from './e2e-smm-v3' +import { + applyResetUserConfig as applyResetUserConfigHost, + updateUserConfig as updateUserConfigHost, + type ResetUserConfigOption, + type UserConfigUpdater, +} from './testbed-core' export type { TestbedOs } from './ui-page-url' // Re-export for convenience (switched wrappers are `export async function` below) @@ -79,11 +85,7 @@ export interface TestBedBeforeOptions { userConfig?: Partial } -export type UserConfigUpdater = ( - userConfig: UserConfig -) => UserConfig | void | Promise - -export type ResetUserConfigOption = boolean | UserConfigUpdater +export type { ResetUserConfigOption, UserConfigUpdater } from './testbed-core' /** * Flip to `false` to roll back all browser-protocol setup/cleanup paths to host Node fs. @@ -91,6 +93,11 @@ export type ResetUserConfigOption = boolean | UserConfigUpdater const TESTBED_V2: boolean = true async function applyResetUserConfig(option: ResetUserConfigOption): Promise { + if (!TESTBED_V2) { + await applyResetUserConfigHost(option) + return + } + if (option === false) { return } @@ -544,19 +551,7 @@ export async function updateUserConfig(updateFn: UserConfigUpdater): Promise { if (token) { headers['Authorization'] = `Bearer ${token}` } - const helloRes = await fetch('/api/hello', { method: 'POST', headers }) + const helloRes = await fetch('/api/hello', { method: 'GET', headers }) const helloBody = await helloRes.json() as { reverseProxyUrl?: string | null } const proxyUrl = helloBody.reverseProxyUrl if (!proxyUrl) { diff --git a/apps/ui/src/api/hello.ts b/apps/ui/src/api/hello.ts index 1b76a8a5..b676bec7 100644 --- a/apps/ui/src/api/hello.ts +++ b/apps/ui/src/api/hello.ts @@ -4,7 +4,7 @@ import { syncPathServerPlatformFromHello } from '@/lib/syncPathServerPlatform'; export async function hello(): Promise { const resp = await apiFetch('/api/hello', { - method: 'POST', + method: 'GET', }) const body = await resp.json() as HelloResponseBody; diff --git a/apps/ui/src/api/verifyHelloWithToken.ts b/apps/ui/src/api/verifyHelloWithToken.ts index 55ca16ef..d0458092 100644 --- a/apps/ui/src/api/verifyHelloWithToken.ts +++ b/apps/ui/src/api/verifyHelloWithToken.ts @@ -4,7 +4,7 @@ import { with401Suppressed } from '@/lib/authSession'; import { syncPathServerPlatformFromHello } from '@/lib/syncPathServerPlatform'; /** - * Validates an auth token by calling POST /api/hello with an explicit Bearer header. + * Validates an auth token by calling GET /api/hello with an explicit Bearer header. * Does not persist the token — callers should use saveAuthToken on success. */ export async function verifyHelloWithToken(token: string): Promise { @@ -15,7 +15,7 @@ export async function verifyHelloWithToken(token: string): Promise { const resp = await fetch('/api/hello', { - method: 'POST', + method: 'GET', headers: { Authorization: buildAuthorizationHeader(trimmed), }, diff --git a/ci/wait-for-docker-e2e-ready.ts b/ci/wait-for-docker-e2e-ready.ts index d23927d7..88505ad2 100644 --- a/ci/wait-for-docker-e2e-ready.ts +++ b/ci/wait-for-docker-e2e-ready.ts @@ -59,7 +59,7 @@ async function main(): Promise { const readyUrl = resolveReadyUrl(); console.log('[wait-for-docker-e2e-ready] waiting for', readyUrl); await waitForHttp(readyUrl, { - method: 'POST', + method: 'GET', headers: { Authorization: `Bearer ${token}` }, }); console.log('[wait-for-docker-e2e-ready] ready'); diff --git a/ci/wait-for-e2e-ready.ts b/ci/wait-for-e2e-ready.ts index e468b45c..bd824429 100644 --- a/ci/wait-for-e2e-ready.ts +++ b/ci/wait-for-e2e-ready.ts @@ -71,7 +71,7 @@ async function main(): Promise { const cliReadyUrl = `http://localhost:${cliPort}/api/hello`; console.log(`[wait-for-e2e-ready] waiting for CLI (${cliReadyUrl})`); await waitForHttp(cliReadyUrl, { - method: 'POST', + method: 'GET', headers: { Authorization: `Bearer ${CLI_AUTH_TOKEN}` }, }); diff --git a/docs/dev/hello.md b/docs/dev/hello.md new file mode 100644 index 00000000..d2c81657 --- /dev/null +++ b/docs/dev/hello.md @@ -0,0 +1,187 @@ +# Hello + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** wip + +## Overview + +`hello` is the application bootstrap handshake. Callers use it to discover runtime environment: version, platform, data directories, uptime, and (for HTTP clients) reverse-proxy / core-routes endpoints. + +| Surface | Entry | Output | +|---------|-------|--------| +| Web UI / Electron / ohos | `GET /api/hello` | `HelloHttpResponseBody` (JSON) | +| CLI | `smm hello` | Human-readable lines (default) or JSON (`-f json`) | + +CLI output omits `reverseProxyUrl` and `coreRoutesPort` — those fields only matter when a long-running HTTP server is up. + +## apps/core + +Layer 2 entry point shared by CLI and HTTP adapters: + +| Method | Role | +|--------|------| +| `hello()` | Return bootstrap info: `uptime`, `version`, `platform`, `userDataDir`, `appDataDir`, `tmpDir`, `logDir`, `osLocale` | + +Implementation notes: + +- `uptime` — `process.uptime()` (seconds) +- `version` — app version from build metadata +- `platform` — `process.platform` (`win32`, `linux`, `darwin`, …) +- `*Dir` — resolved via platform-specific config helpers (same paths as today’s `buildHelloOptions`) +- `osLocale` — OS locale detected by CLI process (e.g. `en-US`, `zh-CN`) + +```mermaid +sequenceDiagram + participant Caller + participant Core + + Caller->>Core: hello() + Core->>Core: resolve version, dirs, osLocale, uptime, platform + Core-->>Caller: HelloCliBody +``` + +### Types + +```typescript +/** Shared bootstrap payload — CLI and HTTP base. */ +interface HelloCliBody { + uptime: number + version: string + platform: string + userDataDir: string + appDataDir: string + tmpDir: string + logDir: string + osLocale: string +} + +/** HTTP-only extensions for browser / embedded UI. */ +interface HelloHttpResponseBody extends HelloCliBody { + reverseProxyUrl: string | null + coreRoutesPort: number + error?: string +} +``` + +`HelloHttpResponseBody` replaces the current monolithic `HelloResponseBody` in `@core/types`. HTTP handlers merge server-specific fields on top of `Core.hello()`. + +## Web UI, Electron and ohos + +Web UI talks to Core via Internal HTTP (`apps/cli`). Electron and ohos reuse the same UI bundle. + +Bootstrap runs once at app start (TanStack Query `helloQueryKey`). The UI binds `Path` helpers to `platform`, reads `userDataDir` for config I/O, and uses `reverseProxyUrl` / `coreRoutesPort` for proxied metadata requests and core-routes calls. + +```mermaid +sequenceDiagram + participant Browser + participant CLI + participant Core + + Browser->>CLI: GET /api/hello + CLI->>Core: hello() + Core-->>CLI: HelloCliBody + Note over CLI: merge reverseProxyUrl, coreRoutesPort + CLI-->>Browser: HelloHttpResponseBody (JSON, 200) + Note over Browser: syncPathServerPlatformFromHello(body) +``` + +HTTP contract: + +- **Method** `GET` (no request body) +- **Response** `200` + JSON `HelloHttpResponseBody` +- **Auth** Bearer token when CLI auth is enabled (same as other `/api/*` routes) +- `reverseProxyUrl` is `null` when the local reverse proxy failed to start +- `coreRoutesPort` is the port of the core-routes Node `http` server (used when UI origin is the Hono/Bun CLI port, not core-routes directly) + +Electron main process and ohos embed the same HTTP stack (`apps/core` + `packages/core-routes`); the UI path is identical. + +## CLI + +``` +smm hello [-f json | --format json] +``` + +| Option | Description | +|--------|-------------| +| `-f, --format ` | Output format. Default: human-readable lines. `json`: pretty-printed JSON. | + +Default (line) output — one field per line, aligned with other `smm` commands (`show`, `metadata`): + +``` +Version: 1.3.8 +Platform: win32 +Uptime: 42.5s +User data dir: C:\Users\lawrence\AppData\Roaming\SMM +App data dir: C:\Users\lawrence\AppData\Local\SMM +Tmp dir: C:\Users\lawrence\AppData\Local\Temp\SMM +Log dir: C:\Users\lawrence\AppData\Local\SMM\logs +OS locale: zh-CN +``` + +`-f json` prints `HelloCliBody` (no `reverseProxyUrl`, no `coreRoutesPort`): + +```json +{ + "uptime": 42.5, + "version": "1.3.8", + "platform": "win32", + "userDataDir": "C:\\Users\\lawrence\\AppData\\Roaming\\SMM", + "appDataDir": "C:\\Users\\lawrence\\AppData\\Local\\SMM", + "tmpDir": "C:\\Users\\lawrence\\AppData\\Local\\Temp\\SMM", + "logDir": "C:\\Users\\lawrence\\AppData\\Local\\SMM\\logs", + "osLocale": "zh-CN" +} +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + + User->>CLI: smm hello [--format json] + activate CLI + CLI->>Core: hello() + Core-->>CLI: HelloCliBody + alt default format + CLI-->>User: Version / Platform / … (one line per field) + else -f json + CLI-->>User: JSON (HelloCliBody) + end + deactivate CLI +``` + +CLI wiring: + +- Register `hello` command in `runCli.ts` (Commander) +- Formatting helper `formatHelloLines(body: HelloCliBody): string[]` in `apps/cli/src/cli/` (mirror `formatShowFolder`) +- `executeHelloTask` / `buildHelloOptions` / `doHello` collapse into `Core.hello()` + thin HTTP/CLI adapters + +## Refactor checklist + +| Area | Change | +|------|--------| +| `apps/core` | Add `hello()` returning `HelloCliBody` | +| `packages/core/types.ts` | Split `HelloResponseBody` → `HelloCliBody` + `HelloHttpResponseBody` | +| `apps/cli/src/route/execute.ts` | `GET /api/hello` (replace `POST`); merge HTTP-only fields after `Core.hello()` | +| `packages/core-routes` | Route handler accepts `GET /api/hello`; delegate to shared hello builder | +| `apps/ui/src/api/hello.ts` | `GET /api/hello` | +| `apps/cli/src/cli/runCli.ts` | Add `smm hello` with `-f \| --format` | +| CI / e2e readiness | Poll `GET /api/hello` (`ci/wait-for-e2e-ready.ts`, docker wait scripts) | + +Deprecate `POST /api/hello` after callers migrate. Remove `name: "hello"` remnants from `/api/execute` docs and tests. + +## Test + +| Test Case | Platform | Test File | +|--|--|--| +| `GET /api/hello` returns full HTTP body | CLI (route unit) | `apps/cli/src/route/execute.test.ts` | +| `hello()` core unit | Core | `apps/core/src/...` (new) | +| `smm hello` default lines | CLI | `apps/cli/src/cli/hello.test.ts` (new) | +| `smm hello -f json` | CLI | `apps/cli/src/cli/hello.test.ts` (new) | +| UI bootstrap on load | Web UI | existing e2e / startup specs | +| ohos bootstrap | ohos | existing e2e via `--platform ohos` | + +## References + +[Supported Platform](./supported-platform.md) diff --git a/packages/core-routes/src/core-routes.test.ts b/packages/core-routes/src/core-routes.test.ts index 77b41b89..5d0ed2db 100644 --- a/packages/core-routes/src/core-routes.test.ts +++ b/packages/core-routes/src/core-routes.test.ts @@ -145,7 +145,7 @@ describe("handleCoreRoutesRequest", () => { }); it("returns HelloResponseBody when config.hello is provided", async () => { - const { status, body } = await requestCoreRoute("POST", "/api/hello", { + const { status, body } = await requestCoreRoute("GET", "/api/hello", { allowlist: [], hello: { version: "1.3.8", @@ -167,7 +167,7 @@ describe("handleCoreRoutesRequest", () => { }); it("sets Cache-Control: no-store on JSON responses so the browser never caches them", async () => { - const { status, headers } = await requestCoreRoute("POST", "/api/hello", { + const { status, headers } = await requestCoreRoute("GET", "/api/hello", { allowlist: [], hello: { version: "1.3.8", @@ -186,7 +186,7 @@ describe("handleCoreRoutesRequest", () => { }); it('returns { error: "hello not configured" } when config.hello is undefined', async () => { - const { status, body } = await requestCoreRoute("POST", "/api/hello", { allowlist: [] }); + const { status, body } = await requestCoreRoute("GET", "/api/hello", { allowlist: [] }); expect(status).toBe(200); expect(body.error).toBe("hello not configured"); diff --git a/packages/core-routes/src/index.ts b/packages/core-routes/src/index.ts index f3b4f310..114bdeec 100644 --- a/packages/core-routes/src/index.ts +++ b/packages/core-routes/src/index.ts @@ -149,6 +149,7 @@ export { handleListFilesGet, handleListFilesPost, handleWriteFilePost, + handleHelloGet, handleHelloPost, handleIsFolderAvailablePost, handleGetEpisodesPost, diff --git a/packages/core-routes/src/register.ts b/packages/core-routes/src/register.ts index 28b225f8..9e33f6f6 100644 --- a/packages/core-routes/src/register.ts +++ b/packages/core-routes/src/register.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type http from "node:http"; import { createRequestUrl, sendJson } from "./http.ts"; import { handleListFilesGet, handleListFilesPost } from "./routes/listFilesRoute.ts"; -import { handleHelloPost } from "./routes/helloRoute.ts"; +import { handleHelloGet } from "./routes/helloRoute.ts"; import { handleIsFolderAvailablePost } from "./routes/isFolderAvailableRoute.ts"; import { handleReadFilePost } from "./routes/readFileRoute.ts"; import { handleWriteFilePost } from "./routes/writeFileRoute.ts"; @@ -40,7 +40,7 @@ export const coreRouteHandlers: RouteHandler[] = [ handleListFilesGet, handleListFilesPost, handleWriteFilePost, - handleHelloPost, + handleHelloGet, handleIsFolderAvailablePost, handleGetEpisodesPost, handleListFilesInMediaFolderPost, @@ -110,7 +110,7 @@ export function registerCoreRoutes(server: http.Server, config: CoreRoutesConfig export { handleListFilesGet, handleListFilesPost } from "./routes/listFilesRoute.ts"; export { handleWriteFilePost } from "./routes/writeFileRoute.ts"; -export { handleHelloPost } from "./routes/helloRoute.ts"; +export { handleHelloGet, handleHelloPost } from "./routes/helloRoute.ts"; export { handleIsFolderAvailablePost } from "./routes/isFolderAvailableRoute.ts"; export { handleReadFilePost } from "./routes/readFileRoute.ts"; export { handleDeleteFilePost } from "./routes/deleteFileRoute.ts"; diff --git a/packages/core-routes/src/routes/helloRoute.ts b/packages/core-routes/src/routes/helloRoute.ts index 50977b18..5433f406 100644 --- a/packages/core-routes/src/routes/helloRoute.ts +++ b/packages/core-routes/src/routes/helloRoute.ts @@ -1,23 +1,32 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import type { HelloHttpResponseBody } from "@smm/core/types"; import { doHello } from "../hello.ts"; import { sendJson } from "../http.ts"; import type { RouteContext } from "../types.ts"; -export async function handleHelloPost( +export async function handleHelloGet( req: IncomingMessage, res: ServerResponse, ctx: RouteContext, ): Promise { - if (req.method !== "POST" || ctx.url.pathname !== "/api/hello") { + if (req.method !== "GET" || ctx.url.pathname !== "/api/hello") { return false; } + if (ctx.config.resolveHello) { + sendJson(res, 200, ctx.config.resolveHello()); + return true; + } + if (ctx.config.hello === undefined) { sendJson(res, 200, { error: "hello not configured" }); return true; } const result = doHello(ctx.config.hello); - sendJson(res, 200, result); + sendJson(res, 200, result satisfies HelloHttpResponseBody); return true; } + +/** @deprecated Use handleHelloGet */ +export const handleHelloPost = handleHelloGet; diff --git a/packages/core-routes/src/types.ts b/packages/core-routes/src/types.ts index 177c2866..ff6d44cb 100644 --- a/packages/core-routes/src/types.ts +++ b/packages/core-routes/src/types.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { FetchLike } from "./fetchInput.ts"; +import type { HelloHttpResponseBody } from "@smm/core/types"; import type { HelloOptions } from "./hello.ts"; import type { ChatConfig } from "./chatTypes.ts"; import type { McpLifecycleManager } from "./mcp/lifecycleTypes.ts"; @@ -29,8 +30,10 @@ export interface CoreRoutesConfig { */ resolveAllowlist?: () => Promise | string[]; logger?: CoreRoutesLogger; - /** When set, POST /api/hello returns bootstrap handshake data via doHello. */ + /** When set, GET /api/hello returns bootstrap handshake data via doHello. */ hello?: HelloOptions; + /** When set, GET /api/hello calls this instead of doHello(config.hello). */ + resolveHello?: () => HelloHttpResponseBody; /** * POSIX or platform-specific app-data directory where media metadata * cache files live (e.g. `{appDataDir}/metadata/{sanitized-folder}.json`). diff --git a/packages/core/path.ts b/packages/core/path.ts index 0ae2399f..2b5d6d88 100644 --- a/packages/core/path.ts +++ b/packages/core/path.ts @@ -223,7 +223,7 @@ export class Path { } /** - * Bind path format to the CLI server (from `POST /api/hello`). + * Bind path format to the CLI server (from `GET /api/hello`). * Call after bootstrap in browser UI; no-op in Node/Bun CLI where `process.platform` applies. */ static setServerPlatform(platform: string): void { diff --git a/packages/core/types.ts b/packages/core/types.ts index 7a92324d..963e124f 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -159,7 +159,7 @@ export interface UserConfig { * Request body for POST /api/execute endpoint. * * Note: the bootstrap application handshake is exposed separately at - * POST /api/hello (no request body required, returns `HelloResponseBody`). + * GET /api/hello (returns `HelloHttpResponseBody`). */ export interface ApiExecutePostRequestBody { name: string; @@ -999,52 +999,39 @@ export interface ScrapeResponseBody { error?: string; } -export interface HelloResponseBody { - /** - * application uptime in seconds - */ +/** Shared bootstrap payload — CLI and HTTP base. */ +export interface HelloCliBody { + /** Application uptime in seconds. */ uptime: number; version: string; - /** * CLI process platform (`process.platform`), e.g. `win32`, `linux`, `darwin`. * The UI uses this (not the browser OS) when converting paths to the server's platform format. */ platform: string; - - /** - * path in platform-specific format - */ + /** Path in platform-specific format. */ userDataDir: string; - - /** - * path in platform-specific format - */ + /** Path in platform-specific format. */ appDataDir: string; - - /** - * path in platform-specific format — cached / transient files (e.g. screenshots) - */ + /** Path in platform-specific format — cached / transient files (e.g. screenshots). */ tmpDir: string; - + /** Path in platform-specific format. */ + logDir: string; /** - * path in platform-specific format + * OS locale detected by the CLI process (e.g. en-US, zh-CN). + * Used by the UI when applicationLanguage is not explicitly configured. */ - logDir: string; + osLocale: string; +} +/** HTTP-only extensions for browser / embedded UI. */ +export interface HelloHttpResponseBody extends HelloCliBody { /** * The base URL of the CLI reverse proxy (e.g. http://127.0.0.1:30001). * Used by the UI to route external metadata API requests through a CORS-safe local proxy. * This field is null when the reverse proxy fails to start. */ reverseProxyUrl: string | null; - - /** - * OS locale detected by the CLI process (e.g. en-US, zh-CN). - * Used by the UI when applicationLanguage is not explicitly configured. - */ - osLocale: string; - /** * Port that the core-routes Node `http` server is listening on. * The UI uses this to call endpoints that live on core-routes @@ -1053,13 +1040,13 @@ export interface HelloResponseBody { * Node server. */ coreRoutesPort: number; - - /** - * error message when the hello task fails (e.g. validation, server error) - */ + /** Error message when the hello task fails (e.g. validation, server error). */ error?: string; } +/** @deprecated Use HelloHttpResponseBody */ +export type HelloResponseBody = HelloHttpResponseBody; + /** * Standard result type for rename validation operations. * All rename validation functions should return this type. diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 138ec8aa..5d0eb3a5 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -251,7 +251,7 @@ export async function hello(): Promise { headers['Authorization'] = `Bearer ${token}` } const response = await fetch('http://localhost:30000/api/hello', { - method: 'POST', + method: 'GET', headers, }) const data = await response.json() as HelloResponse From b0ff19755b69d5f5cf81cb32f9184d4efe701ecc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 00:41:18 +0800 Subject: [PATCH 126/214] test(e2e): migrate CLI e2e tests to apps/e2e/cli and add design docs Move config, recognize, rename-files, rename, and manage-folders CLI coverage to bun-based e2e tests with shared testbed helpers, and document the flows with sequence diagrams under docs/dev. Co-authored-by: Cursor --- apps/cli/test/config.e2e.ts | 68 -------- apps/cli/test/recognize.e2e.ts | 223 -------------------------- apps/cli/test/rename-files.e2e.ts | 238 ---------------------------- apps/cli/test/rename.e2e.ts | 192 ---------------------- apps/e2e/cli/README.md | 1 + apps/e2e/cli/base.ts | 22 +++ apps/e2e/cli/config.test.ts | 70 ++++++++ apps/e2e/cli/helpers.ts | 138 ++++++++++++++++ apps/e2e/cli/import-folder.test.ts | 154 ++++++++++++++++++ apps/e2e/cli/manage-folders.test.ts | 85 ++++++++++ apps/e2e/cli/recognize.test.ts | 195 +++++++++++++++++++++++ apps/e2e/cli/rename-files.test.ts | 226 ++++++++++++++++++++++++++ apps/e2e/cli/rename.test.ts | 159 +++++++++++++++++++ apps/e2e/test/lib/cli-hello.ts | 26 +++ apps/e2e/test/lib/testbed-core.ts | 153 ++++++++++++++++++ apps/e2e/tsconfig.json | 1 + docs/dev/config.md | 86 ++++++++++ docs/dev/manage-folders.md | 79 +++++++++ docs/dev/recognize.md | 79 +++++++++ docs/dev/rename-files.md | 83 ++++++++++ docs/dev/rename.md | 75 +++++++++ 21 files changed, 1632 insertions(+), 721 deletions(-) delete mode 100644 apps/cli/test/config.e2e.ts delete mode 100644 apps/cli/test/recognize.e2e.ts delete mode 100644 apps/cli/test/rename-files.e2e.ts delete mode 100644 apps/cli/test/rename.e2e.ts create mode 100644 apps/e2e/cli/README.md create mode 100644 apps/e2e/cli/base.ts create mode 100644 apps/e2e/cli/config.test.ts create mode 100644 apps/e2e/cli/helpers.ts create mode 100644 apps/e2e/cli/import-folder.test.ts create mode 100644 apps/e2e/cli/manage-folders.test.ts create mode 100644 apps/e2e/cli/recognize.test.ts create mode 100644 apps/e2e/cli/rename-files.test.ts create mode 100644 apps/e2e/cli/rename.test.ts create mode 100644 apps/e2e/test/lib/cli-hello.ts create mode 100644 apps/e2e/test/lib/testbed-core.ts create mode 100644 docs/dev/config.md create mode 100644 docs/dev/manage-folders.md create mode 100644 docs/dev/recognize.md create mode 100644 docs/dev/rename-files.md create mode 100644 docs/dev/rename.md diff --git a/apps/cli/test/config.e2e.ts b/apps/cli/test/config.e2e.ts deleted file mode 100644 index 760083af..00000000 --- a/apps/cli/test/config.e2e.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resetCoreForTests, smm } from './helpers/smm' - -describe('smm config CLI e2e', () => { - let userDataDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-config-e2e-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - }) - - it('lists the full user config as JSON', async () => { - const listed = await smm(['config', 'list']) - expect(listed.code).toBe(0) - const config = JSON.parse(listed.stdout) as { dryRun: boolean; folders: string[] } - expect(config.dryRun).toBe(false) - expect(config.folders).toEqual([]) - }) - - it('sets a key, gets the JSON value, and shows it in list', async () => { - const set = await smm(['config', 'set', 'dryRun', 'true']) - expect(set.code, set.stderr).toBe(0) - expect(JSON.parse(set.stdout)).toBe(true) - - const got = await smm(['config', 'get', 'dryRun']) - expect(got.code, got.stderr).toBe(0) - expect(JSON.parse(got.stdout)).toBe(true) - - const listed = await smm(['config', 'list']) - expect(listed.code).toBe(0) - expect(JSON.parse(listed.stdout).dryRun).toBe(true) - }) - - it('stores a non-JSON value as a string', async () => { - const set = await smm(['config', 'set', 'selectedRenameRule', 'plex']) - expect(set.code, set.stderr).toBe(0) - expect(JSON.parse(set.stdout)).toBe('plex') - - const got = await smm(['config', 'get', 'selectedRenameRule']) - expect(got.code, got.stderr).toBe(0) - expect(JSON.parse(got.stdout)).toBe('plex') - }) - - it('exits 1 when getting an unknown key', async () => { - const got = await smm(['config', 'get', 'notAKey']) - expect(got.code).toBe(1) - expect(got.stderr).toContain('Unknown config key: notAKey') - }) - - it('exits 1 when setting an unknown key', async () => { - const set = await smm(['config', 'set', 'notAKey', '1']) - expect(set.code).toBe(1) - expect(set.stderr).toContain('Unknown config key: notAKey') - }) -}) diff --git a/apps/cli/test/recognize.e2e.ts b/apps/cli/test/recognize.e2e.ts deleted file mode 100644 index 0295bf02..00000000 --- a/apps/cli/test/recognize.e2e.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, renameSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Path } from '@core/path' -import { getCore, resetCoreForTests } from '../src/core/getCore' -import { smm } from './helpers/smm' -import { - createAndImportInitializedFolder, - movieFolder, - tvShowFolder, -} from './helpers/testFolders' - -function planPath(userDataDir: string, planId: string): string { - return join(userDataDir, 'plans', `${planId}.plan.json`) -} - -function parsePlanId(stdout: string): string { - const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] - expect(planId, stdout).toBeTruthy() - return planId! -} - -/** Mirrors e2e: import with mediaFiles=[] so rule-based recognize has work to do. */ -async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: string) { - return createAndImportInitializedFolder( - mediaDir, - { ...tvShowFolder, ...(folderName ? { folderName } : {}) }, - { - updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }), - }, - ) -} - -describe('smm try-to-recognize / apply CLI e2e', () => { - let userDataDir: string - let mediaDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-recognize-e2e-ud-')) - mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-recognize-e2e-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - rmSync(mediaDir, { recursive: true, force: true }) - }) - - // Mirrors apps/e2e TVShow-Recognize: import with no mediaFiles → recognize → confirm - it('rule-based recognize: try-to-recognize then apply maps S01E01..03', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'UnKnown Folder 123123123123') - const path = folder.path! - - const tried = await smm(['try-to-recognize', path]) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - expect(tried.stdout).toMatch(/task:\s+recognize-media-file/) - expect(tried.stdout).toMatch(/status:\s+pending/) - expect(tried.stdout).toContain('S01E01') - expect(tried.stdout).toContain('S01E02') - expect(tried.stdout).toContain('S01E03') - - const planId = parsePlanId(tried.stdout) - expect(existsSync(planPath(userDataDir, planId))).toBe(true) - - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - expect(applied.stdout).toMatch(/applied .* \(3 file\(s\)\)/) - expect(existsSync(planPath(userDataDir, planId))).toBe(false) - - const mm = await getCore().getMediaMetadata(path) - expect(mm!.mediaFiles).toEqual([ - { - absolutePath: Path.posix(join(path, 'S01E01.mkv')), - seasonNumber: 1, - episodeNumber: 1, - }, - { - absolutePath: Path.posix(join(path, 'S01E02.mkv')), - seasonNumber: 1, - episodeNumber: 2, - }, - { - absolutePath: Path.posix(join(path, 'S01E03.mkv')), - seasonNumber: 1, - episodeNumber: 3, - }, - ]) - }) - - // Mirrors e2e PartialRecognition intent: drop S01E03 naming so only two episodes match. - // (e2e renames to S01E03-renamed.mkv which still matches pattern1 via includes('S01E03'); - // use a name without that token so CLI partial coverage is real.) - it('partial coverage: only S01E01..02 match when S01E03 video loses season-episode name', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'PartialRecognition 123123') - const path = folder.path! - renameSync(join(path, 'S01E03.mkv'), join(path, 'extra-video.mkv')) - - const tried = await smm(['try-to-recognize', path]) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - const planId = parsePlanId(tried.stdout) - expect(tried.stdout).toContain('S01E01') - expect(tried.stdout).toContain('S01E02') - expect(tried.stdout).not.toMatch(/S01E03\s+/) - - const plan = await getCore().getPlan(planId) - expect(plan.task).toBe('recognize-media-file') - if (plan.task === 'recognize-media-file') { - expect(plan.files).toHaveLength(2) - expect(plan.files.map((f) => `${f.season}:${f.episode}`)).toEqual(['1:1', '1:2']) - } - - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - - const mm = await getCore().getMediaMetadata(path) - expect(mm!.mediaFiles).toEqual([ - { - absolutePath: Path.posix(join(path, 'S01E01.mkv')), - seasonNumber: 1, - episodeNumber: 1, - }, - { - absolutePath: Path.posix(join(path, 'S01E02.mkv')), - seasonNumber: 1, - episodeNumber: 2, - }, - ]) - }) - - // Spec: zero matches → pending plan with files: [] (not an error) - it('zero matches: pending plan with empty files, apply is no-op on mediaFiles', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'NoPatternMatch 123123') - const path = folder.path! - for (const name of ['S01E01.mkv', 'S01E02.mkv', 'S01E03.mkv']) { - renameSync(join(path, name), join(path, name.replace('S01E', 'Episode'))) - } - - const tried = await smm(['try-to-recognize', path]) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - expect(tried.stdout).toMatch(/status:\s+pending/) - expect(tried.stdout).toContain('(none)') - const planId = parsePlanId(tried.stdout) - - const plan = await getCore().getPlan(planId) - expect(plan.task).toBe('recognize-media-file') - if (plan.task === 'recognize-media-file') { - expect(plan.files).toEqual([]) - } - - const before = await getCore().getMediaMetadata(path) - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - expect(applied.stdout).toMatch(/\(0 file\(s\)\)/) - expect(existsSync(planPath(userDataDir, planId))).toBe(false) - - const after = await getCore().getMediaMetadata(path) - expect(after?.mediaFiles ?? []).toEqual(before?.mediaFiles ?? []) - }) - - // Mirrors e2e AlreadyRecognized: folder already has mediaFiles mappings - it('already recognized: try-to-recognize still builds a plan that can be applied', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { - ...tvShowFolder, - folderName: 'AlreadyRecognized 123123', - }) - const path = folder.path! - - const seeded = await getCore().getMediaMetadata(path) - expect(seeded?.mediaFiles?.length).toBeGreaterThan(0) - - const tried = await smm(['try-to-recognize', path]) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - const planId = parsePlanId(tried.stdout) - expect(tried.stdout).toContain('S01E01') - - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - - const mm = await getCore().getMediaMetadata(path) - expect(mm!.mediaFiles).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - absolutePath: Path.posix(join(path, 'S01E01.mkv')), - seasonNumber: 1, - episodeNumber: 1, - }), - ]), - ) - }) - - it('rejects unmanaged folder', async () => { - const unmanaged = join(mediaDir, 'not-imported') - const result = await smm(['try-to-recognize', unmanaged]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/not managed by SMM/i) - }) - - it('rejects movie folder (not a TV show with episodes)', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { - mediaMetadata: { - type: 'movie-folder', - mediaFiles: [], - movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, - }, - }) - const result = await smm(['try-to-recognize', folder.path!]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/not a TV show with episodes/i) - }) - - it('apply rejects missing plan id', async () => { - const result = await smm(['apply', '00000000-0000-0000-0000-000000000000']) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/Plan not found/i) - }) -}) diff --git a/apps/cli/test/rename-files.e2e.ts b/apps/cli/test/rename-files.e2e.ts deleted file mode 100644 index 7520cd15..00000000 --- a/apps/cli/test/rename-files.e2e.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Path } from '@core/path' -import type { MediaMetadata } from '@smm/core' -import { getCore, resetCoreForTests } from '../src/core/getCore' -import { smm } from './helpers/smm' -import { - createAndImportInitializedFolder, - movieFolder, - tvShowFolder, -} from './helpers/testFolders' - -function planPath(userDataDir: string, planId: string): string { - return join(userDataDir, 'plans', `${planId}.plan.json`) -} - -function parsePlanId(stdout: string): string { - const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] - expect(planId, stdout).toBeTruthy() - return planId! -} - -/** TMDB template name contains ":" which is invalid on Windows paths. */ -function withWindowsSafeTvShowName(mm: MediaMetadata): MediaMetadata { - return { - ...mm, - tvShow: mm.tvShow - ? { ...mm.tvShow, name: 'WATATEN an Angel Flew Down to Me' } - : mm.tvShow, - } -} - -async function importTvShowWithEmptyMediaFiles(mediaDir: string, folderName?: string) { - return createAndImportInitializedFolder( - mediaDir, - { ...tvShowFolder, ...(folderName ? { folderName } : {}) }, - { - updateMediaMetadata: (mm) => - withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), - }, - ) -} - -async function recognizeAndApply(path: string): Promise { - const recognized = await smm(['try-to-recognize', path]) - expect(recognized.code, recognized.stderr || recognized.stdout).toBe(0) - const planId = parsePlanId(recognized.stdout) - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) -} - -const SHOW_NAME = 'WATATEN an Angel Flew Down to Me' -const EP1_NAME = 'A Funny, Squirmy Feeling' - -const PLEX_S01E01_BASENAME = `${SHOW_NAME} - S01E01 - ${EP1_NAME}.mkv` -const EMBY_S01E01_BASENAME = `${SHOW_NAME} S1E1 ${EP1_NAME}.mkv` - -describe('smm try-to-rename / apply CLI e2e', () => { - let userDataDir: string - let mediaDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-files-e2e-ud-')) - mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-files-e2e-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - rmSync(mediaDir, { recursive: true, force: true }) - }) - - it('plex rename: recognize then try-to-rename then apply moves S01E01 into Season 01', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'RenameFiles 123123') - const path = folder.path! - - await recognizeAndApply(path) - - const tried = await smm(['try-to-rename', path, '--rule', 'plex']) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - expect(tried.stdout).toMatch(/task:\s+rename-files/) - expect(tried.stdout).toMatch(/status:\s+pending/) - expect(tried.stdout).toContain('Season 01') - expect(tried.stdout).toContain('S01E01.mkv') - expect(tried.stdout).toContain(PLEX_S01E01_BASENAME) - - const planId = parsePlanId(tried.stdout) - expect(existsSync(planPath(userDataDir, planId))).toBe(true) - - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - expect(applied.stdout).toMatch(/applied .* \(3 file\(s\)\)/) - expect(existsSync(planPath(userDataDir, planId))).toBe(false) - - const renamedVideo = join(path, 'Season 01', PLEX_S01E01_BASENAME) - expect(existsSync(renamedVideo)).toBe(true) - expect(existsSync(join(path, 'S01E01.mkv'))).toBe(false) - - const mm = await getCore().getMediaMetadata(path) - expect(mm!.mediaFiles).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - absolutePath: Path.posix(join(path, 'Season 01', PLEX_S01E01_BASENAME)), - seasonNumber: 1, - episodeNumber: 1, - }), - ]), - ) - }) - - it('emby rename: recognize then try-to-rename --rule emby moves into Season 1', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'EmbyRename 123123') - const path = folder.path! - - await recognizeAndApply(path) - - const tried = await smm(['try-to-rename', path, '--rule', 'emby']) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - expect(tried.stdout).toContain('Season 1') - expect(tried.stdout).toContain(EMBY_S01E01_BASENAME) - - const planId = parsePlanId(tried.stdout) - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - - const renamedVideo = join(path, 'Season 1', EMBY_S01E01_BASENAME) - expect(existsSync(renamedVideo)).toBe(true) - expect(existsSync(join(path, 'S01E01.mkv'))).toBe(false) - - const mm = await getCore().getMediaMetadata(path) - expect(mm!.mediaFiles).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - absolutePath: Path.posix(join(path, 'Season 1', EMBY_S01E01_BASENAME)), - seasonNumber: 1, - episodeNumber: 1, - }), - ]), - ) - }) - - it('associated files: apply renames same-stem subtitle alongside video', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'AssocRename 123123') - const path = folder.path! - - await recognizeAndApply(path) - - const tried = await smm(['try-to-rename', path, '--rule', 'plex']) - expect(tried.code, tried.stderr || tried.stdout).toBe(0) - const planId = parsePlanId(tried.stdout) - - const applied = await smm(['apply', planId]) - expect(applied.code, applied.stderr || applied.stdout).toBe(0) - - const seasonDir = join(path, 'Season 01') - const videoBase = PLEX_S01E01_BASENAME.replace(/\.mkv$/, '') - expect(existsSync(join(seasonDir, `${videoBase}.sc.ass`))).toBe(true) - expect(existsSync(join(seasonDir, `${videoBase}.tc.ass`))).toBe(true) - expect(existsSync(join(seasonDir, `${videoBase}.nfo`))).toBe(true) - expect(existsSync(join(seasonDir, `${videoBase}.jpg`))).toBe(true) - expect(existsSync(join(path, 'S01E01.sc.ass'))).toBe(false) - }) - - it('already at target paths: pending plan with empty files, apply is no-op', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'AlreadyRenamed 123123') - const path = folder.path! - - await recognizeAndApply(path) - - const first = await smm(['try-to-rename', path, '--rule', 'plex']) - expect(first.code, first.stderr || first.stdout).toBe(0) - const firstPlanId = parsePlanId(first.stdout) - const firstApply = await smm(['apply', firstPlanId]) - expect(firstApply.code, firstApply.stderr || firstApply.stdout).toBe(0) - - const before = await getCore().getMediaMetadata(path) - - const second = await smm(['try-to-rename', path, '--rule', 'plex']) - expect(second.code, second.stderr || second.stdout).toBe(0) - expect(second.stdout).toContain('(none)') - const secondPlanId = parsePlanId(second.stdout) - - const plan = await getCore().getPlan(secondPlanId) - expect(plan.task).toBe('rename-files') - if (plan.task === 'rename-files') { - expect(plan.files).toEqual([]) - } - - const secondApply = await smm(['apply', secondPlanId]) - expect(secondApply.code, secondApply.stderr || secondApply.stdout).toBe(0) - expect(secondApply.stdout).toMatch(/\(0 file\(s\)\)/) - expect(existsSync(planPath(userDataDir, secondPlanId))).toBe(false) - - const after = await getCore().getMediaMetadata(path) - expect(after?.mediaFiles ?? []).toEqual(before?.mediaFiles ?? []) - }) - - it('rejects unmanaged folder', async () => { - const unmanaged = join(mediaDir, 'not-imported') - const result = await smm(['try-to-rename', unmanaged]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/not managed by SMM/i) - }) - - it('rejects unsupported rename rule', async () => { - const folder = await importTvShowWithEmptyMediaFiles(mediaDir, 'BadRule 123123') - const result = await smm(['try-to-rename', folder.path!, '--rule', 'jellyfin']) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/Unsupported rename rule/i) - }) - - it('rejects movie folder (not a TV show with episodes)', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { - mediaMetadata: { - type: 'movie-folder', - mediaFiles: [], - movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, - }, - }) - const result = await smm(['try-to-rename', folder.path!]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/not a TV show with episodes/i) - }) - - it('apply rejects missing plan id', async () => { - const result = await smm(['apply', '00000000-0000-0000-0000-000000000000']) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/Plan not found/i) - }) -}) diff --git a/apps/cli/test/rename.e2e.ts b/apps/cli/test/rename.e2e.ts deleted file mode 100644 index e7664b3e..00000000 --- a/apps/cli/test/rename.e2e.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Path } from '@core/path' -import { getCore, resetCoreForTests } from '../src/core/getCore' -import { smm } from './helpers/smm' -import { - createAndImportInitializedFolder, - metadataCachePath, - movieFolder, - renamedFolderPath, - tvShowFolder, -} from './helpers/testFolders' - -describe('smm rename CLI e2e (pre-initialized import)', () => { - let userDataDir: string - let mediaDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-e2e-ud-')) - mediaDir = mkdtempSync(join(tmpdir(), 'smm-cli-rename-e2e-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - rmSync(mediaDir, { recursive: true, force: true }) - }) - - it('renames a pre-initialized tvshow folder via smm rename', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) - const from = folder.path! - const to = renamedFolderPath(from, folder.folderName) - - const renamed = await smm(['rename', from, to]) - expect(renamed.code, renamed.stderr || renamed.stdout).toBe(0) - expect(renamed.stdout).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) - - expect(existsSync(from)).toBe(false) - expect(existsSync(to)).toBe(true) - expect(existsSync(join(to, 'S01E01.mkv'))).toBe(true) - - const listed = await smm(['list']) - expect(listed.code).toBe(0) - expect(listed.stdout.split('\n').filter(Boolean).map((p) => Path.posix(p))).toEqual([ - Path.posix(to), - ]) - - expect(existsSync(metadataCachePath(userDataDir, Path.posix(from)))).toBe(false) - expect(existsSync(metadataCachePath(userDataDir, Path.posix(to)))).toBe(true) - - const mm = await getCore().getMediaMetadata(to) - expect(mm).not.toBeNull() - expect(mm!.type).toBe('tvshow-folder') - expect(mm!.mediaFolderPath).toBe(Path.posix(to)) - expect(mm!.tvShow?.id).toBe('84666') - expect(mm!.mediaFiles).toEqual([ - { - absolutePath: Path.posix(join(to, 'S01E01.mkv')), - seasonNumber: 1, - episodeNumber: 1, - }, - { - absolutePath: Path.posix(join(to, 'S01E02.mkv')), - seasonNumber: 1, - episodeNumber: 2, - }, - { - absolutePath: Path.posix(join(to, 'S01E03.mkv')), - seasonNumber: 1, - episodeNumber: 3, - }, - ]) - }) - - it('renames a pre-initialized movie folder via smm rename', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...movieFolder }, { - updateMediaMetadata: (mediaMetadata) => { - const folderPath = Path.toPlatformPath(mediaMetadata.mediaFolderPath!) - return { - ...mediaMetadata, - type: 'movie-folder', - tvShow: undefined, - mediaFiles: movieFolder.files.map((file) => ({ - absolutePath: Path.posix(join(folderPath, file)), - })), - movie: { - database: 'TVDB', - id: '116', - name: 'The Dark Knight', - }, - } - }, - }) - const from = folder.path! - const to = renamedFolderPath(from, folder.folderName) - - const seeded = await getCore().getMediaMetadata(from) - expect(seeded?.type).toBe('movie-folder') - expect(seeded?.movie?.id).toBe('116') - expect(seeded?.mediaFiles?.[0]?.absolutePath).toBe( - Path.posix(join(from, 'The Dark Knight [1080P].mkv')), - ) - - const renamed = await smm(['rename', from, to]) - expect(renamed.code, renamed.stderr || renamed.stdout).toBe(0) - expect(renamed.stdout).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) - - expect(existsSync(from)).toBe(false) - expect(existsSync(to)).toBe(true) - expect(existsSync(join(to, 'The Dark Knight [1080P].mkv'))).toBe(true) - - const listed = await smm(['list']) - expect(listed.code).toBe(0) - expect(listed.stdout.split('\n').filter(Boolean).map((p) => Path.posix(p))).toEqual([ - Path.posix(to), - ]) - - const mm = await getCore().getMediaMetadata(to) - expect(mm).not.toBeNull() - expect(mm!.type).toBe('movie-folder') - expect(mm!.mediaFolderPath).toBe(Path.posix(to)) - expect(mm!.movie?.id).toBe('116') - expect(mm!.tvShow).toBeUndefined() - expect(mm!.mediaFiles).toEqual([ - { - absolutePath: Path.posix(join(to, 'The Dark Knight [1080P].mkv')), - }, - ]) - }) - - it('renames a linked episode file (+ associates) via smm rename', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) - const media = folder.path! - const from = join(media, 'S01E01.mkv') - const to = join(media, 'S01E01_renamed.mkv') - - const renamed = await smm(['rename', from, to]) - expect(renamed.code, renamed.stderr || renamed.stdout).toBe(0) - expect(renamed.stdout).toContain( - `${Path.posix(from)} → ${Path.posix(to)}`, - ) - expect(renamed.stdout).toContain( - `${Path.posix(join(media, 'S01E01.jpg'))} → ${Path.posix(join(media, 'S01E01_renamed.jpg'))}`, - ) - expect(renamed.stdout).toContain( - `${Path.posix(join(media, 'S01E01.sc.ass'))} → ${Path.posix(join(media, 'S01E01_renamed.sc.ass'))}`, - ) - - expect(existsSync(from)).toBe(false) - expect(existsSync(to)).toBe(true) - expect(existsSync(join(media, 'S01E01_renamed.jpg'))).toBe(true) - expect(existsSync(join(media, 'S01E01.jpg'))).toBe(false) - - const mm = await getCore().getMediaMetadata(media) - expect(mm).not.toBeNull() - expect(mm!.mediaFiles).toEqual( - expect.arrayContaining([ - { - absolutePath: Path.posix(to), - seasonNumber: 1, - episodeNumber: 1, - }, - ]), - ) - }) - - it('rejects renaming a subdirectory that is not a managed media folder', async () => { - const folder = await createAndImportInitializedFolder(mediaDir, { ...tvShowFolder }) - const media = folder.path! - const season = join(media, 'Season 01') - mkdirSync(season) - - const result = await smm(['rename', season, join(media, 'Season 01 - Renamed')]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/directory but not a managed media folder/) - }) - - it('rejects unmanaged paths', async () => { - const orphan = join(mediaDir, 'orphan.mkv') - const result = await smm(['rename', orphan, join(mediaDir, 'orphan2.mkv')]) - expect(result.code).toBe(1) - expect(result.stderr).toMatch(/not under a managed media folder/) - }) -}) diff --git a/apps/e2e/cli/README.md b/apps/e2e/cli/README.md new file mode 100644 index 00000000..29a36ed1 --- /dev/null +++ b/apps/e2e/cli/README.md @@ -0,0 +1 @@ +This folder holds e2e tests for CLI. \ No newline at end of file diff --git a/apps/e2e/cli/base.ts b/apps/e2e/cli/base.ts new file mode 100644 index 00000000..2c2602ca --- /dev/null +++ b/apps/e2e/cli/base.ts @@ -0,0 +1,22 @@ +/** + * Testbed setup/cleanup for CLI e2e tests (`bun test` under `apps/e2e/cli`). + * + * Pass `binary` (path to `smm` executable). Data dirs are resolved via `smm hello -f json`, + * not `GET /api/hello`. Browser-only wdio options are omitted. + */ +export { + cleanupCore as cleanup, + setupCore as setup, + updateUserConfig, + type ResetUserConfigOption, + type TestBedCoreCleanupOptions as CliTestBedCleanupOptions, + type TestBedCoreSetupOptions as CliTestBedSetupOptions, + type UserConfigUpdater, +} from '../test/lib/testbed-core' + +export { runCliHello } from '../test/lib/cli-hello' + +const isWindows = process.platform === 'win32' + +/** Path to the compiled `smm` CLI executable (relative to `apps/e2e/cli`). */ +export const bin = `../../cli/dist/cli${isWindows ? '.exe' : ''}` diff --git a/apps/e2e/cli/config.test.ts b/apps/e2e/cli/config.test.ts new file mode 100644 index 00000000..d79c8d01 --- /dev/null +++ b/apps/e2e/cli/config.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { setup, cleanup, bin } from './base' +import { cliOutput } from './helpers' +import { $ } from 'bun' + +describe('config', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('lists the full user config as JSON', async () => { + const listed = await $`${bin} config list`.nothrow() + expect(listed.exitCode).toBe(0) + const config = JSON.parse(listed.text()) as { dryRun: boolean; folders: string[] } + expect(config.dryRun).toBe(false) + expect(config.folders).toEqual([]) + }) + + it('sets a key, gets the JSON value, and shows it in list', async () => { + const set = await $`${bin} config set dryRun true`.nothrow() + expect(set.exitCode).toBe(0) + expect(JSON.parse(set.text())).toBe(true) + + const got = await $`${bin} config get dryRun`.nothrow() + expect(got.exitCode).toBe(0) + expect(JSON.parse(got.text())).toBe(true) + + const listed = await $`${bin} config list`.nothrow() + expect(listed.exitCode).toBe(0) + expect(JSON.parse(listed.text()).dryRun).toBe(true) + }) + + it('stores a non-JSON value as a string', async () => { + const set = await $`${bin} config set selectedRenameRule plex`.nothrow() + expect(set.exitCode).toBe(0) + expect(JSON.parse(set.text())).toBe('plex') + + const got = await $`${bin} config get selectedRenameRule`.nothrow() + expect(got.exitCode).toBe(0) + expect(JSON.parse(got.text())).toBe('plex') + }) + + it('exits 1 when getting an unknown key', async () => { + const got = await $`${bin} config get notAKey`.nothrow() + expect(got.exitCode).toBe(1) + expect(cliOutput(got)).toContain('Unknown config key: notAKey') + }) + + it('exits 1 when setting an unknown key', async () => { + const set = await $`${bin} config set notAKey 1`.nothrow() + expect(set.exitCode).toBe(1) + expect(cliOutput(set)).toContain('Unknown config key: notAKey') + }) +}) diff --git a/apps/e2e/cli/helpers.ts b/apps/e2e/cli/helpers.ts new file mode 100644 index 00000000..ebfe427b --- /dev/null +++ b/apps/e2e/cli/helpers.ts @@ -0,0 +1,138 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { $, type ShellOutput } from 'bun' +import { Path } from '@smm/core' +import type { MediaMetadata } from '@smm/core/types' +import type { TestFolder } from '@smm/test' +import { createFolderInTestFolder } from '../test/actions/import-folders' +import { runCliHello } from '../test/lib/cli-hello' + +const helpersDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(helpersDir, '../../..') +const MEDIA_METADATA_TEMPLATES_DIR = join(repoRoot, 'test', 'templates', 'mediaMetadatas') + +/** Combine stdout and stderr from a CLI subprocess result. */ +export function cliOutput(result: ShellOutput): string { + const stdout = result.stdout.toString() + const stderr = result.stderr.toString() + if (!stdout) return stderr + if (!stderr) return stdout + return `${stdout}\n${stderr}` +} + +export function parsePlanId(stdout: string): string { + const planId = stdout.match(/plan:\s+([0-9a-f-]{36})/i)?.[1] + if (!planId) { + throw new Error(`plan id not found in: ${stdout}`) + } + return planId +} + +export async function planFilePath(binary: string, planId: string): Promise { + const { appDataDir } = await runCliHello(binary) + const filename = `${planId}.plan.json` + return join(appDataDir, 'plans', filename) +} + +export function metadataCacheFilePath(appDataDir: string, folderPathInPosix: string): string { + const filename = folderPathInPosix.replace(/[/\\:?*|<>"]/g, '_') + return join(appDataDir, 'metadata', `${filename}.json`) +} + +export async function resolveMetadataCachePath( + binary: string, + folderPathInPosix: string, +): Promise { + const { appDataDir } = await runCliHello(binary) + return metadataCacheFilePath(appDataDir, folderPathInPosix) +} + +export function renamedFolderPath(folderPath: string, folderName: string): string { + return join(dirname(folderPath), `${folderName} - Renamed`) +} + +function loadTemplate(templateFileName: string): MediaMetadata { + const templatePath = join(MEDIA_METADATA_TEMPLATES_DIR, templateFileName) + return JSON.parse(readFileSync(templatePath, 'utf-8')) as MediaMetadata +} + +function rewritePathsForFolder(mediaMetadata: MediaMetadata, folderPath: string): MediaMetadata { + const folderPosix = Path.posix(folderPath) + return { + ...mediaMetadata, + mediaFolderPath: folderPosix, + mediaFiles: mediaMetadata.mediaFiles?.map((file) => ({ + ...file, + absolutePath: Path.posix(join(folderPath, file.absolutePath)), + })), + } +} + +export type CreateAndImportInitializedFolderOptions = { + mediaMetadata?: MediaMetadata + templateFileName?: string + updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata +} + +/** Create fixture on disk, `add --skip-init`, seed metadata cache (POSIX paths, Core-aligned). */ +export async function createAndImportInitializedFolder( + binary: string, + folder: TestFolder, + options: CreateAndImportInitializedFolderOptions = {}, +): Promise { + const created = createFolderInTestFolder(folder) + const folderPath = created.path! + + const added = await $`${binary} add ${folderPath} --type ${created.type} --skip-init`.nothrow() + if (added.exitCode !== 0) { + throw new Error(added.text()) + } + + let mediaMetadata: MediaMetadata + if (options.mediaMetadata) { + mediaMetadata = { + ...options.mediaMetadata, + mediaFolderPath: Path.posix(folderPath), + } + } else { + const templateFileName = options.templateFileName ?? '天使降临到我身边.metadata.json' + mediaMetadata = rewritePathsForFolder(loadTemplate(templateFileName), folderPath) + mediaMetadata.files = created.files.map((file) => Path.posix(join(folderPath, file))) + } + + if (options.updateMediaMetadata) { + mediaMetadata = options.updateMediaMetadata(mediaMetadata) + } + mediaMetadata.mediaFolderPath = Path.posix(folderPath) + + const { appDataDir } = await runCliHello(binary) + const cachePath = metadataCacheFilePath(appDataDir, Path.posix(folderPath)) + mkdirSync(dirname(cachePath), { recursive: true }) + const { files: _files, ...toPersist } = mediaMetadata + writeFileSync(cachePath, JSON.stringify(toPersist, null, 2)) + + return created +} + +export async function recognizeAndApply(binary: string, path: string): Promise { + const recognized = await $`${binary} try-to-recognize ${path}`.nothrow() + if (recognized.exitCode !== 0) { + throw new Error(recognized.text()) + } + const planId = parsePlanId(recognized.text()) + const applied = await $`${binary} apply ${planId}`.nothrow() + if (applied.exitCode !== 0) { + throw new Error(applied.text()) + } +} + +/** TMDB template name contains ":" which is invalid on Windows paths. */ +export function withWindowsSafeTvShowName(mm: MediaMetadata): MediaMetadata { + return { + ...mm, + tvShow: mm.tvShow + ? { ...mm.tvShow, name: 'WATATEN an Angel Flew Down to Me' } + : mm.tvShow, + } +} diff --git a/apps/e2e/cli/import-folder.test.ts b/apps/e2e/cli/import-folder.test.ts new file mode 100644 index 00000000..9dc53964 --- /dev/null +++ b/apps/e2e/cli/import-folder.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { folder1, createFolderInTestFolder, folder2 } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { $ } from 'bun' + +const FIVE_MINUTES_MS = 5 * 60 * 1000 + +describe('import folder', () => { + + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: (config) => { + // config.primaryDatabase = 'TMDB' + // config.preferMediaLanguage = 'zh-CN' + }, + }) + + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('import TV show folder', async () => { + + const testFolder = createFolderInTestFolder(folder1) + const folderPath = testFolder.path + + const ret = await $`${bin} add ${folderPath} --type tvshow --verbose +${bin} list +${bin} show ${folderPath} +${bin} metadata ${folderPath} + `.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain(`${folderPath} +Path: ${folderPath} +Status: ok +Type: tvshow-folder +Title: WATATEN!: an Angel Flew Down to Me + +Season 1: Season 1 + S01E01 A Funny, Squirmy Feeling + S01E01.mkv + S01E02 Incontestably Cute + S01E02.mkv + S01E03 Imprinting + S01E03.mkv +mediaFolderPath: ${folderPath} +type: tvshow-folder +tvShow: + name: WATATEN!: an Angel Flew Down to Me + database: TMDB + id: 84666 + airDate: 2019-01-08 + seasons: 2 + S00 Specials (1 episodes) + E01 You Never Let Us Down / Always Growing Closer / Let's Change You Into This! / I'm Your Big Sister + S01 Season 1 (12 episodes) + E01 A Funny, Squirmy Feeling + E02 Incontestably Cute + E03 Imprinting + E04 Can We Talk for a Moment? + E05 Don't Worry! Leave It to Me! + E06 Mya-nee Doesn't Have Any Friends + E07 I Don't Understand What Mya-nee Is Saying + E08 Sometimes Ignorance Is Bliss + E09 Please Stay Until I Fall Asleep + E10 I Said Too Much Again + E11 In Short, It's Your Fault, Onee-san + E12 Angel's Gaze +mediaFiles: + - absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1 + - absolutePath: ${folderPath}\\S01E02.mkv seasonNumber: 1 episodeNumber: 2 + - absolutePath: ${folderPath}\\S01E03.mkv seasonNumber: 1 episodeNumber: 3`) + }, FIVE_MINUTES_MS) + + it('import TV show folder with --skip-init', async () => { + + const testFolder = createFolderInTestFolder(folder1) + const folderPath = testFolder.path + + const ret = await $`${bin} add ${folderPath} --type tvshow --verbose --skip-init +${bin} list +${bin} show ${folderPath} +${bin} metadata ${folderPath} + `.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain(`${folderPath} +Path: ${folderPath} +Status: ok +Type: tvshow-folder +`) + }, FIVE_MINUTES_MS) + + it('import movie folder', async () => { + + const testFolder = createFolderInTestFolder(folder2) + const folderPath = testFolder.path + + const ret = await $`${bin} add ${folderPath} --type movie --verbose +${bin} list +${bin} show ${folderPath} +${bin} metadata ${folderPath} + `.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain(`${folderPath} +Path: ${folderPath} +Status: ok +Type: movie-folder +mediaFolderPath: ${folderPath} +type: movie-folder +mediaFiles: + (empty)`) + }, FIVE_MINUTES_MS) + + it('import music folder', async () => { + + const testFolder = createFolderInTestFolder({ + folderName: 'BilibiliMusic', + files: ['01.mp3'], + type: 'music', + }) + const folderPath = testFolder.path + + const ret = await $`${bin} add ${folderPath} --type music --verbose +${bin} list +${bin} show ${folderPath} +${bin} metadata ${folderPath} + `.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain(`${folderPath} +Path: ${folderPath} +Status: ok +Type: music-folder +mediaFolderPath: ${folderPath} +type: music-folder +mediaFiles: + (empty)`) + }, FIVE_MINUTES_MS) +}) \ No newline at end of file diff --git a/apps/e2e/cli/manage-folders.test.ts b/apps/e2e/cli/manage-folders.test.ts new file mode 100644 index 00000000..9e93b059 --- /dev/null +++ b/apps/e2e/cli/manage-folders.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { folder1, folder2, createFolderInTestFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { $ } from 'bun' + +/** + * This test focus on folder management + * So when adding(importing) a folder, we use "--skip-init" to skip the folder initialization process + * + * Another test "import-folder.test.ts" is created to cover folder initialization process + */ +describe('manage folders', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('list is empty initially, shows imported folder, rm removes it', async () => { + const testFolder = createFolderInTestFolder(folder1) + const folderPath = testFolder.path! + + const listEmpty = await $`${bin} list`.nothrow() + expect(listEmpty.exitCode).toBe(0) + expect(listEmpty.text().trim()).toBe('') + + const added = await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + expect(added.exitCode).toBe(0) + expect(added.text()).toContain(`imported folder ${folderPath}`) + + const listed = await $`${bin} list`.nothrow() + expect(listed.exitCode).toBe(0) + expect(listed.text().trim().split('\n').filter(Boolean)).toEqual([folderPath]) + + const removed = await $`${bin} rm ${folderPath}`.nothrow() + expect(removed.exitCode).toBe(0) + expect(removed.text()).toContain(`Removed ${folderPath}`) + + const listAfter = await $`${bin} list`.nothrow() + expect(listAfter.exitCode).toBe(0) + expect(listAfter.text().trim()).toBe('') + }) + + it('list shows multiple imported folders and rm removes only the targeted folder', async () => { + const tvFolder = createFolderInTestFolder(folder1) + const movieFolder = createFolderInTestFolder(folder2) + const tvPath = tvFolder.path! + const moviePath = movieFolder.path! + + const addTv = await $`${bin} add ${tvPath} --type tvshow --skip-init`.nothrow() + expect(addTv.exitCode).toBe(0) + + const addMovie = await $`${bin} add ${moviePath} --type movie --skip-init`.nothrow() + expect(addMovie.exitCode).toBe(0) + + const listed = await $`${bin} list`.nothrow() + expect(listed.exitCode).toBe(0) + const paths = listed.text().trim().split('\n').filter(Boolean) + expect(paths).toHaveLength(2) + expect(paths).toContain(tvPath) + expect(paths).toContain(moviePath) + + const removed = await $`${bin} rm ${tvPath}`.nothrow() + expect(removed.exitCode).toBe(0) + expect(removed.text()).toContain(`Removed ${tvPath}`) + + const listAfter = await $`${bin} list`.nothrow() + expect(listAfter.exitCode).toBe(0) + expect(listAfter.text().trim().split('\n').filter(Boolean)).toEqual([moviePath]) + }) +}) diff --git a/apps/e2e/cli/recognize.test.ts b/apps/e2e/cli/recognize.test.ts new file mode 100644 index 00000000..b2c202d6 --- /dev/null +++ b/apps/e2e/cli/recognize.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { existsSync, readFileSync, renameSync } from 'node:fs' +import { join } from 'node:path' +import { $ } from 'bun' +import { movieFolder, tvShowFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { + createAndImportInitializedFolder, + parsePlanId, + planFilePath, + cliOutput, +} from './helpers' + +describe('recognize', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('try-to-recognize then apply maps S01E01..03', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'UnKnown Folder 123123123123' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const folderPath = folder.path! + + const tried = await $`${bin} try-to-recognize ${folderPath}`.nothrow() + expect(tried.exitCode).toBe(0) + expect(tried.text()).toMatch(/task:\s+recognize-media-file/) + expect(tried.text()).toMatch(/status:\s+pending/) + expect(tried.text()).toContain('S01E01') + expect(tried.text()).toContain('S01E02') + expect(tried.text()).toContain('S01E03') + + const planId = parsePlanId(tried.text()) + expect(existsSync(await planFilePath(bin, planId))).toBe(true) + + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + expect(applied.text()).toMatch(/applied .* \(3 file\(s\)\)/) + expect(existsSync(await planFilePath(bin, planId))).toBe(false) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + ) + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\S01E02.mkv seasonNumber: 1 episodeNumber: 2`, + ) + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\S01E03.mkv seasonNumber: 1 episodeNumber: 3`, + ) + }) + + it('partial coverage: only S01E01..02 match when S01E03 loses season-episode name', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'PartialRecognition 123123' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const folderPath = folder.path! + renameSync(join(folderPath, 'S01E03.mkv'), join(folderPath, 'extra-video.mkv')) + + const tried = await $`${bin} try-to-recognize ${folderPath}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + expect(tried.text()).toContain('S01E01') + expect(tried.text()).toContain('S01E02') + expect(tried.text()).not.toMatch(/S01E03\s+/) + + const plan = JSON.parse(readFileSync(await planFilePath(bin, planId), 'utf-8')) as { + task: string + files: Array<{ season: number; episode: number }> + } + expect(plan.task).toBe('recognize-media-file') + expect(plan.files).toHaveLength(2) + expect(plan.files.map((f) => `${f.season}:${f.episode}`)).toEqual(['1:1', '1:2']) + + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + ) + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\S01E02.mkv seasonNumber: 1 episodeNumber: 2`, + ) + expect(meta.text()).not.toContain('extra-video.mkv') + }) + + it('zero matches: pending plan with empty files, apply is no-op on mediaFiles', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'NoPatternMatch 123123' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const folderPath = folder.path! + for (const name of ['S01E01.mkv', 'S01E02.mkv', 'S01E03.mkv']) { + renameSync(join(folderPath, name), join(folderPath, name.replace('S01E', 'Episode'))) + } + + const tried = await $`${bin} try-to-recognize ${folderPath}`.nothrow() + expect(tried.exitCode).toBe(0) + expect(tried.text()).toMatch(/status:\s+pending/) + expect(tried.text()).toContain('(none)') + const planId = parsePlanId(tried.text()) + + const plan = JSON.parse(readFileSync(await planFilePath(bin, planId), 'utf-8')) as { + task: string + files: unknown[] + } + expect(plan.task).toBe('recognize-media-file') + expect(plan.files).toEqual([]) + + const before = await $`${bin} metadata ${folderPath}`.nothrow() + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + expect(applied.text()).toMatch(/\(0 file\(s\)\)/) + expect(existsSync(await planFilePath(bin, planId))).toBe(false) + + const after = await $`${bin} metadata ${folderPath}`.nothrow() + expect(after.text()).toBe(before.text()) + }) + + it('already recognized: try-to-recognize still builds a plan that can be applied', async () => { + const folder = await createAndImportInitializedFolder(bin, { + ...tvShowFolder, + folderName: 'AlreadyRecognized 123123', + }) + const folderPath = folder.path! + + const seeded = await $`${bin} metadata ${folderPath}`.nothrow() + expect(seeded.text()).toContain('seasonNumber: 1 episodeNumber: 1') + + const tried = await $`${bin} try-to-recognize ${folderPath}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + expect(tried.text()).toContain('S01E01') + + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + ) + }) + + it('rejects unmanaged folder', async () => { + const result = await $`${bin} try-to-recognize ${join(process.cwd(), 'not-imported')}`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not managed by SMM/i) + }) + + it('rejects movie folder (not a TV show with episodes)', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...movieFolder }, + { + mediaMetadata: { + type: 'movie-folder', + mediaFiles: [], + movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, + }, + }, + ) + const result = await $`${bin} try-to-recognize ${folder.path!}`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not a TV show with episodes/i) + }) + + it('apply rejects missing plan id', async () => { + const result = await $`${bin} apply 00000000-0000-0000-0000-000000000000`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/Plan not found/i) + }) +}) diff --git a/apps/e2e/cli/rename-files.test.ts b/apps/e2e/cli/rename-files.test.ts new file mode 100644 index 00000000..935f1a75 --- /dev/null +++ b/apps/e2e/cli/rename-files.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { $ } from 'bun' +import { movieFolder, tvShowFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { + createAndImportInitializedFolder, + parsePlanId, + planFilePath, + recognizeAndApply, + withWindowsSafeTvShowName, + cliOutput, +} from './helpers' + +const SHOW_NAME = 'WATATEN an Angel Flew Down to Me' +const EP1_NAME = 'A Funny, Squirmy Feeling' +const PLEX_S01E01_BASENAME = `${SHOW_NAME} - S01E01 - ${EP1_NAME}.mkv` +const EMBY_S01E01_BASENAME = `${SHOW_NAME} S1E1 ${EP1_NAME}.mkv` + +describe('rename files', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('plex rename: recognize then try-to-rename then apply moves S01E01 into Season 01', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'RenameFiles 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) + const folderPath = folder.path! + + await recognizeAndApply(bin, folderPath) + + const tried = await $`${bin} try-to-rename ${folderPath} --rule plex`.nothrow() + expect(tried.exitCode).toBe(0) + expect(tried.text()).toMatch(/task:\s+rename-files/) + expect(tried.text()).toMatch(/status:\s+pending/) + expect(tried.text()).toContain('Season 01') + expect(tried.text()).toContain('S01E01.mkv') + expect(tried.text()).toContain(PLEX_S01E01_BASENAME) + + const planId = parsePlanId(tried.text()) + expect(existsSync(await planFilePath(bin, planId))).toBe(true) + + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + expect(applied.text()).toMatch(/applied .* \(3 file\(s\)\)/) + expect(existsSync(await planFilePath(bin, planId))).toBe(false) + + const renamedVideo = join(folderPath, 'Season 01', PLEX_S01E01_BASENAME) + expect(existsSync(renamedVideo)).toBe(true) + expect(existsSync(join(folderPath, 'S01E01.mkv'))).toBe(false) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\Season 01\\${PLEX_S01E01_BASENAME} seasonNumber: 1 episodeNumber: 1`, + ) + }) + + it('emby rename: recognize then try-to-rename --rule emby moves into Season 1', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'EmbyRename 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) + const folderPath = folder.path! + + await recognizeAndApply(bin, folderPath) + + const tried = await $`${bin} try-to-rename ${folderPath} --rule emby`.nothrow() + expect(tried.exitCode).toBe(0) + expect(tried.text()).toContain('Season 1') + expect(tried.text()).toContain(EMBY_S01E01_BASENAME) + + const planId = parsePlanId(tried.text()) + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + + const renamedVideo = join(folderPath, 'Season 1', EMBY_S01E01_BASENAME) + expect(existsSync(renamedVideo)).toBe(true) + expect(existsSync(join(folderPath, 'S01E01.mkv'))).toBe(false) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.text()).toContain( + `absolutePath: ${folderPath}\\Season 1\\${EMBY_S01E01_BASENAME} seasonNumber: 1 episodeNumber: 1`, + ) + }) + + it('associated files: apply renames same-stem subtitle alongside video', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'AssocRename 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) + const folderPath = folder.path! + + await recognizeAndApply(bin, folderPath) + + const tried = await $`${bin} try-to-rename ${folderPath} --rule plex`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + + const seasonDir = join(folderPath, 'Season 01') + const videoBase = PLEX_S01E01_BASENAME.replace(/\.mkv$/, '') + expect(existsSync(join(seasonDir, `${videoBase}.sc.ass`))).toBe(true) + expect(existsSync(join(seasonDir, `${videoBase}.tc.ass`))).toBe(true) + expect(existsSync(join(seasonDir, `${videoBase}.nfo`))).toBe(true) + expect(existsSync(join(seasonDir, `${videoBase}.jpg`))).toBe(true) + expect(existsSync(join(folderPath, 'S01E01.sc.ass'))).toBe(false) + }) + + it('already at target paths: pending plan with empty files, apply is no-op', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'AlreadyRenamed 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) + const folderPath = folder.path! + + await recognizeAndApply(bin, folderPath) + + const first = await $`${bin} try-to-rename ${folderPath} --rule plex`.nothrow() + expect(first.exitCode).toBe(0) + const firstPlanId = parsePlanId(first.text()) + const firstApply = await $`${bin} apply ${firstPlanId}`.nothrow() + expect(firstApply.exitCode).toBe(0) + + const before = await $`${bin} metadata ${folderPath}`.nothrow() + + const second = await $`${bin} try-to-rename ${folderPath} --rule plex`.nothrow() + expect(second.exitCode).toBe(0) + expect(second.text()).toContain('(none)') + const secondPlanId = parsePlanId(second.text()) + + const plan = JSON.parse(readFileSync(await planFilePath(bin, secondPlanId), 'utf-8')) as { + task: string + files: unknown[] + } + expect(plan.task).toBe('rename-files') + expect(plan.files).toEqual([]) + + const secondApply = await $`${bin} apply ${secondPlanId}`.nothrow() + expect(secondApply.exitCode).toBe(0) + expect(secondApply.text()).toMatch(/\(0 file\(s\)\)/) + expect(existsSync(await planFilePath(bin, secondPlanId))).toBe(false) + + const after = await $`${bin} metadata ${folderPath}`.nothrow() + expect(after.text()).toBe(before.text()) + }) + + it('rejects unmanaged folder', async () => { + const result = await $`${bin} try-to-rename ${join(process.cwd(), 'not-imported')}`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not managed by SMM/i) + }) + + it('rejects unsupported rename rule', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'BadRule 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) + const result = await $`${bin} try-to-rename ${folder.path!} --rule jellyfin`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/Unsupported rename rule/i) + }) + + it('rejects movie folder (not a TV show with episodes)', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...movieFolder }, + { + mediaMetadata: { + type: 'movie-folder', + mediaFiles: [], + movie: { database: 'TVDB', id: '116', name: 'The Dark Knight' }, + }, + }, + ) + const result = await $`${bin} try-to-rename ${folder.path!}`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not a TV show with episodes/i) + }) + + it('apply rejects missing plan id', async () => { + const result = await $`${bin} apply 00000000-0000-0000-0000-000000000000`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/Plan not found/i) + }) +}) diff --git a/apps/e2e/cli/rename.test.ts b/apps/e2e/cli/rename.test.ts new file mode 100644 index 00000000..b1710ff7 --- /dev/null +++ b/apps/e2e/cli/rename.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { existsSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { $ } from 'bun' +import { Path } from '@smm/core' +import { movieFolder, tvShowFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { + createAndImportInitializedFolder, + renamedFolderPath, + cliOutput, +} from './helpers' + +describe('rename', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('renames a pre-initialized tvshow folder via smm rename', async () => { + const folder = await createAndImportInitializedFolder(bin, { ...tvShowFolder }) + const from = folder.path! + const to = renamedFolderPath(from, folder.folderName) + + const renamed = await $`${bin} rename ${from} ${to}`.nothrow() + expect(renamed.exitCode).toBe(0) + expect(renamed.text()).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(to, 'S01E01.mkv'))).toBe(true) + + const listed = await $`${bin} list`.nothrow() + expect(listed.exitCode).toBe(0) + expect(listed.text().trim().split('\n').filter(Boolean)).toEqual([to]) + + const meta = await $`${bin} metadata ${to}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(cliOutput(meta)).toContain(`mediaFolderPath: ${to}`) + expect(meta.text()).toContain('type: tvshow-folder') + expect(meta.text()).toContain('id: 84666') + expect(meta.text()).toContain( + `absolutePath: ${to}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + ) + }) + + it('renames a pre-initialized movie folder via smm rename', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...movieFolder }, + { + updateMediaMetadata: (mediaMetadata) => { + const folderPath = Path.toPlatformPath(mediaMetadata.mediaFolderPath!) + return { + ...mediaMetadata, + type: 'movie-folder', + tvShow: undefined, + mediaFiles: movieFolder.files.map((file) => ({ + absolutePath: Path.posix(join(folderPath, file)), + })), + movie: { + database: 'TVDB', + id: '116', + name: 'The Dark Knight', + }, + } + }, + }, + ) + const from = folder.path! + const to = renamedFolderPath(from, folder.folderName) + + const seeded = await $`${bin} metadata ${from}`.nothrow() + expect(seeded.text()).toContain('type: movie-folder') + expect(seeded.text()).toContain('id: 116') + + const renamed = await $`${bin} rename ${from} ${to}`.nothrow() + expect(renamed.exitCode).toBe(0) + expect(renamed.text()).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(to, 'The Dark Knight [1080P].mkv'))).toBe(true) + + const listed = await $`${bin} list`.nothrow() + expect(listed.exitCode).toBe(0) + expect(listed.text().trim().split('\n').filter(Boolean)).toEqual([to]) + + const meta = await $`${bin} metadata ${to}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(cliOutput(meta)).toContain(`mediaFolderPath: ${to}`) + expect(cliOutput(meta)).toContain('type: movie-folder') + expect(cliOutput(meta)).toContain('id: 116') + expect(cliOutput(meta)).not.toContain('tvShow:') + expect(cliOutput(meta)).toContain( + `absolutePath: ${to}\\The Dark Knight [1080P].mkv`, + ) + }) + + it('renames a linked episode file (+ associates) via smm rename', async () => { + const folder = await createAndImportInitializedFolder(bin, { ...tvShowFolder }) + const media = folder.path! + const from = join(media, 'S01E01.mkv') + const to = join(media, 'S01E01_renamed.mkv') + + const renamed = await $`${bin} rename ${from} ${to}`.nothrow() + expect(renamed.exitCode).toBe(0) + expect(renamed.text()).toContain(`${Path.posix(from)} → ${Path.posix(to)}`) + expect(renamed.text()).toContain( + `${Path.posix(join(media, 'S01E01.jpg'))} → ${Path.posix(join(media, 'S01E01_renamed.jpg'))}`, + ) + expect(renamed.text()).toContain( + `${Path.posix(join(media, 'S01E01.sc.ass'))} → ${Path.posix(join(media, 'S01E01_renamed.sc.ass'))}`, + ) + + expect(existsSync(from)).toBe(false) + expect(existsSync(to)).toBe(true) + expect(existsSync(join(media, 'S01E01_renamed.jpg'))).toBe(true) + expect(existsSync(join(media, 'S01E01.jpg'))).toBe(false) + + const meta = await $`${bin} metadata ${media}`.nothrow() + expect(meta.text()).toContain( + `absolutePath: ${media}\\S01E01_renamed.mkv seasonNumber: 1 episodeNumber: 1`, + ) + }) + + it('rejects renaming a subdirectory that is not a managed media folder', async () => { + const folder = await createAndImportInitializedFolder(bin, { ...tvShowFolder }) + const media = folder.path! + const season = join(media, 'Season 01') + mkdirSync(season) + + const result = await $`${bin} rename ${season} ${join(media, 'Season 01 - Renamed')}`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/directory but not a managed media folder/) + }) + + it('rejects unmanaged paths', async () => { + const orphan = join(process.cwd(), 'orphan.mkv') + const result = await $`${bin} rename ${orphan} ${join(process.cwd(), 'orphan2.mkv')}`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not under a managed media folder/) + }) +}) diff --git a/apps/e2e/test/lib/cli-hello.ts b/apps/e2e/test/lib/cli-hello.ts new file mode 100644 index 00000000..c25d51eb --- /dev/null +++ b/apps/e2e/test/lib/cli-hello.ts @@ -0,0 +1,26 @@ +import { $ } from 'bun' +import type { HelloCliBody } from '@smm/core/types' + +/** + * Run `smm hello -f json` and parse {@link HelloCliBody}. + * Used by CLI e2e setup/cleanup instead of `GET /api/hello`. + */ +export async function runCliHello(binary: string): Promise { + const result = await $`${binary} hello -f json`.quiet().nothrow() + const stdout = result.stdout.toString() + const stderr = result.stderr.toString() + if (result.exitCode !== 0) { + throw new Error( + `"${binary} hello -f json" failed (exit ${result.exitCode}): ${stderr || stdout}`, + ) + } + + try { + return JSON.parse(stdout) as HelloCliBody + } catch (error) { + throw new Error( + `Failed to parse "${binary} hello -f json" output as JSON: ${stdout}`, + { cause: error }, + ) + } +} diff --git a/apps/e2e/test/lib/testbed-core.ts b/apps/e2e/test/lib/testbed-core.ts new file mode 100644 index 00000000..aa923491 --- /dev/null +++ b/apps/e2e/test/lib/testbed-core.ts @@ -0,0 +1,153 @@ +/** + * Host-filesystem testbed setup/cleanup shared by wdio and CLI e2e tests. + * + * CLI e2e: pass `binary` and paths are resolved via `smm hello -f json` (no HTTP server). + * Wdio host fallback: omit `binary` and paths come from `GET /api/hello` via `@smm/test`. + */ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import dotenv from 'dotenv' +import type { UserConfig } from '@smm/core/types' +import { + hello as helloHttp, + removeTestMediaTmpDir as removeTestMediaTmpDirV1, + resetUserConfig as resetUserConfigV1, +} from '@smm/test' +import { runCliHello } from './cli-hello' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env.local'), override: true }) + +export type UserConfigUpdater = ( + userConfig: UserConfig, +) => UserConfig | void | Promise + +export type ResetUserConfigOption = boolean | UserConfigUpdater + +export interface TestBedCoreCleanupOptions { + /** Path to the `smm` CLI executable. When set, `smm hello -f json` resolves data dirs. */ + binary?: string + removeMetadataDir?: boolean + removePlansDir?: boolean + removeMediaFolders?: boolean + resetUserConfig?: ResetUserConfigOption +} + +export interface TestBedCoreSetupOptions extends TestBedCoreCleanupOptions { + resetUserConfig: ResetUserConfigOption +} + +async function resolveHelloPaths(binary?: string): Promise<{ + userDataDir: string + appDataDir: string +}> { + if (binary) { + const body = await runCliHello(binary) + return { userDataDir: body.userDataDir, appDataDir: body.appDataDir } + } + + const data = await helloHttp() + return { userDataDir: data.userDataDir, appDataDir: data.appDataDir } +} + +async function getUserConfigPath(binary?: string): Promise { + const { userDataDir } = await resolveHelloPaths(binary) + return path.join(userDataDir, 'smm.json') +} + +async function removeMetadataDir(binary?: string): Promise { + const { appDataDir } = await resolveHelloPaths(binary) + const metadataDir = path.join(appDataDir, 'metadata') + if (!fs.existsSync(metadataDir)) { + return null + } + fs.rmSync(metadataDir, { recursive: true, force: true }) + console.log(`Removed metadata directory: ${metadataDir}`) + return metadataDir +} + +async function removePlansDir(binary?: string): Promise { + const { appDataDir } = await resolveHelloPaths(binary) + const plansDir = path.join(appDataDir, 'plans') + if (!fs.existsSync(plansDir)) { + return null + } + fs.rmSync(plansDir, { recursive: true, force: true }) + console.log(`Removed plans directory: ${plansDir}`) + return plansDir +} + +export async function updateUserConfig( + updateFn: UserConfigUpdater, + options?: { binary?: string }, +): Promise { + const userConfigPath = await getUserConfigPath(options?.binary) + if (!fs.existsSync(userConfigPath)) { + throw new Error(`updateUserConfig: user config not found at ${userConfigPath}`) + } + + const raw = fs.readFileSync(userConfigPath, 'utf-8') + const current = JSON.parse(raw) as UserConfig + const next = await Promise.resolve(updateFn(current)) + const toWrite = next ?? current + + fs.writeFileSync(userConfigPath, JSON.stringify(toWrite, null, 2), 'utf-8') + console.log(`Updated user config at: ${userConfigPath}`) +} + +export async function applyResetUserConfig( + option: ResetUserConfigOption, + options?: { binary?: string }, +): Promise { + if (option === false) { + return + } + + const userConfigPath = await getUserConfigPath(options?.binary) + + if (option === true) { + await resetUserConfigV1(userConfigPath) + return + } + + await resetUserConfigV1(userConfigPath) + await updateUserConfig(async (userConfig) => { + const updated = await Promise.resolve(option(userConfig)) + return updated ?? userConfig + }, options) +} + +/** + * Tear down test artifacts on the host filesystem. + */ +export async function cleanupCore(options?: TestBedCoreCleanupOptions): Promise { + const { + binary, + removeMetadataDir: isToRemoveMetadataDir = true, + removePlansDir: isToRemovePlansDir = true, + removeMediaFolders: isToRemoveMediaFolders = true, + resetUserConfig: needToResetUserConfig = false, + } = options ?? {} + + if (isToRemoveMediaFolders) { + await removeTestMediaTmpDirV1({ waitForUnlockMs: 30_000 }) + } + if (isToRemovePlansDir) { + await removePlansDir(binary) + } + if (isToRemoveMetadataDir) { + await removeMetadataDir(binary) + } + await applyResetUserConfig(needToResetUserConfig, { binary }) +} + +/** + * Prepare a clean host test environment (cleanup, then apply user config). + */ +export async function setupCore(options: TestBedCoreSetupOptions): Promise { + await cleanupCore(options) + await applyResetUserConfig(options.resetUserConfig, { binary: options.binary }) +} diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json index 7fed8f01..994ffbe2 100644 --- a/apps/e2e/tsconfig.json +++ b/apps/e2e/tsconfig.json @@ -41,6 +41,7 @@ "include": [ "test/**/*.ts", "common/**/*.ts", + "cli/**/*.ts", "scenarios/**/*.ts", "wdio.conf.ts", "wdio.conf.test.ts" diff --git a/docs/dev/config.md b/docs/dev/config.md new file mode 100644 index 00000000..f3f0f77b --- /dev/null +++ b/docs/dev/config.md @@ -0,0 +1,86 @@ +# Config + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** done + +## CLI + +``` +smm config list +smm config get +smm config set +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm config list + CLI->>Core: getUserConfig() + Core->>Fs: read smm.json + Core-->>CLI: UserConfig + CLI-->>User: JSON stdout +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm config get + CLI->>Core: getUserConfig() + Core-->>CLI: UserConfig + CLI-->>User: JSON value or error +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm config set + CLI->>CLI: parseConfigValue(value) + CLI->>Core: setUserConfigKey(key, parsed) + Core->>Fs: write smm.json + Core-->>CLI: updated value + CLI-->>User: JSON stdout +``` + +```mermaid +flowchart TD + A[config set value] --> B{valid UserConfig key?} + B -->|no| C[stderr + exit 1] + B -->|yes| D{JSON.parse succeeds?} + D -->|yes| E[store parsed JSON] + D -->|no| F[store raw string] + E --> G[write smm.json] + F --> G + G --> H[print updated value] +``` + +## Web UI, Electron and ohos + +```mermaid +sequenceDiagram + participant Browser + participant CLI + participant Core + participant Fs as FsPort + + Browser->>CLI: read/write user config hooks + CLI->>Core: getUserConfig / setUserConfig + Core->>Fs: smm.json + Core-->>CLI: UserConfig + CLI-->>Browser: { data } / { error } +``` + +## References + +[Supported Platform](./supported-platform.md) diff --git a/docs/dev/manage-folders.md b/docs/dev/manage-folders.md new file mode 100644 index 00000000..c292f182 --- /dev/null +++ b/docs/dev/manage-folders.md @@ -0,0 +1,79 @@ +# Manage Folders + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** done + + +## Import Folder + +See [Import Folder](./import-folder.md) + + +## Web UI, Electron and ohos + + +### List Folders + +```mermaid +sequenceDiagram + participant Browser + participant CLI + participant Core + + Browser->>CLI: POST /api/get-folders + CLI->>Core: getFolders() + Core-->>CLI: folders[] + CLI-->>Browser: { data: { folders } } +``` + +### Unimport Folders + +```mermaid +sequenceDiagram + participant User + participant Browser + participant CLI + participant Core + + User->>Browser: delete folder + Browser->>CLI: POST /api/unimport-folder { path } + CLI->>Core: unimportFolder(path) + Core-->>CLI: void + CLI-->>Browser: { data: { path } } +``` + +## CLI + +### List Folders + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + + User->>CLI: smm list + CLI->>Core: getFolders() + Core-->>CLI: folders[] + CLI-->>User: one line per folder +``` + +### Unimport Folders + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + + User->>CLI: smm rm + CLI->>Core: getFolders() (isFolderImported check) + CLI->>Core: unimportFolder(path) + CLI-->>User: exit 0 +``` + +## References + +[Import Folder](./import-folder.md) — full import pipeline (without `--skip-init`) + +[Supported Platform](./supported-platform.md) diff --git a/docs/dev/recognize.md b/docs/dev/recognize.md new file mode 100644 index 00000000..15f1d3e7 --- /dev/null +++ b/docs/dev/recognize.md @@ -0,0 +1,79 @@ +# Recognize (episode file mapping) + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** done + +## CLI + +``` +smm try-to-recognize +smm apply +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm try-to-recognize + CLI->>Core: tryToRecognizeFolder(folder) + Core->>Fs: listFiles + read metadata + Core->>Core: match videos to episodes + Core->>Fs: write plans/{id}.plan.json + Core-->>CLI: RecognizeMediaFilePlan (pending) + CLI-->>User: plan id + matched files +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm apply + CLI->>Core: getPlan(planId) + CLI->>Core: applyPlan(plan) + Core->>Fs: update metadata cache + Core->>Fs: delete plan file + Core-->>CLI: void + CLI-->>User: applied N file(s) +``` + +```mermaid +flowchart TD + A[try-to-recognize] --> B{folder managed?} + B -->|no| Z[error] + B -->|yes| C{tvshow-folder with episodes?} + C -->|no| Z + C -->|yes| D[list video files on disk] + D --> E[rule match SxxExx / pattern] + E --> F[build pending plan] + F --> G[persist plan.json] + G --> H[apply updates mediaFiles] +``` + +## Web UI, Electron and ohos + +```mermaid +sequenceDiagram + participant User + participant Browser + participant CLI + participant Core + + User->>Browser: Recognize confirm + Browser->>CLI: POST /api/createPlan + updatePlan + CLI->>Core: plan CRUD + applyPlan + Core-->>CLI: updated metadata + CLI-->>Browser: { data: plan } + Note over Browser: upsert mediaFiles (Zustand) +``` + +## References + +[Import Folder](./import-folder.md) + +[Supported Platform](./supported-platform.md) diff --git a/docs/dev/rename-files.md b/docs/dev/rename-files.md new file mode 100644 index 00000000..bff4ff57 --- /dev/null +++ b/docs/dev/rename-files.md @@ -0,0 +1,83 @@ +# Rename Files (plex / emby rule) + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** done + +## CLI + +``` +smm try-to-rename [--rule plex|emby] +smm apply +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm try-to-rename --rule plex + CLI->>Core: tryToRenameFolder(folder, rule) + Core->>Fs: read metadata + listFiles + Core->>Core: build rename pairs (video + associates) + Core->>Fs: write plans/{id}.plan.json + Core-->>CLI: RenameFilesPlan (pending) + CLI-->>User: plan id + from → to lines +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm apply + CLI->>Core: getPlan(planId) + CLI->>Core: applyPlan(plan) + Core->>Fs: mkdir + rename files + Core->>Fs: update metadata cache + Core->>Fs: delete plan file + Core-->>CLI: void + CLI-->>User: applied N file(s) +``` + +```mermaid +flowchart TD + A[try-to-rename] --> B{folder managed tvshow?} + B -->|no| Z[error] + B -->|yes| C{rule plex or emby?} + C -->|no| Z + C -->|yes| D[for each linked episode file] + D --> E[compute target path under Season folder] + E --> F{from equals to?} + F -->|yes| G[skip] + F -->|no| H[add pair + associate stems] + H --> I[persist pending plan] + I --> J[apply renames on disk] +``` + +## Web UI, Electron and ohos + +```mermaid +sequenceDiagram + participant User + participant Browser + participant CLI + participant Core + + User->>Browser: Rename confirm (rule) + Browser->>CLI: createPlan(rename-files) + apply + CLI->>Core: tryToRenameFolder / applyPlan + Core-->>CLI: metadata + disk updated + CLI-->>Browser: { data } +``` + +## References + +[Recognize](./recognize.md) + +[Rename Episode File](./rename-episode-file.md) — single-episode context-menu rename (not rule batch) + +[Supported Platform](./supported-platform.md) diff --git a/docs/dev/rename.md b/docs/dev/rename.md new file mode 100644 index 00000000..4e775a6f --- /dev/null +++ b/docs/dev/rename.md @@ -0,0 +1,75 @@ +# Rename (media folder or episode file) + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** done + +## CLI + +``` +smm rename +smm rename-episode-file --from --to +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm rename + CLI->>Core: getFolders() + CLI->>CLI: classifyRenameTarget(from) + alt managed media folder + CLI->>Core: renameFolder({ from, to }) + Core->>Fs: rename dir + smm.json + metadata cache + else linked episode file + CLI->>Core: renameEpisodeFile({ mediaFolderPath, from, to }) + Core->>Fs: rename episode + same-stem associates + Core->>Fs: update metadata cache + end + CLI-->>User: from → to line(s) +``` + +```mermaid +flowchart TD + A[smm rename from to] --> B{from equals managed folder root?} + B -->|yes| C[renameFolder] + B -->|no| D{from under managed folder?} + D -->|no| Z[error: not managed] + D -->|yes| E{from is directory?} + E -->|yes| F[error: not media root] + E -->|no| G[renameEpisodeFile + expand associates] + C --> H[update list + metadata paths] + G --> H +``` + +## Web UI, Electron and ohos + +### Rename media folder + +```mermaid +sequenceDiagram + participant User + participant Browser + participant CLI + participant Core + + User->>Browser: sidebar rename folder + Browser->>CLI: POST /api/rename-folder { from, to } + CLI->>Core: renameFolder({ from, to }) + Core-->>CLI: void + CLI-->>Browser: { data: { from, to } } +``` + +### Rename episode file + +See [Rename Episode File](./rename-episode-file.md). + +## References + +[Rename Files](./rename-files.md) — plex/emby batch plan + +[Rename Episode File](./rename-episode-file.md) + +[Supported Platform](./supported-platform.md) From ea783d5c9346942fb29979ef022973c16a0fe8f2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 00:42:57 +0800 Subject: [PATCH 127/214] refactor: update media metadata handling and improve test coverage - Introduced `createBlankMediaMetadata` function to streamline metadata creation in the import pipeline. - Updated `Core` class to persist blank metadata during folder import. - Enhanced tests for the `/api/scrape` endpoint to verify job status after import. - Adjusted various tests to remove reliance on local storage for v3 feature flag, simplifying test setup. - Added new e2e command for managing metadata. Co-authored-by: Cursor --- apps/cli/src/route/Scrape.test.ts | 16 ++++++ apps/core/src/Core.test.ts | 10 +++- apps/core/src/Core.ts | 6 ++- .../core/src/pipeline/importFolderPipeline.ts | 18 ++++--- apps/e2e/test/lib/e2e-smm-v3.ts | 2 +- apps/ui/src/api/tmdb.test.ts | 13 ++--- apps/ui/src/components/v2/Sidebar.test.tsx | 49 ++++++++----------- .../src/hooks/folders/useFoldersQuery.test.ts | 18 +------ .../folders/useUnimportFolderMutation.test.ts | 6 +-- .../useAddMediaFolderMutation.test.ts | 22 +-------- apps/ui/src/lib/localStorages.ts | 6 ++- ci/run-e2e-test-lib.ts | 2 +- docs/dev/import-folder.md | 1 + package.json | 3 +- 14 files changed, 78 insertions(+), 94 deletions(-) diff --git a/apps/cli/src/route/Scrape.test.ts b/apps/cli/src/route/Scrape.test.ts index 27ac6f56..09173542 100644 --- a/apps/cli/src/route/Scrape.test.ts +++ b/apps/cli/src/route/Scrape.test.ts @@ -67,6 +67,22 @@ describe('POST /api/scrape', () => { const importJson = (await imported.json()) as { data?: { id: string }; error?: string } expect(importJson.error).toBeUndefined() + const importJobId = importJson.data!.id + const deadline = Date.now() + 2000 + let importStatus: string | undefined + while (Date.now() < deadline) { + const jobRes = await app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: importJobId }), + }) + const jobJson = (await jobRes.json()) as { data?: { status: string } } + importStatus = jobJson.data?.status + if (importStatus === 'succeeded' || importStatus === 'failed') break + await new Promise((r) => setTimeout(r, 20)) + } + expect(importStatus).toBe('succeeded') + const posixPath = Path.posix(folderPath) const metadata: MediaMetadata = { type: 'tvshow-folder', diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index bfa211c3..f6ba3ec4 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -137,7 +137,7 @@ describe("Core", () => { expect(job?.status).toBe("failed"); }); - it("skipInit writes the folder to UserConfig and does not persist metadata", async () => { + it("skipInit writes the folder to UserConfig and persists blank metadata", async () => { const fs = inMemoryFs(); const core = new Core({ fs, @@ -150,10 +150,16 @@ describe("Core", () => { await waitForStatus(core, id, "succeeded"); expect(core.getJob(id)?.status).toBe("succeeded"); + expect(core.getJob(id)?.stage).toBe("metadata"); const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); expect(savedConfig.folders).toContain("/m/Deferred"); expect(fs.listFiles).not.toHaveBeenCalled(); - expect(await fs.exists(metadataCachePath("/data/smm", Path.posix("/m/Deferred")))).toBe(false); + expect(await fs.exists(metadataCachePath("/data/smm", Path.posix("/m/Deferred")))).toBe(true); + expect(await core.getMediaMetadata("/m/Deferred")).toEqual({ + mediaFolderPath: "/m/Deferred", + type: "tvshow-folder", + mediaFiles: [], + }); }); it("getJob returns undefined for unknown id", () => { diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 1f2cef43..e867875e 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -36,7 +36,7 @@ import { import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; import { TvdbClient } from "./clients/TvdbClient"; -import { ImportFolderPipeline } from "./pipeline/importFolderPipeline"; +import { createBlankMediaMetadata, ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { metadataCachePath } from "./pipeline/paths"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; import { @@ -598,7 +598,9 @@ export class Core { ...config, folders: [...new Set([...config.folders, folderPath])], })); - this.jobs.update(job.id, { status: "succeeded", stage: "config", progress: 100 }); + const blankMetadata = createBlankMediaMetadata(folderPath, job.type); + await this.setMetadata(blankMetadata); + this.jobs.update(job.id, { status: "succeeded", stage: "metadata", progress: 100 }); } catch (error) { this.jobs.update(job.id, { status: "failed", diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index a06ab02f..d4dc71fd 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -31,6 +31,17 @@ function mediaMetadataType(type: FolderType): MediaMetadata["type"] { return type === "tvshow" ? "tvshow-folder" : type === "movie" ? "movie-folder" : "music-folder"; } +/** Blank metadata created at the pipeline metadata stage (before listFiles/recognize). */ +export function createBlankMediaMetadata(folderPath: string, type: FolderType): MediaMetadata { + const posixPath = Path.posix(folderPath); + return { + mediaFolderPath: posixPath, + type: mediaMetadataType(type), + files: [], + mediaFiles: [], + }; +} + export class ImportFolderPipeline { constructor(private readonly options: ImportFolderPipelineOptions) {} @@ -49,12 +60,7 @@ export class ImportFolderPipeline { cb.onStage?.("config", 10); logger.info({ folderPath: posixPath, type }, "importFolder: stage=metadata"); - const mm: MediaMetadata = { - mediaFolderPath: posixPath, - type: mediaMetadataType(type), - files: [], - mediaFiles: [], - }; + const mm = createBlankMediaMetadata(folderPath, type); stages.push("metadata"); cb.onStage?.("metadata", 25); diff --git a/apps/e2e/test/lib/e2e-smm-v3.ts b/apps/e2e/test/lib/e2e-smm-v3.ts index 161d8a93..dc925845 100644 --- a/apps/e2e/test/lib/e2e-smm-v3.ts +++ b/apps/e2e/test/lib/e2e-smm-v3.ts @@ -2,7 +2,7 @@ export const SMM_V3_LOCAL_STORAGE_KEY = 'smm.v3.enabled' /** - * Opt-in for e2e: `E2E_SMM_V3=true` turns on UI v3 without changing production defaults. + * Opt-in for e2e: `E2E_SMM_V3=true` injects `smm.v3.enabled` (production default is already on). * Any other value (unset, `"1"`, `"false"`) leaves v3 off. */ export function isE2eSmmV3Enabled(env: NodeJS.ProcessEnv = process.env): boolean { diff --git a/apps/ui/src/api/tmdb.test.ts b/apps/ui/src/api/tmdb.test.ts index c3cb4886..6f09c709 100644 --- a/apps/ui/src/api/tmdb.test.ts +++ b/apps/ui/src/api/tmdb.test.ts @@ -38,6 +38,7 @@ import { getTmdbLanguages, } from './tmdb' import { _resetInternalReverseProxyCacheForTesting } from './fetchByInternalReverseProxy' +import * as localStoragesModule from '@/lib/localStorages' const REVERSE_PROXY_URL = 'http://127.0.0.1:30005' const SMM_TMDB_DEFAULT_UPSTREAM = 'https://mediadb.vercel.app/api/tmdb' @@ -478,6 +479,10 @@ describe('tmdb routing through reverse proxy', () => { .mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })) } + beforeEach(() => { + vi.spyOn(localStoragesModule, 'isSmmV3Enabled').mockReturnValue(false) + }) + it('searches via discovered reverse proxy when TMDB host is empty', async () => { mockReadUserConfig.mockResolvedValue(userConfigWithTmdb()) const fetchSpy = mockOkJson({ results: [], page: 1, total_pages: 1, total_results: 0 }) @@ -696,14 +701,6 @@ describe('getTmdbLanguages', () => { }) describe('v3 Core Internal HTTP', () => { - beforeEach(() => { - localStorage.setItem('smm.v3.enabled', 'true') - }) - - afterEach(() => { - localStorage.removeItem('smm.v3.enabled') - }) - it('searchTmdb POSTs to /api/search-in-tmdb', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/apps/ui/src/components/v2/Sidebar.test.tsx b/apps/ui/src/components/v2/Sidebar.test.tsx index d7420979..3ed51c75 100644 --- a/apps/ui/src/components/v2/Sidebar.test.tsx +++ b/apps/ui/src/components/v2/Sidebar.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, fireEvent } from "@testing-library/react" import type { MediaMetadata } from "@core/types" import { Path } from "@core/path" @@ -147,26 +147,27 @@ describe("Sidebar delete behavior", () => { mockUseMediaMetadataQuery.mockReturnValue({ data: { mediaFolderPath: pathA } }) mockUseQueries.mockReturnValue([{ data: null }, { data: null }]) vi.mocked(useFoldersQuery).mockReturnValue({ - data: undefined, + data: [pathA, pathB], isFetching: false, } as ReturnType) vi.mocked(useUnimportFolderMutation).mockReturnValue({ - mutateAsync: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined), } as ReturnType) }) - afterEach(() => { - localStorage.removeItem("smm.v3.enabled") - }) - it("deletes full selected set when deleting a selected item", () => { + const mutateAsync = vi.fn().mockResolvedValue(undefined) + vi.mocked(useUnimportFolderMutation).mockReturnValue({ + mutateAsync, + } as ReturnType) const onDeleteSelected = vi.fn() render() - fireEvent.click(screen.getByTestId(`delete-${pathA}`)) + fireEvent.click(screen.getByTestId(`delete-${Path.toPlatformPath(pathA)}`)) - expect(onDeleteSelected).toHaveBeenCalledTimes(1) - expect(onDeleteSelected).toHaveBeenCalledWith(expect.arrayContaining([pathA, pathB])) + expect(mutateAsync).toHaveBeenCalledTimes(1) + expect(mutateAsync).toHaveBeenCalledWith(expect.arrayContaining([pathA, pathB])) + expect(onDeleteSelected).not.toHaveBeenCalled() }) it("deletes single item when clicked item is not in selected set", () => { @@ -175,21 +176,6 @@ describe("Sidebar delete behavior", () => { selectedFolders: [pathA], selectedFolderPathsSet: new Set([pathA]), }) - const onDeleteSelected = vi.fn() - render() - - fireEvent.click(screen.getByTestId(`delete-${pathB}`)) - - expect(onDeleteSelected).toHaveBeenCalledTimes(1) - expect(onDeleteSelected).toHaveBeenCalledWith([pathB]) - }) - - it("uses unimport mutation instead of onDeleteSelected when v3 is enabled", () => { - localStorage.setItem("smm.v3.enabled", "true") - vi.mocked(useFoldersQuery).mockReturnValue({ - data: [pathA, pathB], - isFetching: false, - } as ReturnType) const mutateAsync = vi.fn().mockResolvedValue(undefined) vi.mocked(useUnimportFolderMutation).mockReturnValue({ mutateAsync, @@ -197,10 +183,10 @@ describe("Sidebar delete behavior", () => { const onDeleteSelected = vi.fn() render() - fireEvent.click(screen.getByTestId(`delete-${Path.toPlatformPath(pathA)}`)) + fireEvent.click(screen.getByTestId(`delete-${Path.toPlatformPath(pathB)}`)) expect(mutateAsync).toHaveBeenCalledTimes(1) - expect(mutateAsync).toHaveBeenCalledWith(expect.arrayContaining([pathA, pathB])) + expect(mutateAsync).toHaveBeenCalledWith([Path.toPlatformPath(pathB)]) expect(onDeleteSelected).not.toHaveBeenCalled() }) }) @@ -209,6 +195,10 @@ describe("Sidebar i18n", () => { beforeEach(() => { vi.clearAllMocks() baseSidebarMocks() + vi.mocked(useFoldersQuery).mockReturnValue({ + data: [], + isFetching: false, + } as ReturnType) mockUseUIMediaFolderStoreState.mockReturnValue({ folders: [], selectedFolder: null, @@ -239,8 +229,11 @@ describe("Sidebar mediaName", () => { beforeEach(() => { vi.clearAllMocks() - localStorage.removeItem("smm.v3.enabled") baseSidebarMocks() + vi.mocked(useFoldersQuery).mockReturnValue({ + data: [folderPath], + isFetching: false, + } as ReturnType) mockUseUIMediaFolderStoreState.mockReturnValue({ folders: [{ path: folderPath, status: "ok", test: false }], selectedFolder: folderPath, diff --git a/apps/ui/src/hooks/folders/useFoldersQuery.test.ts b/apps/ui/src/hooks/folders/useFoldersQuery.test.ts index 5ffda097..fd39d6fc 100644 --- a/apps/ui/src/hooks/folders/useFoldersQuery.test.ts +++ b/apps/ui/src/hooks/folders/useFoldersQuery.test.ts @@ -11,8 +11,6 @@ vi.mock('@/api/getFolders', () => ({ const mockedGetFolders = vi.mocked(getFoldersApi.getFolders) -const STORAGE_KEY_SMM_V3_ENABLED = 'smm.v3.enabled' - function createWrapper(queryClient: QueryClient) { return ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children) @@ -23,30 +21,16 @@ describe('useFoldersQuery', () => { beforeEach(() => { vi.clearAllMocks() - localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) }) afterEach(() => { - localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) queryClient.clear() }) - it('does not call getFolders when smm.v3.enabled is unset', async () => { - const wrapper = createWrapper(queryClient) - const { result } = renderHook(() => useFoldersQuery(), { wrapper }) - - await new Promise((r) => setTimeout(r, 20)) - - expect(mockedGetFolders).not.toHaveBeenCalled() - expect(result.current.isFetching).toBe(false) - expect(result.current.fetchStatus).toBe('idle') - }) - - it('fetches folders when smm.v3.enabled is true', async () => { - localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, 'true') + it('fetches folders when v3 is enabled by default', async () => { mockedGetFolders.mockResolvedValue({ data: { folders: ['/media/a', '/media/b'] }, }) diff --git a/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts b/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts index 73b67745..9ec89c12 100644 --- a/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts +++ b/apps/ui/src/hooks/folders/useUnimportFolderMutation.test.ts @@ -11,7 +11,6 @@ import { mediaMetadataQueryKey } from "@/lib/mediaMetadataQueryKeys" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { useUnimportFolderMutation } from "./useUnimportFolderMutation" -const STORAGE_KEY_SMM_V3_ENABLED = "smm.v3.enabled" const USER_DATA_DIR = "/tmp/smm-user-data" const PATH_A = "/media/A" const PATH_B = "/media/B" @@ -50,7 +49,6 @@ describe("useUnimportFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() - localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) unimportFolder.mockResolvedValue({ data: { path: PATH_A } }) queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } }, @@ -66,7 +64,6 @@ describe("useUnimportFolderMutation", () => { }) afterEach(() => { - localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) queryClient.clear() useUIMediaFolderStore.setState({ folders: [], @@ -75,8 +72,7 @@ describe("useUnimportFolderMutation", () => { }) }) - it("posts each path and invalidates folders query when v3 is enabled", async () => { - localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, "true") + it("posts each path and invalidates folders query", async () => { seedCaches(queryClient) const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") diff --git a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts index 3d5f60cf..08e4cac9 100644 --- a/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts +++ b/apps/ui/src/hooks/userConfig/useAddMediaFolderMutation.test.ts @@ -9,7 +9,6 @@ import { userConfigQueryKey } from "@/lib/userConfigQueryKeys" import { foldersQueryKey } from "@/hooks/folders/foldersQueryKeys" import { useAddMediaFolderMutation } from "./useAddMediaFolderMutation" -const STORAGE_KEY_SMM_V3_ENABLED = "smm.v3.enabled" const USER_DATA_DIR = "/tmp/smm-user-data" const NEW_FOLDER = "/media/new" @@ -37,19 +36,16 @@ describe("useAddMediaFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() - localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } }, }) }) afterEach(() => { - localStorage.removeItem(STORAGE_KEY_SMM_V3_ENABLED) queryClient.clear() }) - it("invalidates folders query when a new folder is added and v3 is enabled", async () => { - localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, "true") + it("invalidates folders query when a new folder is added", async () => { seedUserConfigCache(queryClient) const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") @@ -65,7 +61,6 @@ describe("useAddMediaFolderMutation", () => { }) it("does not invalidate folders query when folder was already present", async () => { - localStorage.setItem(STORAGE_KEY_SMM_V3_ENABLED, "true") seedUserConfigCache(queryClient, [NEW_FOLDER]) const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") @@ -79,19 +74,4 @@ describe("useAddMediaFolderMutation", () => { expect(invalidateSpy).not.toHaveBeenCalled() }) - - it("does not invalidate folders query when v3 is disabled", async () => { - seedUserConfigCache(queryClient) - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") - - const { result } = renderHook(() => useAddMediaFolderMutation(), { - wrapper: createWrapper(queryClient), - }) - - await act(async () => { - await result.current.mutateAsync({ traceId: "t1", folder: NEW_FOLDER }) - }) - - expect(invalidateSpy).not.toHaveBeenCalled() - }) }) diff --git a/apps/ui/src/lib/localStorages.ts b/apps/ui/src/lib/localStorages.ts index 7bd2eab2..d428366b 100644 --- a/apps/ui/src/lib/localStorages.ts +++ b/apps/ui/src/lib/localStorages.ts @@ -198,9 +198,11 @@ const localStorages = { }, get isSmmV3Enabled(): boolean { try { - return localStorage.getItem(STORAGE_KEY_SMM_V3_ENABLED) === 'true' + const stored = localStorage.getItem(STORAGE_KEY_SMM_V3_ENABLED) + if (stored === 'false') return false + return true } catch { - return false + return true } }, } diff --git a/ci/run-e2e-test-lib.ts b/ci/run-e2e-test-lib.ts index bbd064e1..19ccea96 100644 --- a/ci/run-e2e-test-lib.ts +++ b/ci/run-e2e-test-lib.ts @@ -54,7 +54,7 @@ export function assignE2eLocalPortEnv(env: Record): void { } } -/** Opt-in UI v3 for e2e (`localStorage smm.v3.enabled`). Production default stays off. */ +/** Opt-in UI v3 for e2e (`localStorage smm.v3.enabled`). Production default is on; e2e still injects when E2E_SMM_V3=true. */ function assignE2eSmmV3Env(env: Record): void { if (process.env.E2E_SMM_V3 === 'true') { env.E2E_SMM_V3 = 'true'; diff --git a/docs/dev/import-folder.md b/docs/dev/import-folder.md index 465ef0ad..b280029a 100644 --- a/docs/dev/import-folder.md +++ b/docs/dev/import-folder.md @@ -103,6 +103,7 @@ See [apps/core](#appscore) for pipeline stages and persistence details. smm add --type tvshow|movie|music|anime [--verbose] [--skip-init] smm list smm show +smm metadata ``` ```mermaid diff --git a/package.json b/package.json index 4caab10f..993bc19d 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,14 @@ "e2e:config": "bun ci/run-e2e-test.ts --spec \"./common/config/*.e2e.ts\" --spec \"./common/httpproxy/*.e2e.ts\"", "e2e:mcp": "bun test test/mcp/", "e2e:mcp:ui": "bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", - "e2e:mcp:ui:v3": "cross-env E2E_SMM_V3=true bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", + "e2e:mcp:ui:v3": "bun ci/run-e2e-test.ts --spec \"./common/mcp/*.e2e.ts\"", "e2e:media-folder-initialization": "bun ci/run-e2e-test.ts --spec \"./common/tv/ImportTvShowLibrary.e2e.ts\" --spec \"./common/tv/Initialize*.e2e.ts\" --spec \"./common/movie/ImportMovieLibrary.e2e.ts\" --spec \"./common/movie/Initialize*.e2e.ts\"", "e2e:movie": "bun ci/run-e2e-test.ts --spec \"./common/movie/SearchMovie.e2e.ts\" --spec \"./common/movie/Movie-RenameVideoFile.e2e.ts\"", "e2e:music": "bun ci/run-e2e-test.ts --spec \"./common/music/*.e2e.ts\"", "e2e:other": "bun ci/run-e2e-test.ts --spec \"./common/other/*.e2e.ts\"", "e2e:transcribe": "bun ci/run-e2e-test.ts --spec \"./common/manual/Transcribe.e2e.ts\" --spec \"./common/manual/MusicPanel-Transcribe.e2e.ts\"", "e2e:tv": "bun ci/run-e2e-test.ts --spec \"./common/tv/*.e2e.ts\"", + "e2e:tv:v3": "bun ci/run-e2e-test.ts --spec \"./common/tv/*.e2e.ts\"", "e2e:tvdb": "bun ci/run-e2e-test.ts --spec \"./common/tvdb/*.e2e.ts\"", "e2e:ohos": "bun ci/run-e2e-test.ts --platform ohos --spec \"./ohos/**/*.e2e.ts\"", "e2e:local:tv": "bun ci/run-e2e-test.ts --spec \"./common/tv/*.e2e.ts\"", From 4b97730b1d1e83cba8fb4b8289fc6afa5a637cf4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 01:14:35 +0800 Subject: [PATCH 128/214] docs: update recognize and rename folder documentation - Revised the sequence diagrams for the media recognition process to reflect changes in the CLI commands and user interactions. - Added new documentation for the folder renaming feature, including CLI commands and sequence diagrams for both media folder and episode file renaming. - Improved clarity and structure of the documentation to enhance understanding of the media management workflows. --- docs/dev/recognize.md | 53 ++++++------------------ docs/dev/{rename.md => rename-folder.md} | 1 - docs/dev/test/import-folder-test.md | 9 ---- 3 files changed, 13 insertions(+), 50 deletions(-) rename docs/dev/{rename.md => rename-folder.md} (96%) diff --git a/docs/dev/recognize.md b/docs/dev/recognize.md index 15f1d3e7..2d8e4b5c 100644 --- a/docs/dev/recognize.md +++ b/docs/dev/recognize.md @@ -15,44 +15,13 @@ sequenceDiagram participant User participant CLI participant Core - participant Fs as FsPort User->>CLI: smm try-to-recognize CLI->>Core: tryToRecognizeFolder(folder) - Core->>Fs: listFiles + read metadata - Core->>Core: match videos to episodes - Core->>Fs: write plans/{id}.plan.json Core-->>CLI: RecognizeMediaFilePlan (pending) - CLI-->>User: plan id + matched files -``` - -```mermaid -sequenceDiagram - participant User - participant CLI - participant Core - participant Fs as FsPort - - User->>CLI: smm apply - CLI->>Core: getPlan(planId) - CLI->>Core: applyPlan(plan) - Core->>Fs: update metadata cache - Core->>Fs: delete plan file - Core-->>CLI: void - CLI-->>User: applied N file(s) -``` - -```mermaid -flowchart TD - A[try-to-recognize] --> B{folder managed?} - B -->|no| Z[error] - B -->|yes| C{tvshow-folder with episodes?} - C -->|no| Z - C -->|yes| D[list video files on disk] - D --> E[rule match SxxExx / pattern] - E --> F[build pending plan] - F --> G[persist plan.json] - G --> H[apply updates mediaFiles] + CLI-->>User: plan id + result + User->>CLI: smm apply + CLI->>Core: applyPlan(planId) ``` ## Web UI, Electron and ohos @@ -64,12 +33,16 @@ sequenceDiagram participant CLI participant Core - User->>Browser: Recognize confirm - Browser->>CLI: POST /api/createPlan + updatePlan - CLI->>Core: plan CRUD + applyPlan - Core-->>CLI: updated metadata - CLI-->>Browser: { data: plan } - Note over Browser: upsert mediaFiles (Zustand) + User->>Browser: click recognize button + Browser->>CLI: POST /api/try-to-recognize-episodes + CLI->>Core: tryToRecognizeFolder(folder) + Core->>CLI: RecognizeMediaFilePlan (pending) + CLI->>Browser: RecognizeMediaFilePlan + Browser->>User: show RuleBasedRecognizePrompt + User->>Browser: click confirm button + Browser->>CLI: POST /api/apply-plan + CLI->>Core: applyPlan() + Core->>Core: update MediaMetadata ``` ## References diff --git a/docs/dev/rename.md b/docs/dev/rename-folder.md similarity index 96% rename from docs/dev/rename.md rename to docs/dev/rename-folder.md index 4e775a6f..b0f3d8ba 100644 --- a/docs/dev/rename.md +++ b/docs/dev/rename-folder.md @@ -7,7 +7,6 @@ ``` smm rename -smm rename-episode-file --from --to ``` ```mermaid diff --git a/docs/dev/test/import-folder-test.md b/docs/dev/test/import-folder-test.md index 30c54c85..54fbc4e1 100644 --- a/docs/dev/test/import-folder-test.md +++ b/docs/dev/test/import-folder-test.md @@ -41,19 +41,10 @@ Directory recognition scenarios (TMDB/TVDB ID, NFO, search by name, unknown) are | | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | | Import movie — unknown / no match | Web UI | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | | | ohos | apps/e2e/common/movie/InitializeMovieByTmdb.e2e.ts | -| Import movie — batch media library | Web UI | apps/e2e/common/movie/ImportMovieLibrary.e2e.ts | -| | ohos | apps/e2e/common/movie/ImportMovieLibrary.e2e.ts | | Import music | CLI | apps/cli/test/import-folder.e2e.ts | -| | Web UI | apps/e2e/common/other/App.e2e.ts | -| | ohos | apps/e2e/common/other/App.e2e.ts | -| Import music — tutorial fixture (manual) | Web UI | apps/e2e/common/manual/MusicPanel-Transcribe.e2e.ts | | | ohos | apps/e2e/common/manual/MusicPanel-Transcribe.e2e.ts | | Import with `--skip-init` | CLI | apps/cli/test/import-folder.e2e.ts | -| List imported folders | CLI | apps/cli/test/import-folder.e2e.ts | -| Remove imported folder (`smm rm`) | CLI | apps/cli/test/import-folder.e2e.ts | | `smm show` / `smm metadata` after import | CLI | apps/cli/test/import-folder.e2e.ts | -| Switch UI among TV / movie / music folders | Web UI | apps/e2e/common/other/App.e2e.ts | -| | ohos | apps/e2e/common/other/App.e2e.ts | | Import behind HTTP proxy | Web UI | apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts · InitTvShowByTvdbBehindHttpProxy.e2e.ts | | | ohos | apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts · InitTvShowByTvdbBehindHttpProxy.e2e.ts | | Import with custom TMDB / TVDB host | Web UI | apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts · InitTvShowByCustomTvdbHost.e2e.ts | From f39f76823fefa1df267ac80fbf802b22aaacb53e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 12:51:26 +0800 Subject: [PATCH 129/214] docs: add design for smm tmdb tv/movie CLI commands Co-authored-by: Cursor --- .../2026-08-25-cli-tmdb-tv-movie-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md diff --git a/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md b/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md new file mode 100644 index 00000000..d37336a5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md @@ -0,0 +1,123 @@ +# CLI `smm tmdb tv` / `smm tmdb movie` + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +`docs/dev/tmdb.md` already documents: + +```bash +smm tmdb tv "" -f|--format json|default --lang "" +smm tmdb movie "" -f|--format json|default --lang "" +``` + +Today only `smm tmdb search` is implemented in `apps/cli/src/cli/runCli.ts`. Detail lookup already exists in Core (`getTvShowInTmdb` / `getMovieInTmdb`) and is used by HTTP / AI / MCP. This feature exposes the same Core methods as CLI subcommands so users can fetch full TMDB TV/movie details after a search, without going through the UI. + +## 2. Architecture + +## 2.1 Project Level Architecture + +```mermaid +sequenceDiagram + participant U as User + participant CLI as apps/cli runCli + participant C as Core + participant TMDB as TMDB API + + U->>CLI: smm tmdb tv|movie <id> [options] + CLI->>C: getTvShowInTmdb / getMovieInTmdb + C->>TMDB: HTTP (via NetworkPort) + TMDB-->>C: details payload + C-->>CLI: TmdbSeriesDetails / TmdbMovieDetails + CLI-->>U: default tree or pretty JSON +``` + +No new HTTP routes, AI tools, or MCP tools. CLI talks to Core in-process (same pattern as `smm tmdb search`). + +## 2.2 App Level Architecture + +| Piece | Location | +|-------|----------| +| Command registration | `apps/cli/src/cli/runCli.ts` under existing `tmdb` command | +| Human-readable formatter | `apps/cli/src/cli/tmdbDetailsFormat.ts` | +| Formatter unit tests | `apps/cli/src/cli/tmdbDetailsFormat.test.ts` | +| Optional command unit tests | `apps/cli/src/cli/tmdbGet.test.ts` (mock `getCore`, same style as `hello.test.ts`) | +| Live CLI e2e | Extend `apps/cli/test/tmdb.e2e.ts` and/or `apps/e2e/cli/tmdb.test.ts` | +| Docs | Update `docs/dev/tmdb.md` CLI section | + +## 2.3 Key Design + +### Command surface + +```bash +smm tmdb tv [-f|--format json|default] [--lang ] [--host ] [--password ] [--proxy ] +smm tmdb movie [-f|--format json|default] [--lang ] [--host ] [--password ] [--proxy ] +``` + +- `tmdbid`: required positional; must parse as a positive integer. Invalid → stderr + exit code 1. +- `--format` / `-f`: choices `json` | `default`. Omitted → `default`. +- `--lang` / `--host` / `--password` / `--proxy`: same semantics and Core option mapping as `smm tmdb search` (`language` ← `--lang`). +- Errors thrown by Core: print message to stderr, exit 1. + +### Output formats + +**`json`** + +Reuse existing `printJson` → `JSON.stringify(details, null, 2)` of the full Core return value. + +**`default` (human-readable tree of all fields)** + +`formatTmdbDetailsTree(value: unknown): string` recursively formats the entire payload: + +- Primitives: `key: value` +- `null`: `key: null` +- `undefined`: omit the key +- Objects: `key:` then indented children (` ` × depth) +- Arrays: `key:` then indented `[0]:`, `[1]:`, … (nested objects under each index) + +No curated field subset — every enumerable field is printed so the CLI stays complete as TMDB payloads evolve. + +### Out of scope + +- MCP / AI tool / HTTP API changes (already implemented) +- Changing `smm tmdb search` output +- TVDB get-by-id CLI commands + +## 3. User Stories + +### 3.1 Fetch TV show details (default format) + +* **Given** - Core can reach TMDB (userConfig or CLI overrides) +* **When** - user runs `smm tmdb tv 83095 --lang zh-CN` +* **Then** - exit 0; stdout is an indented key/value tree including at least `id` and localized `name` / overview fields from the full TMDB series details payload + +```mermaid +sequenceDiagram + participant U as User + participant CLI as runCli + participant C as Core + + U->>CLI: tmdb tv 83095 --lang zh-CN + CLI->>C: getTvShowInTmdb(83095, { language: "zh-CN" }) + C-->>CLI: TmdbSeriesDetails + CLI-->>U: formatTmdbDetailsTree(details) +``` + +### 3.2 Fetch movie details as JSON + +* **Given** - Core can reach TMDB +* **When** - user runs `smm tmdb movie 550 -f json` +* **Then** - exit 0; stdout is valid pretty-printed JSON of the full `TmdbMovieDetails` object + +### 3.3 Custom host / proxy (parity with search) + +* **Given** - official TMDB requires proxy / custom host in restricted networks +* **When** - user runs `smm tmdb tv --host ... --password ... --proxy ...` +* **Then** - options are forwarded to Core the same way as `smm tmdb search`; success prints details; failures surface on stderr with exit 1 + +### 3.4 Invalid id + +* **Given** - user passes a non-positive or non-integer id +* **When** - `smm tmdb tv abc` or `smm tmdb movie 0` +* **Then** - stderr explains the id is invalid; exit code 1; Core is not called (or Core throws equivalently and CLI maps to exit 1) From de97e1626ee9858aaa41dd61b18d80f360a650d1 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 12:59:21 +0800 Subject: [PATCH 130/214] feat(cli): add TMDB details tree formatter for tmdb tv/movie Co-authored-by: Cursor --- apps/cli/src/cli/tmdbDetailsFormat.test.ts | 33 +++++++++++++++ apps/cli/src/cli/tmdbDetailsFormat.ts | 47 ++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/cli/src/cli/tmdbDetailsFormat.test.ts create mode 100644 apps/cli/src/cli/tmdbDetailsFormat.ts diff --git a/apps/cli/src/cli/tmdbDetailsFormat.test.ts b/apps/cli/src/cli/tmdbDetailsFormat.test.ts new file mode 100644 index 00000000..e15d09c2 --- /dev/null +++ b/apps/cli/src/cli/tmdbDetailsFormat.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { formatTmdbDetailsTree } from './tmdbDetailsFormat' + +describe('formatTmdbDetailsTree', () => { + it('formats primitives, null, nested objects, and arrays', () => { + const text = formatTmdbDetailsTree({ + id: 83095, + name: 'Wataten', + overview: null, + missing: undefined, + genres: [{ id: 16, name: 'Animation' }], + episode_run_time: [24], + }) + expect(text).toBe( + [ + 'id: 83095', + 'name: Wataten', + 'overview: null', + 'genres:', + ' [0]:', + ' id: 16', + ' name: Animation', + 'episode_run_time:', + ' [0]: 24', + ].join('\n'), + ) + }) + + it('formats a bare non-object as a single line', () => { + expect(formatTmdbDetailsTree(42)).toBe('42') + expect(formatTmdbDetailsTree(null)).toBe('null') + }) +}) diff --git a/apps/cli/src/cli/tmdbDetailsFormat.ts b/apps/cli/src/cli/tmdbDetailsFormat.ts new file mode 100644 index 00000000..da0f12d1 --- /dev/null +++ b/apps/cli/src/cli/tmdbDetailsFormat.ts @@ -0,0 +1,47 @@ +/** + * Format a TMDB details payload as an indented key/value tree for CLI stdout. + * Prints every enumerable field; omits `undefined` keys; prints `null` as `null`. + */ +export function formatTmdbDetailsTree(value: unknown): string { + if (value === null || typeof value !== 'object') { + return formatScalar(value) + } + return formatObjectOrArray(value, 0).join('\n') +} + +function formatScalar(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'string') return value + return String(value) +} + +function indent(depth: number): string { + return ' '.repeat(depth) +} + +function formatObjectOrArray(value: object, depth: number): string[] { + const lines: string[] = [] + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + lines.push(...formatEntry(`[${i}]`, value[i], depth)) + } + return lines + } + for (const [key, child] of Object.entries(value as Record)) { + if (child === undefined) continue + lines.push(...formatEntry(key, child, depth)) + } + return lines +} + +function formatEntry(key: string, value: unknown, depth: number): string[] { + const prefix = `${indent(depth)}${key}:` + if (value !== null && typeof value === 'object') { + const children = formatObjectOrArray(value, depth + 1) + if (children.length === 0) { + return [`${prefix}`] + } + return [`${prefix}`, ...children] + } + return [`${prefix} ${formatScalar(value)}`] +} From bee044c342bbde57814aa2b4e65e64e9476000d8 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 13:00:56 +0800 Subject: [PATCH 131/214] feat(cli): add smm tmdb tv and tmdb movie get-by-id commands Co-authored-by: Cursor --- apps/cli/src/cli/runCli.ts | 74 +++++++++++++++++++++++ apps/cli/src/cli/tmdbGet.test.ts | 100 +++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 apps/cli/src/cli/tmdbGet.test.ts diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 1371f25c..319fc219 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -21,6 +21,7 @@ import { printEpisodeRenameResult, } from './renameDispatch' import { formatTmdbSearchResults } from './tmdbSearchFormat' +import { formatTmdbDetailsTree } from './tmdbDetailsFormat' import { formatTvdbSearchResults } from './tvdbSearchFormat' import { Path } from '@core/path' @@ -463,6 +464,79 @@ export async function runCli(argv: string[] = process.argv): Promise { }, ) + function registerTmdbGetCommand( + name: 'tv' | 'movie', + description: string, + fetch: ( + id: number, + options: { + language?: string + host?: string + password?: string + proxy?: string + }, + ) => Promise, + ) { + tmdbCmd + .command(name) + .description(description) + .argument('', 'TMDB id') + .addOption( + new Option('-f, --format ', 'Output format') + .choices(['json', 'default']) + .default('default'), + ) + .option('--host ', 'TMDB API base URL (overrides userConfig.tmdb.host)') + .option('--password ', 'TMDB API key (overrides userConfig.tmdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tmdb.httpProxy)') + .option( + '--lang ', + 'TMDB primary translation IETF tag (static list from /configuration/primary_translations, e.g. zh-CN, en-US, fr-FR); defaults from userConfig then OS locale', + ) + .action( + async ( + tmdbIdRaw: string, + opts: { + format?: string + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const id = Number(tmdbIdRaw) + if (!Number.isInteger(id) || id <= 0) { + console.error('id must be a positive integer') + exitCode = 1 + return + } + const details = await fetch(id, { + language: opts.lang, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + }) + if (opts.format === 'json') { + printJson(details) + return + } + console.log(formatTmdbDetailsTree(details)) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) + } + + registerTmdbGetCommand('tv', 'Get TMDB TV show details by id', (id, options) => + getCore().getTvShowInTmdb(id, options), + ) + registerTmdbGetCommand('movie', 'Get TMDB movie details by id', (id, options) => + getCore().getMovieInTmdb(id, options), + ) + const tvdbCmd = program.command('tvdb').description('TVDB helpers') tvdbCmd diff --git a/apps/cli/src/cli/tmdbGet.test.ts b/apps/cli/src/cli/tmdbGet.test.ts new file mode 100644 index 00000000..e4d0f367 --- /dev/null +++ b/apps/cli/src/cli/tmdbGet.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCli } from './runCli' + +const mockGetTvShowInTmdb = vi.fn() +const mockGetMovieInTmdb = vi.fn() + +vi.mock('../core/getCore', () => ({ + getCore: () => ({ + getTvShowInTmdb: mockGetTvShowInTmdb, + getMovieInTmdb: mockGetMovieInTmdb, + }), +})) + +const sampleTv = { + id: 83095, + name: 'Wataten', + genres: [{ id: 16, name: 'Animation' }], +} + +const sampleMovie = { + id: 550, + title: 'Fight Club', + genres: [{ id: 18, name: 'Drama' }], +} + +describe('smm tmdb tv / movie', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTvShowInTmdb.mockResolvedValue(sampleTv) + mockGetMovieInTmdb.mockResolvedValue(sampleMovie) + }) + + it('prints default tree for tv and forwards options', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli([ + 'node', + 'smm', + 'tmdb', + 'tv', + '83095', + '--lang', + 'zh-CN', + '--host', + 'https://example.test/3', + '--password', + 'key', + '--proxy', + 'socks5://127.0.0.1:1', + ]) + expect(code).toBe(0) + expect(mockGetTvShowInTmdb).toHaveBeenCalledWith(83095, { + language: 'zh-CN', + host: 'https://example.test/3', + password: 'key', + proxy: 'socks5://127.0.0.1:1', + }) + expect(logs.join('\n')).toContain('id: 83095') + expect(logs.join('\n')).toContain('name: Wataten') + expect(logs.join('\n')).toContain('genres:') + } finally { + console.log = origLog + } + }) + + it('prints pretty JSON for movie with -f json', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli(['node', 'smm', 'tmdb', 'movie', '550', '-f', 'json']) + expect(code).toBe(0) + expect(mockGetMovieInTmdb).toHaveBeenCalledWith(550, { + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + expect(JSON.parse(logs[0]!)).toEqual(sampleMovie) + } finally { + console.log = origLog + } + }) + + it('rejects invalid id without calling Core', async () => { + const errors: string[] = [] + const origError = console.error + console.error = (msg: string) => errors.push(msg) + try { + const code = await runCli(['node', 'smm', 'tmdb', 'tv', 'abc']) + expect(code).toBe(1) + expect(mockGetTvShowInTmdb).not.toHaveBeenCalled() + expect(errors.join('\n')).toMatch(/id/i) + } finally { + console.error = origError + } + }) +}) From 9fcce8f7076cd9652ce89b49a5f414c5878cfb4b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 13:06:23 +0800 Subject: [PATCH 132/214] test(e2e): cover smm tmdb tv/movie get and update TMDB docs Co-authored-by: Cursor --- apps/e2e/cli/base.ts | 72 ++- apps/e2e/cli/tmdb.test.ts | 130 +++++ docs/dev/tmdb.md | 149 +++-- .../plans/2026-08-25-cli-tmdb-tv-movie.md | 537 ++++++++++++++++++ 4 files changed, 816 insertions(+), 72 deletions(-) create mode 100644 apps/e2e/cli/tmdb.test.ts create mode 100644 docs/superpowers/plans/2026-08-25-cli-tmdb-tv-movie.md diff --git a/apps/e2e/cli/base.ts b/apps/e2e/cli/base.ts index 2c2602ca..efce804e 100644 --- a/apps/e2e/cli/base.ts +++ b/apps/e2e/cli/base.ts @@ -4,19 +4,73 @@ * Pass `binary` (path to `smm` executable). Data dirs are resolved via `smm hello -f json`, * not `GET /api/hello`. Browser-only wdio options are omitted. */ -export { - cleanupCore as cleanup, - setupCore as setup, - updateUserConfig, +import { + cleanupCore, + setupCore, + updateUserConfig as updateUserConfigCore, + type HelloPathsResolver, type ResetUserConfigOption, - type TestBedCoreCleanupOptions as CliTestBedCleanupOptions, - type TestBedCoreSetupOptions as CliTestBedSetupOptions, + type TestBedCoreCleanupOptions, + type TestBedCoreSetupOptions, type UserConfigUpdater, } from '../test/lib/testbed-core' +import { runCliHello } from '../test/lib/cli-hello' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' -export { runCliHello } from '../test/lib/cli-hello' +export type { ResetUserConfigOption, UserConfigUpdater } + +export type CliTestBedCleanupOptions = Omit & { + /** Path to the `smm` CLI executable. */ + binary: string +} + +export type CliTestBedSetupOptions = Omit & { + binary: string +} + +function cliHelloResolver(binary: string): HelloPathsResolver { + return async () => { + const body = await runCliHello(binary) + return { userDataDir: body.userDataDir, appDataDir: body.appDataDir } + } +} + +export async function cleanup(options: CliTestBedCleanupOptions): Promise { + const { binary, ...rest } = options + await cleanupCore({ + ...rest, + resolveHelloPaths: cliHelloResolver(binary), + }) +} + +export async function setup(options: CliTestBedSetupOptions): Promise { + const { binary, ...rest } = options + await setupCore({ + ...rest, + resolveHelloPaths: cliHelloResolver(binary), + }) +} + +export async function updateUserConfig( + updateFn: UserConfigUpdater, + options: { binary: string }, +): Promise { + await updateUserConfigCore(updateFn, { + resolveHelloPaths: cliHelloResolver(options.binary), + }) +} + +export { runCliHello } const isWindows = process.platform === 'win32' -/** Path to the compiled `smm` CLI executable (relative to `apps/e2e/cli`). */ -export const bin = `../../cli/dist/cli${isWindows ? '.exe' : ''}` +/** Absolute path to the compiled `smm` CLI executable. */ +export const bin = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'cli', + 'dist', + `cli${isWindows ? '.exe' : ''}`, +) diff --git a/apps/e2e/cli/tmdb.test.ts b/apps/e2e/cli/tmdb.test.ts new file mode 100644 index 00000000..b14e467c --- /dev/null +++ b/apps/e2e/cli/tmdb.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { setup, cleanup, bin } from './base' +import { requiredEnv } from './helpers' +import { $ } from 'bun' + +const FIVE_MINUTES_MS = 5 * 60 * 1000 +const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m +const TV_TITLE = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i +const KEYWORD = '天使降临到我身边' + +function officialTmdb(): { host: string; password: string; proxy: string } { + return { + host: requiredEnv('TMDB_HOST'), + password: requiredEnv('TMDB_API_KEY'), + proxy: requiredEnv('TMDB_HTTP_PROXY'), + } +} + +describe('tmdb search', () => { + + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('search through SMM-provided TMDB host', async () => { + + const ret = await $`${bin} tmdb search ${KEYWORD} --type tv`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('search through SMM-provided TMDB host and HTTP/SOCKS proxy', async () => { + + const { proxy } = officialTmdb() + const ret = await $`${bin} tmdb search ${KEYWORD} --type tv --proxy ${proxy}`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('search through custom TMDB host', async () => { + + const { host, password, proxy } = officialTmdb() + const ret = await $`${bin} tmdb search ${KEYWORD} --type tv --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('search through custom TMDB host and password', async () => { + + const { host, password, proxy } = officialTmdb() + const ret = await $`${bin} tmdb search ${KEYWORD} --type tv --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('search through custom TMDB host, password and HTTP proxy', async () => { + + const { host, password, proxy } = officialTmdb() + const ret = await $`${bin} tmdb search ${KEYWORD} --type tv --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) +}) + +describe('tmdb tv / movie get', () => { + + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('gets TV show details (default format) via SMM-provided host', async () => { + // Search result #1 for KEYWORD is TMDB id 84666 (WATATEN). + const ret = await $`${bin} tmdb tv 84666 --lang zh-CN`.nothrow() + expect(ret.exitCode).toBe(0) + const text = ret.text() + expect(text).toMatch(/id: 84666/) + expect(text).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('gets movie details as JSON via custom host', async () => { + const { host, password, proxy } = officialTmdb() + const ret = await $`${bin} tmdb movie 550 -f json --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.id).toBe(550) + expect(String(body.title ?? '')).toMatch(/Fight Club/i) + }, FIVE_MINUTES_MS) +}) diff --git a/docs/dev/tmdb.md b/docs/dev/tmdb.md index ec3a5c84..1308e2c7 100644 --- a/docs/dev/tmdb.md +++ b/docs/dev/tmdb.md @@ -1,86 +1,76 @@ -# TMDB 集成(v3) +# TMDB -SMM 通过 `apps/core` 统一访问 TMDB:**全部出站流量**经 `Core` + `NetworkPort`,不直连上游(浏览器侧见 [network-core.md](./network-core.md))。 +**Supported Platform** Web UI, CLI, Electron, ohos, AI tool, MCP tool +**Status** done -| 入口 | 状态 | 说明 | -|------|------|------| -| CLI | ✅ | `smm tmdb search` → 进程内 `Core.searchInTmdb` | -| AI Tool | ✅ | 应用内 Chat → 对应 HTTP API → Core;服务端 Chat 进程内注入 Core | -| MCP Tool | ✅ | MCP 工具进程内调用 Core 同名方法(与 HTTP 路由同一 Core) | -| Web UI | ⏳ | `smm.v3.enabled`:对应 HTTP API → Core 同名方法 | +## Overview -Core 方法与 Internal HTTP **一对一**暴露。Web UI(v3)与应用内 AI 走这些 API 再进入 Core;MCP / 服务端 Chat 在进程内调用同一套 Core 方法。均不走 `POST /api/core/fetch`(那是 `BrowserNetworkPort` 的通用出站中继,见 [network-core.md](./network-core.md))。 - -| Core 方法 | HTTP | +| Key Methods in Core Module | HTTP API | |-----------|------| | `searchInTmdb` | `POST /api/search-in-tmdb` | | `getMovieInTmdb` | `POST /api/get-movie-in-tmdb` | | `getTvShowInTmdb` | `POST /api/get-tvshow-in-tmdb` | ```mermaid -flowchart TB - CLI["apps/cli"] - WEB["apps/ui ⏳"] - AI["AI Tool ✅"] - MCP["MCP Tool ✅"] - - API["POST /api/search-in-tmdb
POST /api/get-movie-in-tmdb
POST /api/get-tvshow-in-tmdb"] - CORE["apps/core
searchInTmdb · getMovieInTmdb · getTvShowInTmdb"] - NET["NetworkPort
NodejsNetworkPort"] - TMDB["TMDB 上游"] - - CLI -->|进程内| CORE - WEB --> API - AI --> API - MCP --> API - API -->|一对一| CORE - CORE --> NET --> TMDB +sequenceDiagram + participant F as Frontend + participant S as Server + participant C as Core + participant TMDB + + F->>S: search + S->>C: searchInTmdb() + C->>TMDB: HTTP ``` -**配置**:host / apiKey / httpProxy 来自 `userConfig.tmdb`;各入口可临时覆盖(CLI flags、工具参数 `baseURL` 等)。 - ---- - -## apps/core API - -三个公开方法,共用 `createTmdbClient` 与 `NetworkPort` 出站。每个方法一对一暴露为 HTTP: - -| 方法 | HTTP | 用途 | -|------|------|------| -| `searchInTmdb(keyword, options)` | `POST /api/search-in-tmdb` | 按关键词搜索 TV / movie | -| `getMovieInTmdb(id, options)` | `POST /api/get-movie-in-tmdb` | 按 TMDB id 拉电影详情 | -| `getTvShowInTmdb(id, options)` | `POST /api/get-tvshow-in-tmdb` | 按 TMDB id 拉剧集详情 | - -```ts -core.searchInTmdb(keyword, { - type: "tv" | "movie", - language?: string, // 离线校验 packages/core/tmdbPrimaryTranslations.ts - host?: string, // 默认 userConfig.tmdb.host - password?: string, // 默认 userConfig.tmdb.apiKey - proxy?: string, // 默认 userConfig.tmdb.httpProxy -}) +## Web UI, Electron and ohos -core.getMovieInTmdb(id, { language?, host?, password?, proxy? }) -core.getTvShowInTmdb(id, { language?, host?, password?, proxy? }) +```mermaid +sequenceDiagram + participant U as User + participant W as Web UI + participant S as Server + participant C as Core + + U->>W: click search + W->>S: POST /api/search-in-tmdb + S->>C: searchInTmdb() + C->>S: search result + S->>W: search result + W->>U: display result + U->>W: select tvshow + W->>S: POST /api/get-tvshow-in-tmdb or /api/get-movie-in-tmdb + S->>C: getTvShowInTmdb()/getMovieInTmdb() + C->>S: tvshow details + S->>W: tvshow details + W->>U: display tvshow details ``` -- `TmdbClient` 经 `NetworkPort` 发请求;`reverseProxyUrl: null`,`proxy` 作为 `FetchInit.proxy`(非浏览器 reverse proxy)。 -- 显式 `language` 须在静态 primary translations 列表中;省略时按 `preferMediaLanguage` → OS → `en-US`。 - -实现:`apps/core/src/Core.ts`。 - ---- - ## CLI ### 命令 -目前仅暴露搜索子命令: +已暴露搜索与按 id 拉取详情: ```bash smm tmdb search "" --type tv|movie [options] +smm tmdb tv "" -f|--format json|default --lang "" [options] +smm tmdb movie "" -f|--format json|default --lang "" [options] +``` + +```mermaid +sequenceDiagram + participant U as User + participant CLI as CLI + participant C as Core + + U->>CLI: smm tmdb search + CLI->>C: searchInTmdb() + U->>CLI: smm tmdb tv/movie + CLI->>C: getTvShowInTmdb()/getMovieInTmdb() ``` + ### 输出格式 ```bash @@ -89,8 +79,26 @@ $ smm tmdb search "keyword" --type tv {overview} #2 {tmdbid2} {title2} ({release date}) {overview} + +$ smm tmdb tv 84666 +id: 84666 +name: ... +genres: + [0]: + id: ... + name: ... + +$ smm tmdb movie 550 -f json +{ + "id": 550, + "title": "Fight Club", + ... +} ``` +`default`:完整 TMDB 响应的缩进 key/value 树(含全部字段)。 +`json`:pretty-print JSON。 + ### 常用场景 ```bash @@ -108,19 +116,27 @@ smm tmdb search "keyword" --type tv \ --host "https://api.themoviedb.org/3" \ --password "your-api-key" \ --proxy "socks5://proxy.example.com:7079" + +# 按 id 拉详情 +smm tmdb tv 84666 --lang zh-CN +smm tmdb movie 550 -f json \ + --host "https://api.themoviedb.org/3" \ + --password "your-api-key" \ + --proxy "socks5://proxy.example.com:7079" ``` ### 参数 | 参数 | 说明 | |------|------| -| `--type` | `tv` \| `movie`(必填) | +| `--type` | `tv` \| `movie`(仅 `search`,必填) | +| `-f` / `--format` | `json` \| `default`(仅 `tv` / `movie`;省略为 `default`) | | `--lang` | TMDB primary translation(如 `zh-CN`);无效值离线报错 | | `--host` | 覆盖 `userConfig.tmdb.host` | | `--password` | 覆盖 `userConfig.tmdb.apiKey` | | `--proxy` | 覆盖 `userConfig.tmdb.httpProxy` | -实现:`apps/cli/src/cli/runCli.ts`(格式化:`tmdbSearchFormat.ts`)。 +实现:`apps/cli/src/cli/runCli.ts`(搜索格式化:`tmdbSearchFormat.ts`;详情格式化:`tmdbDetailsFormat.ts`)。 --- @@ -201,13 +217,20 @@ Web 不调用 `smm tmdb search`,但与 CLI 共用 Core 方法与 `userConfig.t | 范围 | 文件 | |------|------| -| CLI e2e | `apps/cli/test/tmdb.e2e.ts` — 文档「常用场景」四类搜索 | +| CLI e2e | `apps/e2e/cli/tmdb.test.ts` — 文档「常用场景」搜索 + `tmdb tv` / `tmdb movie` get-by-id | | core-routes 单测 | `packages/core-routes/src/tools/tmdb.test.ts` | | MCP e2e | `apps/e2e/common/mcp/McpOther-TmdbTools.e2e.ts` — 三工具 + live TMDB | | MCP 客户端 | `apps/e2e/test/lib/McpClient.ts` | MCP / 需直连 TMDB 的 e2e 在 `apps/e2e/.env.local` 配置 `TMDB_HOST`、`TMDB_API_KEY`、`TMDB_HTTP_PROXY`(网络受限环境)。 +运行 CLI e2e: + +```bash +cd apps/e2e/cli +bun test ./tmdb.test.ts +``` + 运行 MCP spec: ```bash diff --git a/docs/superpowers/plans/2026-08-25-cli-tmdb-tv-movie.md b/docs/superpowers/plans/2026-08-25-cli-tmdb-tv-movie.md new file mode 100644 index 00000000..1439ff6b --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-cli-tmdb-tv-movie.md @@ -0,0 +1,537 @@ +# CLI `smm tmdb tv` / `smm tmdb movie` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `smm tmdb tv ` and `smm tmdb movie ` CLI commands that call Core `getTvShowInTmdb` / `getMovieInTmdb` and print either a full human-readable field tree (`default`) or pretty JSON (`json`). + +**Architecture:** Mirror existing `smm tmdb search` in `runCli.ts`: Commander subcommands under `tmdb`, forward `--lang`/`--host`/`--password`/`--proxy` into Core `TmdbRequestOptions`, format stdout via a new pure helper. No new HTTP/MCP/AI surface. + +**Tech Stack:** TypeScript, Commander 15, Vitest (unit), Bun test under `apps/e2e/cli` (live e2e), Core in-process via `getCore()`. + +**Spec:** `docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md` + +## Global Constraints + +- Connection flags must match `search`: `--lang`, `--host`, `--password`, `--proxy` → Core `{ language, host, password, proxy }`. +- `--format` / `-f` choices: `json` | `default`; omitted → `default`. +- `default` prints **all** enumerable fields (no curated subset); `json` uses existing `printJson` (`JSON.stringify(..., null, 2)`). +- Invalid id (non-integer / ≤ 0): stderr + exit 1. +- Prefer extending `apps/e2e/cli/tmdb.test.ts` for live e2e. +- Follow red-green unit testing: fail first, then implement. + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/cli/src/cli/tmdbDetailsFormat.ts` | Pure `formatTmdbDetailsTree(value: unknown): string` | +| `apps/cli/src/cli/tmdbDetailsFormat.test.ts` | Unit tests for tree formatter | +| `apps/cli/src/cli/runCli.ts` | Register `tmdb tv` / `tmdb movie` actions | +| `apps/cli/src/cli/tmdbGet.test.ts` | Command unit tests with mocked `getCore` | +| `apps/e2e/cli/tmdb.test.ts` | Live e2e for tv/movie get (+ existing search) | +| `docs/dev/tmdb.md` | Document tv/movie CLI usage, formats, params | + +--- + +### Task 1: Human-readable details tree formatter + +**Files:** +- Create: `apps/cli/src/cli/tmdbDetailsFormat.ts` +- Test: `apps/cli/src/cli/tmdbDetailsFormat.test.ts` + +**Interfaces:** +- Consumes: nothing (pure) +- Produces: `export function formatTmdbDetailsTree(value: unknown): string` + +- [ ] **Step 1: Write the failing test** + +Create `apps/cli/src/cli/tmdbDetailsFormat.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest' +import { formatTmdbDetailsTree } from './tmdbDetailsFormat' + +describe('formatTmdbDetailsTree', () => { + it('formats primitives, null, nested objects, and arrays', () => { + const text = formatTmdbDetailsTree({ + id: 83095, + name: 'Wataten', + overview: null, + missing: undefined, + genres: [{ id: 16, name: 'Animation' }], + episode_run_time: [24], + }) + expect(text).toBe( + [ + 'id: 83095', + 'name: Wataten', + 'overview: null', + 'genres:', + ' [0]:', + ' id: 16', + ' name: Animation', + 'episode_run_time:', + ' [0]: 24', + ].join('\n'), + ) + }) + + it('formats a bare non-object as a single line', () => { + expect(formatTmdbDetailsTree(42)).toBe('42') + expect(formatTmdbDetailsTree(null)).toBe('null') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/cli && pnpm exec vitest run src/cli/tmdbDetailsFormat.test.ts` + +Expected: FAIL (module not found / `formatTmdbDetailsTree` not exported) + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/cli/src/cli/tmdbDetailsFormat.ts`: + +```typescript +/** + * Format a TMDB details payload as an indented key/value tree for CLI stdout. + * Prints every enumerable field; omits `undefined` keys; prints `null` as `null`. + */ +export function formatTmdbDetailsTree(value: unknown): string { + if (value === null || typeof value !== 'object') { + return formatScalar(value) + } + return formatObjectOrArray(value, 0).join('\n') +} + +function formatScalar(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'string') return value + return String(value) +} + +function indent(depth: number): string { + return ' '.repeat(depth) +} + +function formatObjectOrArray(value: object, depth: number): string[] { + const lines: string[] = [] + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + lines.push(...formatEntry(`[${i}]`, value[i], depth)) + } + return lines + } + for (const [key, child] of Object.entries(value as Record)) { + if (child === undefined) continue + lines.push(...formatEntry(key, child, depth)) + } + return lines +} + +function formatEntry(key: string, value: unknown, depth: number): string[] { + const prefix = `${indent(depth)}${key}:` + if (value !== null && typeof value === 'object') { + const children = formatObjectOrArray(value, depth + 1) + if (children.length === 0) { + return [`${prefix}`] + } + return [`${prefix}`, ...children] + } + return [`${prefix} ${formatScalar(value)}`] +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/cli && pnpm exec vitest run src/cli/tmdbDetailsFormat.test.ts` + +Expected: PASS (2 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/cli/src/cli/tmdbDetailsFormat.ts apps/cli/src/cli/tmdbDetailsFormat.test.ts +git commit -m "$(cat <<'EOF' +feat(cli): add TMDB details tree formatter for tmdb tv/movie + +EOF +)" +``` + +--- + +### Task 2: Wire `tmdb tv` and `tmdb movie` in `runCli` + +**Files:** +- Modify: `apps/cli/src/cli/runCli.ts` (after existing `tmdb search` block, before `tvdbCmd`) +- Create: `apps/cli/src/cli/tmdbGet.test.ts` + +**Interfaces:** +- Consumes: `formatTmdbDetailsTree` from `./tmdbDetailsFormat`; `getCore().getTvShowInTmdb` / `getMovieInTmdb` +- Produces: Commander actions for `smm tmdb tv` and `smm tmdb movie` + +- [ ] **Step 1: Write the failing command tests** + +Create `apps/cli/src/cli/tmdbGet.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCli } from './runCli' + +const mockGetTvShowInTmdb = vi.fn() +const mockGetMovieInTmdb = vi.fn() + +vi.mock('../core/getCore', () => ({ + getCore: () => ({ + getTvShowInTmdb: mockGetTvShowInTmdb, + getMovieInTmdb: mockGetMovieInTmdb, + }), +})) + +const sampleTv = { + id: 83095, + name: 'Wataten', + genres: [{ id: 16, name: 'Animation' }], +} + +const sampleMovie = { + id: 550, + title: 'Fight Club', + genres: [{ id: 18, name: 'Drama' }], +} + +describe('smm tmdb tv / movie', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTvShowInTmdb.mockResolvedValue(sampleTv) + mockGetMovieInTmdb.mockResolvedValue(sampleMovie) + }) + + it('prints default tree for tv and forwards options', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli([ + 'node', + 'smm', + 'tmdb', + 'tv', + '83095', + '--lang', + 'zh-CN', + '--host', + 'https://example.test/3', + '--password', + 'key', + '--proxy', + 'socks5://127.0.0.1:1', + ]) + expect(code).toBe(0) + expect(mockGetTvShowInTmdb).toHaveBeenCalledWith(83095, { + language: 'zh-CN', + host: 'https://example.test/3', + password: 'key', + proxy: 'socks5://127.0.0.1:1', + }) + expect(logs.join('\n')).toContain('id: 83095') + expect(logs.join('\n')).toContain('name: Wataten') + expect(logs.join('\n')).toContain('genres:') + } finally { + console.log = origLog + } + }) + + it('prints pretty JSON for movie with -f json', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli(['node', 'smm', 'tmdb', 'movie', '550', '-f', 'json']) + expect(code).toBe(0) + expect(mockGetMovieInTmdb).toHaveBeenCalledWith(550, { + language: undefined, + host: undefined, + password: undefined, + proxy: undefined, + }) + expect(JSON.parse(logs[0]!)).toEqual(sampleMovie) + } finally { + console.log = origLog + } + }) + + it('rejects invalid id without calling Core', async () => { + const errors: string[] = [] + const origError = console.error + console.error = (msg: string) => errors.push(msg) + try { + const code = await runCli(['node', 'smm', 'tmdb', 'tv', 'abc']) + expect(code).toBe(1) + expect(mockGetTvShowInTmdb).not.toHaveBeenCalled() + expect(errors.join('\n')).toMatch(/id/i) + } finally { + console.error = origError + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/cli && pnpm exec vitest run src/cli/tmdbGet.test.ts` + +Expected: FAIL (unknown command `tv` / `movie`, or Core never called as asserted) + +- [ ] **Step 3: Implement commands in `runCli.ts`** + +1. Add import: + +```typescript +import { formatTmdbDetailsTree } from './tmdbDetailsFormat' +``` + +2. Immediately after the `tmdbCmd.command('search')...` block (before `const tvdbCmd = ...`), add: + +```typescript + function registerTmdbGetCommand( + name: 'tv' | 'movie', + description: string, + fetch: ( + id: number, + options: { + language?: string + host?: string + password?: string + proxy?: string + }, + ) => Promise, + ) { + tmdbCmd + .command(name) + .description(description) + .argument('', 'TMDB id') + .addOption( + new Option('-f, --format ', 'Output format') + .choices(['json', 'default']) + .default('default'), + ) + .option('--host ', 'TMDB API base URL (overrides userConfig.tmdb.host)') + .option('--password ', 'TMDB API key (overrides userConfig.tmdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tmdb.httpProxy)') + .option( + '--lang ', + 'TMDB primary translation IETF tag (static list from /configuration/primary_translations, e.g. zh-CN, en-US, fr-FR); defaults from userConfig then OS locale', + ) + .action( + async ( + tmdbIdRaw: string, + opts: { + format?: string + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const id = Number(tmdbIdRaw) + if (!Number.isInteger(id) || id <= 0) { + console.error('id must be a positive integer') + exitCode = 1 + return + } + const details = await fetch(id, { + language: opts.lang, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + }) + if (opts.format === 'json') { + printJson(details) + return + } + console.log(formatTmdbDetailsTree(details)) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) + } + + registerTmdbGetCommand('tv', 'Get TMDB TV show details by id', (id, options) => + getCore().getTvShowInTmdb(id, options), + ) + registerTmdbGetCommand('movie', 'Get TMDB movie details by id', (id, options) => + getCore().getMovieInTmdb(id, options), + ) +``` + +- [ ] **Step 4: Run unit tests** + +Run: + +```bash +cd apps/cli && pnpm exec vitest run src/cli/tmdbGet.test.ts src/cli/tmdbDetailsFormat.test.ts +``` + +Expected: PASS + +If `mockGetMovieInTmdb` call options fail because Commander omits undefined keys differently, assert with `expect.objectContaining` or match the exact object Core receives (prefer exact parity with how `search` passes options — include `undefined` fields explicitly in the call object as shown above). + +- [ ] **Step 5: Commit** + +```bash +git add apps/cli/src/cli/runCli.ts apps/cli/src/cli/tmdbGet.test.ts +git commit -m "$(cat <<'EOF' +feat(cli): add smm tmdb tv and tmdb movie get-by-id commands + +EOF +)" +``` + +--- + +### Task 3: Live e2e + docs + +**Files:** +- Modify: `apps/e2e/cli/tmdb.test.ts` +- Modify: `docs/dev/tmdb.md` + +**Interfaces:** +- Consumes: compiled `bin` from `apps/e2e/cli/base.ts`; live TMDB via `requiredEnv` +- Produces: e2e coverage for get-by-id; updated developer docs + +- [ ] **Step 1: Extend live e2e** + +Append to `apps/e2e/cli/tmdb.test.ts` (keep existing search suite). Use well-known IDs: + +- TV: `83095` (天使降临到我身边 / Wataten) — same title family as search keyword +- Movie: `550` (Fight Club) — classic stable TMDB id + +```typescript +describe('tmdb tv / movie get', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('gets TV show details (default format) via SMM-provided host', async () => { + const ret = await $`${bin} tmdb tv 83095 --lang zh-CN`.nothrow() + expect(ret.exitCode).toBe(0) + const text = ret.text() + expect(text).toMatch(/id: 83095/) + expect(text).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('gets movie details as JSON via custom host', async () => { + const { host, password, proxy } = officialTmdb() + const ret = await $`${bin} tmdb movie 550 -f json --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.id).toBe(550) + expect(String(body.title ?? '')).toMatch(/Fight Club/i) + }, FIVE_MINUTES_MS) +}) +``` + +- [ ] **Step 2: Update `docs/dev/tmdb.md` CLI section** + +Replace “目前仅暴露搜索子命令:” with text that search + tv + movie are exposed. + +Under **输出格式**, add: + +```bash +$ smm tmdb tv 83095 +id: 83095 +name: ... +genres: + [0]: + id: ... + name: ... + +$ smm tmdb movie 550 -f json +{ + "id": 550, + "title": "Fight Club", + ... +} +``` + +Under **常用场景**, add: + +```bash +smm tmdb tv 83095 --lang zh-CN +smm tmdb movie 550 -f json --host "https://api.themoviedb.org/3" --password "your-api-key" --proxy "socks5://proxy.example.com:7079" +``` + +Update **参数** table: note `--type` is search-only; `--format` applies to `tv`/`movie` (`json`|`default`). + +Update implementation line to mention `tmdbDetailsFormat.ts`. + +Update **测试** table row for CLI e2e to mention get-by-id cases in `apps/e2e/cli/tmdb.test.ts`. + +- [ ] **Step 3: Run live e2e (requires built CLI + env)** + +Prereq: CLI binary built (`pnpm` / existing build so `apps/cli/dist/cli` exists), and `apps/e2e/.env.local` (or env) has `TMDB_HOST`, `TMDB_API_KEY`, `TMDB_HTTP_PROXY` for the custom-host case. + +Run: + +```bash +cd apps/e2e && bun test ./cli/tmdb.test.ts +``` + +Expected: all search + new get tests PASS. + +Also re-run unit tests: + +```bash +cd apps/cli && pnpm exec vitest run src/cli/tmdbDetailsFormat.test.ts src/cli/tmdbGet.test.ts +``` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/e2e/cli/tmdb.test.ts docs/dev/tmdb.md +git commit -m "$(cat <<'EOF' +test(e2e): cover smm tmdb tv/movie get and update TMDB docs + +EOF +)" +``` + +--- + +## Self-Review + +| Spec requirement | Task | +|------------------|------| +| `smm tmdb tv` / `movie` with format + lang | Task 2 | +| `--host` / `--password` / `--proxy` parity | Task 2 | +| `default` = full human-readable tree | Task 1 + 2 | +| `json` = pretty JSON via `printJson` | Task 2 | +| Invalid id → exit 1 | Task 2 unit test | +| Live e2e | Task 3 | +| Docs update | Task 3 | +| No MCP/HTTP/AI changes | Out of scope (honored) | + +No placeholders left. Types: `formatTmdbDetailsTree(value: unknown): string`; Core `(id: number, options?: TmdbRequestOptions) => Promise`. From 70bb332c340b904ae3ffc57e0d88f813dbbd1fdf Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 14:27:55 +0800 Subject: [PATCH 133/214] docs: add design for smm tvdb tv/movie raw API CLI commands Co-authored-by: Cursor --- .../2026-08-25-cli-tvdb-tv-movie-design.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-cli-tvdb-tv-movie-design.md diff --git a/docs/superpowers/specs/2026-08-25-cli-tvdb-tv-movie-design.md b/docs/superpowers/specs/2026-08-25-cli-tvdb-tv-movie-design.md new file mode 100644 index 00000000..fd84e892 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-cli-tvdb-tv-movie-design.md @@ -0,0 +1,141 @@ +# CLI `smm tvdb tv` / `smm tvdb movie` + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +`smm tmdb tv` / `smm tmdb movie` already expose **raw upstream TMDB** payloads via Core (`getTvShowInTmdb` / `getMovieInTmdb`). Users need the same for TVDB: CLI commands that talk to the TVDB v4 API, unrelated to local media folders or SMM `MediaMetadata`. + +Today: + +- `smm tvdb search` is implemented and returns raw search hits. +- `Core.getTvShowInTvdb` / `getMovieInTvdb` build **`TvShowMediaMetadata` / `MovieMediaMetadata`** for recognize / AI / MCP — **not** suitable for a raw-API CLI dump. + +TVDB language handling differs from TMDB: + +| | TMDB | TVDB | +|--|------|------| +| `--lang` format | IETF (`zh-CN`) | ISO 639-3 (`zho`, `eng`, `yue`) | +| How language applies on get | Same details request (`?language=`) | Separate translation endpoint; extended record is language-agnostic | +| Invalid explicit lang | Offline reject via TMDB primary_translations list | Offline reject via `parseTvdbSearchLanguage` (e.g. `zh-CN` fails) | + +## 2. Architecture + +## 2.1 Project Level Architecture + +```mermaid +sequenceDiagram + participant U as User + participant CLI as apps/cli runCli + participant C as Core + participant Client as TvdbClient + participant TVDB as TVDB v4 API + + U->>CLI: smm tvdb tv|movie <id> [--lang zho] ... + CLI->>C: getTvdbSeriesById / getTvdbMovieById + C->>Client: createTvdbClient (ISO 639-3 language) + Client->>TVDB: GET .../extended + Client->>TVDB: GET .../translations/{lang} + Client-->>C: { extended, translation } + C-->>CLI: { extended, translation } + CLI-->>U: default tree or pretty JSON +``` + +No change to existing MediaMetadata getters used by recognize / AI / MCP / HTTP. + +## 2.2 App Level Architecture + +| Piece | Location | +|-------|----------| +| New Core methods | `apps/core/src/Core.ts` (+ export from `apps/core` as needed) | +| Raw fetch helpers | Prefer existing `TvdbClient.getSeriesExtended` / `getMovieExtended` + `getSeriesTranslation` / `getMovieTranslation` | +| CLI commands | `apps/cli/src/cli/runCli.ts` under `tvdb` | +| Output | Reuse `formatTmdbDetailsTree` (generic tree) + `printJson` | +| CLI unit tests | `apps/cli/src/cli/tvdbGet.test.ts` | +| Live e2e | `apps/e2e/cli/tvdb.test.ts` | +| Docs | `docs/dev/tvdb.md` | + +## 2.3 Key Design + +### Command surface + +```bash +smm tvdb tv [-f|--format json|default] [--lang ] [--host ] [--password ] [--proxy ] +smm tvdb movie [-f|--format json|default] [--lang ] [--host ] [--password ] [--proxy ] +``` + +- `tvdbid`: positive integer; invalid → stderr + exit 1. +- `--format` / `-f`: `json` \| `default`; omitted → `default`. +- `--lang`: ISO 639-3 only; validated by `parseTvdbSearchLanguage`. Omitted → `resolveTvdbSearchLanguage` (preferMediaLanguage → OS → `eng`). +- `--host` / `--password` / `--proxy`: same overrides as `smm tvdb search`. + +### Core return shape (raw API, not MediaMetadata) + +```ts +type TvdbByIdResult = { + extended: unknown // series or movie extended `data` + translation: unknown | null // translations/{lang} `data`, or null if unavailable +} +``` + +- Series: `seriesExtendedById` + `seriesTranslationByLangCode` +- Movie: `movieExtendedById` + `movieTranslationByLangCode` +- Missing / failed **extended** → throw (CLI maps to exit 1) +- Missing translation → `translation: null` (still success if extended exists) + +Method names (recommended): + +- `getTvdbSeriesById(id, options?: TvdbRequestOptions): Promise` +- `getTvdbMovieById(id, options?: TvdbRequestOptions): Promise` + +Do **not** rename or change behavior of `getTvShowInTvdb` / `getMovieInTvdb`. + +### Output formats + +- `json`: `printJson(result)` of the full `{ extended, translation }` object. +- `default`: `formatTmdbDetailsTree(result)` (indented key/value tree of all enumerable fields). + +### Out of scope + +- New HTTP / AI / MCP tools for these raw methods (can follow later) +- Changing MediaMetadata builders or search output +- Accepting IETF tags on `--lang` (must stay ISO 639-3) + +## 3. User Stories + +### 3.1 Fetch series (default + ISO 639-3) + +* **Given** - Core can reach TVDB +* **When** - `smm tvdb tv --lang zho` +* **Then** - exit 0; stdout tree includes `extended:` and `translation:`; translated name (when available) matches expected title family; payload is **not** MediaMetadata (`database` key absent at top level) + +### 3.2 Fetch movie as JSON + +* **Given** - Core can reach TVDB +* **When** - `smm tvdb movie -f json` +* **Then** - exit 0; stdout is pretty JSON with `extended` and `translation` keys + +### 3.3 Custom host / proxy + +* **Given** - restricted network needs official host + proxy +* **When** - `smm tvdb tv --host ... --password ... --proxy ...` +* **Then** - options forwarded like search; success prints raw payload + +### 3.4 Invalid id / invalid lang + +* **Given** - bad inputs +* **When** - `smm tvdb tv abc` or `smm tvdb tv --lang zh-CN` +* **Then** - exit 1; stderr explains positive integer id or unsupported ISO 639-3 language + +```mermaid +sequenceDiagram + participant U as User + participant CLI as runCli + participant C as Core + + U->>CLI: tvdb tv 123 --lang zh-CN + CLI->>C: getTvdbSeriesById(123, { language: "zh-CN" }) + C-->>CLI: throw Unsupported language "zh-CN"... + CLI-->>U: stderr + exit 1 +``` From 1c6c820640d27d0632723a4c21256c4ceb492ab9 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 14:35:00 +0800 Subject: [PATCH 134/214] feat(core): add getTvdbSeriesById/getTvdbMovieById raw API helpers Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 62 ++++++++++++++++++++++++++++++++++++++ apps/core/src/Core.ts | 40 ++++++++++++++++++++++++ apps/core/src/index.ts | 2 ++ 3 files changed, 104 insertions(+) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index f6ba3ec4..dbba2f9d 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -1358,3 +1358,65 @@ describe("Core.getTvShowInTvdb / getMovieInTvdb / getTvdbLanguages", () => { await expect(core.getMovieInTvdb(-1)).rejects.toThrow(/positive integer/); }); }); + +describe("Core.getTvdbSeriesById / getTvdbMovieById", () => { + it("returns raw extended + translation for series", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const result = await core.getTvdbSeriesById(1, { language: "eng" }); + expect(result.extended).toMatchObject({ id: 1, name: "My Show" }); + expect(result.translation).toEqual({ name: "My Show" }); + expect(result).not.toHaveProperty("database"); + }); + + it("returns raw extended + translation for movie", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const result = await core.getTvdbMovieById(2, { language: "eng" }); + expect(result.extended).toMatchObject({ id: 2, name: "My Film" }); + expect(result.translation).toEqual({ name: "My Film" }); + }); + + it("rejects IETF language tags (ISO 639-3 only)", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.getTvdbSeriesById(1, { language: "zh-CN" })).rejects.toThrow(/ISO 639-3/); + }); + + it("validates id as a positive integer", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.getTvdbSeriesById(0)).rejects.toThrow(/positive integer/); + await expect(core.getTvdbMovieById(-1)).rejects.toThrow(/positive integer/); + }); + + it("sets translation null when translation endpoint has no data", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { + fetch: async (url) => { + if (url.includes("/translations/zho")) { + return jsonResponse({ status: "success", data: null }); + } + return tvdbResponse(url); + }, + }, + appDataDir: "/data/smm", + }); + const result = await core.getTvdbSeriesById(1, { language: "zho" }); + expect(result.extended).toMatchObject({ id: 1 }); + expect(result.translation).toBeNull(); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index e867875e..a814057f 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -87,6 +87,12 @@ export interface TvdbRequestOptions { proxy?: string; } +/** Raw TVDB get-by-id payload for CLI / API inspection (not MediaMetadata). */ +export type TvdbByIdResult = { + extended: unknown; + translation: unknown | null; +}; + export interface SearchInTvdbOptions extends TvdbRequestOptions { type: "series" | "movie"; } @@ -467,6 +473,40 @@ export class Core { return metadata; } + /** + * Fetch raw TVDB series extended + translation payloads (not MediaMetadata). + * `language` is ISO 639-3 (CLI `--lang`); translation may be null if unavailable. + */ + async getTvdbSeriesById(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const extended = await client.getSeriesExtended(id); + if (!extended) { + throw new Error(`Failed to get TVDB series ${id}`); + } + const translation = (await client.getSeriesTranslation(id, language)) ?? null; + return { extended, translation }; + } + + /** + * Fetch raw TVDB movie extended + translation payloads (not MediaMetadata). + * `language` is ISO 639-3 (CLI `--lang`); translation may be null if unavailable. + */ + async getTvdbMovieById(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const extended = await client.getMovieExtended(id); + if (!extended) { + throw new Error(`Failed to get TVDB movie ${id}`); + } + const translation = (await client.getMovieTranslation(id, language)) ?? null; + return { extended, translation }; + } + /** Fetch the TVDB supported language list via {@link NetworkPort}. */ async getTvdbLanguages(options: TvdbRequestOptions = {}): Promise { const { client } = await this.createTvdbClient(options, false); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index dc390f4c..4efc10f6 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -9,6 +9,8 @@ export { type ScrapeFolderResult, type SearchInTmdbOptions, type TmdbRequestOptions, + type TvdbByIdResult, + type TvdbRequestOptions, } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; From a45c53082dab3f98ec72c51822d575d85c71dddc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 14:35:36 +0800 Subject: [PATCH 135/214] feat(cli): add smm tvdb tv and tvdb movie raw get-by-id commands Co-authored-by: Cursor --- apps/cli/src/cli/runCli.ts | 73 +++++++++++++++++++++++++ apps/cli/src/cli/tvdbGet.test.ts | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 apps/cli/src/cli/tvdbGet.test.ts diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 319fc219..5868de4a 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -583,6 +583,79 @@ export async function runCli(argv: string[] = process.argv): Promise { }, ) + function registerTvdbGetCommand( + name: 'tv' | 'movie', + description: string, + fetch: ( + id: number, + options: { + language?: string + host?: string + password?: string + proxy?: string + }, + ) => Promise, + ) { + tvdbCmd + .command(name) + .description(description) + .argument('', 'TVDB id') + .addOption( + new Option('-f, --format ', 'Output format') + .choices(['json', 'default']) + .default('default'), + ) + .option('--host ', 'TVDB API base URL (overrides userConfig.tvdb.host)') + .option('--password ', 'TVDB API key (overrides userConfig.tvdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tvdb.httpProxy)') + .option( + '--lang ', + 'TVDB ISO 639-3 language code (static list, e.g. eng, zho, yue); defaults from userConfig then OS locale', + ) + .action( + async ( + tvdbIdRaw: string, + opts: { + format?: string + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const id = Number(tvdbIdRaw) + if (!Number.isInteger(id) || id <= 0) { + console.error('id must be a positive integer') + exitCode = 1 + return + } + const details = await fetch(id, { + language: opts.lang, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + }) + if (opts.format === 'json') { + printJson(details) + return + } + console.log(formatTmdbDetailsTree(details)) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) + } + + registerTvdbGetCommand('tv', 'Get TVDB series details by id (raw API)', (id, options) => + getCore().getTvdbSeriesById(id, options), + ) + registerTvdbGetCommand('movie', 'Get TVDB movie details by id (raw API)', (id, options) => + getCore().getTvdbMovieById(id, options), + ) + const configCmd = program.command('config').description('Read or write user config (smm.json)') configCmd diff --git a/apps/cli/src/cli/tvdbGet.test.ts b/apps/cli/src/cli/tvdbGet.test.ts new file mode 100644 index 00000000..f184c779 --- /dev/null +++ b/apps/cli/src/cli/tvdbGet.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCli } from './runCli' + +const mockGetTvdbSeriesById = vi.fn() +const mockGetTvdbMovieById = vi.fn() + +vi.mock('../core/getCore', () => ({ + getCore: () => ({ + getTvdbSeriesById: mockGetTvdbSeriesById, + getTvdbMovieById: mockGetTvdbMovieById, + }), +})) + +const sampleSeries = { + extended: { id: 355969, name: 'Wataten' }, + translation: { name: '天使降临到我身边!' }, +} + +const sampleMovie = { + extended: { id: 116, name: 'The Dark Knight' }, + translation: { name: 'The Dark Knight' }, +} + +describe('smm tvdb tv / movie', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTvdbSeriesById.mockResolvedValue(sampleSeries) + mockGetTvdbMovieById.mockResolvedValue(sampleMovie) + }) + + it('prints default tree for tv and forwards ISO 639-3 lang + connection options', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli([ + 'node', + 'smm', + 'tvdb', + 'tv', + '355969', + '--lang', + 'zho', + '--host', + 'https://tvdb.example/v4', + '--password', + 'key', + '--proxy', + 'socks5://127.0.0.1:1', + ]) + expect(code).toBe(0) + expect(mockGetTvdbSeriesById).toHaveBeenCalledWith(355969, { + language: 'zho', + host: 'https://tvdb.example/v4', + password: 'key', + proxy: 'socks5://127.0.0.1:1', + }) + const out = logs.join('\n') + expect(out).toContain('extended:') + expect(out).toContain('translation:') + expect(out).not.toMatch(/^database:/m) + } finally { + console.log = origLog + } + }) + + it('prints pretty JSON for movie with -f json', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli(['node', 'smm', 'tvdb', 'movie', '116', '-f', 'json']) + expect(code).toBe(0) + expect(JSON.parse(logs[0]!)).toEqual(sampleMovie) + } finally { + console.log = origLog + } + }) + + it('rejects invalid id without calling Core', async () => { + const errors: string[] = [] + const origError = console.error + console.error = (msg: string) => errors.push(msg) + try { + const code = await runCli(['node', 'smm', 'tvdb', 'tv', 'abc']) + expect(code).toBe(1) + expect(mockGetTvdbSeriesById).not.toHaveBeenCalled() + expect(errors.join('\n')).toMatch(/id/i) + } finally { + console.error = origError + } + }) +}) From 03a947d6d3cd29507734f33d7a840bb581071125 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 14:38:00 +0800 Subject: [PATCH 136/214] test(e2e): cover smm tvdb tv/movie get and update TVDB docs Co-authored-by: Cursor --- apps/e2e/cli/tvdb.test.ts | 139 +++++ docs/dev/tvdb.md | 46 +- .../plans/2026-08-25-cli-tvdb-tv-movie.md | 523 ++++++++++++++++++ 3 files changed, 703 insertions(+), 5 deletions(-) create mode 100644 apps/e2e/cli/tvdb.test.ts create mode 100644 docs/superpowers/plans/2026-08-25-cli-tvdb-tv-movie.md diff --git a/apps/e2e/cli/tvdb.test.ts b/apps/e2e/cli/tvdb.test.ts new file mode 100644 index 00000000..e12b4e14 --- /dev/null +++ b/apps/e2e/cli/tvdb.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { setup, cleanup, bin } from './base' +import { requiredEnv } from './helpers' +import { $ } from 'bun' + +const FIVE_MINUTES_MS = 5 * 60 * 1000 +const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m +// TVDB search returns the primary (often Japanese) title and the CLI prints +// only `item.name` — WATATEN's TVDB primary title is "私に天使が舞い降りた!". +const SERIES_TITLE = /天使|WATATEN|Angel Flew Down|Wataten/i +const KEYWORD = '天使降临到我身边' + +function officialTvdb(): { host: string; password: string; proxy: string } { + return { + host: requiredEnv('TVDB_HOST'), + password: requiredEnv('TVDB_API_KEY'), + proxy: requiredEnv('TVDB_HTTP_PROXY'), + } +} + +describe('tvdb search', () => { + + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('search through SMM-provided TVDB host', async () => { + + const ret = await $`${bin} tvdb search ${KEYWORD} --type series`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(SERIES_TITLE) + }, FIVE_MINUTES_MS) + + it('search through SMM-provided TVDB host and HTTP/SOCKS proxy', async () => { + + const { proxy } = officialTvdb() + const ret = await $`${bin} tvdb search ${KEYWORD} --type series --proxy ${proxy}`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(SERIES_TITLE) + }, FIVE_MINUTES_MS) + + it('search through custom TVDB host with password and proxy', async () => { + + const { host, password, proxy } = officialTvdb() + const ret = await $`${bin} tvdb search ${KEYWORD} --type series --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toMatch(RESULT_HEADER) + expect(ret.text()).toMatch(SERIES_TITLE) + }, FIVE_MINUTES_MS) +}) + +describe('tvdb tv / movie get', () => { + + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('gets series details (default) via SMM host with --lang zho', async () => { + // Wataten / 天使降临到我身边 — same id as nfo recognition fixtures. + const ret = await $`${bin} tvdb tv 355969 --lang zho`.nothrow() + expect(ret.exitCode).toBe(0) + const text = ret.text() + expect(text).toMatch(/extended:/) + expect(text).toMatch(/translation:/) + expect(text).toMatch(/id: 355969/) + expect(text).toMatch(SERIES_TITLE) + expect(text).not.toMatch(/^database: TVDB$/m) + }, FIVE_MINUTES_MS) + + it('gets series as JSON via custom host', async () => { + const { host, password, proxy } = officialTvdb() + const ret = await $`${bin} tvdb tv 355969 -f json --lang zho --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.extended).toBeTruthy() + expect(body.extended.id).toBe(355969) + expect(body).toHaveProperty('translation') + expect(body).not.toHaveProperty('database') + }, FIVE_MINUTES_MS) + + it('gets movie details as JSON via SMM host', async () => { + const ret = await $`${bin} tvdb movie 116 -f json --lang eng`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.extended.id).toBe(116) + expect(String(body.extended.name ?? body.translation?.name ?? '')).toMatch(/Dark Knight/i) + }, FIVE_MINUTES_MS) + + it('rejects IETF --lang zh-CN', async () => { + const ret = await $`${bin} tvdb tv 355969 --lang zh-CN`.nothrow() + expect(ret.exitCode).toBe(1) + const err = ret.stderr.toString() || ret.text() + expect(err).toMatch(/ISO 639-3/i) + }, FIVE_MINUTES_MS) + + it('rejects invalid id', async () => { + const ret = await $`${bin} tvdb tv abc`.nothrow() + expect(ret.exitCode).toBe(1) + const err = ret.stderr.toString() || ret.text() + expect(err).toMatch(/id must be a positive integer/i) + }, FIVE_MINUTES_MS) +}) diff --git a/docs/dev/tvdb.md b/docs/dev/tvdb.md index 3087960f..b5e89a60 100644 --- a/docs/dev/tvdb.md +++ b/docs/dev/tvdb.md @@ -80,12 +80,17 @@ core.getTvdbLanguages({ host?, password?, proxy? }) ### 命令 -仅暴露搜索子命令: +已暴露搜索与按 id 拉取**原始 TVDB API**详情(与本地 MediaMetadata / `getTvShowInTvdb` 无关): ```bash smm tvdb search "" --type series|movie [options] +smm tvdb tv "" -f|--format json|default --lang "" [options] +smm tvdb movie "" -f|--format json|default --lang "" [options] ``` +Core:`getTvdbSeriesById` / `getTvdbMovieById` → `{ extended, translation }`。 +勿与 `getTvShowInTvdb` / `getMovieInTvdb`(构建 MediaMetadata)混淆。 + ### 输出格式 ```bash @@ -94,8 +99,24 @@ $ smm tvdb search "keyword" --type series {overview} #2 {tvdbid2} {title2} ({release date}) {overview} + +$ smm tvdb tv 355969 --lang zho +extended: + id: 355969 + name: ... +translation: + name: ... + +$ smm tvdb movie 116 -f json --lang eng +{ + "extended": { "id": 116, "name": "The Dark Knight", ... }, + "translation": { "name": "...", ... } +} ``` +`default`:完整 `{ extended, translation }` 的缩进 key/value 树。 +`json`:pretty-print JSON。 + ### 常用场景 ```bash @@ -113,19 +134,27 @@ smm tvdb search "keyword" --type series \ --host "https://api4.thetvdb.com/v4" \ --password "your-api-key" \ --proxy "socks5://proxy.example.com:7079" + +# 按 id 拉原始详情(--lang 为翻译语言,ISO 639-3;勿传 zh-CN) +smm tvdb tv 355969 --lang zho +smm tvdb movie 116 -f json --lang eng \ + --host "https://api4.thetvdb.com/v4" \ + --password "your-api-key" \ + --proxy "socks5://proxy.example.com:7079" ``` ### 参数 | 参数 | 说明 | |------|------| -| `--type` | `series` \| `movie`(必填) | -| `--lang` | ISO 639-3 主语言过滤(如 `zho`);无效值离线报错 | +| `--type` | `series` \| `movie`(仅 `search`,必填) | +| `-f` / `--format` | `json` \| `default`(仅 `tv` / `movie`;省略为 `default`) | +| `--lang` | ISO 639-3(如 `zho`)。`search`:主语言过滤;`tv`/`movie`:翻译语言。`zh-CN` 等 IETF 标签离线报错 | | `--host` | 覆盖 `userConfig.tvdb.host` | | `--password` | 覆盖 `userConfig.tvdb.apiKey` | | `--proxy` | 覆盖 `userConfig.tvdb.httpProxy` | -实现:`apps/cli/src/cli/runCli.ts`(格式化:`tvdbSearchFormat.ts`)。 +实现:`apps/cli/src/cli/runCli.ts`(搜索:`tvdbSearchFormat.ts`;详情树:复用 `tmdbDetailsFormat.ts`)。 --- @@ -208,13 +237,20 @@ Web 不调用 `smm tvdb search`,但与 CLI 共用 Core 方法与 `userConfig.t | 范围 | 文件 | |------|------| -| CLI e2e | `apps/cli/test/tvdb.e2e.ts` — 文档「常用场景」四类搜索 | +| CLI e2e | `apps/e2e/cli/tvdb.test.ts` — 搜索 + `tvdb tv` / `tvdb movie` get-by-id | | core-routes 单测 | `packages/core-routes/src/tools/tvdb.test.ts` | | MCP e2e (⏳) | `apps/e2e/common/mcp/McpOther-TvdbTools.e2e.ts` — 四工具 + live TVDB(待添加) | | MCP 客户端 | `apps/e2e/test/lib/McpClient.ts` | MCP / 需直连 TVDB 的 e2e 在 `apps/e2e/.env.local` 配置 `TVDB_HOST`、`TVDB_API_KEY`、`TVDB_HTTP_PROXY`(网络受限环境)。 +运行 CLI e2e: + +```bash +cd apps/e2e/cli +bun test ./tvdb.test.ts +``` + 运行 MCP spec(⏳ 待 spec 添加后可用): ```bash diff --git a/docs/superpowers/plans/2026-08-25-cli-tvdb-tv-movie.md b/docs/superpowers/plans/2026-08-25-cli-tvdb-tv-movie.md new file mode 100644 index 00000000..e8efbe87 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-cli-tvdb-tv-movie.md @@ -0,0 +1,523 @@ +# CLI `smm tvdb tv` / `smm tvdb movie` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `smm tvdb tv ` and `smm tvdb movie ` that fetch **raw** TVDB v4 `{ extended, translation }` via new Core methods (not MediaMetadata), with ISO 639-3 `--lang`, and print tree/JSON like TMDB get. + +**Architecture:** Keep `getTvShowInTvdb` / `getMovieInTvdb` unchanged. Add `getTvdbSeriesById` / `getTvdbMovieById` that call `TvdbClient` extended + translation endpoints. CLI mirrors `tmdb tv|movie` under `tvdbCmd`. + +**Tech Stack:** TypeScript, Core + TvdbClient, Commander, Vitest (`apps/core`, `apps/cli`), Bun test (`apps/e2e/cli`). + +**Spec:** `docs/superpowers/specs/2026-08-25-cli-tvdb-tv-movie-design.md` + +## Global Constraints + +- Output is raw API: `{ extended, translation }` — **never** `TvShowMediaMetadata` / `MovieMediaMetadata`. +- `--lang` is ISO 639-3 only (`zho`, `eng`, …); `zh-CN` must fail via `parseTvdbSearchLanguage`. +- Do not change existing MediaMetadata getters or AI/MCP/HTTP wiring for them. +- Reuse `formatTmdbDetailsTree` + `printJson` for CLI output. +- Connection flags: `--host` / `--password` / `--proxy` / `--lang` same option names as `tvdb search`. +- E2e stable IDs: series `355969` (天使降临到我身边 / Wataten), movie `116` (The Dark Knight). + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/Core.ts` | `getTvdbSeriesById` / `getTvdbMovieById` | +| `apps/core/src/Core.test.ts` | Unit tests for new methods + lang rejection | +| `apps/cli/src/cli/runCli.ts` | Register `tvdb tv` / `tvdb movie` | +| `apps/cli/src/cli/tvdbGet.test.ts` | CLI unit tests (mock Core) | +| `apps/e2e/cli/tvdb.test.ts` | Live e2e get cases | +| `docs/dev/tvdb.md` | Document commands + ISO 639-3 vs MediaMetadata getters | + +--- + +### Task 1: Core `getTvdbSeriesById` / `getTvdbMovieById` + +**Files:** +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` +- Export type from Core if useful: `TvdbByIdResult` + +**Interfaces:** +- Consumes: `TvdbClient.getSeriesExtended`, `getSeriesTranslation`, `getMovieExtended`, `getMovieTranslation`; `createTvdbClient` +- Produces: + +```typescript +export type TvdbByIdResult = { + extended: unknown + translation: unknown | null +} + +async getTvdbSeriesById(id: number, options?: TvdbRequestOptions): Promise +async getTvdbMovieById(id: number, options?: TvdbRequestOptions): Promise +``` + +- [ ] **Step 1: Write failing Core tests** + +In `apps/core/src/Core.test.ts`, add (after existing TVDB get describe, or new describe): + +```typescript +describe("Core.getTvdbSeriesById / getTvdbMovieById", () => { + it("returns raw extended + translation for series", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const result = await core.getTvdbSeriesById(1, { language: "eng" }); + expect(result.extended).toMatchObject({ id: 1, name: "My Show" }); + expect(result.translation).toEqual({ name: "My Show" }); + expect(result).not.toHaveProperty("database"); + }); + + it("returns raw extended + translation for movie", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { fetch: async (url) => tvdbResponse(url) }, + appDataDir: "/data/smm", + }); + const result = await core.getTvdbMovieById(2, { language: "eng" }); + expect(result.extended).toMatchObject({ id: 2, name: "My Film" }); + expect(result.translation).toEqual({ name: "My Film" }); + }); + + it("rejects IETF language tags (ISO 639-3 only)", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.getTvdbSeriesById(1, { language: "zh-CN" })).rejects.toThrow(/ISO 639-3/); + }); + + it("validates id as a positive integer", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + await expect(core.getTvdbSeriesById(0)).rejects.toThrow(/positive integer/); + await expect(core.getTvdbMovieById(-1)).rejects.toThrow(/positive integer/); + }); + + it("sets translation null when translation endpoint has no data", async () => { + const core = new Core({ + fs: inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: [], tmdb: {}, tvdb: {} }) }), + network: { + fetch: async (url) => { + if (url.includes("/translations/zho")) { + return jsonResponse({ status: "success", data: null }); + } + return tvdbResponse(url); + }, + }, + appDataDir: "/data/smm", + }); + // Need series/1/extended still served; add zho path handling: + // If tvdbResponse throws on /translations/zho, the override above handles it. + // Also need /series/1/extended — tvdbResponse already has it. + // For language zho, login + extended + translation/zho. + const result = await core.getTvdbSeriesById(1, { language: "zho" }); + expect(result.extended).toMatchObject({ id: 1 }); + expect(result.translation).toBeNull(); + }); +}); +``` + +If `tvdbResponse` throws on `/series/1/translations/zho`, the override in the last test is required. Optionally extend `tvdbResponse` with a zho translation fixture instead. + +- [ ] **Step 2: Run tests — expect FAIL** + +Run: `cd apps/core && pnpm exec vitest run src/Core.test.ts -t "getTvdbSeriesById"` + +Expected: FAIL (methods missing) + +- [ ] **Step 3: Implement Core methods** + +In `apps/core/src/Core.ts`, near other TVDB methods: + +```typescript +export type TvdbByIdResult = { + extended: unknown; + translation: unknown | null; +}; + +async getTvdbSeriesById(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const extended = await client.getSeriesExtended(id); + if (!extended) { + throw new Error(`Failed to get TVDB series ${id}`); + } + const translation = (await client.getSeriesTranslation(id, language)) ?? null; + return { extended, translation }; +} + +async getTvdbMovieById(id: number, options: TvdbRequestOptions = {}): Promise { + if (!Number.isInteger(id) || id <= 0) { + throw new Error("id must be a positive integer"); + } + const { client, language } = await this.createTvdbClient(options); + const extended = await client.getMovieExtended(id); + if (!extended) { + throw new Error(`Failed to get TVDB movie ${id}`); + } + const translation = (await client.getMovieTranslation(id, language)) ?? null; + return { extended, translation }; +} +``` + +Export `TvdbByIdResult` from `apps/core/src/index.ts` if other packages need it (CLI can use inferred return). + +- [ ] **Step 4: Run Core tests — expect PASS** + +Run: `cd apps/core && pnpm exec vitest run src/Core.test.ts -t "getTvdbSeriesById|getTvdbMovieById|getTvShowInTvdb"` + +Expected: new tests PASS; existing MediaMetadata get tests still PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/Core.ts apps/core/src/Core.test.ts apps/core/src/index.ts +git commit -m "$(cat <<'EOF' +feat(core): add getTvdbSeriesById/getTvdbMovieById raw API helpers + +EOF +)" +``` + +--- + +### Task 2: Wire `tvdb tv` / `tvdb movie` in CLI + +**Files:** +- Modify: `apps/cli/src/cli/runCli.ts` +- Create: `apps/cli/src/cli/tvdbGet.test.ts` + +**Interfaces:** +- Consumes: `getCore().getTvdbSeriesById` / `getTvdbMovieById`; `formatTmdbDetailsTree`; `printJson` +- Produces: Commander actions for `smm tvdb tv` / `smm tvdb movie` + +- [ ] **Step 1: Write failing CLI tests** + +Create `apps/cli/src/cli/tvdbGet.test.ts` (mirror `tmdbGet.test.ts`): + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runCli } from './runCli' + +const mockGetTvdbSeriesById = vi.fn() +const mockGetTvdbMovieById = vi.fn() + +vi.mock('../core/getCore', () => ({ + getCore: () => ({ + getTvdbSeriesById: mockGetTvdbSeriesById, + getTvdbMovieById: mockGetTvdbMovieById, + }), +})) + +const sampleSeries = { + extended: { id: 355969, name: 'Wataten' }, + translation: { name: '天使降临到我身边!' }, +} + +const sampleMovie = { + extended: { id: 116, name: 'The Dark Knight' }, + translation: { name: 'The Dark Knight' }, +} + +describe('smm tvdb tv / movie', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTvdbSeriesById.mockResolvedValue(sampleSeries) + mockGetTvdbMovieById.mockResolvedValue(sampleMovie) + }) + + it('prints default tree for tv and forwards ISO 639-3 lang + connection options', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli([ + 'node', 'smm', 'tvdb', 'tv', '355969', + '--lang', 'zho', + '--host', 'https://tvdb.example/v4', + '--password', 'key', + '--proxy', 'socks5://127.0.0.1:1', + ]) + expect(code).toBe(0) + expect(mockGetTvdbSeriesById).toHaveBeenCalledWith(355969, { + language: 'zho', + host: 'https://tvdb.example/v4', + password: 'key', + proxy: 'socks5://127.0.0.1:1', + }) + const out = logs.join('\n') + expect(out).toContain('extended:') + expect(out).toContain('translation:') + expect(out).not.toMatch(/^database:/m) + } finally { + console.log = origLog + } + }) + + it('prints pretty JSON for movie with -f json', async () => { + const logs: string[] = [] + const origLog = console.log + console.log = (msg: string) => logs.push(msg) + try { + const code = await runCli(['node', 'smm', 'tvdb', 'movie', '116', '-f', 'json']) + expect(code).toBe(0) + expect(JSON.parse(logs[0]!)).toEqual(sampleMovie) + } finally { + console.log = origLog + } + }) + + it('rejects invalid id without calling Core', async () => { + const errors: string[] = [] + const origError = console.error + console.error = (msg: string) => errors.push(msg) + try { + const code = await runCli(['node', 'smm', 'tvdb', 'tv', 'abc']) + expect(code).toBe(1) + expect(mockGetTvdbSeriesById).not.toHaveBeenCalled() + expect(errors.join('\n')).toMatch(/id/i) + } finally { + console.error = origError + } + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `cd apps/cli && pnpm exec vitest run src/cli/tvdbGet.test.ts` + +Expected: unknown command `tv` / `movie` + +- [ ] **Step 3: Register commands in `runCli.ts`** + +After `tvdbCmd.command('search')...` block, add a helper analogous to `registerTmdbGetCommand`: + +```typescript + function registerTvdbGetCommand( + name: 'tv' | 'movie', + description: string, + fetch: ( + id: number, + options: { + language?: string + host?: string + password?: string + proxy?: string + }, + ) => Promise, + ) { + tvdbCmd + .command(name) + .description(description) + .argument('', 'TVDB id') + .addOption( + new Option('-f, --format ', 'Output format') + .choices(['json', 'default']) + .default('default'), + ) + .option('--host ', 'TVDB API base URL (overrides userConfig.tvdb.host)') + .option('--password ', 'TVDB API key (overrides userConfig.tvdb.apiKey)') + .option('--proxy ', 'Outbound HTTP/SOCKS proxy (overrides userConfig.tvdb.httpProxy)') + .option( + '--lang ', + 'TVDB ISO 639-3 language code (static list, e.g. eng, zho, yue); defaults from userConfig then OS locale', + ) + .action( + async ( + tvdbIdRaw: string, + opts: { + format?: string + host?: string + password?: string + proxy?: string + lang?: string + }, + ) => { + try { + const id = Number(tvdbIdRaw) + if (!Number.isInteger(id) || id <= 0) { + console.error('id must be a positive integer') + exitCode = 1 + return + } + const details = await fetch(id, { + language: opts.lang, + host: opts.host, + password: opts.password, + proxy: opts.proxy, + }) + if (opts.format === 'json') { + printJson(details) + return + } + console.log(formatTmdbDetailsTree(details)) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }, + ) + } + + registerTvdbGetCommand('tv', 'Get TVDB series details by id (raw API)', (id, options) => + getCore().getTvdbSeriesById(id, options), + ) + registerTvdbGetCommand('movie', 'Get TVDB movie details by id (raw API)', (id, options) => + getCore().getTvdbMovieById(id, options), + ) +``` + +- [ ] **Step 4: Run CLI unit tests — expect PASS** + +```bash +cd apps/cli && pnpm exec vitest run src/cli/tvdbGet.test.ts src/cli/tmdbGet.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/cli/src/cli/runCli.ts apps/cli/src/cli/tvdbGet.test.ts +git commit -m "$(cat <<'EOF' +feat(cli): add smm tvdb tv and tvdb movie raw get-by-id commands + +EOF +)" +``` + +--- + +### Task 3: Live e2e + docs + +**Files:** +- Modify: `apps/e2e/cli/tvdb.test.ts` +- Modify: `docs/dev/tvdb.md` + +**Interfaces:** +- Consumes: rebuilt `bin`; env `TVDB_*` for custom-host cases +- Produces: e2e coverage; updated docs + +- [ ] **Step 1: Extend `apps/e2e/cli/tvdb.test.ts`** + +Append (keep search suite). Use IDs `355969` (series) and `116` (movie): + +```typescript +describe('tvdb tv / movie get', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('gets series details (default) via SMM host with --lang zho', async () => { + const ret = await $`${bin} tvdb tv 355969 --lang zho`.nothrow() + expect(ret.exitCode).toBe(0) + const text = ret.text() + expect(text).toMatch(/extended:/) + expect(text).toMatch(/translation:/) + expect(text).toMatch(/id: 355969/) + expect(text).toMatch(SERIES_TITLE) + expect(text).not.toMatch(/^database: TVDB$/m) + }, FIVE_MINUTES_MS) + + it('gets series as JSON via custom host', async () => { + const { host, password, proxy } = officialTvdb() + const ret = await $`${bin} tvdb tv 355969 -f json --lang zho --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.extended).toBeTruthy() + expect(body.extended.id).toBe(355969) + expect(body).toHaveProperty('translation') + expect(body).not.toHaveProperty('database') + }, FIVE_MINUTES_MS) + + it('gets movie details as JSON via SMM host', async () => { + const ret = await $`${bin} tvdb movie 116 -f json --lang eng`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.extended.id).toBe(116) + expect(String(body.extended.name ?? body.translation?.name ?? '')).toMatch(/Dark Knight/i) + }, FIVE_MINUTES_MS) + + it('rejects IETF --lang zh-CN', async () => { + const ret = await $`${bin} tvdb tv 355969 --lang zh-CN`.nothrow() + expect(ret.exitCode).toBe(1) + const err = ret.stderr.toString() || ret.text() + expect(err).toMatch(/ISO 639-3/i) + }, FIVE_MINUTES_MS) + + it('rejects invalid id', async () => { + const ret = await $`${bin} tvdb tv abc`.nothrow() + expect(ret.exitCode).toBe(1) + const err = ret.stderr.toString() || ret.text() + expect(err).toMatch(/id must be a positive integer/i) + }, FIVE_MINUTES_MS) +}) +``` + +- [ ] **Step 2: Update `docs/dev/tvdb.md` CLI section** + +- State search + `tv` / `movie` are exposed. +- Document commands, `{ extended, translation }` output, ISO 639-3 `--lang` (contrast: search lang = primary filter; get lang = translation). +- Explicitly note: these CLI gets are **raw TVDB API**, distinct from `getTvShowInTvdb` / `getMovieInTvdb` MediaMetadata helpers. +- Update 测试 table to mention get-by-id e2e. + +- [ ] **Step 3: Rebuild CLI + run e2e** + +```bash +cd apps/cli && pnpm run build +cd ../e2e && bun test ./cli/tvdb.test.ts -t "tvdb tv / movie get" +``` + +Expected: all new get tests PASS. + +Also: `cd apps/core && pnpm exec vitest run src/Core.test.ts -t "getTvdbSeriesById|getTvdbMovieById"` + +- [ ] **Step 4: Commit** + +```bash +git add apps/e2e/cli/tvdb.test.ts docs/dev/tvdb.md +git commit -m "$(cat <<'EOF' +test(e2e): cover smm tvdb tv/movie get and update TVDB docs + +EOF +)" +``` + +--- + +## Self-Review + +| Spec requirement | Task | +|------------------|------| +| Raw `{ extended, translation }` | Task 1 | +| ISO 639-3 only; reject `zh-CN` | Task 1 + 3 | +| Keep MediaMetadata getters unchanged | Task 1 (no edits to those methods) | +| CLI `tvdb tv` / `movie` + format/flags | Task 2 | +| E2e + docs | Task 3 | + +No placeholders. Method names match design: `getTvdbSeriesById` / `getTvdbMovieById`. From ffae86307611bccfe973074b36ed43bfd9a3d8ff Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 14:46:22 +0800 Subject: [PATCH 137/214] chore(e2e): finish CLI testbed split and expand tmdb get coverage Remove legacy apps/cli tmdb/tvdb e2e, inject hello via resolver for CLI tests, add remaining tmdb get cases, and lengthen SearchTvShow waits with TMDB get timing logs. Co-authored-by: Cursor --- apps/cli/src/route/Tmdb.ts | 13 +- apps/cli/test/tmdb.e2e.ts | 145 ------------------ apps/cli/test/tvdb.e2e.ts | 82 ---------- apps/e2e/cli/helpers.ts | 9 ++ apps/e2e/cli/tmdb.test.ts | 32 ++++ apps/e2e/common/tv/SearchTvShow.e2e.ts | 17 +- .../e2e/test/componentobjects/Searchbox.co.ts | 4 +- apps/e2e/test/lib/cli-hello.ts | 4 +- apps/e2e/test/lib/testbed-core.ts | 70 +++++---- apps/ui/src/hooks/useGetTmdbTvShowMutation.ts | 22 ++- docs/dev/import-folder.md | 2 +- .../2026-08-25-cli-tmdb-tv-movie-design.md | 2 +- package.json | 1 - 13 files changed, 124 insertions(+), 279 deletions(-) delete mode 100644 apps/cli/test/tmdb.e2e.ts delete mode 100644 apps/cli/test/tvdb.e2e.ts diff --git a/apps/cli/src/route/Tmdb.ts b/apps/cli/src/route/Tmdb.ts index 6b407295..e0df056d 100644 --- a/apps/cli/src/route/Tmdb.ts +++ b/apps/cli/src/route/Tmdb.ts @@ -107,18 +107,29 @@ export function handleTmdb(app: Hono): void { }) app.post('/api/get-tvshow-in-tmdb', async (c) => { + const startedAt = Date.now() + let idForLog: number | undefined try { const rec = await readJsonObject(c) const id = parsePositiveInt(rec.id) + idForLog = id if (id === undefined) { const err: TmdbDetailsHttpResponseBody = errorBody('id is required') return c.json(err, 200) } + logger.info({ id }, '[POST /api/get-tvshow-in-tmdb] start') const data = await getCore().getTvShowInTmdb(id, tmdbRequestOptions(rec)) + logger.info( + { id, durationMs: Date.now() - startedAt }, + '[POST /api/get-tvshow-in-tmdb] ok', + ) const ok: TmdbDetailsHttpResponseBody = { data } return c.json(ok, 200) } catch (error) { - logger.error({ error }, '[POST /api/get-tvshow-in-tmdb] route error') + logger.error( + { error, id: idForLog, durationMs: Date.now() - startedAt }, + '[POST /api/get-tvshow-in-tmdb] route error', + ) const err: TmdbDetailsHttpResponseBody = errorBody( error instanceof Error ? error.message : 'Unknown error', ) diff --git a/apps/cli/test/tmdb.e2e.ts b/apps/cli/test/tmdb.e2e.ts deleted file mode 100644 index b63f4785..00000000 --- a/apps/cli/test/tmdb.e2e.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resetCoreForTests, smm } from './helpers/smm' -import { loadEnvLocal, requiredEnv } from './helpers/loadEnvLocal' - -loadEnvLocal(import.meta.dirname) - -const TMDB_SEARCH_TIMEOUT_MS = 60_000 -const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m -const TV_TITLE = /天使降临到我身边|WATATEN|Angel Flew Down|Wataten/i - -function officialTmdb(): { host: string; password: string; proxy: string } { - return { - host: requiredEnv('TMDB_HOST'), - password: requiredEnv('TMDB_API_KEY'), - proxy: requiredEnv('TMDB_HTTP_PROXY'), - } -} - -describe('smm tmdb search CLI e2e (docs/dev/tmdb.md scenarios)', () => { - let userDataDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tmdb-e2e-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - }) - - it( - '1) Search through SMM-provided TMDB host', - { timeout: TMDB_SEARCH_TIMEOUT_MS }, - async () => { - const result = await smm(['tmdb', 'search', '天使降临到我身边', '--type', 'tv']) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(TV_TITLE) - }, - ) - - it( - '2) Search through SMM-provided TMDB host and HTTP/SOCKS proxy', - { timeout: TMDB_SEARCH_TIMEOUT_MS }, - async () => { - const { proxy } = officialTmdb() - const result = await smm([ - 'tmdb', - 'search', - '天使降临到我身边', - '--type', - 'tv', - '--proxy', - proxy, - ]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(TV_TITLE) - }, - ) - - it( - '3) Search through custom TMDB host', - { timeout: TMDB_SEARCH_TIMEOUT_MS }, - async () => { - // Official TMDB is often unreachable without a proxy in restricted networks. - // --password is required; --proxy matches docs/dev/tmdb.md custom-host example. - const { host, password, proxy } = officialTmdb() - const result = await smm([ - 'tmdb', - 'search', - '天使降临到我身边', - '--type', - 'tv', - '--host', - host, - '--password', - password, - '--proxy', - proxy, - ]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(TV_TITLE) - }, - ) - - it( - '4) Search through custom TMDB host and password', - { timeout: TMDB_SEARCH_TIMEOUT_MS }, - async () => { - const { host, password, proxy } = officialTmdb() - const result = await smm([ - 'tmdb', - 'search', - '天使降临到我身边', - '--type', - 'tv', - '--host', - host, - '--password', - password, - '--proxy', - proxy, - ]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(TV_TITLE) - }, - ) - - it( - '5) Search through custom TMDB host, password and HTTP proxy', - { timeout: TMDB_SEARCH_TIMEOUT_MS }, - async () => { - const { host, password, proxy } = officialTmdb() - const result = await smm([ - 'tmdb', - 'search', - '天使降临到我身边', - '--type', - 'tv', - '--host', - host, - '--password', - password, - '--proxy', - proxy, - ]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(TV_TITLE) - }, - ) -}) diff --git a/apps/cli/test/tvdb.e2e.ts b/apps/cli/test/tvdb.e2e.ts deleted file mode 100644 index 33d7a5c6..00000000 --- a/apps/cli/test/tvdb.e2e.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { resetCoreForTests, smm } from './helpers/smm' -import { loadEnvLocal, requiredEnv } from './helpers/loadEnvLocal' - -loadEnvLocal(import.meta.dirname) - -const TVDB_SEARCH_TIMEOUT_MS = 60_000 -const RESULT_HEADER = /^#1 \d+ .+ \(\d{4}(-\d{2}-\d{2})?\)$/m -// TVDB search returns the primary (often Japanese) title and the CLI prints -// only `item.name` — WATATEN's TVDB primary title is "私に天使が舞い降りた!". -const SERIES_TITLE = /天使|WATATEN|Angel Flew Down|Wataten/i - -function officialTvdb(): { host: string; password: string; proxy: string } { - return { - host: requiredEnv('TVDB_HOST'), - password: requiredEnv('TVDB_API_KEY'), - proxy: requiredEnv('TVDB_HTTP_PROXY'), - } -} - -describe('smm tvdb search CLI e2e (docs/dev/tvdb.md scenarios)', () => { - let userDataDir: string - let prevUserDataDir: string | undefined - - beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-cli-tvdb-e2e-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() - }) - - afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) - }) - - it( - '1) Search through SMM-provided TVDB host', - { timeout: TVDB_SEARCH_TIMEOUT_MS }, - async () => { - const result = await smm(['tvdb', 'search', '天使降临到我身边', '--type', 'series']) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(SERIES_TITLE) - }, - ) - - it( - '2) Search through SMM-provided TVDB host and HTTP/SOCKS proxy', - { timeout: TVDB_SEARCH_TIMEOUT_MS }, - async () => { - const { proxy } = officialTvdb() - const result = await smm([ - 'tvdb', 'search', '天使降临到我身边', '--type', 'series', '--proxy', proxy, - ]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(SERIES_TITLE) - }, - ) - - it( - '3) Search through custom TVDB host with password and proxy', - { timeout: TVDB_SEARCH_TIMEOUT_MS }, - async () => { - const { host, password, proxy } = officialTvdb() - const result = await smm([ - 'tvdb', 'search', '天使降临到我身边', '--type', 'series', - '--host', host, '--password', password, '--proxy', proxy, - ]) - expect(result.code, result.stderr || result.stdout).toBe(0) - expect(result.stdout).toMatch(RESULT_HEADER) - expect(result.stdout).toMatch(SERIES_TITLE) - }, - ) -}) diff --git a/apps/e2e/cli/helpers.ts b/apps/e2e/cli/helpers.ts index ebfe427b..89efb667 100644 --- a/apps/e2e/cli/helpers.ts +++ b/apps/e2e/cli/helpers.ts @@ -12,6 +12,15 @@ const helpersDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = join(helpersDir, '../../..') const MEDIA_METADATA_TEMPLATES_DIR = join(repoRoot, 'test', 'templates', 'mediaMetadatas') +/** Require a non-empty env var (loaded from `apps/e2e/.env.local` via testbed-core). */ +export function requiredEnv(name: string): string { + const value = process.env[name]?.trim() + if (!value) { + throw new Error(`${name} is not set (apps/e2e/.env.local)`) + } + return value +} + /** Combine stdout and stderr from a CLI subprocess result. */ export function cliOutput(result: ShellOutput): string { const stdout = result.stdout.toString() diff --git a/apps/e2e/cli/tmdb.test.ts b/apps/e2e/cli/tmdb.test.ts index b14e467c..b604ef32 100644 --- a/apps/e2e/cli/tmdb.test.ts +++ b/apps/e2e/cli/tmdb.test.ts @@ -119,6 +119,31 @@ describe('tmdb tv / movie get', () => { expect(text).toMatch(TV_TITLE) }, FIVE_MINUTES_MS) + it('gets TV show details as JSON via SMM-provided host', async () => { + const ret = await $`${bin} tmdb tv 84666 -f json --lang zh-CN`.nothrow() + expect(ret.exitCode).toBe(0) + const body = JSON.parse(ret.text()) + expect(body.id).toBe(84666) + expect(String(body.name ?? '')).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('gets TV show details via custom host, password and HTTP proxy', async () => { + const { host, password, proxy } = officialTmdb() + const ret = await $`${bin} tmdb tv 84666 --lang zh-CN --host ${host} --password ${password} --proxy ${proxy}`.nothrow() + expect(ret.exitCode).toBe(0) + const text = ret.text() + expect(text).toMatch(/id: 84666/) + expect(text).toMatch(TV_TITLE) + }, FIVE_MINUTES_MS) + + it('gets movie details (default format) via SMM-provided host', async () => { + const ret = await $`${bin} tmdb movie 550`.nothrow() + expect(ret.exitCode).toBe(0) + const text = ret.text() + expect(text).toMatch(/id: 550/) + expect(text).toMatch(/Fight Club/i) + }, FIVE_MINUTES_MS) + it('gets movie details as JSON via custom host', async () => { const { host, password, proxy } = officialTmdb() const ret = await $`${bin} tmdb movie 550 -f json --host ${host} --password ${password} --proxy ${proxy}`.nothrow() @@ -127,4 +152,11 @@ describe('tmdb tv / movie get', () => { expect(body.id).toBe(550) expect(String(body.title ?? '')).toMatch(/Fight Club/i) }, FIVE_MINUTES_MS) + + it('rejects invalid TMDB id', async () => { + const ret = await $`${bin} tmdb tv abc`.nothrow() + expect(ret.exitCode).toBe(1) + const err = ret.stderr.toString() || ret.text() + expect(err).toMatch(/id must be a positive integer/i) + }, FIVE_MINUTES_MS) }) diff --git a/apps/e2e/common/tv/SearchTvShow.e2e.ts b/apps/e2e/common/tv/SearchTvShow.e2e.ts index b6ab70c2..285b9468 100644 --- a/apps/e2e/common/tv/SearchTvShow.e2e.ts +++ b/apps/e2e/common/tv/SearchTvShow.e2e.ts @@ -120,24 +120,17 @@ describe('Search TV Show', () => { await when('I search for "我推的孩子"') await when('I select search result with title "【我推的孩子】" and date "April 12, 2023"') - // TODO: in macOS, query large block of HTML will random caused more than 1 minutes - // So I can't use waitUntil to check HTML state in some interval. - // hard delay is added here as workaround + // After select: UI clears the panel, then POST /api/get-tvshow-in-tmdb (~20–30s in e2e) + // plus sequential season fetches. Wait for panel state (not full getHTML — slow on macOS). await then('episode table shows Oshi no Ko TMDB seasons', async () => { - console.log(`${new Date().toISOString()} PAUSED 10 seconds`) - await browser.pause(10000) - console.log(`${new Date().toISOString()} RESUMED`) - const html = await $('[data-testid="tv-show-panel"]').getHTML() - console.log(`${new Date().toISOString()} html="${html}"`) - await browser.waitUntil(async () => { const stateInString = await TvShowPanelCO.toString() console.log(`${new Date().toISOString()} stateInString="${stateInString}"`) return stateInString.includes(OSHI_NO_KO_TMDB_EPISODE_TABLE) }, { - timeout: 5000, - interval: 1000, - timeoutMsg: 'Expected to see Season 0 in the TV show panel', + timeout: 3 * 60 * 1000, + interval: 2000, + timeoutMsg: 'Expected to see Season 0 in the TV show panel within 3 minutes after TMDB select', }) }) diff --git a/apps/e2e/test/componentobjects/Searchbox.co.ts b/apps/e2e/test/componentobjects/Searchbox.co.ts index 3f30a6e5..966a7e70 100644 --- a/apps/e2e/test/componentobjects/Searchbox.co.ts +++ b/apps/e2e/test/componentobjects/Searchbox.co.ts @@ -162,9 +162,9 @@ class SearchboxComponentObject { return (await resultItems.length) > 0 }, { - timeout: 10000, + timeout: 60000, interval: 200, - timeoutMsg: 'Search result items did not appear after 10000ms', + timeoutMsg: 'Search result items did not appear after 60000ms', } ) diff --git a/apps/e2e/test/lib/cli-hello.ts b/apps/e2e/test/lib/cli-hello.ts index c25d51eb..12f309dc 100644 --- a/apps/e2e/test/lib/cli-hello.ts +++ b/apps/e2e/test/lib/cli-hello.ts @@ -3,7 +3,9 @@ import type { HelloCliBody } from '@smm/core/types' /** * Run `smm hello -f json` and parse {@link HelloCliBody}. - * Used by CLI e2e setup/cleanup instead of `GET /api/hello`. + * + * CLI e2e only (`apps/e2e/cli`). Web UI / wdio e2e must use `GET /api/hello` + * via `@smm/test` hello — do not import this module from wdio conf or `testbed.ts`. */ export async function runCliHello(binary: string): Promise { const result = await $`${binary} hello -f json`.quiet().nothrow() diff --git a/apps/e2e/test/lib/testbed-core.ts b/apps/e2e/test/lib/testbed-core.ts index aa923491..e75c9961 100644 --- a/apps/e2e/test/lib/testbed-core.ts +++ b/apps/e2e/test/lib/testbed-core.ts @@ -1,8 +1,9 @@ /** - * Host-filesystem testbed setup/cleanup shared by wdio and CLI e2e tests. + * Host-filesystem testbed setup/cleanup for Web UI e2e (wdio) host fallback. * - * CLI e2e: pass `binary` and paths are resolved via `smm hello -f json` (no HTTP server). - * Wdio host fallback: omit `binary` and paths come from `GET /api/hello` via `@smm/test`. + * Resolves data dirs via `GET /api/hello` (`@smm/test` hello). + * CLI e2e must not import this path for hello — see `apps/e2e/cli/base.ts`, + * which injects `smm hello -f json` via {@link HelloPathsResolver}. */ import * as fs from 'node:fs' import * as path from 'node:path' @@ -14,7 +15,6 @@ import { removeTestMediaTmpDir as removeTestMediaTmpDirV1, resetUserConfig as resetUserConfigV1, } from '@smm/test' -import { runCliHello } from './cli-hello' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -27,9 +27,17 @@ export type UserConfigUpdater = ( export type ResetUserConfigOption = boolean | UserConfigUpdater +export type HelloPaths = { + userDataDir: string + appDataDir: string +} + +/** Optional override; default uses `GET /api/hello`. CLI supplies `smm hello`. */ +export type HelloPathsResolver = () => Promise + export interface TestBedCoreCleanupOptions { - /** Path to the `smm` CLI executable. When set, `smm hello -f json` resolves data dirs. */ - binary?: string + /** When set, used instead of `GET /api/hello` (CLI e2e). */ + resolveHelloPaths?: HelloPathsResolver removeMetadataDir?: boolean removePlansDir?: boolean removeMediaFolders?: boolean @@ -40,26 +48,22 @@ export interface TestBedCoreSetupOptions extends TestBedCoreCleanupOptions { resetUserConfig: ResetUserConfigOption } -async function resolveHelloPaths(binary?: string): Promise<{ - userDataDir: string - appDataDir: string -}> { - if (binary) { - const body = await runCliHello(binary) - return { userDataDir: body.userDataDir, appDataDir: body.appDataDir } - } - +async function defaultHelloPaths(): Promise { const data = await helloHttp() return { userDataDir: data.userDataDir, appDataDir: data.appDataDir } } -async function getUserConfigPath(binary?: string): Promise { - const { userDataDir } = await resolveHelloPaths(binary) +function helloPathsOf(options?: { resolveHelloPaths?: HelloPathsResolver }): HelloPathsResolver { + return options?.resolveHelloPaths ?? defaultHelloPaths +} + +async function getUserConfigPath(resolveHelloPaths: HelloPathsResolver): Promise { + const { userDataDir } = await resolveHelloPaths() return path.join(userDataDir, 'smm.json') } -async function removeMetadataDir(binary?: string): Promise { - const { appDataDir } = await resolveHelloPaths(binary) +async function removeMetadataDir(resolveHelloPaths: HelloPathsResolver): Promise { + const { appDataDir } = await resolveHelloPaths() const metadataDir = path.join(appDataDir, 'metadata') if (!fs.existsSync(metadataDir)) { return null @@ -69,8 +73,8 @@ async function removeMetadataDir(binary?: string): Promise { return metadataDir } -async function removePlansDir(binary?: string): Promise { - const { appDataDir } = await resolveHelloPaths(binary) +async function removePlansDir(resolveHelloPaths: HelloPathsResolver): Promise { + const { appDataDir } = await resolveHelloPaths() const plansDir = path.join(appDataDir, 'plans') if (!fs.existsSync(plansDir)) { return null @@ -82,9 +86,10 @@ async function removePlansDir(binary?: string): Promise { export async function updateUserConfig( updateFn: UserConfigUpdater, - options?: { binary?: string }, + options?: { resolveHelloPaths?: HelloPathsResolver }, ): Promise { - const userConfigPath = await getUserConfigPath(options?.binary) + const resolveHelloPaths = helloPathsOf(options) + const userConfigPath = await getUserConfigPath(resolveHelloPaths) if (!fs.existsSync(userConfigPath)) { throw new Error(`updateUserConfig: user config not found at ${userConfigPath}`) } @@ -100,13 +105,14 @@ export async function updateUserConfig( export async function applyResetUserConfig( option: ResetUserConfigOption, - options?: { binary?: string }, + options?: { resolveHelloPaths?: HelloPathsResolver }, ): Promise { if (option === false) { return } - const userConfigPath = await getUserConfigPath(options?.binary) + const resolveHelloPaths = helloPathsOf(options) + const userConfigPath = await getUserConfigPath(resolveHelloPaths) if (option === true) { await resetUserConfigV1(userConfigPath) @@ -125,23 +131,25 @@ export async function applyResetUserConfig( */ export async function cleanupCore(options?: TestBedCoreCleanupOptions): Promise { const { - binary, + resolveHelloPaths, removeMetadataDir: isToRemoveMetadataDir = true, removePlansDir: isToRemovePlansDir = true, removeMediaFolders: isToRemoveMediaFolders = true, resetUserConfig: needToResetUserConfig = false, } = options ?? {} + const resolve = helloPathsOf({ resolveHelloPaths }) + if (isToRemoveMediaFolders) { await removeTestMediaTmpDirV1({ waitForUnlockMs: 30_000 }) } if (isToRemovePlansDir) { - await removePlansDir(binary) + await removePlansDir(resolve) } if (isToRemoveMetadataDir) { - await removeMetadataDir(binary) + await removeMetadataDir(resolve) } - await applyResetUserConfig(needToResetUserConfig, { binary }) + await applyResetUserConfig(needToResetUserConfig, { resolveHelloPaths: resolve }) } /** @@ -149,5 +157,7 @@ export async function cleanupCore(options?: TestBedCoreCleanupOptions): Promise< */ export async function setupCore(options: TestBedCoreSetupOptions): Promise { await cleanupCore(options) - await applyResetUserConfig(options.resetUserConfig, { binary: options.binary }) + await applyResetUserConfig(options.resetUserConfig, { + resolveHelloPaths: helloPathsOf(options), + }) } diff --git a/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts b/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts index 0af9023d..614d7780 100644 --- a/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts +++ b/apps/ui/src/hooks/useGetTmdbTvShowMutation.ts @@ -21,24 +21,40 @@ export function useGetTmdbTvShowMutation< return useMutation({ ...options, mutationFn: async (variables: TVariables) => { - console.log(`useGetTmdbTvShowMutation CALLED`, {...variables}) + const startedAt = Date.now() + console.log(`useGetTmdbTvShowMutation CALLED`, { ...variables }) const tmdbTvSeriesDetails: TmdbSeriesDetails = await getTvShowById( variables.id, variables.language, variables.tmdb ) + console.log(`useGetTmdbTvShowMutation series ok`, { + id: variables.id, + seasonCount: tmdbTvSeriesDetails.seasons?.length ?? 0, + durationMs: Date.now() - startedAt, + }) const seasonDetails: TmdbSeasonDetails[] = [] - for(const season of tmdbTvSeriesDetails.seasons) { + for (const season of tmdbTvSeriesDetails.seasons) { + const seasonStartedAt = Date.now() const tmdbTvShowSeasonDetails: TmdbSeasonDetails = await getTvShowSeasonDetails( variables.id, season.season_number, variables.language, variables.tmdb ) + console.log(`useGetTmdbTvShowMutation season ok`, { + id: variables.id, + season: season.season_number, + durationMs: Date.now() - seasonStartedAt, + }) seasonDetails.push(tmdbTvShowSeasonDetails) } - + + console.log(`useGetTmdbTvShowMutation done`, { + id: variables.id, + durationMs: Date.now() - startedAt, + }) return buildTvShowMediaMetadata(tmdbTvSeriesDetails, seasonDetails) }, }) diff --git a/docs/dev/import-folder.md b/docs/dev/import-folder.md index b280029a..d672e65b 100644 --- a/docs/dev/import-folder.md +++ b/docs/dev/import-folder.md @@ -1,7 +1,7 @@ # Import Folder **Supported Platform** Web UI, CLI, Electron, ohos -**Status** wip +**Status** done ## apps/core diff --git a/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md b/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md index d37336a5..b12f9c56 100644 --- a/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md +++ b/docs/superpowers/specs/2026-08-25-cli-tmdb-tv-movie-design.md @@ -43,7 +43,7 @@ No new HTTP routes, AI tools, or MCP tools. CLI talks to Core in-process (same p | Human-readable formatter | `apps/cli/src/cli/tmdbDetailsFormat.ts` | | Formatter unit tests | `apps/cli/src/cli/tmdbDetailsFormat.test.ts` | | Optional command unit tests | `apps/cli/src/cli/tmdbGet.test.ts` (mock `getCore`, same style as `hello.test.ts`) | -| Live CLI e2e | Extend `apps/cli/test/tmdb.e2e.ts` and/or `apps/e2e/cli/tmdb.test.ts` | +| Live CLI e2e | Extend `apps/e2e/cli/tmdb.test.ts` | | Docs | Update `docs/dev/tmdb.md` CLI section | ## 2.3 Key Design diff --git a/package.json b/package.json index 993bc19d..25d6aa6f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "e2e:other": "bun ci/run-e2e-test.ts --spec \"./common/other/*.e2e.ts\"", "e2e:transcribe": "bun ci/run-e2e-test.ts --spec \"./common/manual/Transcribe.e2e.ts\" --spec \"./common/manual/MusicPanel-Transcribe.e2e.ts\"", "e2e:tv": "bun ci/run-e2e-test.ts --spec \"./common/tv/*.e2e.ts\"", - "e2e:tv:v3": "bun ci/run-e2e-test.ts --spec \"./common/tv/*.e2e.ts\"", "e2e:tvdb": "bun ci/run-e2e-test.ts --spec \"./common/tvdb/*.e2e.ts\"", "e2e:ohos": "bun ci/run-e2e-test.ts --platform ohos --spec \"./ohos/**/*.e2e.ts\"", "e2e:local:tv": "bun ci/run-e2e-test.ts --spec \"./common/tv/*.e2e.ts\"", From cff57d15c692e6579b28786ad6fb2a6b8213a469 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 16:39:14 +0800 Subject: [PATCH 138/214] docs: add design for CLI manage-plan commands Co-authored-by: Cursor --- .../2026-08-25-cli-manage-plan-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-cli-manage-plan-design.md diff --git a/docs/superpowers/specs/2026-08-25-cli-manage-plan-design.md b/docs/superpowers/specs/2026-08-25-cli-manage-plan-design.md new file mode 100644 index 00000000..199bc2ee --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-cli-manage-plan-design.md @@ -0,0 +1,168 @@ +# CLI Manage Plan (`smm plan` / `reject`) + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +[Manage Plan](../../dev/manage-plan.md) defines CLI commands to list, show, apply, and reject plans, plus creators `try-to-recognize` / `try-to-rename`. + +**Already implemented** + +- `smm try-to-recognize ` +- `smm try-to-rename [--rule plex|emby]` +- `smm apply ` +- Core: `tryToRecognizeFolder`, `tryToRenameFolder`, `getPlan`, `applyPlan` +- e2e: `apps/e2e/cli/recognize.test.ts` (and related rename coverage) + +**Still missing** + +``` +smm plan list [-a|--all] +smm plan list [-a|--all] +smm plan show +smm plan apply +smm plan reject +smm reject # alias to plan reject +``` + +Plans live at `{appDataDir}/plans/{planId}.plan.json`. Lifecycle: `preparing` → `pending` → `completed` (file deleted) | `rejected` (file kept). HTTP already supports folder-scoped active listing via `getActivePlansForFolder`; CLI needs Core-level list (optional folder + optional `--all`) and reject. + +**Agreed product decisions** + +- `--all` / `-a`: include `rejected` in addition to active (`preparing` / `pending`). `completed` plans are already deleted, so they never appear. +- Lifecycle APIs live on Core (`listPlans`, `rejectPlan`), matching `getPlan` / `applyPlan`. +- Output: human-readable default; `-f|--format json` for `plan list` and `plan show`. +- `plan list` summary: one line per plan ` `; `plan show` matches try-to-* multi-line detail. + +## 2. Architecture + +## 2.1 Project Level Architecture + +```mermaid +sequenceDiagram + participant U as User + participant CLI as apps/cli runCli + participant C as Core + participant Fs as FsPort + participant Disk as plans/*.plan.json + + U->>CLI: smm plan list|show|apply|reject + CLI->>C: listPlans / getPlan / applyPlan / rejectPlan + C->>Fs: read / write / delete + Fs->>Disk: plan files + C-->>CLI: Plan or Plan[] + CLI-->>U: human lines or JSON +``` + +- No new HTTP endpoints for this feature. +- No UI changes. +- `try-to-recognize` / `try-to-rename` / top-level `apply` remain unchanged; `plan apply` is an alias of `apply`. + +## 2.2 App Level Architecture + +| Piece | Location | Role | +|--------|----------|------| +| Core list/reject | `apps/core/src/pipeline/plans.ts` (+ `Core.ts`) | Scan plan dir; reject patches status | +| CLI commands | `apps/cli/src/cli/runCli.ts` | Commander `plan` group + `reject` | +| Entry allowlist | `apps/cli/index.ts` | Add `plan`, `reject` to `cliCommands` | +| Format helpers | `apps/cli/src/cli/` (small helpers near runCli) | List line / show detail / JSON | +| Core unit tests | `apps/core` (`plans` / `Core` tests) | Filter + reject persistence | +| CLI e2e | `apps/e2e/cli/manage-plan.test.ts` | Full command flows | +| Docs | `docs/dev/manage-plan.md` | Align CLI section with `--format` if needed | + +## 2.3 Key Design + +### Core API + +```ts +listPlans(options?: { + mediaFolderPath?: string + all?: boolean +}): Promise + +rejectPlan(id: string): Promise +``` + +- `listPlans`: enumerate `{appDataDir}/plans/*.plan.json` via `FsPort`; skip unreadable files; optional POSIX folder equality filter; default filter = `isActivePlanStatus`; `all: true` also keeps `rejected`. +- `rejectPlan`: `getPlan` equivalent load → set `status: "rejected"` → write file (keep on disk) → return plan; missing id throws `Plan not found: …`. + +### CLI surface + +```bash +smm plan list [folder] [-a|--all] [-f|--format json] +smm plan show [-f|--format json] +smm plan apply +smm plan reject +smm apply +smm reject +smm try-to-recognize +smm try-to-rename +``` + +| Mode | `plan list` | `plan show` | +|------|-------------|-------------| +| default | one line: `id task status folder` | multi-line like try-to-* | +| `--format json` | `{ "plans": Plan[] }` | `{ "plan": Plan }` | + +- Empty list: exit 0, no lines / `"plans": []`. +- Errors: stderr message, exit 1 (missing plan, etc.). +- `apply` / `reject` confirmations: `applied (N file(s))` / `rejected `. + +### Out of scope + +- New HTTP APIs or changing `updatePlan` semantics +- UI / MCP / AI tool changes +- Changing try-to-* plan creation behavior + +## 3. User Stories + +### 3.1 List pending plans for a folder + +* **Given** an imported TV folder and a pending recognize plan from `try-to-recognize` +* **When** user runs `smm plan list ` +* **Then** stdout includes that plan’s id/task/status/folder; another folder’s list does not + +```mermaid +sequenceDiagram + participant U as User + participant CLI + participant Core + U->>CLI: plan list folder + CLI->>Core: listPlans({ mediaFolderPath }) + Core-->>CLI: active plans + CLI-->>U: summary lines +``` + +### 3.2 Show plan detail (JSON) + +* **Given** a known `planId` +* **When** user runs `smm plan show --format json` +* **Then** stdout is `{ "plan": … }` with files; missing id exits 1 + +### 3.3 Reject hides from default list, visible with `--all` + +* **Given** a pending plan +* **When** user runs `smm reject ` (or `plan reject`) +* **Then** plan file remains with `status: rejected`; `plan list` omits it; `plan list --all` includes it + +### 3.4 Apply via `plan apply` + +* **Given** a pending plan +* **When** user runs `smm plan apply ` +* **Then** same as `smm apply`: domain apply runs, plan file deleted + +## 4. Test Plan + +**Core unit** + +- `listPlans` default vs `all`, with/without folder filter +- `rejectPlan` writes rejected status; throws when missing + +**e2e** (`apps/e2e/cli/manage-plan.test.ts`, same testbed as recognize) + +1. try-to-recognize → list / list folder / list other folder +2. plan show + `--format json` +3. plan apply deletes plan file +4. reject / plan reject → list vs list `--all` +5. Smoke aliases (`plan apply` ↔ `apply`) From d9f0956524f9058953d0c292db99ae3ceed06abd Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Tue, 25 Aug 2026 17:34:19 +0800 Subject: [PATCH 139/214] feat: align rename-episodes with try/reject/apply plan APIs Route Web UI and CLI through Core manage-plan (try-to-rename-episodes, reject-plan, apply-plan) and add e2e coverage for UC1/UC2. Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/server.ts | 2 + apps/cli/src/cli/planFormat.test.ts | 56 +++ apps/cli/src/cli/planFormat.ts | 38 ++ apps/cli/src/cli/runCli.ts | 101 ++++- apps/cli/src/route/RenameEpisodesPlan.ts | 155 ++++++++ apps/core/src/Core.ts | 16 +- apps/core/src/pipeline/paths.ts | 7 +- apps/core/src/pipeline/plans.test.ts | 93 ++++- apps/core/src/pipeline/plans.ts | 71 +++- apps/e2e/cli/manage-plan.test.ts | 189 ++++++++++ ...-files.test.ts => rename-episodes.test.ts} | 42 +++ .../{rename.test.ts => rename-folder.test.ts} | 0 apps/e2e/common/tv/TVShow-RenameByPlan.e2e.ts | 8 +- ...me.e2e.ts => TVShow-RenameEpisodes.e2e.ts} | 75 ++++ apps/e2e/test/componentobjects/Prompts.ts | 17 + apps/e2e/test/steps/index.ts | 1 + apps/e2e/test/steps/rename-prompt-is-open.ts | 6 +- .../select-naming-rule-on-rename-prompt.ts | 35 ++ apps/ui/src/api/applyPlan.ts | 29 ++ apps/ui/src/api/rejectPlan.ts | 30 ++ apps/ui/src/api/tryToRenameEpisodes.ts | 33 ++ .../components/RuleBasedRenameFilePrompt.tsx | 11 +- .../tv/useRuleBasedRenameFilesFlow.test.tsx | 293 ++++----------- .../hooks/tv/useRuleBasedRenameFilesFlow.ts | 349 +++++++----------- docs/dev/manage-plan.md | 175 +++++++++ docs/dev/overview.md | 42 ++- docs/dev/rename-episode-file.md | 9 + docs/dev/rename-episodes.md | 135 +++++++ docs/dev/rename-files.md | 83 ----- docs/dev/rename-folder.md | 8 + 31 files changed, 1565 insertions(+), 546 deletions(-) create mode 100644 apps/cli/src/cli/planFormat.test.ts create mode 100644 apps/cli/src/cli/planFormat.ts create mode 100644 apps/cli/src/route/RenameEpisodesPlan.ts create mode 100644 apps/e2e/cli/manage-plan.test.ts rename apps/e2e/cli/{rename-files.test.ts => rename-episodes.test.ts} (82%) rename apps/e2e/cli/{rename.test.ts => rename-folder.test.ts} (100%) rename apps/e2e/common/tv/{TVShow-Rename.e2e.ts => TVShow-RenameEpisodes.e2e.ts} (54%) create mode 100644 apps/e2e/test/steps/select-naming-rule-on-rename-prompt.ts create mode 100644 apps/ui/src/api/applyPlan.ts create mode 100644 apps/ui/src/api/rejectPlan.ts create mode 100644 apps/ui/src/api/tryToRenameEpisodes.ts create mode 100644 docs/dev/manage-plan.md create mode 100644 docs/dev/rename-episodes.md delete mode 100644 docs/dev/rename-files.md diff --git a/apps/cli/index.ts b/apps/cli/index.ts index edd9b665..4664e329 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'reject', 'plan', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) const firstArg = process.argv[2] const isCliHelp = firstArg === '--help' || firstArg === '-h' if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 4389932c..ce6968cf 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -46,6 +46,7 @@ import { handleDebugGetMediaFoldersRoute } from './src/route/debug/debugGetMedia import { handleDebugGetEpisodesToolRoute } from './src/route/debug/debugGetEpisodesTool'; import { handleDebugIsFolderExistToolRoute } from './src/route/debug/debugIsFolderExistTool'; import { handlePlans } from './src/route/Plans'; +import { handleRenameEpisodesPlan } from './src/route/RenameEpisodesPlan'; import { handleGetFolders } from './src/route/GetFolders'; import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleImportFolder } from './src/route/ImportFolder'; @@ -293,6 +294,7 @@ export class Server { handleDebugGetEpisodesToolRoute(this.app); handleDebugIsFolderExistToolRoute(this.app); handlePlans(this.app); + handleRenameEpisodesPlan(this.app); handleGetFolders(this.app); handleUnimportFolder(this.app); handleImportFolder(this.app); diff --git a/apps/cli/src/cli/planFormat.test.ts b/apps/cli/src/cli/planFormat.test.ts new file mode 100644 index 00000000..3fc85564 --- /dev/null +++ b/apps/cli/src/cli/planFormat.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { Plan } from 'core-app' +import { formatPlanDetailLines, formatPlanListLine, planFileCount } from './planFormat' + +const recognize: Plan = { + id: '11111111-1111-1111-1111-111111111111', + task: 'recognize-media-file', + status: 'pending', + creator: 'app', + mediaFolderPath: '/m/Show', + files: [{ season: 1, episode: 2, path: '/m/Show/S01E02.mkv' }], +} + +const rename: Plan = { + id: '22222222-2222-2222-2222-222222222222', + task: 'rename-files', + status: 'rejected', + creator: 'app', + mediaFolderPath: '/m/Show', + files: [{ from: '/m/Show/a.mkv', to: '/m/Show/b.mkv' }], +} + +describe('planFormat', () => { + it('formats list line', () => { + expect(formatPlanListLine(recognize)).toBe( + '11111111-1111-1111-1111-111111111111 recognize-media-file pending /m/Show', + ) + }) + + it('formats recognize detail like try-to-recognize', () => { + expect(formatPlanDetailLines(recognize)).toEqual([ + 'plan: 11111111-1111-1111-1111-111111111111', + 'task: recognize-media-file', + 'status: pending', + 'folder: /m/Show', + 'files:', + ' S01E02 /m/Show/S01E02.mkv', + ]) + }) + + it('formats rename detail like try-to-rename', () => { + expect(formatPlanDetailLines(rename)).toEqual([ + 'plan: 22222222-2222-2222-2222-222222222222', + 'task: rename-files', + 'status: rejected', + 'folder: /m/Show', + 'files:', + ' /m/Show/a.mkv → /m/Show/b.mkv', + ]) + }) + + it('planFileCount', () => { + expect(planFileCount(recognize)).toBe(1) + expect(planFileCount(rename)).toBe(1) + }) +}) diff --git a/apps/cli/src/cli/planFormat.ts b/apps/cli/src/cli/planFormat.ts new file mode 100644 index 00000000..fd21843e --- /dev/null +++ b/apps/cli/src/cli/planFormat.ts @@ -0,0 +1,38 @@ +import type { Plan } from 'core-app' + +/** One summary line for `smm plan list` (human-readable). */ +export function formatPlanListLine(plan: Plan): string { + return `${plan.id} ${plan.task} ${plan.status} ${plan.mediaFolderPath}` +} + +/** Multi-line detail matching try-to-recognize / try-to-rename output. */ +export function formatPlanDetailLines(plan: Plan): string[] { + const lines = [ + `plan: ${plan.id}`, + `task: ${plan.task}`, + `status: ${plan.status}`, + `folder: ${plan.mediaFolderPath}`, + 'files:', + ] + if (plan.files.length === 0) { + lines.push(' (none)') + return lines + } + if (plan.task === 'recognize-media-file') { + for (const f of plan.files) { + const ep = `S${String(f.season).padStart(2, '0')}E${String(f.episode).padStart(2, '0')}` + lines.push(` ${ep} ${f.path}`) + } + return lines + } + for (const f of plan.files) { + lines.push(` ${f.from} → ${f.to}`) + } + return lines +} + +export function planFileCount(plan: Plan): number { + return plan.task === 'recognize-media-file' || plan.task === 'rename-files' + ? plan.files.length + : 0 +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 5868de4a..a0da45cb 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -23,6 +23,11 @@ import { import { formatTmdbSearchResults } from './tmdbSearchFormat' import { formatTmdbDetailsTree } from './tmdbDetailsFormat' import { formatTvdbSearchResults } from './tvdbSearchFormat' +import { + formatPlanDetailLines, + formatPlanListLine, + planFileCount, +} from './planFormat' import { Path } from '@core/path' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] @@ -62,7 +67,7 @@ const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 const SCRAPE_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `scrape`, `rename-episode-file`, `job`, `config`, `tmdb`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `reject`, `plan`, `scrape`, `rename-episode-file`, `job`, `config`, `tmdb`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -282,25 +287,107 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + const applyPlanById = async (planId: string): Promise => { + try { + const plan = await getCore().getPlan(planId) + await getCore().applyPlan(plan) + console.log(`applied ${plan.id} (${planFileCount(plan)} file(s))`) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + } + + const rejectPlanById = async (planId: string): Promise => { + try { + const plan = await getCore().rejectPlan(planId) + console.log(`rejected ${plan.id}`) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + } + program .command('apply') .description('Apply a pending plan by id (recognize-media-file or rename-files)') .argument('', 'Plan id from try-to-recognize or try-to-rename') .action(async (planId: string) => { + await applyPlanById(planId) + }) + + program + .command('reject') + .description('Reject a plan by id (keeps plan file with status rejected)') + .argument('', 'Plan id') + .action(async (planId: string) => { + await rejectPlanById(planId) + }) + + const planCmd = program.command('plan').description('List, show, apply, or reject plans') + + planCmd + .command('list') + .description('List pending plans (optionally for a folder)') + .argument('[folder]', 'Media folder path (omit to list all)') + .option('-a, --all', 'Include rejected plans') + .option('-f, --format ', 'Output format (json)') + .action(async (folder: string | undefined, opts: { all?: boolean; format?: string }) => { + try { + const plans = await getCore().listPlans({ + mediaFolderPath: folder, + all: Boolean(opts.all), + }) + if (opts.format === 'json') { + printJson({ plans }) + return + } + for (const plan of plans) { + console.log(formatPlanListLine(plan)) + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + + planCmd + .command('show') + .description('Show a plan by id') + .argument('', 'Plan id') + .option('-f, --format ', 'Output format (json)') + .action(async (planId: string, opts: { format?: string }) => { try { const plan = await getCore().getPlan(planId) - await getCore().applyPlan(plan) - const count = - plan.task === 'recognize-media-file' || plan.task === 'rename-files' - ? plan.files.length - : 0 - console.log(`applied ${plan.id} (${count} file(s))`) + if (opts.format === 'json') { + printJson({ plan }) + return + } + for (const line of formatPlanDetailLines(plan)) { + console.log(line) + } } catch (error) { console.error(error instanceof Error ? error.message : String(error)) exitCode = 1 } }) + planCmd + .command('apply') + .description('Apply a pending plan by id (alias of smm apply)') + .argument('', 'Plan id') + .action(async (planId: string) => { + await applyPlanById(planId) + }) + + planCmd + .command('reject') + .description('Reject a plan by id (alias of smm reject)') + .argument('', 'Plan id') + .action(async (planId: string) => { + await rejectPlanById(planId) + }) + program .command('scrape') .description('Start TMDB scrape (poster, fanart, thumbnails, NFO) for a TV show folder') diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts new file mode 100644 index 00000000..fe8e1b4a --- /dev/null +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -0,0 +1,155 @@ +import type { Hono } from 'hono' +import { Path } from '@core/path' +import type { Plan } from 'core-app' +import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' +import { getCore } from '../core/getCore' +import { broadcast } from '@/utils/socketIO' +import { logger } from '../../lib/logger' + +export interface TryToRenameEpisodesRequestBody { + mediaFolderPath: string + rule?: 'plex' | 'emby' +} + +export interface TryToRenameEpisodesResponseBody { + data?: { plan: RenameFilesPlan } + error?: string +} + +export interface ApplyPlanRequestBody { + id: string +} + +export interface ApplyPlanResponseBody { + data?: { id: string } + error?: string +} + +export interface RejectPlanRequestBody { + id: string +} + +export interface RejectPlanResponseBody { + data?: { plan: Plan } + error?: string +} + +function readStringField(body: unknown, key: string): string | undefined { + if (typeof body !== 'object' || body === null || !(key in body)) return undefined + const value = (body as Record)[key] + return typeof value === 'string' ? value : undefined +} + +/** + * Rename-episodes plan HTTP surface matching docs/dev/rename-episodes.md: + * - POST /api/try-to-rename-episodes → Core.tryToRenameFolder + * - POST /api/apply-plan → Core.applyPlan + * - POST /api/reject-plan → Core.rejectPlan + */ +export function handleRenameEpisodesPlan(app: Hono): void { + app.post('/api/try-to-rename-episodes', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const mediaFolderPath = readStringField(body, 'mediaFolderPath') + if (!mediaFolderPath?.trim()) { + const err: TryToRenameEpisodesResponseBody = { + error: 'Error Reason: mediaFolderPath is required', + } + return c.json(err, 200) + } + + const ruleRaw = readStringField(body, 'rule') + const rule = + ruleRaw === 'plex' || ruleRaw === 'emby' || ruleRaw === undefined + ? ruleRaw + : undefined + if (ruleRaw !== undefined && rule === undefined) { + const err: TryToRenameEpisodesResponseBody = { + error: `Error Reason: Unsupported rename rule: ${ruleRaw}`, + } + return c.json(err, 200) + } + + const plan = await getCore().tryToRenameFolder(mediaFolderPath, rule) + const ok: TryToRenameEpisodesResponseBody = { data: { plan } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/try-to-rename-episodes] route error') + const err: TryToRenameEpisodesResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) + + app.post('/api/apply-plan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const id = readStringField(body, 'id') + if (!id?.trim()) { + const err: ApplyPlanResponseBody = { error: 'Error Reason: id is required' } + return c.json(err, 200) + } + + const clientId = c.req.header('clientId') + const plan = await getCore().getPlan(id) + await getCore().applyPlan(plan) + + if (plan.task === 'rename-files') { + broadcast({ + clientId: clientId ?? undefined, + event: 'mediaMetadataUpdated', + data: { folderPath: Path.posix(plan.mediaFolderPath) }, + }) + } + + const ok: ApplyPlanResponseBody = { data: { id: plan.id } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/apply-plan] route error') + const err: ApplyPlanResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) + + app.post('/api/reject-plan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const id = readStringField(body, 'id') + if (!id?.trim()) { + const err: RejectPlanResponseBody = { error: 'Error Reason: id is required' } + return c.json(err, 200) + } + + const plan = await getCore().rejectPlan(id) + const ok: RejectPlanResponseBody = { data: { plan } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/reject-plan] route error') + const err: RejectPlanResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index a814057f..f422e770 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -45,7 +45,13 @@ import { type RenameEpisodeFileResult, } from "./pipeline/renameEpisodeFile"; import { applyPlanPipeline } from "./pipeline/applyPlan"; -import { readPlan, type Plan } from "./pipeline/plans"; +import { + listPlans, + readPlan, + rejectPlan, + type ListPlansOptions, + type Plan, +} from "./pipeline/plans"; import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; import { @@ -371,6 +377,14 @@ export class Core { return plan; } + async listPlans(options?: ListPlansOptions): Promise { + return listPlans(this.fs, this.appDataDir, options); + } + + async rejectPlan(id: string): Promise { + return rejectPlan(this.fs, this.appDataDir, id); + } + async applyPlan(plan: Plan): Promise { await applyPlanPipeline(plan, { fs: this.fs, diff --git a/apps/core/src/pipeline/paths.ts b/apps/core/src/pipeline/paths.ts index 7bc98d01..61d2c31b 100644 --- a/apps/core/src/pipeline/paths.ts +++ b/apps/core/src/pipeline/paths.ts @@ -25,9 +25,14 @@ export function userConfigPath(appDataDir: string): string { return joinPosix(Path.posix(appDataDir), "smm.json"); } +/** `/plans`, POSIX form (same layout as core-routes). */ +export function plansDir(appDataDir: string): string { + return joinPosix(Path.posix(appDataDir), "plans"); +} + /** `/plans/{id}.plan.json`, POSIX form (same layout as core-routes). */ export function planFilePath(appDataDir: string, planId: string): string { - return joinPosix(Path.posix(appDataDir), "plans", `${planId}.plan.json`); + return joinPosix(plansDir(appDataDir), `${planId}.plan.json`); } /** diff --git a/apps/core/src/pipeline/plans.test.ts b/apps/core/src/pipeline/plans.test.ts index 202eb55d..ebc7a46d 100644 --- a/apps/core/src/pipeline/plans.test.ts +++ b/apps/core/src/pipeline/plans.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import type { FsPort } from "../ports/FsPort"; import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import { planFilePath } from "./paths"; -import { deletePlan, readPlan, writePlan } from "./plans"; +import { deletePlan, listPlans, readPlan, rejectPlan, writePlan } from "./plans"; function inMemoryFs(): FsPort & { raw: Map } { const files = new Map(); @@ -18,7 +19,10 @@ function inMemoryFs(): FsPort & { raw: Map } { }), writeBinaryFile: vi.fn(async () => {}), exists: vi.fn(async (path: string) => files.has(path)), - listFiles: vi.fn(async () => []), + listFiles: vi.fn(async (dir: string) => { + const prefix = dir.endsWith("/") ? dir : `${dir}/`; + return [...files.keys()].filter((p) => p.startsWith(prefix) || p === dir); + }), deleteFile: vi.fn(async (path: string) => { files.delete(path); }), @@ -27,6 +31,17 @@ function inMemoryFs(): FsPort & { raw: Map } { }; } +function recognizePlan( + overrides: Partial & Pick, +): RecognizeMediaFilePlan { + return { + task: "recognize-media-file", + creator: "app", + files: [], + ...overrides, + }; +} + describe("plans storage", () => { it("writes and reads a recognize plan", async () => { const fs = inMemoryFs(); @@ -48,4 +63,78 @@ describe("plans storage", () => { await deletePlan(fs, "/data", "missing"); expect(fs.deleteFile).toHaveBeenCalledWith(planFilePath("/data", "missing")); }); + + it("listPlans returns only active plans by default", async () => { + const fs = inMemoryFs(); + const pending = recognizePlan({ id: "a", mediaFolderPath: "/m/A", status: "pending" }); + const preparing = recognizePlan({ id: "b", mediaFolderPath: "/m/B", status: "preparing" }); + const rejected = recognizePlan({ id: "c", mediaFolderPath: "/m/A", status: "rejected" }); + await writePlan(fs, "/data", pending); + await writePlan(fs, "/data", preparing); + await writePlan(fs, "/data", rejected); + + const listed = await listPlans(fs, "/data"); + expect(listed.map((p) => p.id).sort()).toEqual(["a", "b"]); + }); + + it("listPlans all includes rejected", async () => { + const fs = inMemoryFs(); + await writePlan(fs, "/data", recognizePlan({ id: "a", mediaFolderPath: "/m/A", status: "pending" })); + await writePlan(fs, "/data", recognizePlan({ id: "c", mediaFolderPath: "/m/A", status: "rejected" })); + + const listed = await listPlans(fs, "/data", { all: true }); + expect(listed.map((p) => p.id).sort()).toEqual(["a", "c"]); + }); + + it("listPlans filters by mediaFolderPath", async () => { + const fs = inMemoryFs(); + await writePlan(fs, "/data", recognizePlan({ id: "a", mediaFolderPath: "/m/A", status: "pending" })); + await writePlan(fs, "/data", recognizePlan({ id: "b", mediaFolderPath: "/m/B", status: "pending" })); + + const listed = await listPlans(fs, "/data", { mediaFolderPath: "/m/A" }); + expect(listed.map((p) => p.id)).toEqual(["a"]); + }); + + it("listPlans returns empty when plans dir missing", async () => { + const fs = inMemoryFs(); + fs.listFiles = vi.fn(async () => { + throw new Error("ENOENT"); + }); + await expect(listPlans(fs, "/data")).resolves.toEqual([]); + }); + + it("rejectPlan sets status rejected and keeps file", async () => { + const fs = inMemoryFs(); + const plan = recognizePlan({ + id: "p1", + mediaFolderPath: "/m/Show", + status: "pending", + files: [{ season: 1, episode: 1, path: "/m/Show/S01E01.mkv" }], + }); + await writePlan(fs, "/data", plan); + + const rejected = await rejectPlan(fs, "/data", "p1"); + expect(rejected.status).toBe("rejected"); + expect(await readPlan(fs, "/data", "p1")).toEqual(rejected); + }); + + it("rejectPlan throws when missing", async () => { + const fs = inMemoryFs(); + await expect(rejectPlan(fs, "/data", "missing")).rejects.toThrow("Plan not found: missing"); + }); + + it("listPlans can return rename-files plans", async () => { + const fs = inMemoryFs(); + const rename: RenameFilesPlan = { + id: "r1", + task: "rename-files", + status: "pending", + creator: "app", + mediaFolderPath: "/m/Show", + files: [{ from: "/m/Show/a.mkv", to: "/m/Show/b.mkv" }], + }; + await writePlan(fs, "/data", rename); + const listed = await listPlans(fs, "/data"); + expect(listed).toEqual([rename]); + }); }); diff --git a/apps/core/src/pipeline/plans.ts b/apps/core/src/pipeline/plans.ts index 699ce0c4..0a80e836 100644 --- a/apps/core/src/pipeline/plans.ts +++ b/apps/core/src/pipeline/plans.ts @@ -1,10 +1,18 @@ +import { Path } from "@core/path"; +import { isActivePlanStatus } from "@core/types/planCommon"; import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "../ports/FsPort"; -import { planFilePath } from "./paths"; +import { planFilePath, plansDir } from "./paths"; export type Plan = RecognizeMediaFilePlan | RenameFilesPlan; +export interface ListPlansOptions { + mediaFolderPath?: string; + /** When true, include `rejected` plans in addition to active ones. */ + all?: boolean; +} + export async function writePlan(fs: FsPort, appDataDir: string, plan: Plan): Promise { await fs.writeTextFile(planFilePath(appDataDir, plan.id), JSON.stringify(plan, null, 2)); } @@ -26,3 +34,64 @@ export async function readPlan( export async function deletePlan(fs: FsPort, appDataDir: string, id: string): Promise { await fs.deleteFile(planFilePath(appDataDir, id)); } + +function isPlanFilePath(path: string): boolean { + return path.endsWith(".plan.json"); +} + +/** + * List plan files under `{appDataDir}/plans`. + * Default: active (`preparing` / `pending`) only. + * `all: true` also includes `rejected` (completed plans are deleted). + */ +export async function listPlans( + fs: FsPort, + appDataDir: string, + options: ListPlansOptions = {}, +): Promise { + const dir = plansDir(appDataDir); + let files: string[]; + try { + files = await fs.listFiles(dir); + } catch { + return []; + } + + const target = + options.mediaFolderPath !== undefined ? Path.posix(options.mediaFolderPath) : undefined; + const includeRejected = options.all === true; + const plans: Plan[] = []; + + for (const file of files) { + const posixFile = Path.posix(file); + if (!isPlanFilePath(posixFile)) continue; + let plan: Plan | null = null; + try { + plan = JSON.parse(await fs.readTextFile(posixFile)) as Plan; + } catch { + continue; + } + if (!plan || typeof plan !== "object" || !("status" in plan)) continue; + + const folder = Path.posix(plan.mediaFolderPath); + if (target !== undefined && folder !== target) continue; + + const active = isActivePlanStatus(plan.status); + if (!active && !(includeRejected && plan.status === "rejected")) continue; + + plans.push({ ...plan, mediaFolderPath: folder }); + } + + return plans; +} + +/** Mark a plan as rejected and keep the file on disk. */ +export async function rejectPlan(fs: FsPort, appDataDir: string, id: string): Promise { + const existing = await readPlan(fs, appDataDir, id); + if (!existing) { + throw new Error(`Plan not found: ${id}`); + } + const rejected: Plan = { ...existing, status: "rejected" }; + await writePlan(fs, appDataDir, rejected); + return rejected; +} diff --git a/apps/e2e/cli/manage-plan.test.ts b/apps/e2e/cli/manage-plan.test.ts new file mode 100644 index 00000000..86324549 --- /dev/null +++ b/apps/e2e/cli/manage-plan.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { existsSync, readFileSync } from 'node:fs' +import { $ } from 'bun' +import { tvShowFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { + createAndImportInitializedFolder, + parsePlanId, + planFilePath, + cliOutput, +} from './helpers' + +const timeout = 60_000 + +describe('manage-plan', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it( + 'plan list filters by folder and supports json', + async () => { + const folderA = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'ManagePlan A 123123' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const folderB = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'ManagePlan B 456456' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const pathA = folderA.path! + const pathB = folderB.path! + + const tried = await $`${bin} try-to-recognize ${pathA}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + + const listedAll = await $`${bin} plan list`.nothrow() + expect(listedAll.exitCode).toBe(0) + expect(listedAll.text()).toContain(planId) + expect(listedAll.text()).toContain('recognize-media-file') + expect(listedAll.text()).toContain('pending') + + const listedA = await $`${bin} plan list ${pathA}`.nothrow() + expect(listedA.exitCode).toBe(0) + expect(listedA.text()).toContain(planId) + + const listedB = await $`${bin} plan list ${pathB}`.nothrow() + expect(listedB.exitCode).toBe(0) + expect(listedB.text()).not.toContain(planId) + + const listJson = await $`${bin} plan list --format json`.nothrow() + expect(listJson.exitCode).toBe(0) + const listBody = JSON.parse(listJson.text()) as { plans: Array<{ id: string }> } + expect(listBody.plans.some((p) => p.id === planId)).toBe(true) + }, + timeout, + ) + + it( + 'plan show and plan apply', + async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'ManagePlan ShowApply 111' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const folderPath = folder.path! + + const tried = await $`${bin} try-to-recognize ${folderPath}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + + const shown = await $`${bin} plan show ${planId}`.nothrow() + expect(shown.exitCode).toBe(0) + expect(shown.text()).toMatch(new RegExp(`plan:\\s+${planId}`)) + expect(shown.text()).toMatch(/task:\s+recognize-media-file/) + expect(shown.text()).toContain('S01E01') + + const shownJson = await $`${bin} plan show ${planId} --format json`.nothrow() + expect(shownJson.exitCode).toBe(0) + const showBody = JSON.parse(shownJson.text()) as { plan: { id: string; task: string } } + expect(showBody.plan.id).toBe(planId) + expect(showBody.plan.task).toBe('recognize-media-file') + + const applied = await $`${bin} plan apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + expect(applied.text()).toMatch(/applied .* \(3 file\(s\)\)/) + expect(existsSync(await planFilePath(bin, planId))).toBe(false) + }, + timeout, + ) + + it( + 'reject hides from list unless --all', + async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'ManagePlan Reject 222' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const tried = await $`${bin} try-to-recognize ${folder.path!}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + + const rejected = await $`${bin} reject ${planId}`.nothrow() + expect(rejected.exitCode).toBe(0) + expect(rejected.text()).toMatch(new RegExp(`rejected\\s+${planId}`)) + expect(existsSync(await planFilePath(bin, planId))).toBe(true) + const planAfterReject = JSON.parse( + readFileSync(await planFilePath(bin, planId), 'utf-8'), + ) as { status: string } + expect(planAfterReject.status).toBe('rejected') + + const listedAfterReject = await $`${bin} plan list`.nothrow() + expect(listedAfterReject.exitCode).toBe(0) + expect(listedAfterReject.text()).not.toContain(planId) + + const listedAllStatuses = await $`${bin} plan list --all`.nothrow() + expect(listedAllStatuses.exitCode).toBe(0) + expect(listedAllStatuses.text()).toContain(planId) + expect(listedAllStatuses.text()).toContain('rejected') + }, + timeout, + ) + + it( + 'plan reject alias and missing plan', + async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'ManagePlan RejectAlias 789' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const tried = await $`${bin} try-to-recognize ${folder.path!}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + + const rejected = await $`${bin} plan reject ${planId}`.nothrow() + expect(rejected.exitCode).toBe(0) + expect(cliOutput(rejected)).toMatch(new RegExp(`rejected\\s+${planId}`)) + + const missing = await $`${bin} plan show missing-id`.nothrow() + expect(missing.exitCode).toBe(1) + expect(cliOutput(missing)).toMatch(/Plan not found/i) + }, + timeout, + ) + + it( + 'apply alias matches plan apply', + async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'ManagePlan ApplyAlias 333' }, + { updateMediaMetadata: (mm) => ({ ...mm, mediaFiles: [] }) }, + ) + const folderPath = folder.path! + + const tried = await $`${bin} try-to-recognize ${folderPath}`.nothrow() + expect(tried.exitCode).toBe(0) + const planId = parsePlanId(tried.text()) + + const applied = await $`${bin} apply ${planId}`.nothrow() + expect(applied.exitCode).toBe(0) + expect(applied.text()).toMatch(/applied .* \(3 file\(s\)\)/) + expect(existsSync(await planFilePath(bin, planId))).toBe(false) + }, + timeout, + ) +}) diff --git a/apps/e2e/cli/rename-files.test.ts b/apps/e2e/cli/rename-episodes.test.ts similarity index 82% rename from apps/e2e/cli/rename-files.test.ts rename to apps/e2e/cli/rename-episodes.test.ts index 935f1a75..77be0b69 100644 --- a/apps/e2e/cli/rename-files.test.ts +++ b/apps/e2e/cli/rename-episodes.test.ts @@ -110,6 +110,48 @@ describe('rename files', () => { ) }) + it('UC2: reject plex plan then try-to-rename --rule emby builds new plan', async () => { + const folder = await createAndImportInitializedFolder( + bin, + { ...tvShowFolder, folderName: 'SwitchRule 123123' }, + { + updateMediaMetadata: (mm) => + withWindowsSafeTvShowName({ ...mm, mediaFiles: [] }), + }, + ) + const folderPath = folder.path! + + await recognizeAndApply(bin, folderPath) + + const plexTried = await $`${bin} try-to-rename ${folderPath} --rule plex`.nothrow() + expect(plexTried.exitCode).toBe(0) + expect(plexTried.text()).toContain(PLEX_S01E01_BASENAME) + const plexPlanId = parsePlanId(plexTried.text()) + expect(existsSync(await planFilePath(bin, plexPlanId))).toBe(true) + + const rejected = await $`${bin} reject ${plexPlanId}`.nothrow() + expect(rejected.exitCode).toBe(0) + expect(existsSync(await planFilePath(bin, plexPlanId))).toBe(true) + const rejectedPlan = JSON.parse( + readFileSync(await planFilePath(bin, plexPlanId), 'utf-8'), + ) as { status: string } + expect(rejectedPlan.status).toBe('rejected') + + const embyTried = await $`${bin} try-to-rename ${folderPath} --rule emby`.nothrow() + expect(embyTried.exitCode).toBe(0) + expect(embyTried.text()).toContain(EMBY_S01E01_BASENAME) + expect(embyTried.text()).not.toContain(PLEX_S01E01_BASENAME) + + const embyPlanId = parsePlanId(embyTried.text()) + expect(embyPlanId).not.toBe(plexPlanId) + expect(existsSync(await planFilePath(bin, embyPlanId))).toBe(true) + + const applied = await $`${bin} apply ${embyPlanId}`.nothrow() + expect(applied.exitCode).toBe(0) + expect(existsSync(join(folderPath, 'Season 1', EMBY_S01E01_BASENAME))).toBe(true) + expect(existsSync(join(folderPath, 'Season 01', PLEX_S01E01_BASENAME))).toBe(false) + }) + it('associated files: apply renames same-stem subtitle alongside video', async () => { const folder = await createAndImportInitializedFolder( bin, diff --git a/apps/e2e/cli/rename.test.ts b/apps/e2e/cli/rename-folder.test.ts similarity index 100% rename from apps/e2e/cli/rename.test.ts rename to apps/e2e/cli/rename-folder.test.ts diff --git a/apps/e2e/common/tv/TVShow-RenameByPlan.e2e.ts b/apps/e2e/common/tv/TVShow-RenameByPlan.e2e.ts index 4ae6f19e..72752962 100644 --- a/apps/e2e/common/tv/TVShow-RenameByPlan.e2e.ts +++ b/apps/e2e/common/tv/TVShow-RenameByPlan.e2e.ts @@ -15,9 +15,9 @@ import { testbedOs } from 'test/lib/e2e-platform' const RENAMED_EPISODE_TABLE = `Specials S00E01 - - - - Season 1 -S01E01 Season 01/WATATEN!: an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv V V V -S01E02 Season 01/WATATEN!: an Angel Flew Down to Me - S01E02 - Incontestably Cute.mkv V V V -S01E03 Season 01/WATATEN!: an Angel Flew Down to Me - S01E03 - Imprinting.mkv V V V +S01E01 Season 01/WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv V V V +S01E02 Season 01/WATATEN an Angel Flew Down to Me - S01E02 - Incontestably Cute.mkv V V V +S01E03 Season 01/WATATEN an Angel Flew Down to Me - S01E03 - Imprinting.mkv V V V S01E04 - - - - S01E05 - - - - S01E06 - - - - @@ -79,7 +79,7 @@ describe('TVShow - Rename By Plan', () => { await browser.waitUntil( async () => (await TvShowPanelCO.toString()).includes( - 'Season 01/WATATEN!: an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv', + 'Season 01/WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv', ), { timeout: 15000, interval: 500 }, ) diff --git a/apps/e2e/common/tv/TVShow-Rename.e2e.ts b/apps/e2e/common/tv/TVShow-RenameEpisodes.e2e.ts similarity index 54% rename from apps/e2e/common/tv/TVShow-Rename.e2e.ts rename to apps/e2e/common/tv/TVShow-RenameEpisodes.e2e.ts index 34f78fc0..cba113af 100644 --- a/apps/e2e/common/tv/TVShow-Rename.e2e.ts +++ b/apps/e2e/common/tv/TVShow-RenameEpisodes.e2e.ts @@ -6,11 +6,44 @@ import { } from 'test/lib/browser-fs' import { TvShowPanelCO } from 'test/componentobjects/TVShowPanel.co' import Prompts from 'test/componentobjects/Prompts' +import { folder1 } from 'test/actions/import-folders' import { given, when, then, resetStepContext } from 'test/lib/gherkin' import 'test/steps' import { testbedOs } from 'test/lib/e2e-platform' +const RENAMED_PLEX_EPISODE_TABLE = `Specials +S00E01 - - - - +Season 1 +S01E01 Season 01/WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv V V V +S01E02 Season 01/WATATEN an Angel Flew Down to Me - S01E02 - Incontestably Cute.mkv V V V +S01E03 Season 01/WATATEN an Angel Flew Down to Me - S01E03 - Imprinting.mkv V V V +S01E04 - - - - +S01E05 - - - - +S01E06 - - - - +S01E07 - - - - +S01E08 - - - - +S01E09 - - - - +S01E10 - - - - +S01E11 - - - - +S01E12 - - - -` + +const RENAMED_EMBY_EPISODE_TABLE = `Specials +S00E01 - - - - +Season 1 +S01E01 Season 1/WATATEN an Angel Flew Down to Me S1E1 A Funny, Squirmy Feeling.mkv V V V +S01E02 Season 1/WATATEN an Angel Flew Down to Me S1E2 Incontestably Cute.mkv V V V +S01E03 Season 1/WATATEN an Angel Flew Down to Me S1E3 Imprinting.mkv V V V +S01E04 - - - - +S01E05 - - - - +S01E06 - - - - +S01E07 - - - - +S01E08 - - - - +S01E09 - - - - +S01E10 - - - - +S01E11 - - - - +S01E12 - - - -` + /** * @supports local, Electron, HarmonyOS, Docker */ @@ -55,6 +88,48 @@ describe('TVShow - Rename', () => { await then('"Rename" button is disabled') }) + it('UC1: confirm rename applies default Plex naming rule', async function () { + this.timeout(90 * 1000) + + await given('TV show folder with three episodes was imported and recognized') + await when(`folder "${folder1.folderName}" was selected`) + await when('I click "Rename" button') + await then('"Rename" prompt is open') + await when('I click "Confirm" on rename prompt') + await then('episode table shows renamed Plex-style paths', async () => { + await browser.waitUntil( + async () => + (await TvShowPanelCO.toString()).includes( + 'Season 01/WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv', + ), + { timeout: 15000, interval: 500 }, + ) + expect(await TvShowPanelCO.toString()).toContain(RENAMED_PLEX_EPISODE_TABLE) + }) + }) + + it('UC2: switch naming rule regenerates preview then apply Emby names', async function () { + this.timeout(90 * 1000) + + await given('TV show folder with three episodes was imported and recognized') + await when(`folder "${folder1.folderName}" was selected`) + await when('I click "Rename" button') + await then('"Rename" prompt is open') + await when('I select naming rule "emby" on rename prompt') + await then('"Rename" prompt shows Emby-style preview paths') + await when('I click "Confirm" on rename prompt') + await then('episode table shows renamed Emby-style paths', async () => { + await browser.waitUntil( + async () => + (await TvShowPanelCO.toString()).includes( + 'Season 1/WATATEN an Angel Flew Down to Me S1E1 A Funny, Squirmy Feeling.mkv', + ), + { timeout: 15000, interval: 500 }, + ) + expect(await TvShowPanelCO.toString()).toContain(RENAMED_EMBY_EPISODE_TABLE) + }) + }) + it('cancel rename dialog — prompt closes and files remain unchanged (S2)', async function () { this.timeout(60 * 1000) diff --git a/apps/e2e/test/componentobjects/Prompts.ts b/apps/e2e/test/componentobjects/Prompts.ts index 83caa790..043401f9 100644 --- a/apps/e2e/test/componentobjects/Prompts.ts +++ b/apps/e2e/test/componentobjects/Prompts.ts @@ -28,6 +28,23 @@ class Prompts { return browser.$('[data-testid="rule-based-recognize-hint-tooltip"]') } + get renameNamingRuleSelect() { + return browser.$('[data-testid="rename-naming-rule-select"]') + } + + namingRuleOption(rule: 'plex' | 'emby') { + return browser.$(`[data-testid="rename-naming-rule-option-${rule}"]`) + } + + async selectNamingRule(rule: 'plex' | 'emby'): Promise { + const trigger = await this.renameNamingRuleSelect + await trigger.waitForClickable({ timeout: 5000 }) + await trigger.click() + const option = await this.namingRuleOption(rule) + await option.waitForClickable({ timeout: 5000 }) + await option.click() + } + } export default new Prompts() \ No newline at end of file diff --git a/apps/e2e/test/steps/index.ts b/apps/e2e/test/steps/index.ts index b703bcb7..7fcdab27 100644 --- a/apps/e2e/test/steps/index.ts +++ b/apps/e2e/test/steps/index.ts @@ -11,6 +11,7 @@ import './rename-button-is-disabled' import './rename-button-is-clicked' import './recognize-button-is-clicked' import './rename-prompt-is-open' +import './select-naming-rule-on-rename-prompt' import './cancel-on-rename-prompt' import './rename-prompt-is-closed' import './confirm-rename-prompt' diff --git a/apps/e2e/test/steps/rename-prompt-is-open.ts b/apps/e2e/test/steps/rename-prompt-is-open.ts index d16fbc52..df0f76e0 100644 --- a/apps/e2e/test/steps/rename-prompt-is-open.ts +++ b/apps/e2e/test/steps/rename-prompt-is-open.ts @@ -13,8 +13,8 @@ registerStep('"Rename" prompt is open', async (_ctx, _args) => { const newVideoFilePaths = await TvShowPanelCO.newVideoFilePaths.map(i => i.getText()) expect(newVideoFilePaths).toEqual([ - 'Season 01/WATATEN!: an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv', - 'Season 01/WATATEN!: an Angel Flew Down to Me - S01E02 - Incontestably Cute.mkv', - 'Season 01/WATATEN!: an Angel Flew Down to Me - S01E03 - Imprinting.mkv', + 'Season 01/WATATEN an Angel Flew Down to Me - S01E01 - A Funny, Squirmy Feeling.mkv', + 'Season 01/WATATEN an Angel Flew Down to Me - S01E02 - Incontestably Cute.mkv', + 'Season 01/WATATEN an Angel Flew Down to Me - S01E03 - Imprinting.mkv', ]) }) diff --git a/apps/e2e/test/steps/select-naming-rule-on-rename-prompt.ts b/apps/e2e/test/steps/select-naming-rule-on-rename-prompt.ts new file mode 100644 index 00000000..9df38d4e --- /dev/null +++ b/apps/e2e/test/steps/select-naming-rule-on-rename-prompt.ts @@ -0,0 +1,35 @@ +import { expect, browser } from '@wdio/globals' +import { registerStep, requiredStepArg } from '../lib/gherkin' +import Prompts from '../componentobjects/Prompts' +import { TvShowPanelCO } from '../componentobjects/TVShowPanel.co' + +const EMBY_PREVIEW_PATHS = [ + 'Season 1/WATATEN an Angel Flew Down to Me S1E1 A Funny, Squirmy Feeling.mkv', + 'Season 1/WATATEN an Angel Flew Down to Me S1E2 Incontestably Cute.mkv', + 'Season 1/WATATEN an Angel Flew Down to Me S1E3 Imprinting.mkv', +] + +registerStep('I select naming rule "xxx" on rename prompt', async (_ctx, args) => { + const rule = requiredStepArg(args, 0) + if (rule !== 'plex' && rule !== 'emby') { + throw new Error(`Unsupported naming rule: ${rule}`) + } + await Prompts.selectNamingRule(rule) +}) + +registerStep('"Rename" prompt shows Emby-style preview paths', async () => { + await browser.waitUntil( + async () => { + const paths = await TvShowPanelCO.newVideoFilePaths.map((i) => i.getText()) + return paths.length === 3 && paths[0]?.includes('Season 1/') + }, + { + timeout: 30000, + interval: 500, + timeoutMsg: 'Expected Emby-style rename preview paths in episode table', + }, + ) + + const newVideoFilePaths = await TvShowPanelCO.newVideoFilePaths.map((i) => i.getText()) + expect(newVideoFilePaths).toEqual(EMBY_PREVIEW_PATHS) +}) diff --git a/apps/ui/src/api/applyPlan.ts b/apps/ui/src/api/applyPlan.ts new file mode 100644 index 00000000..04f3f9e1 --- /dev/null +++ b/apps/ui/src/api/applyPlan.ts @@ -0,0 +1,29 @@ +import { apiFetch } from '@/lib/apiFetch' + +export interface ApplyPlanRequest { + id: string +} + +export interface ApplyPlanResponseBody { + data?: { id: string } + error?: string +} + +/** POST /api/apply-plan — apply a pending plan by id. */ +export async function applyPlan( + request: ApplyPlanRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/apply-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`Failed to apply-plan: ${resp.statusText}`) + } + + return (await resp.json()) as ApplyPlanResponseBody +} diff --git a/apps/ui/src/api/rejectPlan.ts b/apps/ui/src/api/rejectPlan.ts new file mode 100644 index 00000000..75018c2e --- /dev/null +++ b/apps/ui/src/api/rejectPlan.ts @@ -0,0 +1,30 @@ +import type { Plan } from './getPlans' +import { apiFetch } from '@/lib/apiFetch' + +export interface RejectPlanRequest { + id: string +} + +export interface RejectPlanResponseBody { + data?: { plan: Plan } + error?: string +} + +/** POST /api/reject-plan — reject a plan by id (file kept with status rejected). */ +export async function rejectPlan( + request: RejectPlanRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/reject-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`Failed to reject-plan: ${resp.statusText}`) + } + + return (await resp.json()) as RejectPlanResponseBody +} diff --git a/apps/ui/src/api/tryToRenameEpisodes.ts b/apps/ui/src/api/tryToRenameEpisodes.ts new file mode 100644 index 00000000..c99224f3 --- /dev/null +++ b/apps/ui/src/api/tryToRenameEpisodes.ts @@ -0,0 +1,33 @@ +import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' +import { apiFetch } from '@/lib/apiFetch' + +export type RenameRuleName = 'plex' | 'emby' + +export interface TryToRenameEpisodesRequest { + mediaFolderPath: string + rule?: RenameRuleName +} + +export interface TryToRenameEpisodesResponseBody { + data?: { plan: RenameFilesPlan } + error?: string +} + +/** POST /api/try-to-rename-episodes — build a pending rename-files plan. */ +export async function tryToRenameEpisodes( + request: TryToRenameEpisodesRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/try-to-rename-episodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`Failed to try-to-rename-episodes: ${resp.statusText}`) + } + + return (await resp.json()) as TryToRenameEpisodesResponseBody +} diff --git a/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx b/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx index 1816f47d..09ce9f53 100644 --- a/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx +++ b/apps/ui/src/components/RuleBasedRenameFilePrompt.tsx @@ -62,12 +62,19 @@ export function RuleBasedRenameFilePrompt({ onNamingRulesSelected?.(value) }} > - + {namingRuleOptions.map((option) => ( - + {option.label} ))} diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx index 73f4d6e6..113db597 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest" -import { renderHook, waitFor } from "@testing-library/react" +import { renderHook, waitFor, act } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import type { ReactNode } from "react" import { useRuleBasedRenameFilesFlow } from "./useRuleBasedRenameFilesFlow" @@ -7,13 +7,19 @@ import { plansQueryKey } from "@/hooks/plans/plansQueryKeys" import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" import type { MediaMetadata } from "@core/types" -const { toastErrorMock, createPlanOptimisticMock, updatePlanMutateAsyncMock, generateNewFileNamesMock } = - vi.hoisted(() => ({ - toastErrorMock: vi.fn(), - createPlanOptimisticMock: vi.fn(), - updatePlanMutateAsyncMock: vi.fn(), - generateNewFileNamesMock: vi.fn(), - })) +const { + toastErrorMock, + tryToRenameEpisodesMock, + rejectPlanMock, + applyPlanMock, + fetchMediaMetadataMock, +} = vi.hoisted(() => ({ + toastErrorMock: vi.fn(), + tryToRenameEpisodesMock: vi.fn(), + rejectPlanMock: vi.fn(), + applyPlanMock: vi.fn(), + fetchMediaMetadataMock: vi.fn(), +})) vi.mock("sonner", () => ({ toast: { @@ -22,27 +28,21 @@ vi.mock("sonner", () => ({ }, })) -vi.mock("@/hooks/plans", () => ({ - useCreatePlanMutation: () => ({ - createPlanOptimistic: createPlanOptimisticMock, - }), - useUpdatePlanMutation: () => ({ - mutateAsync: updatePlanMutateAsyncMock, - }), - toUpdatePlanPatch: (patch: unknown) => patch, +vi.mock("@/api/tryToRenameEpisodes", () => ({ + tryToRenameEpisodes: (...args: unknown[]) => tryToRenameEpisodesMock(...args), })) -vi.mock("@/hooks/mediaMetadata/useUpdateMediaMetadataMutation", () => ({ - useUpdateMediaMetadataMutation: () => ({ persistMediaMetadata: vi.fn() }), +vi.mock("@/api/rejectPlan", () => ({ + rejectPlan: (...args: unknown[]) => rejectPlanMock(...args), })) -vi.mock("@/actions/handleRenamePromptConfirmForTvShow", () => ({ - handleRenamePromptConfirmForTvShow: vi.fn(), +vi.mock("@/api/applyPlan", () => ({ + applyPlan: (...args: unknown[]) => applyPlanMock(...args), })) -vi.mock("./useTvShowFileNameGeneration", () => ({ - useTvShowFileNameGeneration: () => ({ - generateNewFileNames: generateNewFileNamesMock, +vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ + useFetchMediaMetadataMutation: () => ({ + mutateAsync: fetchMediaMetadataMock, }), })) @@ -54,13 +54,13 @@ vi.mock("@/lib/i18n", () => ({ describe("useRuleBasedRenameFilesFlow", () => { const mediaFolderPath = "/storage/Users/currentUser/Download/Anime/show" - const preparingPlan: UIRenameFilesPlan = { + const pendingPlan: UIRenameFilesPlan = { id: "plan-1", task: "rename-files", - status: "preparing", + status: "pending", creator: "app", mediaFolderPath, - files: [], + files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/plex.mkv` }], } const mediaMetadata = { @@ -71,14 +71,6 @@ describe("useRuleBasedRenameFilesFlow", () => { mediaFiles: [{ absolutePath: `${mediaFolderPath}/S01E01.mkv`, seasonNumber: 1, episodeNumber: 1 }], } as MediaMetadata - const noMediaMetadata = { - mediaFolderPath, - type: "tvshow-folder", - tvShow: { id: "123", name: "Test Show", seasons: [] }, - files: [], - mediaFiles: [], - } as MediaMetadata - let queryClient: QueryClient const wrapper = ({ children }: { children: ReactNode }) => ( @@ -90,48 +82,39 @@ describe("useRuleBasedRenameFilesFlow", () => { defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) vi.clearAllMocks() - createPlanOptimisticMock.mockResolvedValue(preparingPlan) - updatePlanMutateAsyncMock.mockResolvedValue(null) - generateNewFileNamesMock.mockReturnValue({ - id: "generated", - task: "rename-files", - status: "pending", - creator: "app", - mediaFolderPath, - files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/S01E01 - Episode.mkv` }], - }) + tryToRenameEpisodesMock.mockResolvedValue({ data: { plan: pendingPlan } }) + rejectPlanMock.mockResolvedValue({ data: { plan: { ...pendingPlan, status: "rejected" } } }) + applyPlanMock.mockResolvedValue({ data: { id: pendingPlan.id } }) + fetchMediaMetadataMock.mockResolvedValue(mediaMetadata) }) - it("shows failure toast and rejects plan when no media files are associated with the folder", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) - generateNewFileNamesMock.mockReturnValue(null) - - renderHook( + it("startRenameFlow calls try-to-rename-episodes with the default rule", async () => { + const { result } = renderHook( () => useRuleBasedRenameFilesFlow({ - plans: [preparingPlan], - mediaMetadata: noMediaMetadata, + plans: [], + mediaMetadata, uiStatus: "ok", beforeConfirm: (plan) => plan, }), { wrapper }, ) - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith( - "Unable to generate a rename plan. Check that episodes have video files.", - ) + act(() => { + result.current.startRenameFlow() }) - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ - id: "plan-1", - mediaFolderPath, - patch: { status: "rejected" }, + await waitFor(() => { + expect(tryToRenameEpisodesMock).toHaveBeenCalledWith({ + mediaFolderPath, + rule: "plex", + }) }) + expect(rejectPlanMock).not.toHaveBeenCalled() }) - it("shows failure toast when createPlan fails during startRenameFlow", async () => { - createPlanOptimisticMock.mockRejectedValue(new Error("create plan failed")) + it("shows failure toast when try-to-rename-episodes fails", async () => { + tryToRenameEpisodesMock.mockResolvedValue({ error: "Error Reason: boom" }) const { result } = renderHook( () => @@ -144,74 +127,53 @@ describe("useRuleBasedRenameFilesFlow", () => { { wrapper }, ) - result.current.startRenameFlow() + act(() => { + result.current.startRenameFlow() + }) await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith("create plan failed") + expect(toastErrorMock).toHaveBeenCalledWith("Error Reason: boom") }) }) - it("fails preparing plan when metadata loading errors", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) + it("switches naming rule via reject-plan then try-to-rename-episodes", async () => { + const embyPlan: UIRenameFilesPlan = { + ...pendingPlan, + id: "plan-2", + files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/emby.mkv` }], + } + queryClient.setQueryData(plansQueryKey(mediaFolderPath), [pendingPlan]) + tryToRenameEpisodesMock.mockResolvedValue({ data: { plan: embyPlan } }) - renderHook( + const { result } = renderHook( () => useRuleBasedRenameFilesFlow({ - plans: [preparingPlan], + plans: [pendingPlan], mediaMetadata, - uiStatus: "error_loading_metadata", + uiStatus: "ok", beforeConfirm: (plan) => plan, }), { wrapper }, ) - await waitFor(() => { - expect(toastErrorMock).toHaveBeenCalledWith("Rename failed. Please try again.") + await act(async () => { + await result.current.onNamingRuleSelected("emby") }) - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ - id: "plan-1", + expect(rejectPlanMock).toHaveBeenCalledWith({ id: "plan-1" }) + expect(tryToRenameEpisodesMock).toHaveBeenCalledWith({ mediaFolderPath, - patch: { status: "rejected" }, + rule: "emby", }) }) - it("updates plan to pending when rename files are generated on first prompt open", async () => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) - - renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [preparingPlan], - mediaMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) - - await waitFor(() => { - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ - id: "plan-1", - mediaFolderPath, - patch: { - status: "pending", - files: [ - { - from: `${mediaFolderPath}/S01E01.mkv`, - to: `${mediaFolderPath}/S01E01 - Episode.mkv`, - }, - ], - }, - }) - }) - }) + it("confirm calls apply-plan and refreshes media metadata", async () => { + queryClient.setQueryData(plansQueryKey(mediaFolderPath), [pendingPlan]) - it("startRenameFlow only creates plan and does not generate preview", async () => { const { result } = renderHook( () => useRuleBasedRenameFilesFlow({ - plans: [], + plans: [pendingPlan], mediaMetadata, uiStatus: "ok", beforeConfirm: (plan) => plan, @@ -219,22 +181,15 @@ describe("useRuleBasedRenameFilesFlow", () => { { wrapper }, ) - result.current.startRenameFlow() - - await waitFor(() => { - expect(createPlanOptimisticMock).toHaveBeenCalled() + await act(async () => { + await result.current.onConfirm("plan-1") }) - expect(updatePlanMutateAsyncMock).not.toHaveBeenCalled() - expect(generateNewFileNamesMock).not.toHaveBeenCalled() + expect(applyPlanMock).toHaveBeenCalledWith({ id: "plan-1" }) + expect(fetchMediaMetadataMock).toHaveBeenCalledWith({ path: mediaFolderPath }) }) - it("regenerates preview when user selects a different naming rule", async () => { - const pendingPlan: UIRenameFilesPlan = { - ...preparingPlan, - status: "pending", - files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/plex.mkv` }], - } + it("cancel calls reject-plan", async () => { queryClient.setQueryData(plansQueryKey(mediaFolderPath), [pendingPlan]) const { result } = renderHook( @@ -248,108 +203,10 @@ describe("useRuleBasedRenameFilesFlow", () => { { wrapper }, ) - updatePlanMutateAsyncMock.mockClear() - generateNewFileNamesMock.mockReturnValue({ - id: "generated", - task: "rename-files", - status: "pending", - creator: "app", - mediaFolderPath, - files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/emby.mkv` }], - }) - - await result.current.onNamingRuleSelected("emby") - - expect(generateNewFileNamesMock).toHaveBeenCalledWith("emby") - expect(updatePlanMutateAsyncMock).toHaveBeenCalledWith({ - id: "plan-1", - mediaFolderPath, - patch: { - status: "pending", - files: [{ from: `${mediaFolderPath}/S01E01.mkv`, to: `${mediaFolderPath}/emby.mkv` }], - }, + await act(async () => { + await result.current.onCancel("plan-1") }) - }) - - // Regression: see issue — when all video files already match the default naming - // rule (e.g. Plex), the rename prompt must stay open so the user can switch to a - // different rule (e.g. Emby). Today the hook closes the prompt and shows a - // misleading "no video files" error toast. - describe("when all video files already match the default naming rule", () => { - const allMatchMetadata = { - mediaFolderPath, - type: "tvshow-folder", - tvShow: { - id: "123", - name: "Test Show", - seasons: [ - { - season: 1, - name: "Season 1", - episodes: [{ season: 1, episode: 1, name: "Pilot" }], - }, - ], - }, - files: ["Season 01/S01E01.mkv"], - mediaFiles: [ - { - absolutePath: `${mediaFolderPath}/Season 01/S01E01.mkv`, - seasonNumber: 1, - episodeNumber: 1, - }, - ], - } as MediaMetadata - beforeEach(() => { - queryClient.setQueryData(plansQueryKey(mediaFolderPath), [preparingPlan]) - // buildTvShowRenamePlanFileEntries returns [] when all generated paths - // match the current paths; useTvShowFileNameGeneration returns null in - // that case. We mimic that here. - generateNewFileNamesMock.mockReturnValue(null) - }) - - it("does not reject the rename plan — keeps the prompt open so the user can switch naming rules", async () => { - renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [preparingPlan], - mediaMetadata: allMatchMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) - - await waitFor(() => { - expect(generateNewFileNamesMock).toHaveBeenCalled() - }) - - expect(updatePlanMutateAsyncMock).not.toHaveBeenCalledWith( - expect.objectContaining({ - patch: { status: "rejected" }, - }), - ) - }) - - it("does not show the 'unable to generate rename plan' error toast when mediaFiles are populated", async () => { - renderHook( - () => - useRuleBasedRenameFilesFlow({ - plans: [preparingPlan], - mediaMetadata: allMatchMetadata, - uiStatus: "ok", - beforeConfirm: (plan) => plan, - }), - { wrapper }, - ) - - await waitFor(() => { - expect(generateNewFileNamesMock).toHaveBeenCalled() - }) - - expect(toastErrorMock).not.toHaveBeenCalledWith( - "Unable to generate a rename plan. Check that episodes have video files.", - ) - }) + expect(rejectPlanMock).toHaveBeenCalledWith({ id: "plan-1" }) }) }) diff --git a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts index 1b50af96..ecc8f7f8 100644 --- a/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useRuleBasedRenameFilesFlow.ts @@ -1,15 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" -import { handleRenamePromptConfirmForTvShow } from "@/actions/handleRenamePromptConfirmForTvShow" -import { renameFiles } from "@/api/renameFiles" +import { applyPlan } from "@/api/applyPlan" +import { rejectPlan } from "@/api/rejectPlan" +import { tryToRenameEpisodes, type RenameRuleName } from "@/api/tryToRenameEpisodes" import { selectActiveAppPlan } from "@/components/tv/plans/selectActiveAppPlan" -import { useTvShowFileNameGeneration } from "./useTvShowFileNameGeneration" -import { useCreatePlanMutation, toUpdatePlanPatch, useUpdatePlanMutation } from "@/hooks/plans" import { plansQueryKey } from "@/hooks/plans/plansQueryKeys" -import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" -import { useOnFirstOpen } from "@/hooks/useOnFirstOpen" -import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" +import { + mediaMetadataQueryKey, + normalizeMediaFolderPathForQuery, +} from "@/lib/mediaMetadataQueryKeys" import { useTranslation } from "@/lib/i18n" import type { RenameToolbarOption } from "@/components/tv/plans/TvShowAppPlanPromptContext" import type { Plan } from "@/api/getPlans" @@ -32,29 +33,27 @@ function fileBaseName(path: string): string { return i >= 0 ? path.slice(i + 1) : path } +/** + * Rule-based rename flow aligned with docs/dev/rename-episodes.md: + * try-to-rename-episodes → (reject-plan + try-to-rename-episodes on rule switch) → apply-plan. + */ export function useRuleBasedRenameFilesFlow({ plans, mediaMetadata, - uiStatus, + uiStatus: _uiStatus, beforeConfirm, onFlowStart, }: UseRuleBasedRenameFilesFlowOptions) { const { t } = useTranslation(["components"]) const queryClient = useQueryClient() const mediaFolderPath = mediaMetadata?.mediaFolderPath - const createPlanMutation = useCreatePlanMutation() - const updatePlanMutation = useUpdatePlanMutation() - const { persistMediaMetadata } = useUpdateMediaMetadataMutation() - const generationRef = useRef(new Set()) - /** Ensures preview generation waits until createPlan API persists the plan file. */ - const pendingCreateRef = useRef | null>(null) + const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() + const [loading, setLoading] = useState(false) + const inFlightRef = useRef(false) const renameFailedMessage = t("toast.renameFailed", { defaultValue: "Rename failed. Please try again.", }) - const noRenameFilesMessage = t("toast.noRenameFiles", { - defaultValue: "Unable to generate a rename plan. Check that episodes have video files.", - }) const namingRuleOptions = useMemo( (): RenameToolbarOption[] => [ @@ -64,7 +63,7 @@ export function useRuleBasedRenameFilesFlow({ [t], ) - const [selectedNamingRule, setSelectedNamingRule] = useState<"plex" | "emby">( + const [selectedNamingRule, setSelectedNamingRule] = useState( namingRuleOptions[0]?.value ?? "plex", ) @@ -79,165 +78,146 @@ export function useRuleBasedRenameFilesFlow({ ) const open = plan !== undefined - const loading = plan?.status === "preparing" - const { generateNewFileNames } = useTvShowFileNameGeneration({ - mediaMetadata, - selectedNamingRule, - }) + const plansKey = useMemo( + () => + mediaFolderPath + ? plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) + : null, + [mediaFolderPath], + ) + + const upsertPlanInCache = useCallback( + (next: UIRenameFilesPlan, removeId?: string) => { + if (!plansKey) return + queryClient.setQueryData(plansKey, (prev) => { + const list = (prev ?? []).filter( + (p) => p.id !== next.id && (removeId === undefined || p.id !== removeId), + ) + return [...list, next] + }) + }, + [plansKey, queryClient], + ) const removePlanFromCache = useCallback( (planId: string) => { - if (!mediaFolderPath) return - console.log("[rename] removed stuck rename plan from UI cache", { planId, mediaFolderPath }) - const key = plansQueryKey(normalizeMediaFolderPathForQuery(mediaFolderPath)) - queryClient.setQueryData(key, (prev) => + if (!plansKey) return + console.log("[rename] removed rename plan from UI cache", { planId, mediaFolderPath }) + queryClient.setQueryData(plansKey, (prev) => (prev ?? []).filter((p) => p.id !== planId), ) }, - [mediaFolderPath, queryClient], + [plansKey, mediaFolderPath, queryClient], ) - const failRenamePlan = useCallback( - async (planId: string, message: string, reason: string) => { - console.warn("[rename] rename flow failed — closing prompt", { planId, reason, message }) - toast.error(message) + const requestTryToRename = useCallback( + async (rule: RenameRuleName, removePlanId?: string): Promise => { if (!mediaFolderPath) { - removePlanFromCache(planId) - return + throw new Error("No media folder path available") } - try { - await updatePlanMutation.mutateAsync({ - id: planId, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "rejected" }), - }) - console.log("[rename] rename plan marked rejected", { planId }) - } catch (error) { - console.error("[rename] could not reject rename plan on server, cleared from cache", { - planId, - error, - }) - removePlanFromCache(planId) + console.log("[rename] POST /api/try-to-rename-episodes", { + mediaFolderPath, + namingRule: rule, + }) + const resp = await tryToRenameEpisodes({ mediaFolderPath, rule }) + if (resp.error || !resp.data?.plan) { + throw new Error(resp.error ?? "try-to-rename-episodes: empty response") } + const next = resp.data.plan as UIRenameFilesPlan + upsertPlanInCache(next, removePlanId) + console.log("[rename] rename preview ready — user can review and confirm", { + planId: next.id, + namingRule: rule, + fileCount: next.files.length, + preview: next.files.map((f) => ({ + from: fileBaseName(f.from), + to: fileBaseName(f.to), + })), + }) + return next }, - [mediaFolderPath, updatePlanMutation, removePlanFromCache], + [mediaFolderPath, upsertPlanInCache], ) - const applyGeneratedRenamePlan = useCallback( - async (planId: string, rule: "plex" | "emby") => { - const renamePlan = generateNewFileNames(rule) - if (renamePlan && renamePlan.files.length > 0) { - await updatePlanMutation.mutateAsync({ - id: planId, - mediaFolderPath: mediaFolderPath!, - patch: toUpdatePlanPatch({ status: "pending", files: renamePlan.files }), - }) - console.log("[rename] rename preview ready — user can review and confirm", { - planId, - namingRule: rule, - tvShow: mediaMetadata?.tvShow?.name, - fileCount: renamePlan.files.length, - preview: renamePlan.files.map((f) => ({ - from: fileBaseName(f.from), - to: fileBaseName(f.to), - })), - }) - return - } - - // The candidate list is empty. Two cases: - // 1. No media files at all → genuine failure (existing behavior). - // 2. Media files exist but all already match this naming rule → keep - // the prompt open so the user can switch to a different rule. - const mediaFileCount = mediaMetadata?.mediaFiles?.length ?? 0 - if (mediaFileCount > 0) { - // All files already match this naming rule — update plan to pending with 0 files - // so the prompt can show appropriate UI (confirm disabled, info message). - await updatePlanMutation.mutateAsync({ - id: planId, - mediaFolderPath: mediaFolderPath!, - patch: toUpdatePlanPatch({ status: "pending", files: [] }), - }) - console.log( - "[rename] all files already match naming rule — prompt open, confirm disabled", - { planId, namingRule: rule, mediaFileCount, tvShow: mediaMetadata?.tvShow?.name }, - ) - return + const requestRejectPlan = useCallback( + async (planId: string): Promise => { + console.log("[rename] POST /api/reject-plan", { planId }) + const resp = await rejectPlan({ id: planId }) + if (resp.error) { + throw new Error(resp.error) } - - await failRenamePlan(planId, noRenameFilesMessage, "no rename candidates") + removePlanFromCache(planId) + console.log("[rename] rename plan rejected", { planId }) }, - [ - generateNewFileNames, - mediaMetadata?.mediaFiles?.length, - mediaMetadata?.tvShow?.name, - mediaFolderPath, - updatePlanMutation, - failRenamePlan, - noRenameFilesMessage, - ], + [removePlanFromCache], ) /** - * Generate or refresh the rename preview for the active plan. - * Triggered only from: - * 1. First open of RuleBasedRenameFilePrompt (default naming rule via useOnFirstOpen) - * 2. User changing the naming rule dropdown in the prompt + * Generate or refresh the rename preview. + * Triggered from: + * 1. Click Rename (default naming rule) + * 2. User changing the naming rule dropdown (reject + try-to-rename) */ const onNamingRuleSelected = useCallback( - async (rule: "plex" | "emby") => { - if (!plan || !mediaFolderPath || !mediaMetadata) { - console.warn("[rename] cannot generate preview — plan or metadata missing", { rule }) + async (rule: RenameRuleName) => { + if (!mediaFolderPath) { + console.warn("[rename] cannot generate preview — media folder path missing", { rule }) toast.error(renameFailedMessage) return } - if (generationRef.current.has(plan.id)) { + if (inFlightRef.current) { return } - generationRef.current.add(plan.id) + inFlightRef.current = true + setLoading(true) + + const previousPlanId = plan?.id + const isSwitch = previousPlanId !== undefined - const isInitialGeneration = plan.status === "preparing" && plan.files.length === 0 console.log( - isInitialGeneration - ? "[rename] rename prompt opened — generating preview with default naming rule" - : "[rename] user selected naming rule — regenerating preview", + isSwitch + ? "[rename] user selected naming rule — regenerating preview" + : "[rename] rename prompt opened — generating preview with default naming rule", { - planId: plan.id, + planId: previousPlanId, namingRule: rule, - planStatus: plan.status, - tvShow: mediaMetadata.tvShow?.name, + planStatus: plan?.status, + tvShow: mediaMetadata?.tvShow?.name, mediaFolderPath, }, ) - console.log("[rename] computing episode file names for rename preview", { - planId: plan.id, - namingRule: rule, - }) - try { - await applyGeneratedRenamePlan(plan.id, rule) + if (previousPlanId) { + await requestRejectPlan(previousPlanId) + } + await requestTryToRename(rule, previousPlanId) } catch (error) { console.error("[rename] failed to build rename preview", { - planId: plan.id, + planId: previousPlanId, namingRule: rule, error, }) const message = error instanceof Error && error.message ? error.message : renameFailedMessage - await failRenamePlan(plan.id, message, "preview generation error") + toast.error(message) + if (previousPlanId) { + removePlanFromCache(previousPlanId) + } } finally { - generationRef.current.delete(plan.id) + inFlightRef.current = false + setLoading(false) } }, [ plan, mediaFolderPath, - mediaMetadata, - applyGeneratedRenamePlan, - failRenamePlan, + mediaMetadata?.tvShow?.name, + requestRejectPlan, + requestTryToRename, + removePlanFromCache, renameFailedMessage, ], ) @@ -252,14 +232,16 @@ export function useRuleBasedRenameFilesFlow({ return } - if (!mediaMetadata) { + if (!mediaMetadata || !mediaFolderPath) { console.warn("[rename] user confirmed but media metadata missing", { planId }) toast.error("No media metadata available") return } + // Selection filtering is applied client-side for UX; server apply uses the stored plan. + // Checkbox-limited applies still go through apply-plan (sequence diagram). const preparedPlan = beforeConfirm(targetPlan) - console.log("[rename] user confirmed — renaming files on disk", { + console.log("[rename] user confirmed — applying plan", { planId, fileCount: preparedPlan.files.length, files: preparedPlan.files.map((f) => ({ @@ -269,28 +251,15 @@ export function useRuleBasedRenameFilesFlow({ }) try { - await handleRenamePromptConfirmForTvShow( - { - planId, - plan: preparedPlan, - mediaMetadata, - selectedEpisodePaths: preparedPlan.files.map((f) => f.from), - renameFailedLabel: t("episodeFile.renameFailed"), - noMediaPathErrorLabel: t("movie.noMediaPathError"), - }, - { - setPlanById: async (id, patch) => { - if (!mediaFolderPath) return - await updatePlanMutation.mutateAsync({ - id, - mediaFolderPath, - patch: toUpdatePlanPatch(patch), - }) - }, - persistUiMediaMetadata: persistMediaMetadata, - renameFilesApi: renameFiles, - }, - ) + console.log("[rename] POST /api/apply-plan", { planId }) + const resp = await applyPlan({ id: planId }) + if (resp.error) { + throw new Error(resp.error) + } + removePlanFromCache(planId) + const pathPosix = normalizeMediaFolderPathForQuery(mediaFolderPath) + await queryClient.invalidateQueries({ queryKey: mediaMetadataQueryKey(pathPosix) }) + await fetchMediaMetadata({ path: mediaFolderPath }) console.log("[rename] rename completed successfully", { planId }) } catch (error) { console.error("[rename] unexpected error while applying rename", { planId, error }) @@ -302,9 +271,9 @@ export function useRuleBasedRenameFilesFlow({ mediaMetadata, mediaFolderPath, beforeConfirm, - updatePlanMutation, - persistMediaMetadata, - t, + removePlanFromCache, + queryClient, + fetchMediaMetadata, renameFailedMessage, ], ) @@ -312,15 +281,8 @@ export function useRuleBasedRenameFilesFlow({ const onCancel = useCallback( async (planId: string) => { console.log("[rename] user cancelled rename preview", { planId }) - if (!mediaFolderPath) { - return - } try { - await updatePlanMutation.mutateAsync({ - id: planId, - mediaFolderPath, - patch: toUpdatePlanPatch({ status: "rejected" }), - }) + await requestRejectPlan(planId) console.log("[rename] rename plan cancelled", { planId }) } catch (error) { console.error("[rename] failed to cancel rename plan, cleared from cache", { planId, error }) @@ -328,10 +290,10 @@ export function useRuleBasedRenameFilesFlow({ toast.error(renameFailedMessage) } }, - [mediaFolderPath, updatePlanMutation, removePlanFromCache, renameFailedMessage], + [requestRejectPlan, removePlanFromCache, renameFailedMessage], ) - /** Opens RuleBasedRenameFilePrompt by creating an empty preparing plan. Preview generation is deferred to prompt open / naming rule selection. */ + /** Opens RuleBasedRenameFilePrompt by calling try-to-rename-episodes with the default rule. */ const startRenameFlow = useCallback(() => { if (!mediaFolderPath) { console.warn("[rename] cannot start — media folder path missing") @@ -341,76 +303,21 @@ export function useRuleBasedRenameFilesFlow({ onFlowStart?.() - const planId = crypto.randomUUID() console.log("[rename] user clicked rename — opening rename prompt", { - planId, mediaFolderPath, defaultNamingRule: selectedNamingRule, tvShow: mediaMetadata?.tvShow?.name, }) - const createPromise = createPlanMutation.createPlanOptimistic({ - id: planId, - task: "rename-files", - mediaFolderPath, - creator: "app", - }) - pendingCreateRef.current = createPromise - void createPromise - .then(() => { - console.log("[rename] empty rename plan created, waiting for prompt to generate preview", { - planId, - }) - }) - .catch((err) => { - console.error("[rename] failed to create rename plan", { planId, error: err }) - const message = - err instanceof Error && err.message ? err.message : renameFailedMessage - toast.error(message) - removePlanFromCache(planId) - }) - .finally(() => { - if (pendingCreateRef.current === createPromise) { - pendingCreateRef.current = null - } - }) + void onNamingRuleSelected(selectedNamingRule) }, [ mediaFolderPath, mediaMetadata?.tvShow?.name, onFlowStart, - createPlanMutation, selectedNamingRule, - renameFailedMessage, - removePlanFromCache, + onNamingRuleSelected, ]) - useOnFirstOpen( - () => { - void (async () => { - const pending = pendingCreateRef.current - if (pending) { - await pending.catch(() => undefined) - } - await onNamingRuleSelected(selectedNamingRule) - })() - }, - open && - plan?.status === "preparing" && - plan.files.length === 0 && - !!mediaMetadata && - uiStatus === "ok", - [plan?.id, selectedNamingRule, onNamingRuleSelected, mediaMetadata, uiStatus], - ) - - useEffect(() => { - if (plan?.status === "preparing" && uiStatus === "error_loading_metadata") { - console.warn("[rename] metadata failed to load while preparing rename preview", { - planId: plan.id, - }) - void failRenamePlan(plan.id, renameFailedMessage, "metadata load error") - } - }, [plan?.id, plan?.status, uiStatus, failRenamePlan, renameFailedMessage]) - useEffect(() => { if (plan) { onFlowStart?.() diff --git a/docs/dev/manage-plan.md b/docs/dev/manage-plan.md new file mode 100644 index 00000000..5a474b1f --- /dev/null +++ b/docs/dev/manage-plan.md @@ -0,0 +1,175 @@ +# Manage Plan + +**Supported Platform** Web UI, CLI, Electron, ohos, MCP tool, AI tool +**Status** done + +The term "Plan" is a concept of task that require user's review and approval. + +For example: + +**Rename Plan** stores the old and new file names +**Recognize Plan** stores the mapping from episode to local media file + +This page focus on a technical solution to create/approve/reject plan. This page doesn't care the actual plan and how the plan apply. + +## CLI + +``` +smm plan list [-a|--all] [-f|--format json] # list all pending plans +smm plan list [-a|--all] [-f|--format json] # list pending plans for given folder + +smm plan show [-f|--format json] +smm plan apply +smm apply # alias to "plan apply" +smm plan reject +smm reject # alias to "plan reject" + +# commands creates various types of plan +smm try-to-recognize +smm try-to-rename +``` + +- Default `plan list` shows active plans (`preparing` / `pending`). +- `-a` / `--all` also includes `rejected` (completed plans are deleted). +- Human-readable list: one line per plan ` `. +- `plan show` prints the same multi-line detail as try-to-recognize / try-to-rename. + +## HTTP API + +Source Code: `packages/core-routes/src/plansApi.ts` (handlers), `packages/core-routes/src/routes/plansRoute.ts` (Node http routes). CLI wrapper: `apps/cli/src/route/Plans.ts`. + +Plans are persisted as `{appDataDir}/plans/{planId}.plan.json`. All endpoints use RPC-style `POST` and return HTTP `200` with `{ data, error }` — business success/failure is determined by the `error` field. Requires `appDataDir` to be configured; otherwise returns `Error Reason: appDataDir is not configured`. + +Plan lifecycle: `preparing` → `pending` → `completed` | `rejected`. Active plans (`preparing` / `pending`) are listed by `getPlans`; terminal `completed` deletes the plan file; terminal `rejected` keeps the file so in-flight AI workflows can detect cancellation. + +### `POST /api/getPlans` + +List active (non-terminal) plans for a media folder. Equivalent to `smm plan list `. + +Request body: + +```json +{ "mediaFolderPath": "" } +``` + +Response: + +```json +{ "data": { "plans": [ /* RecognizeMediaFilePlan | RenameFilesPlan */ ] } } +``` + +### `POST /api/getPlanById` + +Load a single plan by id. Equivalent to `smm plan show `. + +Request body: + +```json +{ "id": "" } +``` + +Response: + +```json +{ "data": { "plan": { /* RecognizeMediaFilePlan | RenameFilesPlan */ } } } +``` + +Returns `{ "error": "Error Reason: Plan with id \"…\" not found" }` when missing. + +### `POST /api/createPlan` + +Create an empty plan in `preparing` status. Used by rule-based UI flows and AI/MCP task begin tools before entries are added. + +Request body: + +```json +{ + "id": "", + "task": "recognize-media-file" | "rename-files", + "mediaFolderPath": "", + "creator": "app" | "ai" +} +``` + +Response: + +```json +{ "data": { "plan": { /* plan with status \"preparing\", files: [] */ } } } +``` + +### `POST /api/updatePlan` + +Patch a plan's `status` and/or `files`. Replaces `files` wholesale when provided (not append). Equivalent to approve/reject in the CLI sense: + +- **Apply** — frontend runs domain apply logic (rename files, update metadata, etc.), then calls with `{ "id", "status": "completed" }` to delete the plan file. +- **Reject** — call with `{ "id", "status": "rejected" }` to mark cancelled; the plan file is kept. + +Request body: + +```json +{ + "id": "", + "status": "preparing" | "pending" | "completed" | "rejected", + "files": [ /* RecognizedFile[] or RenameFileEntry[] */ ] +} +``` + +`RecognizedFile`: `{ season: number, episode: number, path: string }` (POSIX absolute path). + +`RenameFileEntry`: `{ from: string, to: string }` (POSIX absolute paths). + +Response: + +```json +{ "data": { "plan": { /* updated plan (or last snapshot before delete) */ } } } +``` + +Returns `{ "error": "Error Reason: Plan with id \"…\" not found" }` when missing. + +## Apply Plan + +The term "Frontend" represent frotends of all supported platforms. +Talk to server via method call or HTTP API. +See [Overview](./overview.md). + +```mermaid +sequenceDiagram + participant F as Frontend + participant S as Server + participant C as Core + participant File + participant P as PlanExecutor + + F->>S: create plan + S->>C: createPlan() + C->>File: write + C->>S: plan id + S->>F: plan id + F->>S: apply plan + S->>C: applyPlan() + C->>P: deliver by type + P->>C: return + C->>File: write + File->>C: return + C->>S: return + S->>F: return +``` + +## Reject Plan + +```mermaid +sequenceDiagram + participant F as Frontend + participant S as Server + participant C as Core + participant File + + F->>S: create plan + S->>C: createPlan() + C->>C: save plan to disk + C->>S: plan id + S->>F: plan id + F->>S: reject plan + S->>C: rejectPlan() + C->>File: set status to "rejected" +``` \ No newline at end of file diff --git a/docs/dev/overview.md b/docs/dev/overview.md index ba6c6b9d..84e5257c 100644 --- a/docs/dev/overview.md +++ b/docs/dev/overview.md @@ -5,6 +5,44 @@ This document describe the overview architecture of SMM. Key Requirements * Multi-Frontend - Web UI, Electron, HarmonyOS(ohos), CLI, AI Tool, MCP Tool frontends * Multi-Distribution - Desktop app, HarmonyOS app, Docker image, CLI +* Layering - The core module abstract the iteraction and state managmenet for SMM business + +```mermaid +graph TD + subgraph L1[Frontend 层] + A[CLI] + B[WebUI/MCP/AI] + end + + subgraph L2[Server 层] + C[Node.js Server] + D[Electron] + end + + subgraph L3[Core 层] + E[业务逻辑抽象接口] + end + + subgraph L4[Adapter 层] + F[FsPort] + G[NetworkPort] + H[LoggingPort] + I[NotificationPort] + end + + %% 自上而下的调用关系 + A -->|直接函数调用| C + B -->|HTTP API 调用| C + B -->|HTTP API 调用| D + + C -->|函数调用接口| E + D -->|函数调用接口| E + + E -->|适配调用| F + E -->|适配调用| G + E -->|适配调用| H + E -->|适配调用| I +``` ```mermaid flowchart TB @@ -15,8 +53,8 @@ flowchart TB ohos["ohos"] AI["AI Tool"] MCP["MCP Tool"] - CORE["apps/core"] - N["apps/cli"] + CORE["core"] + N["Node.js Server"] NET["NetworkPort"] FS["FsPort"] LOG["LoggingPort"] diff --git a/docs/dev/rename-episode-file.md b/docs/dev/rename-episode-file.md index 213c7726..b3359170 100644 --- a/docs/dev/rename-episode-file.md +++ b/docs/dev/rename-episode-file.md @@ -1,5 +1,14 @@ # Rename Episode File (context-menu rename → Core) + +SMM provides 3 renaming functions: +**Rename Folder** rename media folder, see [Rename Folder](./rename-folder.md) +**Rename Episodes** Rename recognized episode files all at once. See [Rename Episode Files](./rename-episodes.md) +**Rename Episode** Rename single recognized episode file + +This page is for **Rename Episode**. Don't get confused. + + This design document is the **golden source** for porting the **TV episode context-menu “Rename”** flow into Layer 2 Core, then wiring Layer 1 clients through the Internal HTTP API (and thin Core callers). **Naming:** Core / HTTP / CLI / MCP / AI use **`renameEpisodeFile`** / **`rename-episode-file`** only. Do **not** name this `renameVideoFile` — that is too broad. This API renames an **episode’s linked media file** (and same-stem associates) inside a **TV show** folder. It does **not** support renaming arbitrary video files (e.g. orphan files, music videos, or movie-panel videos). diff --git a/docs/dev/rename-episodes.md b/docs/dev/rename-episodes.md new file mode 100644 index 00000000..85c06cef --- /dev/null +++ b/docs/dev/rename-episodes.md @@ -0,0 +1,135 @@ +# Rename Episodes + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** wip + +SMM provides 3 renaming functions: +**Rename Folder** rename media folder, see [Rename Folder](./rename-folder.md) +**Rename Episodes** Rename recognized episode files all at once +**Rename Episode** Rename single recognized episode file, see [Rename Episode File](./rename-episode-file.md) + +This page is for **Rename Episodes**. Don't get confused. + + +## CLI + +``` +smm try-to-rename [--rule plex|emby] +smm reject +smm apply +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm try-to-rename --rule plex + CLI->>Core: tryToRenameFolder(folder, rule) + Core->>Fs: read metadata + listFiles + Core->>Core: build rename pairs (video + associates) + Core->>Fs: write plans/{id}.plan.json + Core-->>CLI: RenameFilesPlan (pending) + CLI-->>User: plan id + from → to lines +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + participant Fs as FsPort + + User->>CLI: smm apply + CLI->>Core: getPlan(planId) + CLI->>Core: applyPlan(plan) + Core->>Fs: mkdir + rename files + Core->>Fs: update metadata cache + Core->>Fs: delete plan file + Core-->>CLI: void + CLI-->>User: applied N file(s) +``` + + +## Web UI, Electron and ohos + + +### UC1: Rename episodes + +When user click the rename button +Then UI display rename plan with default naming rule(Plex/Emby) +When user click the confirm button +Then SMM apply plan to rename episode files + +```mermaid +sequenceDiagram + participant U as User + participant W as UI + participant S as Server + participant C as Core + + U->>W: click rename button + W->>S: POST /api/try-to-rename-episodes + S->>C: tryToRenameEpisodes() + C->>S: plan id + data + S->>W: plan id + data + W->>U: display plan + U->>W: click confirm button + W->>S: POST /api/apply-plan + S->>C: applyPlan() + W->>W: invalidate useMediaMetadataQuery +``` + + +### UC2: Switch naming rule + +When user click the rename button +Then UI display rename plan with default naming rule(Plex/Emby) +When user switch to another naming rule +Then UI display new rename plan + +```mermaid +sequenceDiagram + participant U as User + participant W as UI + participant S as Server + participant C as Core + + U->>W: click rename button + W->>S: POST /api/try-to-rename-episodes + S->>C: tryToRenameEpisodes() + C->>S: plan id + data + U->>W: switch naming rule + W->>S: POST /api/reject-plan + S->>C: rejectPlan() + W->>S: POST /api/try-to-rename-episodes + S->>C: tryToRenameEpisodes() + S->>W: plan id + data + W->>U: display plan + U->>W: click confirm button + W->>S: POST /api/apply-plan + S->>C: applyPlan() + W->>W: invalidate useMediaMetadataQuery +``` + +# Testing + +| Use Case | Platform | Test | +|--|--|--| +| UC1 | Web UI, Electron and Ohos | apps/e2e/common/tv/TVShow-RenameEpisodes.e2e.ts | +| UC1 | CLI | apps/e2e/cli/rename-episodes.test.ts | +| UC2 | Web UI, Electron and Ohos | apps/e2e/common/tv/TVShow-RenameEpisodes.e2e.ts | +| UC2 | CLI | apps/e2e/cli/rename-episodes.test.ts | + + +## References + +[Manage Plan](./manage-plan.md) + +[Recognize](./recognize.md) + +[Rename Episode File](./rename-episode-file.md) — single-episode context-menu rename (not rule batch) + +[Supported Platform](./supported-platform.md) diff --git a/docs/dev/rename-files.md b/docs/dev/rename-files.md deleted file mode 100644 index bff4ff57..00000000 --- a/docs/dev/rename-files.md +++ /dev/null @@ -1,83 +0,0 @@ -# Rename Files (plex / emby rule) - -**Supported Platform** Web UI, CLI, Electron, ohos -**Status** done - -## CLI - -``` -smm try-to-rename [--rule plex|emby] -smm apply -``` - -```mermaid -sequenceDiagram - participant User - participant CLI - participant Core - participant Fs as FsPort - - User->>CLI: smm try-to-rename --rule plex - CLI->>Core: tryToRenameFolder(folder, rule) - Core->>Fs: read metadata + listFiles - Core->>Core: build rename pairs (video + associates) - Core->>Fs: write plans/{id}.plan.json - Core-->>CLI: RenameFilesPlan (pending) - CLI-->>User: plan id + from → to lines -``` - -```mermaid -sequenceDiagram - participant User - participant CLI - participant Core - participant Fs as FsPort - - User->>CLI: smm apply - CLI->>Core: getPlan(planId) - CLI->>Core: applyPlan(plan) - Core->>Fs: mkdir + rename files - Core->>Fs: update metadata cache - Core->>Fs: delete plan file - Core-->>CLI: void - CLI-->>User: applied N file(s) -``` - -```mermaid -flowchart TD - A[try-to-rename] --> B{folder managed tvshow?} - B -->|no| Z[error] - B -->|yes| C{rule plex or emby?} - C -->|no| Z - C -->|yes| D[for each linked episode file] - D --> E[compute target path under Season folder] - E --> F{from equals to?} - F -->|yes| G[skip] - F -->|no| H[add pair + associate stems] - H --> I[persist pending plan] - I --> J[apply renames on disk] -``` - -## Web UI, Electron and ohos - -```mermaid -sequenceDiagram - participant User - participant Browser - participant CLI - participant Core - - User->>Browser: Rename confirm (rule) - Browser->>CLI: createPlan(rename-files) + apply - CLI->>Core: tryToRenameFolder / applyPlan - Core-->>CLI: metadata + disk updated - CLI-->>Browser: { data } -``` - -## References - -[Recognize](./recognize.md) - -[Rename Episode File](./rename-episode-file.md) — single-episode context-menu rename (not rule batch) - -[Supported Platform](./supported-platform.md) diff --git a/docs/dev/rename-folder.md b/docs/dev/rename-folder.md index b0f3d8ba..746c4ea3 100644 --- a/docs/dev/rename-folder.md +++ b/docs/dev/rename-folder.md @@ -3,6 +3,14 @@ **Supported Platform** Web UI, CLI, Electron, ohos **Status** done + +SMM provides 3 renaming functions: +**Rename Folder** rename media folder +**Rename Episodes** Rename recognized episode files all at once. See [Rename Episode Files](./rename-episodes.md) +**Rename Episode** Rename single recognized episode file. see [Rename Episode File](./rename-episode-file.md) + +This page is for **Rename Folder**. Don't get confused. + ## CLI ``` From b01996eb28d1c2a56798a47bd00f290c6f147154 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 00:41:10 +0800 Subject: [PATCH 140/214] docs: add docs recognize-folder.md --- docs/dev/recognize-folder.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/dev/recognize-folder.md diff --git a/docs/dev/recognize-folder.md b/docs/dev/recognize-folder.md new file mode 100644 index 00000000..ad8838a4 --- /dev/null +++ b/docs/dev/recognize-folder.md @@ -0,0 +1,36 @@ +# Recognize Folder + +**Supported Platform** CLI +**Status** wip + +We have two similar recognition functions, don't get confused: + +**Recognize Folder** – determines the TV series or movie title for a given media folder. This is what this page describes. +**Recognize Episodes** – matches local video files to their correct episode numbers within a series. This function is described in [Recognize Episodes](./recognize-episodes.md) + + +There are two way to recognize folder: +1. manual - User tell SMM the TV series or movie for a folder +2. auto - Let SMM find out + +## CLI + +``` +# Manual +smm recognize --db tmdb/tvdb --id + +# Auto +smm try-to-recognize --skip-episodes +Is it "{title} ({release year})"? [Y/n] +> Y +Metadata is updated +``` + +NOTE: `try-to-recognize` starts both folder recognition and episode recognition. Use `--skip-episodes` to skip the episode recognition process. + + +## References + +[Import Folder](./import-folder.md) + +[Supported Platform](./supported-platform.md) From 566be7a56864911047b5d27db9c0771e0b0b3989 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 02:44:58 +0800 Subject: [PATCH 141/214] docs: add design for CLI smm recognize (folder) Co-authored-by: Cursor --- .../2026-08-26-cli-recognize-folder-design.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-cli-recognize-folder-design.md diff --git a/docs/superpowers/specs/2026-08-26-cli-recognize-folder-design.md b/docs/superpowers/specs/2026-08-26-cli-recognize-folder-design.md new file mode 100644 index 00000000..f65e6bf0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-cli-recognize-folder-design.md @@ -0,0 +1,135 @@ +# CLI `smm recognize` (Recognize Folder) + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +Product doc: [docs/dev/recognize-folder.md](../../dev/recognize-folder.md). + +## 1. Background + +**Recognize Folder** assigns a TMDB/TVDB TV series or movie to an imported media folder. It is distinct from **Recognize Episodes** (`smm try-to-recognize`), which only maps local video files to season/episode numbers. + +Today folder recognition runs only inside `importFolder`. Operators need a dedicated CLI to (re)recognize an already-imported folder: + +- **Manual:** pass database + id +- **Auto:** probe with the same rules as import, confirm interactively (or `--yes`) + +## 2. Architecture + +### 2.1 Project Level Architecture + +```mermaid +sequenceDiagram + participant U as User + participant CLI as apps/cli runCli + participant C as Core + participant DB as TMDB/TVDB + + alt Manual + U->>CLI: smm recognize folder --db tmdb|tvdb --id id + CLI->>C: recognizeFolder(path, { db, id }) + C->>DB: getTvShow/MovieMediaMetadata + C->>C: setMetadata (mediaFiles=[]) + else Auto + U->>CLI: smm recognize folder [--yes] + CLI->>C: tryToRecognizeFolder(path) + C->>C: recognizeMediaFolder (no write) + C-->>CLI: candidate + CLI->>U: Is it "title (year)"? [Y/n] + U->>CLI: Y or --yes + CLI->>C: recognizeFolder(path, { db, id }) + end +``` + +No new HTTP routes, MCP tools, or UI changes in this feature. + +### 2.2 App Level Architecture + +| Piece | Location | +|-------|----------| +| `tryToRecognizeFolder` / `recognizeFolder` | `apps/core` pipeline + `Core` methods | +| Command | `apps/cli/src/cli/runCli.ts` → `smm recognize` | +| Optional readline helper | small CLI helper next to `runCli` if needed | +| Core unit tests | `apps/core` (in-memory fs + mock network) | +| Live CLI e2e | `apps/e2e/cli/recognize-folder.test.ts` (new; keep existing `recognize.test.ts` for episodes) | +| Product doc | update `docs/dev/recognize-folder.md` status / `--yes` | + +### 2.3 Key Design + +#### Command surface + +```bash +smm recognize --db tmdb|tvdb --id +smm recognize +smm recognize --yes +``` + +| Case | Behavior | +|------|----------| +| `--db` and `--id` both set | Call `recognizeFolder`; `--yes` ignored | +| Only one of `--db` / `--id` | Error, exit 1 | +| Neither set | `tryToRecognizeFolder` → prompt; `--yes` accepts; empty input = Y; `n`/`N` cancels (exit 0, no write) | +| No candidate | Error, exit 1 | +| Unmanaged / music / missing metadata | Error, exit 1 | +| Success | Print short confirmation (e.g. `Metadata is updated`) | + +#### Core API + +```ts +type RecognizeFolderDb = "tmdb" | "tvdb" + +interface RecognizeFolderCandidate { + db: RecognizeFolderDb + id: string + title: string + year?: string // first 4 chars of airDate when present + kind: "tvshow" | "movie" +} + +tryToRecognizeFolder(path: string): Promise + +recognizeFolder( + path: string, + options: { db: RecognizeFolderDb; id: string }, +): Promise +``` + +- **Preconditions:** folder imported; metadata exists; type is `tvshow-folder` or `movie-folder`. +- **`tryToRecognizeFolder`:** reuse `recognizeMediaFolder` (NFO → tmdbid/tvdbid in folder name → search ordered by `primaryDatabase`). Map hit to candidate; do **not** write. No hit → throw. +- **`recognizeFolder`:** fetch full show/movie metadata via existing TMDB/TVDB clients (`getTvShowMediaMetadata` / `getMovieMediaMetadata`) for the folder’s type + `db`/`id`. Persist via `setMetadata`: + - keep `mediaFolderPath`, `type` + - set `tvShow` **or** `movie` (clear the other) + - force `mediaFiles: []` +- **Out of scope:** episode matching, picking movie video file, HTTP/MCP/UI. + +## 3. User Stories + +### 3.1 UC1 — Recognize with TMDB id + +* **Given** - an imported TV show folder with blank/skip-init metadata +* **When** - `smm recognize --db tmdb --id 84666` then `smm metadata ` +* **Then** - exit 0; metadata shows TMDB tvShow (e.g. id `84666`); `mediaFiles` empty + +### 3.2 UC2 — Recognize with TVDB id + +* **Given** - an imported TV show folder with skip-init metadata +* **When** - `smm recognize --db tvdb --id 421069` then `smm metadata ` +* **Then** - exit 0; metadata shows TVDB tvShow; `mediaFiles` empty + +### 3.3 Auto with `--yes` + +* **Given** - imported skip-init folder whose name contains `{tmdbid=…}` (or otherwise recognizable by import rules) +* **When** - `smm recognize --yes` +* **Then** - no interactive prompt; metadata updated; `mediaFiles` empty + +### 3.4 Reject unmanaged / incomplete flags + +* **Given** - unmanaged path, or only `--db` / only `--id` +* **When** - `smm recognize …` +* **Then** - exit 1 with clear stderr message + +## 4. Testing + +- **e2e:** `apps/e2e/cli/recognize-folder.test.ts` — UC1, UC2, auto `--yes`, unmanaged, flag pairing +- **Core unit:** `tryToRecognizeFolder` hit/miss; `recognizeFolder` write + `mediaFiles: []` +- Do **not** change episode coverage in `apps/e2e/cli/recognize.test.ts` From 6b4a98de8af29561e29de0b26cc0434bb0d50095 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 02:47:52 +0800 Subject: [PATCH 142/214] docs: add implementation plan for smm recognize folder Co-authored-by: Cursor --- .../plans/2026-08-26-cli-recognize-folder.md | 919 ++++++++++++++++++ 1 file changed, 919 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-cli-recognize-folder.md diff --git a/docs/superpowers/plans/2026-08-26-cli-recognize-folder.md b/docs/superpowers/plans/2026-08-26-cli-recognize-folder.md new file mode 100644 index 00000000..e08fea3d --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-cli-recognize-folder.md @@ -0,0 +1,919 @@ +# CLI `smm recognize` (Recognize Folder) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement `smm recognize [--db tmdb|tvdb --id ] [--yes]` so operators can (re)assign TMDB/TVDB title metadata to an imported folder, with auto probe + confirm, without matching episodes. + +**Architecture:** New Core pipelines `tryToRecognizeFolder` (reuse `recognizeMediaFolder`, no write) and `recognizeFolder` (fetch by db+id, persist with `mediaFiles: []`). CLI wraps both; interactive confirm uses readline unless `--yes`. + +**Tech Stack:** TypeScript, Vitest (`apps/core`, `apps/cli`), Bun test (`apps/e2e/cli`), Commander, Core `FsPort` / `TmdbClient` / `TvdbClient`. + +**Spec:** `docs/superpowers/specs/2026-08-26-cli-recognize-folder-design.md` + +## Global Constraints + +- Do **not** match episodes or pick movie video files after recognition. +- Always persist `mediaFiles: []` on successful `recognizeFolder`. +- Reuse `recognizeMediaFolder` for auto probe; do not fork its rule order. +- No HTTP / MCP / UI changes. +- Keep `apps/e2e/cli/recognize.test.ts` (episodes) unchanged; add `recognize-folder.test.ts`. +- Follow red-green: fail first, then implement. +- Core methods throw `Error`; CLI prints message, exit 1. + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/pipeline/recognizeFolder.ts` | `tryToRecognizeFolderPipeline` + `recognizeFolderPipeline` + candidate types | +| `apps/core/src/pipeline/recognizeFolder.test.ts` | Pipeline unit tests | +| `apps/core/src/Core.ts` | Public `tryToRecognizeFolder` / `recognizeFolder` | +| `apps/core/src/Core.test.ts` | Core-level tests (managed check, write) | +| `apps/core/src/index.ts` | Export candidate types | +| `apps/cli/src/cli/runCli.ts` | Register `recognize` command | +| `apps/cli/src/cli/recognizeConfirm.ts` | Prompt helper (`confirmRecognizeCandidate`) | +| `apps/cli/src/cli/recognize.test.ts` | CLI unit tests with mocked Core | +| `apps/e2e/cli/recognize-folder.test.ts` | Live e2e UC1/UC2/auto/--flags | +| `docs/dev/recognize-folder.md` | Document `--yes`, mark status | + +--- + +### Task 1: Core pipeline `tryToRecognizeFolder` / `recognizeFolder` + +**Files:** +- Create: `apps/core/src/pipeline/recognizeFolder.ts` +- Create: `apps/core/src/pipeline/recognizeFolder.test.ts` + +**Interfaces:** +- Consumes: `recognizeMediaFolder`, `RecognitionDeps`, `FsPort`, `UserConfig`, `metadataCachePath`, `TmdbRecognitionClient` / `TvdbRecognitionClient` +- Produces: + ```ts + export type RecognizeFolderDb = "tmdb" | "tvdb"; + export interface RecognizeFolderCandidate { + db: RecognizeFolderDb; + id: string; + title: string; + year?: string; + kind: "tvshow" | "movie"; + } + export interface RecognizeFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + tmdb: TmdbRecognitionClient; + tvdb: TvdbRecognitionClient; + language: string; + primaryDatabase?: PrimaryDatabase; + } + export async function tryToRecognizeFolderPipeline(path: string, deps: RecognizeFolderDeps): Promise + export async function recognizeFolderPipeline(path: string, options: { db: RecognizeFolderDb; id: string }, deps: RecognizeFolderDeps): Promise + ``` + +- [ ] **Step 1: Write the failing test** + +Create `apps/core/src/pipeline/recognizeFolder.test.ts`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { UserConfig } from "./userConfig"; +import { + recognizeFolderPipeline, + tryToRecognizeFolderPipeline, + type RecognizeFolderDeps, +} from "./recognizeFolder"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + }; +} + +function deps(partial: Partial & { fs: FsPort; appDataDir: string }): RecognizeFolderDeps { + const userConfig = new UserConfig(partial.fs, `${partial.appDataDir}/smm.json`); + return { + userConfig, + normalizePosix: (p) => p.replace(/\\/g, "/"), + language: "en-US", + tmdb: { + search: vi.fn(async () => ({ results: [], page: 1, total_pages: 1, total_results: 0 })), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + }, + tvdb: { + searchSeries: vi.fn(async () => []), + searchMovie: vi.fn(async () => []), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + }, + ...partial, + }; +} + +describe("tryToRecognizeFolderPipeline", () => { + it("returns candidate from tmdbid in folder name without writing", async () => { + const mm = { + mediaFolderPath: "/m/Show {tmdbid=84666}", + type: "tvshow-folder" as const, + mediaFiles: [{ absolutePath: "/m/Show {tmdbid=84666}/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const cacheKey = "/data/metadata/_m_Show_{tmdbid=84666}.json"; // use real metadataCachePath in impl tests + // Prefer: seed via metadataCachePath helper in the real test file. + const fs = inMemoryFs({ + "/data/smm.json": JSON.stringify({ folders: ["/m/Show {tmdbid=84666}"] }), + }); + const d = deps({ fs, appDataDir: "/data" }); + // After writing cache with metadataCachePath — see Step 3 for exact seed using metadataCachePath. + await d.userConfig.update(() => ({ folders: ["/m/Show {tmdbid=84666}"] } as never)); + const { metadataCachePath } = await import("./paths"); + const cachePath = metadataCachePath("/data", "/m/Show {tmdbid=84666}"); + await fs.writeTextFile(cachePath, JSON.stringify(mm)); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + database: "TMDB", + id: "84666", + name: "WATATEN", + airDate: "2019-01-08", + seasons: [], + }); + + const candidate = await tryToRecognizeFolderPipeline("/m/Show {tmdbid=84666}", d); + expect(candidate).toEqual({ + db: "tmdb", + id: "84666", + title: "WATATEN", + year: "2019", + kind: "tvshow", + }); + const writes = (fs.writeTextFile as ReturnType).mock.calls.filter( + ([p]) => p === cachePath, + ); + expect(writes).toHaveLength(1); // only the seed write above + }); + + it("throws when unmanaged", async () => { + const fs = inMemoryFs({ "/data/smm.json": JSON.stringify({ folders: [] }) }); + const d = deps({ fs, appDataDir: "/data" }); + await expect(tryToRecognizeFolderPipeline("/m/Other", d)).rejects.toThrow(/not managed by SMM/); + }); +}); + +describe("recognizeFolderPipeline", () => { + it("writes tvShow and clears mediaFiles", async () => { + const mm = { + mediaFolderPath: "/m/Show", + type: "tvshow-folder" as const, + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ "/data/smm.json": JSON.stringify({ folders: ["/m/Show"] }) }); + const d = deps({ fs, appDataDir: "/data" }); + const { metadataCachePath } = await import("./paths"); + const cachePath = metadataCachePath("/data", "/m/Show"); + await fs.writeTextFile(cachePath, JSON.stringify(mm)); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + database: "TMDB", + id: "84666", + name: "WATATEN", + airDate: "2019-01-08", + seasons: [{ season: 1, name: "Season 1", episodes: [] }], + }); + + await recognizeFolderPipeline("/m/Show", { db: "tmdb", id: "84666" }, d); + + const raw = await fs.readTextFile(cachePath); + const saved = JSON.parse(raw) as typeof mm & { tvShow: { id: string }; movie?: unknown }; + expect(saved.tvShow.id).toBe("84666"); + expect(saved.mediaFiles).toEqual([]); + expect(saved.movie).toBeUndefined(); + }); +}); +``` + +Adjust `metadataCachePath` seeding to match `apps/core/src/pipeline/paths.ts` (do not invent cache key strings). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/core && pnpm exec vitest run src/pipeline/recognizeFolder.test.ts` + +Expected: FAIL (module not found) + +- [ ] **Step 3: Write minimal implementation** + +Create `apps/core/src/pipeline/recognizeFolder.ts`: + +```typescript +import { Path } from "@core/path"; +import type { MediaMetadata, MovieMediaMetadata, PrimaryDatabase, TvShowMediaMetadata } from "@smm/core"; +import type { FsPort } from "../ports/FsPort"; +import { + recognizeMediaFolder, + type RecognitionDeps, + type TmdbRecognitionClient, + type TvdbRecognitionClient, +} from "./recognizeMediaFolder"; +import { metadataCachePath } from "./paths"; +import type { UserConfig } from "./userConfig"; + +export type RecognizeFolderDb = "tmdb" | "tvdb"; + +export interface RecognizeFolderCandidate { + db: RecognizeFolderDb; + id: string; + title: string; + year?: string; + kind: "tvshow" | "movie"; +} + +export interface RecognizeFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + tmdb: TmdbRecognitionClient; + tvdb: TvdbRecognitionClient; + language: string; + primaryDatabase?: PrimaryDatabase; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +async function loadManagedMediaMetadata( + path: string, + deps: RecognizeFolderDeps, +): Promise<{ posixPath: string; mm: MediaMetadata }> { + const posixPath = deps.normalizePosix(path); + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], path)) { + throw new Error(`${posixPath} is not managed by SMM`); + } + const cachePath = metadataCachePath(deps.appDataDir, posixPath); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${path}`); + } + let mm: MediaMetadata; + try { + mm = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${path}`); + } + if (mm.type !== "tvshow-folder" && mm.type !== "movie-folder") { + throw new Error(`Folder type cannot be recognized: ${mm.type ?? "unknown"}`); + } + return { posixPath, mm: { ...mm, mediaFolderPath: posixPath } }; +} + +function yearFromAirDate(airDate?: string): string | undefined { + if (!airDate || airDate.length < 4) return undefined; + return airDate.slice(0, 4); +} + +function dbFromDatabase(database: "TMDB" | "TVDB"): RecognizeFolderDb { + return database === "TMDB" ? "tmdb" : "tvdb"; +} + +function candidateFromHit( + tvShow: TvShowMediaMetadata | undefined, + movie: MovieMediaMetadata | undefined, +): RecognizeFolderCandidate { + if (tvShow) { + return { + db: dbFromDatabase(tvShow.database), + id: tvShow.id, + title: tvShow.name, + year: yearFromAirDate(tvShow.airDate), + kind: "tvshow", + }; + } + if (movie) { + return { + db: dbFromDatabase(movie.database), + id: movie.id, + title: movie.name, + year: yearFromAirDate(movie.airDate), + kind: "movie", + }; + } + throw new Error("Unable to recognize folder"); +} + +export async function tryToRecognizeFolderPipeline( + path: string, + deps: RecognizeFolderDeps, +): Promise { + const { mm } = await loadManagedMediaMetadata(path, deps); + const recognitionDeps: RecognitionDeps = { + fs: deps.fs, + tmdb: deps.tmdb, + tvdb: deps.tvdb, + language: deps.language, + primaryDatabase: deps.primaryDatabase, + }; + const result = await recognizeMediaFolder(mm, recognitionDeps); + if (result.tvShow === undefined && result.movie === undefined) { + throw new Error(`Unable to recognize folder: ${path}`); + } + return candidateFromHit(result.tvShow, result.movie); +} + +export async function recognizeFolderPipeline( + path: string, + options: { db: RecognizeFolderDb; id: string }, + deps: RecognizeFolderDeps, +): Promise { + const { posixPath, mm } = await loadManagedMediaMetadata(path, deps); + const idNum = Number(options.id); + if (!Number.isInteger(idNum) || idNum <= 0) { + throw new Error("id must be a positive integer"); + } + const isTv = mm.type === "tvshow-folder"; + let tvShow: TvShowMediaMetadata | undefined; + let movie: MovieMediaMetadata | undefined; + + if (options.db === "tmdb") { + if (isTv) { + tvShow = await deps.tmdb.getTvShowMediaMetadata(idNum, deps.language); + } else { + movie = await deps.tmdb.getMovieMediaMetadata(idNum, deps.language); + } + } else { + // TVDB clients expect ISO 639-3; map prefer language the same way recognizeMediaFolder does via caller-provided language for tmdb and tvdb lang for tvdb. + // Prefer: pass language already mapped for TVDB when db=tvdb from Core (see Task 2). + if (isTv) { + tvShow = await deps.tvdb.getTvShowMediaMetadata(idNum, deps.language); + } else { + movie = await deps.tvdb.getMovieMediaMetadata(idNum, deps.language); + } + } + + if (isTv) { + if (!tvShow) throw new Error(`Failed to fetch ${options.db} TV show ${options.id}`); + } else if (!movie) { + throw new Error(`Failed to fetch ${options.db} movie ${options.id}`); + } + + const next: MediaMetadata = { + mediaFolderPath: posixPath, + type: mm.type, + mediaFiles: [], + ...(isTv ? { tvShow } : { movie }), + }; + await deps.fs.writeTextFile( + metadataCachePath(deps.appDataDir, posixPath), + JSON.stringify(next, null, 2), + ); +} +``` + +**TVDB language note:** In Task 2, when `db === "tvdb"`, Core must set `deps.language` to TVDB ISO 639-3 (reuse `mapToTvdbLangCode` / `createTvdbClient` language). For `tryToRecognizeFolder`, pass TMDB IETF as `language` and let `recognizeMediaFolder` map TVDB internally (same as import). Split if needed: either two language fields on deps, or build RecognitionDeps inside Core with the same pattern as `ImportFolderPipeline` (`preferMediaLanguage` for TMDB; `recognizeMediaFolder` maps TVDB). Prefer mirroring import: `language = userConfig.preferMediaLanguage ?? "en-US"` for both try and recognize when using TMDB; for `recognizeFolder` with `db=tvdb`, use `mapToTvdbLangCode(preferMediaLanguage)`. + +Refine `RecognizeFolderDeps` if cleaner: + +```typescript +language: string; // TMDB IETF for try + tmdb recognize +tvdbLanguage: string; // ISO 639-3 for tvdb recognize / recognizeMediaFolder path +``` + +And pass `language` + map inside pipeline for `recognizeMediaFolder` as import does (single `language` IETF). For `recognizeFolder` TVDB branch use `mapToTvdbLangCode(deps.language)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/core && pnpm exec vitest run src/pipeline/recognizeFolder.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/recognizeFolder.ts apps/core/src/pipeline/recognizeFolder.test.ts +git commit -m "feat(core): add recognizeFolder pipelines" +``` + +--- + +### Task 2: Wire `Core.tryToRecognizeFolder` / `Core.recognizeFolder` + +**Files:** +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/Core.test.ts` +- Modify: `apps/core/src/index.ts` + +**Interfaces:** +- Consumes: `tryToRecognizeFolderPipeline`, `recognizeFolderPipeline`, `TmdbClient`, `TvdbClient`, `userConfig` +- Produces: `Core.tryToRecognizeFolder(path)`, `Core.recognizeFolder(path, { db, id })` + +- [ ] **Step 1: Write the failing Core tests** + +Append to `apps/core/src/Core.test.ts` (reuse existing `inMemoryFs` / `jsonResponse` helpers in that file): + +```typescript +describe("tryToRecognizeFolder / recognizeFolder", () => { + it("tryToRecognizeFolder returns candidate from folder tmdbid", async () => { + const folder = "/m/Show {tmdbid=84666}"; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [folder] }), + [metadataCachePath("/data/smm", folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [], + }), + }); + const network: NetworkPort = { + fetch: vi.fn(async (url: string) => { + if (String(url).includes("/tv/") && String(url).includes("84666")) { + return jsonResponse({ + id: 84666, + name: "WATATEN", + first_air_date: "2019-01-08", + seasons: [{ season_number: 1, episode_count: 1, name: "S1" }], + }); + } + if (String(url).includes("/tv/") && String(url).includes("/season/")) { + return jsonResponse({ season_number: 1, episodes: [] }); + } + return jsonResponse({ results: [] }); + }) as never, + }; + const core = new Core({ + fs, + network, + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + const candidate = await core.tryToRecognizeFolder(folder); + expect(candidate.db).toBe("tmdb"); + expect(candidate.id).toBe("84666"); + expect(candidate.title).toContain("WATATEN"); + }); + + it("recognizeFolder clears mediaFiles", async () => { + const folder = "/m/Show"; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [folder] }), + [metadataCachePath("/data/smm", folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [{ absolutePath: "/m/Show/a.mkv", seasonNumber: 1, episodeNumber: 1 }], + }), + }); + const network: NetworkPort = { + fetch: vi.fn(async (url: string) => { + if (String(url).includes("/tv/") && !String(url).includes("/season/")) { + return jsonResponse({ + id: 84666, + name: "WATATEN", + first_air_date: "2019-01-08", + seasons: [{ season_number: 1, episode_count: 0, name: "S1" }], + }); + } + if (String(url).includes("/season/")) { + return jsonResponse({ season_number: 1, episodes: [] }); + } + return jsonResponse({}); + }) as never, + }; + const core = new Core({ + fs, + network, + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + await core.recognizeFolder(folder, { db: "tmdb", id: "84666" }); + const mm = await core.getMediaMetadata(folder); + expect(mm?.tvShow?.id).toBe("84666"); + expect(mm?.mediaFiles).toEqual([]); + }); +}); +``` + +Adapt network mocks to whatever `TmdbClient.getTvShowMediaMetadata` actually requests (copy patterns from `TmdbClient.test.ts` / existing Core TMDB tests). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/core && pnpm exec vitest run src/Core.test.ts -t "tryToRecognizeFolder"` + +Expected: FAIL (`tryToRecognizeFolder` not a function) + +- [ ] **Step 3: Implement Core methods** + +In `Core.ts`, add imports and: + +```typescript +async tryToRecognizeFolder(path: string): Promise { + const config = await this.userConfig.read(); + const language = config.preferMediaLanguage ?? "en-US"; + const { client: tmdb } = await this.createTmdbClient({}); + const { client: tvdb } = await this.createTvdbClient({}, false); + return tryToRecognizeFolderPipeline(path, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + tmdb, + tvdb, + language, + primaryDatabase: config.primaryDatabase, + }); +} + +async recognizeFolder( + path: string, + options: { db: RecognizeFolderDb; id: string }, +): Promise { + const config = await this.userConfig.read(); + const language = config.preferMediaLanguage ?? "en-US"; + const { client: tmdb } = await this.createTmdbClient({}); + const { client: tvdb, language: tvdbLanguage } = await this.createTvdbClient({}); + // Pipeline must use tvdbLanguage when options.db === "tvdb"; see Task 1 language note. + await recognizeFolderPipeline(path, options, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + tmdb, + tvdb, + language: options.db === "tvdb" ? tvdbLanguage : language, + primaryDatabase: config.primaryDatabase, + }); +} +``` + +Export types from `index.ts`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/core && pnpm exec vitest run src/Core.test.ts -t "tryToRecognizeFolder|recognizeFolder"` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/Core.ts apps/core/src/Core.test.ts apps/core/src/index.ts +git commit -m "feat(core): expose tryToRecognizeFolder and recognizeFolder" +``` + +--- + +### Task 3: CLI `smm recognize` + confirm helper + +**Files:** +- Create: `apps/cli/src/cli/recognizeConfirm.ts` +- Create: `apps/cli/src/cli/recognize.test.ts` +- Modify: `apps/cli/src/cli/runCli.ts` + +**Interfaces:** +- Consumes: `getCore().tryToRecognizeFolder`, `getCore().recognizeFolder` +- Produces: Commander command `recognize` + +- [ ] **Step 1: Write failing CLI unit tests** + +Create `apps/cli/src/cli/recognize.test.ts`: + +```typescript +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetCoreForTests } from '../core/getCore' + +describe('smm recognize', () => { + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + }) + + it('manual mode calls recognizeFolder with db+id', async () => { + const { Core } = await import('core-app') + const recognizeFolder = vi.spyOn(Core.prototype, 'recognizeFolder').mockResolvedValue() + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', 'smm', 'recognize', '/m/Show', '--db', 'tmdb', '--id', '84666', + ]) + expect(code).toBe(0) + expect(recognizeFolder).toHaveBeenCalledWith('/m/Show', { db: 'tmdb', id: '84666' }) + expect(logSpy).toHaveBeenCalledWith('Metadata is updated') + }) + + it('exits 1 when only --db is provided', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'recognize', '/m/Show', '--db', 'tmdb']) + expect(code).toBe(1) + expect(errorSpy).toHaveBeenCalled() + }) + + it('--yes accepts tryToRecognizeFolder candidate', async () => { + const { Core } = await import('core-app') + vi.spyOn(Core.prototype, 'tryToRecognizeFolder').mockResolvedValue({ + db: 'tmdb', + id: '84666', + title: 'WATATEN', + year: '2019', + kind: 'tvshow', + }) + const recognizeFolder = vi.spyOn(Core.prototype, 'recognizeFolder').mockResolvedValue() + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'recognize', '/m/Show', '--yes']) + expect(code).toBe(0) + expect(recognizeFolder).toHaveBeenCalledWith('/m/Show', { db: 'tmdb', id: '84666' }) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd apps/cli && pnpm exec vitest run src/cli/recognize.test.ts` + +Expected: FAIL (unknown command / not called) + +- [ ] **Step 3: Implement confirm helper + command** + +Create `apps/cli/src/cli/recognizeConfirm.ts`: + +```typescript +import * as readline from 'node:readline' + +export function formatRecognizePrompt(candidate: { + title: string + year?: string +}): string { + const label = + candidate.year !== undefined + ? `${candidate.title} (${candidate.year})` + : candidate.title + return `Is it "${label}"? [Y/n]` +} + +/** Returns true if user accepts (empty / y / yes). */ +export async function confirmRecognizeCandidate( + candidate: { title: string; year?: string }, + options: { yes?: boolean; ask?: (question: string) => Promise } = {}, +): Promise { + if (options.yes) return true + const ask = + options.ask ?? + (async (question: string) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + const answer = await new Promise((resolve) => { + rl.question(question + ' ', resolve) + }) + rl.close() + return answer + }) + const raw = (await ask(formatRecognizePrompt(candidate))).trim().toLowerCase() + if (raw === '' || raw === 'y' || raw === 'yes') return true + return false +} +``` + +In `runCli.ts`, register: + +```typescript +program + .command('recognize') + .description('Recognize an imported media folder as a TMDB/TVDB TV show or movie') + .argument('', 'Imported media folder path') + .addOption(new Option('--db ', 'Media database').choices(['tmdb', 'tvdb'])) + .option('--id ', 'TMDB or TVDB id') + .option('-y, --yes', 'Accept auto-recognition candidate without prompting') + .action(async (folder: string, opts: { db?: string; id?: string; yes?: boolean }) => { + try { + const hasDb = opts.db !== undefined + const hasId = opts.id !== undefined + if (hasDb !== hasId) { + console.error('--db and --id must be provided together') + exitCode = 1 + return + } + const core = getCore() + if (hasDb && hasId) { + await core.recognizeFolder(folder, { + db: opts.db as 'tmdb' | 'tvdb', + id: opts.id!, + }) + console.log('Metadata is updated') + return + } + const candidate = await core.tryToRecognizeFolder(folder) + const label = + candidate.year !== undefined + ? `${candidate.title} (${candidate.year})` + : candidate.title + // optional: print suggestion line before confirm + const accepted = await confirmRecognizeCandidate(candidate, { yes: Boolean(opts.yes) }) + if (!accepted) { + console.log('Cancelled') + return + } + await core.recognizeFolder(folder, { db: candidate.db, id: candidate.id }) + console.log('Metadata is updated') + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) +``` + +Update `runCli` JSDoc command list to include `recognize`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd apps/cli && pnpm exec vitest run src/cli/recognize.test.ts` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/cli/src/cli/runCli.ts apps/cli/src/cli/recognizeConfirm.ts apps/cli/src/cli/recognize.test.ts +git commit -m "feat(cli): add smm recognize command" +``` + +--- + +### Task 4: Live e2e `apps/e2e/cli/recognize-folder.test.ts` + +**Files:** +- Create: `apps/e2e/cli/recognize-folder.test.ts` + +**Interfaces:** +- Consumes: `bin`, `setup`/`cleanup`, `folder1`/`folder4`, `createFolderInTestFolder`, `$` + +- [ ] **Step 1: Write the e2e tests** + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { join } from 'node:path' +import { $ } from 'bun' +import { folder1, folder4, createFolderInTestFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { cliOutput } from './helpers' + +const FIVE_MINUTES_MS = 5 * 60 * 1000 + +describe('recognize folder', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('UC1: recognize with TMDB id', async () => { + const folder = createFolderInTestFolder({ ...folder1 }) + const folderPath = folder.path! + const added = await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + expect(added.exitCode).toBe(0) + + const recognized = await $`${bin} recognize ${folderPath} --db tmdb --id 84666`.nothrow() + expect(recognized.exitCode).toBe(0) + expect(recognized.text()).toMatch(/Metadata is updated/i) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(meta.text()).toContain('database: TMDB') + expect(meta.text()).toContain('id: 84666') + expect(meta.text()).toMatch(/mediaFiles:\s*\n\s*\(empty\)/) + }, FIVE_MINUTES_MS) + + it('UC2: recognize with TVDB id', async () => { + const folder = createFolderInTestFolder({ ...folder4 }) + const folderPath = folder.path! + const added = await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + expect(added.exitCode).toBe(0) + + const recognized = await $`${bin} recognize ${folderPath} --db tvdb --id 421069`.nothrow() + expect(recognized.exitCode).toBe(0) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(meta.text()).toContain('database: TVDB') + expect(meta.text()).toContain('id: 421069') + expect(meta.text()).toMatch(/mediaFiles:\s*\n\s*\(empty\)/) + }, FIVE_MINUTES_MS) + + it('auto recognize with --yes using tmdbid in folder name', async () => { + const folder = createFolderInTestFolder({ ...folder1 }) + const folderPath = folder.path! + await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + + const recognized = await $`${bin} recognize ${folderPath} --yes`.nothrow() + expect(recognized.exitCode).toBe(0) + expect(recognized.text()).toMatch(/Metadata is updated/i) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.text()).toContain('id: 84666') + expect(meta.text()).toMatch(/mediaFiles:\s*\n\s*\(empty\)/) + }, FIVE_MINUTES_MS) + + it('rejects unmanaged folder', async () => { + const result = await $`${bin} recognize ${join(process.cwd(), 'not-imported')} --db tmdb --id 1`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not managed by SMM/i) + }) + + it('rejects unpaired --db / --id', async () => { + const folder = createFolderInTestFolder({ ...folder1 }) + const folderPath = folder.path! + await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + const result = await $`${bin} recognize ${folderPath} --db tmdb`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/--db and --id/i) + }) +}) +``` + +- [ ] **Step 2: Run e2e (expect fail before Task 3, pass after)** + +Run from repo root: + +```bash +cd apps/e2e && pnpm exec bun test ./cli/recognize-folder.test.ts +``` + +(or project’s usual CLI e2e runner if documented in `apps/e2e/cli/README.md`) + +Expected after Tasks 1–3: PASS (network required for UC1/UC2/auto) + +- [ ] **Step 3: Commit** + +```bash +git add apps/e2e/cli/recognize-folder.test.ts +git commit -m "test(e2e): cover smm recognize folder" +``` + +--- + +### Task 5: Update product docs + +**Files:** +- Modify: `docs/dev/recognize-folder.md` +- Modify: `docs/api/index.md` (brief CLI note if other CLI commands are listed) + +- [ ] **Step 1: Update docs** + +In `recognize-folder.md`: + +- Set **Status** to `done` (or `implemented`) when e2e passes +- Document `--yes` / `-y` under auto section +- Keep UC1/UC2; mention auto `--yes` test + +In `docs/api/index.md`, add a short line for `smm recognize` next to other CLI notes. + +- [ ] **Step 2: Commit** + +```bash +git add docs/dev/recognize-folder.md docs/api/index.md +git commit -m "docs: document smm recognize --yes" +``` + +--- + +## Self-Review + +1. **Spec coverage:** UC1, UC2, auto+`--yes`, flag pairing, unmanaged, Core APIs, `mediaFiles: []` — all have tasks. +2. **Placeholders:** TVDB language handling called out explicitly in Tasks 1–2 (use `mapToTvdbLangCode` / `createTvdbClient` language). +3. **Type consistency:** `RecognizeFolderDb`, `RecognizeFolderCandidate`, `{ db, id }` options match across Core, CLI, e2e. +4. **Episode recognize untouched:** `try-to-recognize` / `recognize.test.ts` not modified. From 07ef5935e7597dd0f8bf0ce1211bdeaed0fc19f8 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 02:51:59 +0800 Subject: [PATCH 143/214] feat(core): add recognizeFolder pipelines Co-authored-by: Cursor --- .../core/src/pipeline/recognizeFolder.test.ts | 150 +++++++++++++++ apps/core/src/pipeline/recognizeFolder.ts | 173 ++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 apps/core/src/pipeline/recognizeFolder.test.ts create mode 100644 apps/core/src/pipeline/recognizeFolder.ts diff --git a/apps/core/src/pipeline/recognizeFolder.test.ts b/apps/core/src/pipeline/recognizeFolder.test.ts new file mode 100644 index 00000000..e51a8f42 --- /dev/null +++ b/apps/core/src/pipeline/recognizeFolder.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath, userConfigPath } from "./paths"; +import { UserConfig } from "./userConfig"; +import { + recognizeFolderPipeline, + tryToRecognizeFolderPipeline, + type RecognizeFolderDeps, +} from "./recognizeFolder"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + }; +} + +function deps(partial: Partial & { fs: FsPort; appDataDir: string }): RecognizeFolderDeps { + const userConfig = new UserConfig(partial.fs, partial.appDataDir); + return { + userConfig, + normalizePosix: (p) => p.replace(/\\/g, "/"), + language: "en-US", + tmdb: { + search: vi.fn(async () => ({ results: [], page: 1, total_pages: 1, total_results: 0 })), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + }, + tvdb: { + searchSeries: vi.fn(async () => []), + searchMovie: vi.fn(async () => []), + getTvShowMediaMetadata: vi.fn(), + getMovieMediaMetadata: vi.fn(), + }, + ...partial, + }; +} + +describe("tryToRecognizeFolderPipeline", () => { + it("returns candidate from tmdbid in folder name without writing", async () => { + const mm = { + mediaFolderPath: "/m/Show {tmdbid=84666}", + type: "tvshow-folder" as const, + mediaFiles: [{ absolutePath: "/m/Show {tmdbid=84666}/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ + [userConfigPath("/data")]: JSON.stringify({ folders: ["/m/Show {tmdbid=84666}"] }), + }); + const d = deps({ fs, appDataDir: "/data" }); + const cachePath = metadataCachePath("/data", "/m/Show {tmdbid=84666}"); + await fs.writeTextFile(cachePath, JSON.stringify(mm)); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + database: "TMDB", + id: "84666", + name: "WATATEN", + airDate: "2019-01-08", + seasons: [], + }); + + const candidate = await tryToRecognizeFolderPipeline("/m/Show {tmdbid=84666}", d); + expect(candidate).toEqual({ + db: "tmdb", + id: "84666", + title: "WATATEN", + year: "2019", + kind: "tvshow", + }); + const writes = (fs.writeTextFile as ReturnType).mock.calls.filter( + ([p]) => p === cachePath, + ); + expect(writes).toHaveLength(1); // only the seed write above + }); + + it("throws when unmanaged", async () => { + const fs = inMemoryFs({ [userConfigPath("/data")]: JSON.stringify({ folders: [] }) }); + const d = deps({ fs, appDataDir: "/data" }); + await expect(tryToRecognizeFolderPipeline("/m/Other", d)).rejects.toThrow(/not managed by SMM/); + }); +}); + +describe("recognizeFolderPipeline", () => { + it("writes tvShow and clears mediaFiles", async () => { + const mm = { + mediaFolderPath: "/m/Show", + type: "tvshow-folder" as const, + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ + [userConfigPath("/data")]: JSON.stringify({ folders: ["/m/Show"] }), + }); + const d = deps({ fs, appDataDir: "/data" }); + const cachePath = metadataCachePath("/data", "/m/Show"); + await fs.writeTextFile(cachePath, JSON.stringify(mm)); + (d.tmdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + database: "TMDB", + id: "84666", + name: "WATATEN", + airDate: "2019-01-08", + seasons: [{ season: 1, name: "Season 1", episodes: [] }], + }); + + await recognizeFolderPipeline("/m/Show", { db: "tmdb", id: "84666" }, d); + + const raw = await fs.readTextFile(cachePath); + const saved = JSON.parse(raw) as typeof mm & { tvShow: { id: string }; movie?: unknown }; + expect(saved.tvShow.id).toBe("84666"); + expect(saved.mediaFiles).toEqual([]); + expect(saved.movie).toBeUndefined(); + }); + + it("maps IETF language to TVDB ISO 639-3 when fetching by id", async () => { + const mm = { + mediaFolderPath: "/m/Show", + type: "tvshow-folder" as const, + mediaFiles: [], + }; + const fs = inMemoryFs({ + [userConfigPath("/data")]: JSON.stringify({ folders: ["/m/Show"] }), + }); + const d = deps({ fs, appDataDir: "/data" }); + const cachePath = metadataCachePath("/data", "/m/Show"); + await fs.writeTextFile(cachePath, JSON.stringify(mm)); + (d.tvdb.getTvShowMediaMetadata as ReturnType).mockResolvedValue({ + database: "TVDB", + id: "421069", + name: "WATATEN", + seasons: [], + }); + + await recognizeFolderPipeline("/m/Show", { db: "tvdb", id: "421069" }, d); + + expect(d.tvdb.getTvShowMediaMetadata).toHaveBeenCalledWith(421069, "eng"); + expect(d.tmdb.getTvShowMediaMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/core/src/pipeline/recognizeFolder.ts b/apps/core/src/pipeline/recognizeFolder.ts new file mode 100644 index 00000000..1e384835 --- /dev/null +++ b/apps/core/src/pipeline/recognizeFolder.ts @@ -0,0 +1,173 @@ +import { Path } from "@core/path"; +import type { + MediaMetadata, + MovieMediaMetadata, + PreferMediaLanguage, + PrimaryDatabase, + TvShowMediaMetadata, +} from "@smm/core"; +import { mapToTvdbLangCode } from "../clients/TvdbClient"; +import type { FsPort } from "../ports/FsPort"; +import { + recognizeMediaFolder, + type RecognitionDeps, + type TmdbRecognitionClient, + type TvdbRecognitionClient, +} from "./recognizeMediaFolder"; +import { metadataCachePath } from "./paths"; +import type { UserConfig } from "./userConfig"; + +export type RecognizeFolderDb = "tmdb" | "tvdb"; + +export interface RecognizeFolderCandidate { + db: RecognizeFolderDb; + id: string; + title: string; + year?: string; + kind: "tvshow" | "movie"; +} + +export interface RecognizeFolderDeps { + fs: FsPort; + appDataDir: string; + userConfig: UserConfig; + normalizePosix: (path: string) => string; + tmdb: TmdbRecognitionClient; + tvdb: TvdbRecognitionClient; + language: string; + primaryDatabase?: PrimaryDatabase; +} + +function isManaged(folders: string[], mediaFolderPath: string): boolean { + const targetPlatform = Path.toPlatformPath(mediaFolderPath); + const targetPosix = Path.posix(mediaFolderPath); + return folders.some( + (folder) => + Path.toPlatformPath(folder) === targetPlatform || Path.posix(folder) === targetPosix, + ); +} + +async function loadManagedMediaMetadata( + path: string, + deps: RecognizeFolderDeps, +): Promise<{ posixPath: string; mm: MediaMetadata }> { + const posixPath = deps.normalizePosix(path); + const config = await deps.userConfig.read(); + if (!isManaged(config.folders ?? [], path)) { + throw new Error(`${posixPath} is not managed by SMM`); + } + const cachePath = metadataCachePath(deps.appDataDir, posixPath); + if (!(await deps.fs.exists(cachePath))) { + throw new Error(`Media metadata not found: ${path}`); + } + let mm: MediaMetadata; + try { + mm = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; + } catch { + throw new Error(`Media metadata not found: ${path}`); + } + if (mm.type !== "tvshow-folder" && mm.type !== "movie-folder") { + throw new Error(`Folder type cannot be recognized: ${mm.type ?? "unknown"}`); + } + return { posixPath, mm: { ...mm, mediaFolderPath: posixPath } }; +} + +function yearFromAirDate(airDate?: string): string | undefined { + if (!airDate || airDate.length < 4) return undefined; + return airDate.slice(0, 4); +} + +function dbFromDatabase(database: "TMDB" | "TVDB"): RecognizeFolderDb { + return database === "TMDB" ? "tmdb" : "tvdb"; +} + +function candidateFromHit( + tvShow: TvShowMediaMetadata | undefined, + movie: MovieMediaMetadata | undefined, +): RecognizeFolderCandidate { + if (tvShow) { + return { + db: dbFromDatabase(tvShow.database), + id: tvShow.id, + title: tvShow.name, + year: yearFromAirDate(tvShow.airDate), + kind: "tvshow", + }; + } + if (movie) { + return { + db: dbFromDatabase(movie.database), + id: movie.id, + title: movie.name, + year: yearFromAirDate(movie.airDate), + kind: "movie", + }; + } + throw new Error("Unable to recognize folder"); +} + +export async function tryToRecognizeFolderPipeline( + path: string, + deps: RecognizeFolderDeps, +): Promise { + const { mm } = await loadManagedMediaMetadata(path, deps); + const recognitionDeps: RecognitionDeps = { + fs: deps.fs, + tmdb: deps.tmdb, + tvdb: deps.tvdb, + language: deps.language, + primaryDatabase: deps.primaryDatabase, + }; + const result = await recognizeMediaFolder(mm, recognitionDeps); + if (result.tvShow === undefined && result.movie === undefined) { + throw new Error(`Unable to recognize folder: ${path}`); + } + return candidateFromHit(result.tvShow, result.movie); +} + +export async function recognizeFolderPipeline( + path: string, + options: { db: RecognizeFolderDb; id: string }, + deps: RecognizeFolderDeps, +): Promise { + const { posixPath, mm } = await loadManagedMediaMetadata(path, deps); + const idNum = Number(options.id); + if (!Number.isInteger(idNum) || idNum <= 0) { + throw new Error("id must be a positive integer"); + } + const isTv = mm.type === "tvshow-folder"; + let tvShow: TvShowMediaMetadata | undefined; + let movie: MovieMediaMetadata | undefined; + + if (options.db === "tmdb") { + if (isTv) { + tvShow = await deps.tmdb.getTvShowMediaMetadata(idNum, deps.language); + } else { + movie = await deps.tmdb.getMovieMediaMetadata(idNum, deps.language); + } + } else { + const tvdbLang = mapToTvdbLangCode(deps.language as PreferMediaLanguage); + if (isTv) { + tvShow = await deps.tvdb.getTvShowMediaMetadata(idNum, tvdbLang); + } else { + movie = await deps.tvdb.getMovieMediaMetadata(idNum, tvdbLang); + } + } + + if (isTv) { + if (!tvShow) throw new Error(`Failed to fetch ${options.db} TV show ${options.id}`); + } else if (!movie) { + throw new Error(`Failed to fetch ${options.db} movie ${options.id}`); + } + + const next: MediaMetadata = { + mediaFolderPath: posixPath, + type: mm.type, + mediaFiles: [], + ...(isTv ? { tvShow } : { movie }), + }; + await deps.fs.writeTextFile( + metadataCachePath(deps.appDataDir, posixPath), + JSON.stringify(next, null, 2), + ); +} From 67041885a76584d30abd6437a4700d79a68d18a0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 02:57:55 +0800 Subject: [PATCH 144/214] feat(core): expose tryToRecognizeFolder and recognizeFolder Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 88 +++++++++++++++++++++++++++++++++++--- apps/core/src/Core.ts | 49 ++++++++++++++++++++- apps/core/src/index.ts | 9 +++- 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index dbba2f9d..69b91481 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -572,7 +572,7 @@ describe("renameFolder", () => { }); }); -describe("tryToRecognizeFolder", () => { +describe("tryToRecognizeEpisodes", () => { const appDataDir = "/data"; const folder = "/m/Show"; const tvMetadata = { @@ -608,7 +608,7 @@ describe("tryToRecognizeFolder", () => { "/m/Show/S01E02.mkv": "", }); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); - const plan = await core.tryToRecognizeFolder("/m/Show"); + const plan = await core.tryToRecognizeEpisodes("/m/Show"); expect(plan.task).toBe("recognize-media-file"); expect(plan.status).toBe("pending"); expect(plan.creator).toBe("app"); @@ -622,7 +622,7 @@ describe("tryToRecognizeFolder", () => { it("returns pending plan with empty files when nothing matches", async () => { const fs = seed({ "/m/Show/random.mkv": "" }); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); - const plan = await core.tryToRecognizeFolder("/m/Show"); + const plan = await core.tryToRecognizeEpisodes("/m/Show"); expect(plan.files).toEqual([]); expect(plan.status).toBe("pending"); }); @@ -630,7 +630,7 @@ describe("tryToRecognizeFolder", () => { it("rejects unmanaged folders", async () => { const fs = seed(); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); - await expect(core.tryToRecognizeFolder("/m/Other")).rejects.toThrow(/not managed by SMM/); + await expect(core.tryToRecognizeEpisodes("/m/Other")).rejects.toThrow(/not managed by SMM/); }); }); @@ -735,7 +735,7 @@ describe("applyPlan", () => { "/m/Show/S01E02.mkv": "", }); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); - const plan = await core.tryToRecognizeFolder("/m/Show"); + const plan = await core.tryToRecognizeEpisodes("/m/Show"); await core.applyPlan(plan); const mm = await core.getMediaMetadata("/m/Show"); expect(mm?.mediaFiles).toEqual([ @@ -749,7 +749,7 @@ describe("applyPlan", () => { const fs = seed({ "/m/Show/random.mkv": "" }); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); const before = await core.getMediaMetadata("/m/Show"); - const plan = await core.tryToRecognizeFolder("/m/Show"); + const plan = await core.tryToRecognizeEpisodes("/m/Show"); expect(plan.files).toEqual([]); await core.applyPlan(plan); const after = await core.getMediaMetadata("/m/Show"); @@ -1420,3 +1420,79 @@ describe("Core.getTvdbSeriesById / getTvdbMovieById", () => { expect(result.translation).toBeNull(); }); }); + +describe("tryToRecognizeFolder / recognizeFolder", () => { + const series84666 = { + id: 84666, + name: "WATATEN", + first_air_date: "2019-01-08", + seasons: [{ id: 1, name: "S1", season_number: 1, air_date: "2019-01-08", episode_count: 1 }], + }; + const season84666 = { + id: 1, + name: "S1", + season_number: 1, + air_date: "2019-01-08", + episode_count: 1, + episodes: [], + }; + + function tmdbTvShowNetwork(): NetworkPort { + return { + fetch: vi.fn(async (url: string) => { + if (url.includes("/tv/84666/season/")) { + return jsonResponse(season84666); + } + if (url.includes("/tv/84666")) { + return jsonResponse(series84666); + } + return jsonResponse({ results: [], page: 1, total_pages: 0, total_results: 0 }); + }) as never, + }; + } + + it("tryToRecognizeFolder returns candidate from folder tmdbid", async () => { + const folder = "/m/Show {tmdbid=84666}"; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [folder] }), + [metadataCachePath("/data/smm", folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [], + }), + }); + const core = new Core({ + fs, + network: tmdbTvShowNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + const candidate = await core.tryToRecognizeFolder(folder); + expect(candidate.db).toBe("tmdb"); + expect(candidate.id).toBe("84666"); + expect(candidate.title).toContain("WATATEN"); + }); + + it("recognizeFolder clears mediaFiles", async () => { + const folder = "/m/Show"; + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [folder] }), + [metadataCachePath("/data/smm", folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [{ absolutePath: "/m/Show/a.mkv", seasonNumber: 1, episodeNumber: 1 }], + }), + }); + const core = new Core({ + fs, + network: tmdbTvShowNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + await core.recognizeFolder(folder, { db: "tmdb", id: "84666" }); + const mm = await core.getMediaMetadata(folder); + expect(mm?.tvShow?.id).toBe("84666"); + expect(mm?.mediaFiles).toEqual([]); + }); +}); + diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index f422e770..cb24f75c 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -52,7 +52,13 @@ import { type ListPlansOptions, type Plan, } from "./pipeline/plans"; -import { tryToRecognizeFolderPipeline } from "./pipeline/tryToRecognizeFolder"; +import { tryToRecognizeFolderPipeline as tryToRecognizeEpisodesPipeline } from "./pipeline/tryToRecognizeFolder"; +import { + recognizeFolderPipeline, + tryToRecognizeFolderPipeline, + type RecognizeFolderCandidate, + type RecognizeFolderDb, +} from "./pipeline/recognizeFolder"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; import { prepareScrapeFolder, @@ -109,6 +115,8 @@ export type { RenameEpisodeFileResult, ScrapeFolderOptions, ScrapeFolderResult, + RecognizeFolderCandidate, + RecognizeFolderDb, }; export interface CoreOptions { @@ -353,12 +361,49 @@ export class Core { }); } - async tryToRecognizeFolder(path: string): Promise { + async tryToRecognizeEpisodes(path: string): Promise { + return tryToRecognizeEpisodesPipeline(path, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + }); + } + + async tryToRecognizeFolder(path: string): Promise { + const config = await this.userConfig.read(); + const language = config.preferMediaLanguage ?? "en-US"; + const { client: tmdb } = await this.createTmdbClient({}); + const { client: tvdb } = await this.createTvdbClient({}, false); return tryToRecognizeFolderPipeline(path, { fs: this.fs, appDataDir: this.appDataDir, userConfig: this.userConfig, normalizePosix: (p) => this.normalizePosix(p), + tmdb, + tvdb, + language, + primaryDatabase: config.primaryDatabase, + }); + } + + async recognizeFolder( + path: string, + options: { db: RecognizeFolderDb; id: string }, + ): Promise { + const config = await this.userConfig.read(); + const language = config.preferMediaLanguage ?? "en-US"; + const { client: tmdb } = await this.createTmdbClient({}); + const { client: tvdb } = await this.createTvdbClient({}, false); + await recognizeFolderPipeline(path, options, { + fs: this.fs, + appDataDir: this.appDataDir, + userConfig: this.userConfig, + normalizePosix: (p) => this.normalizePosix(p), + tmdb, + tvdb, + language, + primaryDatabase: config.primaryDatabase, }); } diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 4efc10f6..9bed3152 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -11,6 +11,8 @@ export { type TmdbRequestOptions, type TvdbByIdResult, type TvdbRequestOptions, + type RecognizeFolderCandidate, + type RecognizeFolderDb, } from "./Core"; export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; @@ -46,7 +48,12 @@ export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFo export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } from "./pipeline/userConfig"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; -export { tryToRecognizeFolderPipeline, type TryToRecognizeFolderDeps } from "./pipeline/tryToRecognizeFolder"; +export { tryToRecognizeFolderPipeline as tryToRecognizeEpisodesPipeline, type TryToRecognizeFolderDeps as TryToRecognizeEpisodesDeps } from "./pipeline/tryToRecognizeFolder"; +export { + tryToRecognizeFolderPipeline, + recognizeFolderPipeline, + type RecognizeFolderDeps, +} from "./pipeline/recognizeFolder"; export { tryToRenameFolderPipeline, type TryToRenameFolderDeps } from "./pipeline/tryToRenameFolder"; export type { RenameRuleName } from "./pipeline/renameRules"; export type { Plan } from "./pipeline/plans"; From a8a9beab116830a26dc2a5b506fa820441678cee Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 03:02:09 +0800 Subject: [PATCH 145/214] refactor(core): rename tryToRecognizeFolder episodes pipeline to tryToRecognizeEpisodes Separates episode recognition from folder recognition so CLI try-to-recognize calls tryToRecognizeEpisodes while tryToRecognizeFolder returns folder candidates. Co-authored-by: Cursor --- apps/cli/src/cli/runCli.ts | 2 +- apps/core/src/Core.ts | 2 +- apps/core/src/index.ts | 2 +- .../{tryToRecognizeFolder.ts => tryToRecognizeEpisodes.ts} | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) rename apps/core/src/pipeline/{tryToRecognizeFolder.ts => tryToRecognizeEpisodes.ts} (95%) diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index a0da45cb..716691c1 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -240,7 +240,7 @@ export async function runCli(argv: string[] = process.argv): Promise { .argument('', 'Imported media folder path') .action(async (folder: string) => { try { - const plan = await getCore().tryToRecognizeFolder(folder) + const plan = await getCore().tryToRecognizeEpisodes(folder) console.log(`plan: ${plan.id}`) console.log(`task: ${plan.task}`) console.log(`status: ${plan.status}`) diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index cb24f75c..83d0f66c 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -52,7 +52,7 @@ import { type ListPlansOptions, type Plan, } from "./pipeline/plans"; -import { tryToRecognizeFolderPipeline as tryToRecognizeEpisodesPipeline } from "./pipeline/tryToRecognizeFolder"; +import { tryToRecognizeEpisodesPipeline } from "./pipeline/tryToRecognizeEpisodes"; import { recognizeFolderPipeline, tryToRecognizeFolderPipeline, diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 9bed3152..3850b594 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -48,7 +48,7 @@ export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFo export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } from "./pipeline/userConfig"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; -export { tryToRecognizeFolderPipeline as tryToRecognizeEpisodesPipeline, type TryToRecognizeFolderDeps as TryToRecognizeEpisodesDeps } from "./pipeline/tryToRecognizeFolder"; +export { tryToRecognizeEpisodesPipeline, type TryToRecognizeEpisodesDeps } from "./pipeline/tryToRecognizeEpisodes"; export { tryToRecognizeFolderPipeline, recognizeFolderPipeline, diff --git a/apps/core/src/pipeline/tryToRecognizeFolder.ts b/apps/core/src/pipeline/tryToRecognizeEpisodes.ts similarity index 95% rename from apps/core/src/pipeline/tryToRecognizeFolder.ts rename to apps/core/src/pipeline/tryToRecognizeEpisodes.ts index 2c639a5a..2b2f0435 100644 --- a/apps/core/src/pipeline/tryToRecognizeFolder.ts +++ b/apps/core/src/pipeline/tryToRecognizeEpisodes.ts @@ -8,7 +8,7 @@ import { writePlan } from "./plans"; import { recognizeEpisodes } from "./recognizeEpisodes"; import type { UserConfig } from "./userConfig"; -export interface TryToRecognizeFolderDeps { +export interface TryToRecognizeEpisodesDeps { fs: FsPort; appDataDir: string; userConfig: UserConfig; @@ -34,9 +34,9 @@ function hasTvShowEpisodes(mm: MediaMetadata): boolean { } /** Rule-based episode matching → pending recognize-media-file plan (throws instead of { error }). */ -export async function tryToRecognizeFolderPipeline( +export async function tryToRecognizeEpisodesPipeline( path: string, - deps: TryToRecognizeFolderDeps, + deps: TryToRecognizeEpisodesDeps, ): Promise { const posixPath = deps.normalizePosix(path); const createId = deps.createId ?? randomUUID; From 921f729055a62579d745a3b74e3775dc19aad1cf Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 03:04:08 +0800 Subject: [PATCH 146/214] feat(cli): add smm recognize command Co-authored-by: Cursor --- apps/cli/src/cli/recognize.test.ts | 55 +++++++++++++++++++++++ apps/cli/src/cli/recognizeConfirm.test.ts | 32 +++++++++++++ apps/cli/src/cli/recognizeConfirm.ts | 33 ++++++++++++++ apps/cli/src/cli/runCli.ts | 42 ++++++++++++++++- 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/cli/recognize.test.ts create mode 100644 apps/cli/src/cli/recognizeConfirm.test.ts create mode 100644 apps/cli/src/cli/recognizeConfirm.ts diff --git a/apps/cli/src/cli/recognize.test.ts b/apps/cli/src/cli/recognize.test.ts new file mode 100644 index 00000000..3b19b409 --- /dev/null +++ b/apps/cli/src/cli/recognize.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetCoreForTests } from '../core/getCore' + +describe('smm recognize', () => { + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + }) + + it('manual mode calls recognizeFolder with db+id', async () => { + const { Core } = await import('core-app') + const recognizeFolder = vi.spyOn(Core.prototype, 'recognizeFolder').mockResolvedValue() + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', 'smm', 'recognize', '/m/Show', '--db', 'tmdb', '--id', '84666', + ]) + expect(code).toBe(0) + expect(recognizeFolder).toHaveBeenCalledWith('/m/Show', { db: 'tmdb', id: '84666' }) + expect(logSpy).toHaveBeenCalledWith('Metadata is updated') + }) + + it('exits 1 when only --db is provided', async () => { + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'recognize', '/m/Show', '--db', 'tmdb']) + expect(code).toBe(1) + expect(errorSpy).toHaveBeenCalled() + }) + + it('--yes accepts tryToRecognizeFolder candidate', async () => { + const { Core } = await import('core-app') + vi.spyOn(Core.prototype, 'tryToRecognizeFolder').mockResolvedValue({ + db: 'tmdb', + id: '84666', + title: 'WATATEN', + year: '2019', + kind: 'tvshow', + }) + const recognizeFolder = vi.spyOn(Core.prototype, 'recognizeFolder').mockResolvedValue() + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'recognize', '/m/Show', '--yes']) + expect(code).toBe(0) + expect(recognizeFolder).toHaveBeenCalledWith('/m/Show', { db: 'tmdb', id: '84666' }) + }) +}) diff --git a/apps/cli/src/cli/recognizeConfirm.test.ts b/apps/cli/src/cli/recognizeConfirm.test.ts new file mode 100644 index 00000000..47d61117 --- /dev/null +++ b/apps/cli/src/cli/recognizeConfirm.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { confirmRecognizeCandidate, formatRecognizePrompt } from './recognizeConfirm' + +describe('formatRecognizePrompt', () => { + it('includes title and year when year is present', () => { + expect(formatRecognizePrompt({ title: 'WATATEN', year: '2019' })).toBe( + 'Is it "WATATEN (2019)"? [Y/n]', + ) + }) + + it('omits year parentheses when year is missing', () => { + expect(formatRecognizePrompt({ title: 'WATATEN' })).toBe('Is it "WATATEN"? [Y/n]') + }) +}) + +describe('confirmRecognizeCandidate', () => { + const candidate = { title: 'WATATEN', year: '2019' } + + it('returns true immediately when --yes is set', async () => { + expect(await confirmRecognizeCandidate(candidate, { yes: true })).toBe(true) + }) + + it('treats empty, y, and yes as accept', async () => { + expect(await confirmRecognizeCandidate(candidate, { ask: async () => '' })).toBe(true) + expect(await confirmRecognizeCandidate(candidate, { ask: async () => ' y ' })).toBe(true) + expect(await confirmRecognizeCandidate(candidate, { ask: async () => 'YES' })).toBe(true) + }) + + it('treats n as reject', async () => { + expect(await confirmRecognizeCandidate(candidate, { ask: async () => 'n' })).toBe(false) + }) +}) diff --git a/apps/cli/src/cli/recognizeConfirm.ts b/apps/cli/src/cli/recognizeConfirm.ts new file mode 100644 index 00000000..ffce237e --- /dev/null +++ b/apps/cli/src/cli/recognizeConfirm.ts @@ -0,0 +1,33 @@ +import * as readline from 'node:readline' + +export function formatRecognizePrompt(candidate: { + title: string + year?: string +}): string { + const label = + candidate.year !== undefined + ? `${candidate.title} (${candidate.year})` + : candidate.title + return `Is it "${label}"? [Y/n]` +} + +/** Returns true if user accepts (empty / y / yes). */ +export async function confirmRecognizeCandidate( + candidate: { title: string; year?: string }, + options: { yes?: boolean; ask?: (question: string) => Promise } = {}, +): Promise { + if (options.yes) return true + const ask = + options.ask ?? + (async (question: string) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + const answer = await new Promise((resolve) => { + rl.question(question + ' ', resolve) + }) + rl.close() + return answer + }) + const raw = (await ask(formatRecognizePrompt(candidate))).trim().toLowerCase() + if (raw === '' || raw === 'y' || raw === 'yes') return true + return false +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 716691c1..99ccebe4 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -29,6 +29,7 @@ import { planFileCount, } from './planFormat' import { Path } from '@core/path' +import { confirmRecognizeCandidate } from './recognizeConfirm' const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] const TYPE_CHOICES = [...FOLDER_TYPES, 'anime'] as const @@ -67,7 +68,7 @@ const IMPORT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 const SCRAPE_WAIT_TIMEOUT_MS = 5 * 60 * 1000 /** - * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `try-to-recognize`, `try-to-rename`, `apply`, `reject`, `plan`, `scrape`, `rename-episode-file`, `job`, `config`, `tmdb`). + * Run the `smm` Commander program (`list`, `add`, `show`, `metadata`, `rm`, `recognize`, `try-to-recognize`, `try-to-rename`, `apply`, `reject`, `plan`, `scrape`, `rename-episode-file`, `job`, `config`, `tmdb`). * @param argv Full process argv (e.g. `['node', 'smm', 'list']`). * @returns Process exit code (0 success, 1 on error). */ @@ -234,6 +235,45 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('recognize') + .description('Recognize an imported media folder as a TMDB/TVDB TV show or movie') + .argument('', 'Imported media folder path') + .addOption(new Option('--db ', 'Media database').choices(['tmdb', 'tvdb'])) + .option('--id ', 'TMDB or TVDB id') + .option('-y, --yes', 'Accept auto-recognition candidate without prompting') + .action(async (folder: string, opts: { db?: string; id?: string; yes?: boolean }) => { + try { + const hasDb = opts.db !== undefined + const hasId = opts.id !== undefined + if (hasDb !== hasId) { + console.error('--db and --id must be provided together') + exitCode = 1 + return + } + const core = getCore() + if (hasDb && hasId) { + await core.recognizeFolder(folder, { + db: opts.db as 'tmdb' | 'tvdb', + id: opts.id!, + }) + console.log('Metadata is updated') + return + } + const candidate = await core.tryToRecognizeFolder(folder) + const accepted = await confirmRecognizeCandidate(candidate, { yes: Boolean(opts.yes) }) + if (!accepted) { + console.log('Cancelled') + return + } + await core.recognizeFolder(folder, { db: candidate.db, id: candidate.id }) + console.log('Metadata is updated') + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + exitCode = 1 + } + }) + program .command('try-to-recognize') .description('Build a pending recognize-media-file plan for a TV show folder') From 16209d779a2bcfdb5950bcd79e020ab1f96e9c4f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 03:16:06 +0800 Subject: [PATCH 147/214] test(e2e): cover smm recognize folder Co-authored-by: Cursor --- apps/e2e/cli/recognize-folder.test.ts | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/e2e/cli/recognize-folder.test.ts diff --git a/apps/e2e/cli/recognize-folder.test.ts b/apps/e2e/cli/recognize-folder.test.ts new file mode 100644 index 00000000..090f5678 --- /dev/null +++ b/apps/e2e/cli/recognize-folder.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { join } from 'node:path' +import { $ } from 'bun' +import { folder1, folder4, createFolderInTestFolder } from '../test/actions/import-folders' +import { setup, cleanup, bin } from './base' +import { cliOutput } from './helpers' + +const FIVE_MINUTES_MS = 5 * 60 * 1000 + +describe('recognize folder', () => { + beforeEach(async () => { + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: () => {}, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + it('UC1: recognize with TMDB id', async () => { + const folder = createFolderInTestFolder({ ...folder1 }) + const folderPath = folder.path! + const added = await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + expect(added.exitCode).toBe(0) + + const recognized = await $`${bin} recognize ${folderPath} --db tmdb --id 84666`.nothrow() + expect(recognized.exitCode).toBe(0) + expect(recognized.text()).toMatch(/Metadata is updated/i) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(meta.text()).toContain('database: TMDB') + expect(meta.text()).toContain('id: 84666') + expect(meta.text()).toMatch(/mediaFiles:\s*\n\s*\(empty\)/) + }, FIVE_MINUTES_MS) + + it('UC2: recognize with TVDB id', async () => { + const folder = createFolderInTestFolder({ ...folder4 }) + const folderPath = folder.path! + const added = await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + expect(added.exitCode).toBe(0) + + const recognized = await $`${bin} recognize ${folderPath} --db tvdb --id 421069`.nothrow() + expect(recognized.exitCode).toBe(0) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.exitCode).toBe(0) + expect(meta.text()).toContain('database: TVDB') + expect(meta.text()).toContain('id: 421069') + expect(meta.text()).toMatch(/mediaFiles:\s*\n\s*\(empty\)/) + }, FIVE_MINUTES_MS) + + it('auto recognize with --yes using tmdbid in folder name', async () => { + const folder = createFolderInTestFolder({ ...folder1 }) + const folderPath = folder.path! + await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + + const recognized = await $`${bin} recognize ${folderPath} --yes`.nothrow() + expect(recognized.exitCode).toBe(0) + expect(recognized.text()).toMatch(/Metadata is updated/i) + + const meta = await $`${bin} metadata ${folderPath}`.nothrow() + expect(meta.text()).toContain('id: 84666') + expect(meta.text()).toMatch(/mediaFiles:\s*\n\s*\(empty\)/) + }, FIVE_MINUTES_MS) + + it('rejects unmanaged folder', async () => { + const result = await $`${bin} recognize ${join(process.cwd(), 'not-imported')} --db tmdb --id 1`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/not managed by SMM/i) + }) + + it('rejects unpaired --db / --id', async () => { + const folder = createFolderInTestFolder({ ...folder1 }) + const folderPath = folder.path! + await $`${bin} add ${folderPath} --type tvshow --skip-init`.nothrow() + const result = await $`${bin} recognize ${folderPath} --db tmdb`.nothrow() + expect(result.exitCode).toBe(1) + expect(cliOutput(result)).toMatch(/--db and --id/i) + }) +}) From 7328cdef3dbbcb869218984bc4fd536eb88eecd6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 03:17:07 +0800 Subject: [PATCH 148/214] fix(cli): register recognize in cliCommands dispatch set Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 4664e329..c4e58919 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'try-to-recognize', 'try-to-rename', 'apply', 'reject', 'plan', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) +const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'recognize', 'try-to-recognize', 'try-to-rename', 'apply', 'reject', 'plan', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) const firstArg = process.argv[2] const isCliHelp = firstArg === '--help' || firstArg === '-h' if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { From d8ae8564632b88689df4eeb19ad6a2e59f8e2904 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 03:19:18 +0800 Subject: [PATCH 149/214] docs: document smm recognize --yes Co-authored-by: Cursor --- docs/api/index.md | 6 +- docs/dev/recognize-folder.md | 139 ++++++++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 9b6c4053..41558dc2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -93,8 +93,12 @@ HTTP: `POST /api/unimport-folder` — removes an imported media folder from `use Source Code: apps/cli/src/route/RenameFolderV3.ts HTTP: `POST /api/rename-folder` — renames a managed media folder via Layer 2 `Core.renameFolder({ from, to })` (metadata cache + `UserConfig.folders` + on-disk rename). Request body: `{ from: string, to: string }`. Response: `{ data: { from, to } }` or `{ error }`. Broadcasts the same folder-renamed / userConfigUpdated socket events as legacy `POST /api/renameFolder`. Used by UI Sidebar rename when `localStorage["smm.v3.enabled"] === "true"`. +## CLI: recognize +Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / Core.recognizeFolder +`smm recognize [--db tmdb|tvdb --id ] [--yes]` — assign TMDB/TVDB TV show or movie metadata to an imported folder. Manual mode requires both `--db` and `--id`; auto mode runs import recognition rules and prompts for confirmation (or pass `--yes` / `-y` to accept). Sets `mediaFiles: []`. Distinct from episode recognition (`smm try-to-recognize`). Product doc: [docs/dev/recognize-folder.md](../dev/recognize-folder.md). + ## CLI: try-to-recognize / try-to-rename / apply -Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeFolder / Core.tryToRenameFolder / applyPlan +Source Code: apps/cli/src/cli/runCli.ts + apps/core Core.tryToRecognizeEpisodes / Core.tryToRenameFolder / applyPlan `smm try-to-recognize ` — rule-based episode recognition → pending plan under `{userDataDir}/plans/`. `smm try-to-rename [--rule plex|emby]` — build a pending rename-files plan (default rule: plex). `smm apply ` — apply a pending recognize-media-file or rename-files plan (updates metadata cache, deletes plan file). diff --git a/docs/dev/recognize-folder.md b/docs/dev/recognize-folder.md index ad8838a4..9bea1a83 100644 --- a/docs/dev/recognize-folder.md +++ b/docs/dev/recognize-folder.md @@ -1,7 +1,7 @@ # Recognize Folder **Supported Platform** CLI -**Status** wip +**Status** implemented We have two similar recognition functions, don't get confused: @@ -13,21 +13,152 @@ There are two way to recognize folder: 1. manual - User tell SMM the TV series or movie for a folder 2. auto - Let SMM find out +## Core Module + +`recognizeFolder` method is used to recognize given folder as TMDB/TVDB TV series or movie + +```mermaid +sequenceDiagram + participant Upstream + participant Core + participant M as MediaMetadata File + + Upstream->>Core: recognizeFolder() + Core->>M: write +``` + ## CLI +### Recognize folder manually ``` # Manual smm recognize --db tmdb/tvdb --id +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + + User->>CLI: smm recognize --db tmdb/tvdb --id + CLI->>Core: recognizeFolder(folder) +``` -# Auto -smm try-to-recognize --skip-episodes +### Recognize folder in auto + +``` +# Auto (interactive) +smm recognize Is it "{title} ({release year})"? [Y/n] > Y Metadata is updated + +# Auto (non-interactive) +smm recognize --yes +# or +smm recognize -y +Metadata is updated +``` + +Use `--yes` / `-y` to accept the auto-recognition candidate without prompting. Empty input at the prompt defaults to yes; `n` or `N` cancels without writing metadata. `--yes` is only used in auto mode; it is ignored when `--db` and `--id` are provided. + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Core + + User->>CLI: smm recognize + CLI->>Core: tryToRecognizeFolder(folder) + Core->>CLI: return + CLI->>User: print result + User->>CLI: enter "Y" + CLI->>Core: recognizeFolder() ``` -NOTE: `try-to-recognize` starts both folder recognition and episode recognition. Use `--skip-episodes` to skip the episode recognition process. +## Recognize Folder by Rule +```mermaid +flowchart TD + Start([importFolder: recognize stage]) --> TypeCheck{Folder type?} + TypeCheck -->|music| Skip([Skip recognition]) + TypeCheck -->|tvshow / movie| NfoRule + + NfoRule{tvshow.nfo or movie.nfo exists?} + NfoRule -->|no| TmdbIdRule + NfoRule -->|yes| NfoTmdb{tmdbid in NFO?} + NfoTmdb -->|yes| FetchNfoTmdb[Fetch metadata from TMDB by id] + NfoTmdb -->|no| NfoTvdb{tvdbid in NFO?} + NfoTvdb -->|yes| FetchNfoTvdb[Fetch metadata from TVDB by id] + NfoTvdb -->|no| TmdbIdRule + FetchNfoTmdb --> HitCheck1{Recognized?} + FetchNfoTvdb --> HitCheck1 + HitCheck1 -->|yes| AfterRecognize + HitCheck1 -->|no| TmdbIdRule + + TmdbIdRule{tmdbid in folder name
e.g. [tmdbid=123]?} + TmdbIdRule -->|yes| FetchFolderTmdb[Fetch metadata from TMDB by id] + TmdbIdRule -->|no| TvdbIdRule + FetchFolderTmdb --> HitCheck2{Recognized?} + HitCheck2 -->|yes| AfterRecognize + HitCheck2 -->|no| TvdbIdRule + + TvdbIdRule{tvdbid in folder name
e.g. [tvdbid=456]?} + TvdbIdRule -->|yes| FetchFolderTvdb[Fetch metadata from TVDB by id] + TvdbIdRule -->|no| SearchOrder + FetchFolderTvdb --> HitCheck3{Recognized?} + HitCheck3 -->|yes| AfterRecognize + HitCheck3 -->|no| SearchOrder + + SearchOrder{primaryDatabase?} + SearchOrder -->|TVDB| SearchTvdbFirst[Search folder name in TVDB] + SearchOrder -->|TMDB or default| SearchTmdbFirst[Search folder name in TMDB] + SearchTvdbFirst --> HitCheck4{Recognized?} + HitCheck4 -->|yes| AfterRecognize + HitCheck4 -->|no| SearchTmdbSecond[Search folder name in TMDB] + SearchTmdbFirst --> HitCheck5{Recognized?} + HitCheck5 -->|yes| AfterRecognize + HitCheck5 -->|no| SearchTvdbSecond[Search folder name in TVDB] + SearchTmdbSecond --> HitCheck6{Recognized?} + SearchTvdbSecond --> HitCheck7{Recognized?} + HitCheck6 -->|yes| AfterRecognize + HitCheck6 -->|no| Unrecognized + HitCheck7 -->|yes| AfterRecognize + HitCheck7 -->|no| Unrecognized + + AfterRecognize{Folder type?} + AfterRecognize -->|tvshow| RecognizeEpisodes[Match local video files to episodes] + AfterRecognize -->|movie| PickFirstVideo[Pick first video file as media file] + RecognizeEpisodes --> Persist([Persist metadata cache]) + PickFirstVideo --> Persist + + Unrecognized([Leave metadata blank]) --> Persist + Skip --> Persist +``` + +## Testing + +### UC1: Recognize folder with TMDB id (CLI only) +Run below commands and check result +``` +smm recognize --db tmdb --id +smm metadata +``` + +### UC2: Recognize folder with TVDB id (CLI only) +Run below commands and check result +``` +smm recognize --db tvdb --id +smm metadata +``` + +### UC3: Auto recognize with `--yes` (CLI only) +Run below commands and check result (folder name should be recognizable by import rules, e.g. contains `{tmdbid=…}`) +``` +smm recognize --yes +smm metadata +``` ## References From 89681692e01a01a070eadb43fccba2a29ea770ec Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 12:28:32 +0800 Subject: [PATCH 150/214] docs: update docs --- AGENTS.md | 5 ++-- .../{recognize.md => recognize-episodes.md} | 13 +++++++--- docs/dev/recognize-folder.md | 25 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) rename docs/dev/{recognize.md => recognize-episodes.md} (66%) diff --git a/AGENTS.md b/AGENTS.md index 38ae67cb..85bca901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,8 @@ pnpm ci # 构建 + 测试 + 类型检查 **识别季集视频文件(Recognize Episode Video File)**: 该操作用于指定电视剧每一集对应的本地视频文件 **元数据(Media Metadata)**: 元数据, 保存了文件夹对应的电视剧或电影的信息,以及本地视频文件和季集的对应关系 **视频文件和关联文件(Video File and Associated Files)** 视频文件通常还对应着字幕文件, 音频文件, 封面文件和 NFO 文件等, 这类文件被称为关联文件 +**DVD** UI组件 Download Video Dialog, 其代码位于 `apps/ui/src/components/dialogs/UIDownloadVideoDialogContent.tsx` + ## 技术架构 @@ -253,9 +255,6 @@ bun ci/run-e2e-test.ts --spec ./test/specs/[test file].e2e.ts ## apps/cli API 列表 API列表可查阅文件: `docs/api/index.md`. -## 术语 - -**DVD** Download Video Dialog ## 注意事项 diff --git a/docs/dev/recognize.md b/docs/dev/recognize-episodes.md similarity index 66% rename from docs/dev/recognize.md rename to docs/dev/recognize-episodes.md index 2d8e4b5c..678fded3 100644 --- a/docs/dev/recognize.md +++ b/docs/dev/recognize-episodes.md @@ -1,12 +1,17 @@ -# Recognize (episode file mapping) +# Recognize **Supported Platform** Web UI, CLI, Electron, ohos **Status** done +We have two similar recognition functions, don't get confused: + +**Recognize Folder** – determines the TV series or movie title for a given media folder. This function is described in [Recognize Folder](./recognize-folder.md) +**Recognize Episodes** – matches local video files to their correct episode numbers within a series. This is what this page describes. + ## CLI ``` -smm try-to-recognize +smm try-to-recognize --skip smm apply ``` @@ -17,7 +22,7 @@ sequenceDiagram participant Core User->>CLI: smm try-to-recognize - CLI->>Core: tryToRecognizeFolder(folder) + CLI->>Core: tryToRecognizeEpisodes(folder) Core-->>CLI: RecognizeMediaFilePlan (pending) CLI-->>User: plan id + result User->>CLI: smm apply @@ -35,7 +40,7 @@ sequenceDiagram User->>Browser: click recognize button Browser->>CLI: POST /api/try-to-recognize-episodes - CLI->>Core: tryToRecognizeFolder(folder) + CLI->>Core: tryToRecognizeEpisodes(folder) Core->>CLI: RecognizeMediaFilePlan (pending) CLI->>Browser: RecognizeMediaFilePlan Browser->>User: show RuleBasedRecognizePrompt diff --git a/docs/dev/recognize-folder.md b/docs/dev/recognize-folder.md index 9bea1a83..280818c6 100644 --- a/docs/dev/recognize-folder.md +++ b/docs/dev/recognize-folder.md @@ -45,6 +45,31 @@ sequenceDiagram CLI->>Core: recognizeFolder(folder) ``` +## Web UI, Electron and HarmonyOS + +NOTE: The function of searching media in TMDB/TVDB is described in [Search in TMDB](./tmdb.md) and [Search in TVDB](./tvdb.md). + +```mermaid +sequenceDiagram + participant U as User + participant W as Web UI + participant S as Server + participant C as Core + + U->>W: Search TV series or movie + W->>S: POST /api/search-in-tmdb + S->>C: searchInTmdb() + C->>S: return + S->>W: return + W->>U: display results + U->>W: select TV series or movie + W->>S: POST /api/recognize-folder + S->>C: recognizeFolder() + C->>S: return + S->>W: return + W->>W: invalidate useMediaMetadataQuery +``` + ### Recognize folder in auto ``` From a93e625dc332ed673d717c624d28aa8db31c607a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 14:31:03 +0800 Subject: [PATCH 151/214] feat(ui): wire folder recognition to POST /api/recognize-folder Align Web UI search selection with Core.recognizeFolder instead of client-side writeFile, and keep legacy scrape tests on the non-v3 path. Co-authored-by: Cursor --- apps/cli/server.ts | 2 + apps/cli/src/route/RecognizeFolder.test.ts | 72 +++++++++++++ apps/cli/src/route/RecognizeFolder.ts | 72 +++++++++++++ apps/ui/src/api/recognizeFolder.ts | 41 +++++++ .../dialogs/useScrapeDialog.test.tsx | 13 +++ .../useSelectMovieForFolderMutation.test.ts | 58 ++++++++++ .../movie/useSelectMovieForFolderMutation.ts | 86 +++++++++++++-- .../ui/src/hooks/useScrapeNfoMutation.test.ts | 10 ++ .../useSelectTvShowForFolderMutation.test.ts | 102 ++++++++++++++++++ .../hooks/useSelectTvShowForFolderMutation.ts | 92 +++++++++++++--- docs/api/index.md | 7 ++ 11 files changed, 532 insertions(+), 23 deletions(-) create mode 100644 apps/cli/src/route/RecognizeFolder.test.ts create mode 100644 apps/cli/src/route/RecognizeFolder.ts create mode 100644 apps/ui/src/api/recognizeFolder.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index ce6968cf..5b52dae6 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -53,6 +53,7 @@ import { handleImportFolder } from './src/route/ImportFolder'; import { handleGetJob } from './src/route/GetJob'; import { handleScrape } from './src/route/Scrape'; import { handleTmdb } from './src/route/Tmdb'; +import { handleRecognizeFolder } from './src/route/RecognizeFolder'; import { handleTvdb } from './src/route/Tvdb'; import { handleCoreFetch } from './src/route/CoreFetch'; import { handleShowFolder } from './src/route/ShowFolder'; @@ -301,6 +302,7 @@ export class Server { handleGetJob(this.app); handleScrape(this.app); handleTmdb(this.app); + handleRecognizeFolder(this.app); handleTvdb(this.app); handleCoreFetch(this.app); handleShowFolder(this.app); diff --git a/apps/cli/src/route/RecognizeFolder.test.ts b/apps/cli/src/route/RecognizeFolder.test.ts new file mode 100644 index 00000000..f779bbe1 --- /dev/null +++ b/apps/cli/src/route/RecognizeFolder.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + recognizeFolder: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +import { handleRecognizeFolder } from './RecognizeFolder' + +describe('RecognizeFolder HTTP API', () => { + let app: Hono + + beforeEach(() => { + mocks.recognizeFolder.mockReset() + app = new Hono() + handleRecognizeFolder(app) + }) + + async function post(body: unknown) { + return app.request('/api/recognize-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('returns Error Reason when path is missing', async () => { + const res = await post({ db: 'tmdb', id: '84666' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + expect(mocks.recognizeFolder).not.toHaveBeenCalled() + }) + + it('returns Error Reason when db is invalid', async () => { + const res = await post({ path: '/m/Show', db: 'imdb', id: '84666' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: db must be tmdb or tvdb/) + expect(mocks.recognizeFolder).not.toHaveBeenCalled() + }) + + it('returns Error Reason when id is missing', async () => { + const res = await post({ path: '/m/Show', db: 'tmdb' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: id is required/) + expect(mocks.recognizeFolder).not.toHaveBeenCalled() + }) + + it('calls Core.recognizeFolder and returns data', async () => { + mocks.recognizeFolder.mockResolvedValue(undefined) + const res = await post({ path: '/m/Show', db: 'tmdb', id: '84666' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { data?: { path: string }; error?: string } + expect(json.error).toBeUndefined() + expect(json.data).toEqual({ path: '/m/Show' }) + expect(mocks.recognizeFolder).toHaveBeenCalledWith('/m/Show', { db: 'tmdb', id: '84666' }) + }) + + it('maps Core errors to Error Reason', async () => { + mocks.recognizeFolder.mockRejectedValue(new Error('/m/Show is not managed by SMM')) + const res = await post({ path: '/m/Show', db: 'tvdb', id: '421069' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toBe('Error Reason: /m/Show is not managed by SMM') + }) +}) diff --git a/apps/cli/src/route/RecognizeFolder.ts b/apps/cli/src/route/RecognizeFolder.ts new file mode 100644 index 00000000..ed233f3d --- /dev/null +++ b/apps/cli/src/route/RecognizeFolder.ts @@ -0,0 +1,72 @@ +import type { Hono } from 'hono' +import type { RecognizeFolderDb } from '@smm/core' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +export interface RecognizeFolderRequestBody { + path: string + db: RecognizeFolderDb + id: string +} + +export interface RecognizeFolderResponseBody { + data?: { path: string } + error?: string +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined +} + +function parseDb(value: unknown): RecognizeFolderDb | undefined { + if (value === 'tmdb' || value === 'tvdb') return value + return undefined +} + +async function readJsonObject(c: { req: { json: () => Promise } }): Promise> { + try { + const body = await c.req.json() + if (typeof body === 'object' && body !== null) { + return body as Record + } + } catch { + /* empty / invalid JSON */ + } + return {} +} + +function errorBody(message: string): RecognizeFolderResponseBody { + return { error: `Error Reason: ${message}` } +} + +/** `POST /api/recognize-folder` → `Core.recognizeFolder`. */ +export function handleRecognizeFolder(app: Hono): void { + app.post('/api/recognize-folder', async (c) => { + try { + const rec = await readJsonObject(c) + const path = optionalString(rec.path) + if (!path) { + return c.json(errorBody('path is required'), 200) + } + const db = parseDb(rec.db) + if (!db) { + return c.json(errorBody('db must be tmdb or tvdb'), 200) + } + const id = optionalString(rec.id) + if (!id) { + return c.json(errorBody('id is required'), 200) + } + + await getCore().recognizeFolder(path, { db, id }) + + const ok: RecognizeFolderResponseBody = { data: { path } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/recognize-folder] route error') + return c.json( + errorBody(error instanceof Error ? error.message : 'Unknown error'), + 200, + ) + } + }) +} diff --git a/apps/ui/src/api/recognizeFolder.ts b/apps/ui/src/api/recognizeFolder.ts new file mode 100644 index 00000000..f8a4ba77 --- /dev/null +++ b/apps/ui/src/api/recognizeFolder.ts @@ -0,0 +1,41 @@ +import { apiFetch } from '@/lib/apiFetch' + +export type RecognizeFolderDb = 'tmdb' | 'tvdb' + +export interface RecognizeFolderParams { + path: string + db: RecognizeFolderDb + id: string +} + +export interface RecognizeFolderResponseBody { + data?: { path: string } + error?: string +} + +/** `POST /api/recognize-folder` → `Core.recognizeFolder`. */ +export async function recognizeFolder( + params: RecognizeFolderParams, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/recognize-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as RecognizeFolderResponseBody +} + +/** Throws on business error. */ +export async function recognizeFolderViaCore(params: RecognizeFolderParams): Promise { + const body = await recognizeFolder(params) + if (body.error) { + throw new Error(body.error) + } +} diff --git a/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx b/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx index 84fe5632..b525c94d 100644 --- a/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx +++ b/apps/ui/src/components/dialogs/useScrapeDialog.test.tsx @@ -40,6 +40,16 @@ vi.mock("@/hooks/userConfig", () => ({ useConfig: () => ({ userConfig: userConfigMock }), })) +vi.mock("@/lib/localStorages", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isSmmV3Enabled: vi.fn().mockReturnValue(false), + } +}) + +import { isSmmV3Enabled } from "@/lib/localStorages" + const I18N_KEYS: Record = { "scrape.tasks.poster": "海报", "scrape.tasks.fanart": "背景图", @@ -105,6 +115,7 @@ describe("useScrapeDialog — movie folder tasks", () => { beforeEach(() => { listFilesMock.mockReset() listFilesMock.mockResolvedValue({ data: { items: [] } }) + vi.mocked(isSmmV3Enabled).mockReturnValue(false) }) it("does not show the thumbnails row for movie folders", async () => { @@ -128,6 +139,7 @@ describe("useScrapeDialog — error propagation", () => { } as any beforeEach(() => { + vi.mocked(isSmmV3Enabled).mockReturnValue(false) scrapePosterMock.mockReset() scrapeFanartMock.mockReset() scrapeThumbnailMock.mockReset() @@ -235,6 +247,7 @@ describe("useScrapeDialog — cancel button", () => { } as any beforeEach(() => { + vi.mocked(isSmmV3Enabled).mockReturnValue(false) scrapePosterMock.mockReset() scrapeFanartMock.mockReset() scrapeThumbnailMock.mockReset() diff --git a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts index 4154c724..3f167020 100644 --- a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts +++ b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.test.ts @@ -8,12 +8,14 @@ import type { TVDBSearchItem } from "@/lib/tvdbSearchNormalize" import { useSelectMovieForFolderMutation } from "./useSelectMovieForFolderMutation" import { useGetTmdbMovieMutation } from "../useGetTmdbMovieMutation" import { useGetTvdbMovieMutation } from "../useGetTvdbMovieMutation" +import { isSmmV3Enabled } from "@/lib/localStorages" import { toast } from "sonner" const hoisted = vi.hoisted(() => ({ fetchMediaMetadataAsync: vi.fn(), updateMediaMetadataAsync: vi.fn(), updateFolderStatus: vi.fn(), + recognizeFolderViaCore: vi.fn(), })) vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ @@ -34,6 +36,14 @@ vi.mock("@/stores/uiMediaFolderStore", () => ({ }, })) +vi.mock("@/api/recognizeFolder", () => ({ + recognizeFolderViaCore: hoisted.recognizeFolderViaCore, +})) + +vi.mock("@/lib/localStorages", () => ({ + isSmmV3Enabled: vi.fn().mockReturnValue(false), +})) + vi.mock("@/lib/utils", async (importOriginal) => { const mod = await importOriginal() return { ...mod, nextTraceId: () => "test-trace" } @@ -128,10 +138,12 @@ function tvdbResult(overrides: Partial = {}): TVDBv4SearchRe describe("useSelectMovieForFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(isSmmV3Enabled).mockReturnValue(false) hoisted.fetchMediaMetadataAsync.mockResolvedValue({ ...baseMovieFolderMetadata, }) hoisted.updateMediaMetadataAsync.mockResolvedValue(undefined) + hoisted.recognizeFolderViaCore.mockResolvedValue(undefined) }) it("TMDB: routes mutate to useGetTmdbMovieMutation with id, language, mediaFolderPath, traceId, baseMetadata", () => { @@ -345,4 +357,50 @@ describe("useSelectMovieForFolderMutation", () => { expect(out).toEqual(resolvedMovie) }) + + describe("when SMM v3 is enabled", () => { + beforeEach(() => { + vi.mocked(isSmmV3Enabled).mockReturnValue(true) + }) + + it("TMDB: calls recognizeFolderViaCore and invalidates media metadata query", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const { result } = renderHook(() => useSelectMovieForFolderMutation(), { + wrapper: Wrapper, + }) + + await act(async () => { + result.current.selectMovieForFolderMutation.mutate({ + mediaFolderPath: "/library/movie", + baseMetadata: baseMovieFolderMetadata, + database: "TMDB", + result: minimalTmdbMovie, + searchLanguage: "en-US", + }) + }) + + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/movie", + db: "tmdb", + id: "99", + }) + }) + + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "loading") + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith("/library/movie", "ok") + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["mediaMetadata", "/library/movie"], + }) + expect(hoisted.updateMediaMetadataAsync).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts index 06cc7a42..91f35131 100644 --- a/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts +++ b/apps/ui/src/hooks/movie/useSelectMovieForFolderMutation.ts @@ -1,11 +1,14 @@ import { useCallback, useMemo } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" -import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { normalizeMediaFolderPathForQuery, mediaMetadataQueryKey } from "@/lib/mediaMetadataQueryKeys" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import type { MediaMetadata, MovieMediaMetadata, TMDBMovie, TMDBTVShow } from "@core/types" import { useGetTmdbMovieMutation } from "@/hooks/useGetTmdbMovieMutation" import { useGetTvdbMovieMutation } from "@/hooks/useGetTvdbMovieMutation" +import { recognizeFolderViaCore } from "@/api/recognizeFolder" +import { isSmmV3Enabled } from "@/lib/localStorages" import { nextTraceId } from "@/lib/utils" import { toast } from "sonner" import type { SearchLanguage } from "@/components/MediaDatabaseSearchbox" @@ -43,7 +46,22 @@ export type SelectMovieForFolderVariables = searchLanguage: SearchLanguage } +function recognizeDb(database: "TMDB" | "TVDB"): "tmdb" | "tvdb" { + return database === "TVDB" ? "tvdb" : "tmdb" +} + +function recognizeId( + database: "TMDB" | "TVDB", + result: TMDBTVShow | TMDBMovie | TVDBSearchItem, +): string { + if (database === "TVDB") { + return String((result as TVDBSearchItem).tvdb_id) + } + return String((result as TMDBTVShow | TMDBMovie).id) +} + export function useSelectMovieForFolderMutation() { + const queryClient = useQueryClient() const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() const updateMediaMetadataMutation = useUpdateMediaMetadataMutation() @@ -69,7 +87,7 @@ export function useSelectMovieForFolderMutation() { [fetchMediaMetadata, updateMediaMetadataMutation], ) - const onMutate = useCallback( + const onMutateLegacy = useCallback( (variables: ApplyMovieSelectionShared) => { useUIMediaFolderStore.getState().updateFolderStatus(variables.mediaFolderPath, "loading") void updateMediaMetadata(variables.mediaFolderPath, { ...variables.baseMetadata }, { @@ -79,7 +97,7 @@ export function useSelectMovieForFolderMutation() { [updateMediaMetadata], ) - const onSuccess = useCallback( + const onSuccessLegacy = useCallback( (movie: MovieMediaMetadata, variables: ApplyMovieSelectionShared) => { void updateMediaMetadata( variables.mediaFolderPath, @@ -104,18 +122,42 @@ export function useSelectMovieForFolderMutation() { }, []) const applyTmdbMovieSelectionMutation = useGetTmdbMovieMutation({ - onMutate, - onSuccess, + onMutate: onMutateLegacy, + onSuccess: onSuccessLegacy, onError: onTmdbError, }) const applyTvdbMovieSelectionMutation = useGetTvdbMovieMutation({ - onMutate, - onSuccess, + onMutate: onMutateLegacy, + onSuccess: onSuccessLegacy, onError: onTvdbError, }) - const mutate = useCallback( + const recognizeFolderMutation = useMutation({ + mutationFn: async (variables: SelectMovieForFolderVariables) => { + await recognizeFolderViaCore({ + path: variables.mediaFolderPath, + db: recognizeDb(variables.database), + id: recognizeId(variables.database, variables.result), + }) + }, + onMutate: (variables) => { + useUIMediaFolderStore.getState().updateFolderStatus(variables.mediaFolderPath, "loading") + }, + onSuccess: (_data, variables) => { + const pathPosix = normalizeMediaFolderPathForQuery(variables.mediaFolderPath) + if (pathPosix) { + void queryClient.invalidateQueries({ queryKey: mediaMetadataQueryKey(pathPosix) }) + } + useUIMediaFolderStore.getState().updateFolderStatus(variables.mediaFolderPath, "ok") + }, + onError: (error, variables) => { + toast.error(error instanceof Error ? error.message : "Failed to recognize folder") + useUIMediaFolderStore.getState().updateFolderStatus(variables.mediaFolderPath, "ok") + }, + }) + + const mutateLegacy = useCallback( (variables: SelectMovieForFolderVariables) => { const { database, result, searchLanguage, mediaFolderPath, baseMetadata } = variables const traceId = `MovieSearchResultSelected-${nextTraceId()}` @@ -141,7 +183,7 @@ export function useSelectMovieForFolderMutation() { [applyTmdbMovieSelectionMutation, applyTvdbMovieSelectionMutation], ) - const mutateAsync = useCallback( + const mutateAsyncLegacy = useCallback( async (variables: SelectMovieForFolderVariables) => { const { database, result, searchLanguage, mediaFolderPath, baseMetadata } = variables const traceId = `MovieSearchResultSelected-${nextTraceId()}` @@ -166,13 +208,37 @@ export function useSelectMovieForFolderMutation() { [applyTmdbMovieSelectionMutation, applyTvdbMovieSelectionMutation], ) + const mutate = useCallback( + (variables: SelectMovieForFolderVariables) => { + if (isSmmV3Enabled()) { + recognizeFolderMutation.mutate(variables) + return + } + mutateLegacy(variables) + }, + [mutateLegacy, recognizeFolderMutation], + ) + + const mutateAsync = useCallback( + async (variables: SelectMovieForFolderVariables) => { + if (isSmmV3Enabled()) { + await recognizeFolderMutation.mutateAsync(variables) + return + } + return mutateAsyncLegacy(variables) + }, + [mutateAsyncLegacy, recognizeFolderMutation], + ) + const selectMovieForFolderMutation = useMemo( () => ({ mutate, mutateAsync }), [mutate, mutateAsync], ) const isSelectMovieForFolderPending = - applyTmdbMovieSelectionMutation.isPending || applyTvdbMovieSelectionMutation.isPending + recognizeFolderMutation.isPending || + applyTmdbMovieSelectionMutation.isPending || + applyTvdbMovieSelectionMutation.isPending return { selectMovieForFolderMutation, diff --git a/apps/ui/src/hooks/useScrapeNfoMutation.test.ts b/apps/ui/src/hooks/useScrapeNfoMutation.test.ts index 208c0987..c07ec9fb 100644 --- a/apps/ui/src/hooks/useScrapeNfoMutation.test.ts +++ b/apps/ui/src/hooks/useScrapeNfoMutation.test.ts @@ -98,10 +98,19 @@ vi.mock("debug", () => ({ default: () => vi.fn(), })) +vi.mock("@/lib/localStorages", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isSmmV3Enabled: vi.fn().mockReturnValue(false), + } +}) + import { readUserConfig } from "@/api/readUserConfig" import { hello } from "@/api/hello" import { fetchDiscoverConfig } from "@/api/discover" import { _resetInternalReverseProxyCacheForTesting } from "@/api/fetchByInternalReverseProxy" +import { isSmmV3Enabled } from "@/lib/localStorages" const mockReadUserConfig = vi.mocked(readUserConfig) const mockHello = vi.mocked(hello) @@ -153,6 +162,7 @@ describe("useScrapeNfoMutation — TMDB fetch wiring", () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(isSmmV3Enabled).mockReturnValue(false) _resetInternalReverseProxyCacheForTesting() mockReadUserConfig.mockResolvedValue(userConfigWithTmdb()) mockHello.mockResolvedValue({ diff --git a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts index 2dc0a0a5..f5e20d2e 100644 --- a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts +++ b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.test.ts @@ -9,12 +9,14 @@ import type { TVDBSearchItem } from "@/lib/tvdbSearchNormalize" import { useSelectTvShowForFolderMutation } from "./useSelectTvShowForFolderMutation" import { useGetTmdbTvShowMutation } from "./useGetTmdbTvShowMutation" import { useGetTvdbTvShowMutation } from "./useGetTvdbTvShowMutation" +import { isSmmV3Enabled } from "@/lib/localStorages" import { toast } from "sonner" const hoisted = vi.hoisted(() => ({ fetchMediaMetadataAsync: vi.fn(), updateMediaMetadataAsync: vi.fn(), updateFolderStatus: vi.fn(), + recognizeFolderViaCore: vi.fn(), })) vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ @@ -35,6 +37,14 @@ vi.mock("@/stores/uiMediaFolderStore", () => ({ }, })) +vi.mock("@/api/recognizeFolder", () => ({ + recognizeFolderViaCore: hoisted.recognizeFolderViaCore, +})) + +vi.mock("@/lib/localStorages", () => ({ + isSmmV3Enabled: vi.fn().mockReturnValue(false), +})) + vi.mock("@/lib/utils", async (importOriginal) => { const mod = await importOriginal() return { ...mod, nextTraceId: () => "test-trace" } @@ -124,11 +134,13 @@ function tvdbResult(overrides: Partial = {}): TVDBv4SearchRe describe("useSelectTvShowForFolderMutation", () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(isSmmV3Enabled).mockReturnValue(false) hoisted.fetchMediaMetadataAsync.mockResolvedValue({ mediaFolderPath: "/library/show", type: "tvshow-folder", }) hoisted.updateMediaMetadataAsync.mockResolvedValue(undefined) + hoisted.recognizeFolderViaCore.mockResolvedValue(undefined) }) it("TMDB: routes mutate to useGetTmdbTvShowMutation with id, language, mediaFolderPath, traceId", () => { @@ -306,4 +318,94 @@ describe("useSelectTvShowForFolderMutation", () => { expect(result.current.isSelectTvShowForFolderPending).toBe(true) }) + + describe("when SMM v3 is enabled", () => { + beforeEach(() => { + vi.mocked(isSmmV3Enabled).mockReturnValue(true) + }) + + it("TMDB: calls recognizeFolderViaCore and invalidates media metadata query", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries") + + function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children) + } + + const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { + wrapper: Wrapper, + }) + + await act(async () => { + result.current.selectTvShowForFolderMutation.mutate({ + mediaFolderPath: "/library/show", + database: "TMDB", + result: minimalTmdbTv, + searchLanguage: "en-US", + }) + }) + + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/show", + db: "tmdb", + id: "42", + }) + }) + + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "loading") + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["mediaMetadata", "/library/show"], + }) + expect(hoisted.updateMediaMetadataAsync).not.toHaveBeenCalled() + }) + + it("TVDB: calls recognizeFolderViaCore with tvdb db and id", async () => { + const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.selectTvShowForFolderMutation.mutate({ + mediaFolderPath: "/library/show", + database: "TVDB", + result: tvdbResult({ tvdb_id: "999" }) as unknown as TVDBSearchItem, + searchLanguage: "zh-CN", + }) + }) + + await waitFor(() => { + expect(hoisted.recognizeFolderViaCore).toHaveBeenCalledWith({ + path: "/library/show", + db: "tvdb", + id: "999", + }) + }) + }) + + it("on recognize error: toast.error and folder status ok", async () => { + hoisted.recognizeFolderViaCore.mockRejectedValueOnce(new Error("not managed")) + + const { result } = renderHook(() => useSelectTvShowForFolderMutation(), { + wrapper: createWrapper(), + }) + + await act(async () => { + result.current.selectTvShowForFolderMutation.mutate({ + mediaFolderPath: "/library/show", + database: "TMDB", + result: minimalTmdbTv, + searchLanguage: "en-US", + }) + }) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("not managed") + }) + expect(hoisted.updateFolderStatus).toHaveBeenCalledWith(expect.any(String), "ok") + }) + }) }) diff --git a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts index 35baf181..d247f3bc 100644 --- a/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts +++ b/apps/ui/src/hooks/useSelectTvShowForFolderMutation.ts @@ -1,12 +1,15 @@ import { useCallback, useMemo } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation" -import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { normalizeMediaFolderPathForQuery, mediaMetadataQueryKey } from "@/lib/mediaMetadataQueryKeys" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { Path } from "@core/path" import type { MediaMetadata, TMDBMovie, TMDBTVShow, TvShowMediaMetadata } from "@core/types" import { useGetTmdbTvShowMutation } from "@/hooks/useGetTmdbTvShowMutation" import { useGetTvdbTvShowMutation } from "@/hooks/useGetTvdbTvShowMutation" +import { recognizeFolderViaCore } from "@/api/recognizeFolder" +import { isSmmV3Enabled } from "@/lib/localStorages" import { nextTraceId } from "@/lib/utils" import { toast } from "sonner" import type { SearchLanguage } from "@/components/MediaDatabaseSearchbox" @@ -41,7 +44,22 @@ export type SelectTvShowForFolderVariables = searchLanguage: SearchLanguage } +function recognizeDb(database: "TMDB" | "TVDB"): "tmdb" | "tvdb" { + return database === "TVDB" ? "tvdb" : "tmdb" +} + +function recognizeId( + database: "TMDB" | "TVDB", + result: TMDBTVShow | TMDBMovie | TVDBSearchItem, +): string { + if (database === "TVDB") { + return String((result as TVDBv4SearchResult).tvdb_id) + } + return String((result as TMDBTVShow | TMDBMovie).id) +} + export function useSelectTvShowForFolderMutation() { + const queryClient = useQueryClient() const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() const updateMediaMetadataMutation = useUpdateMediaMetadataMutation() @@ -69,7 +87,7 @@ export function useSelectTvShowForFolderMutation() { const setFolderStatus = useUIMediaFolderStore.getState().updateFolderStatus - const onMutate = useCallback( + const onMutateLegacy = useCallback( (variables: { mediaFolderPath: string }) => { setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "loading") void updateMediaMetadata(variables.mediaFolderPath, (prev) => ({ @@ -80,7 +98,7 @@ export function useSelectTvShowForFolderMutation() { [setFolderStatus, updateMediaMetadata], ) - const onSuccess = useCallback( + const onSuccessLegacy = useCallback( (tvShow: TvShowMediaMetadata, variables: { mediaFolderPath: string }) => { void updateMediaMetadata(variables.mediaFolderPath, (prev) => ({ ...prev, @@ -91,24 +109,48 @@ export function useSelectTvShowForFolderMutation() { [setFolderStatus, updateMediaMetadata], ) - const onError = useCallback((error: Error, variables: { mediaFolderPath: string }) => { + const onErrorLegacy = useCallback((error: Error, variables: { mediaFolderPath: string }) => { toast.error(error instanceof Error ? error.message : "Failed to get TV show details") setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "ok") }, [setFolderStatus]) const applyTmdbTvShowSelectionMutation = useGetTmdbTvShowMutation({ - onMutate, - onSuccess, - onError, + onMutate: onMutateLegacy, + onSuccess: onSuccessLegacy, + onError: onErrorLegacy, }) const applyTvdbTvShowSelectionMutation = useGetTvdbTvShowMutation({ - onMutate, - onSuccess, - onError, + onMutate: onMutateLegacy, + onSuccess: onSuccessLegacy, + onError: onErrorLegacy, }) - const mutate = useCallback( + const recognizeFolderMutation = useMutation({ + mutationFn: async (variables: SelectTvShowForFolderVariables) => { + await recognizeFolderViaCore({ + path: variables.mediaFolderPath, + db: recognizeDb(variables.database), + id: recognizeId(variables.database, variables.result), + }) + }, + onMutate: (variables) => { + setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "loading") + }, + onSuccess: (_data, variables) => { + const pathPosix = normalizeMediaFolderPathForQuery(variables.mediaFolderPath) + if (pathPosix) { + void queryClient.invalidateQueries({ queryKey: mediaMetadataQueryKey(pathPosix) }) + } + setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "ok") + }, + onError: (error, variables) => { + toast.error(error instanceof Error ? error.message : "Failed to recognize folder") + setFolderStatus(Path.toPlatformPath(variables.mediaFolderPath), "ok") + }, + }) + + const mutateLegacy = useCallback( (variables: SelectTvShowForFolderVariables) => { const { database, result, searchLanguage, mediaFolderPath } = variables const traceId = `TvShowSearchResultSelected-${nextTraceId()}` @@ -133,7 +175,7 @@ export function useSelectTvShowForFolderMutation() { [applyTmdbTvShowSelectionMutation, applyTvdbTvShowSelectionMutation], ) - const mutateAsync = useCallback( + const mutateAsyncLegacy = useCallback( async (variables: SelectTvShowForFolderVariables) => { const { database, result, searchLanguage, mediaFolderPath } = variables const traceId = `TvShowSearchResultSelected-${nextTraceId()}` @@ -157,13 +199,37 @@ export function useSelectTvShowForFolderMutation() { [applyTmdbTvShowSelectionMutation, applyTvdbTvShowSelectionMutation], ) + const mutate = useCallback( + (variables: SelectTvShowForFolderVariables) => { + if (isSmmV3Enabled()) { + recognizeFolderMutation.mutate(variables) + return + } + mutateLegacy(variables) + }, + [mutateLegacy, recognizeFolderMutation], + ) + + const mutateAsync = useCallback( + async (variables: SelectTvShowForFolderVariables) => { + if (isSmmV3Enabled()) { + await recognizeFolderMutation.mutateAsync(variables) + return + } + return mutateAsyncLegacy(variables) + }, + [mutateAsyncLegacy, recognizeFolderMutation], + ) + const selectTvShowForFolderMutation = useMemo( () => ({ mutate, mutateAsync }), [mutate, mutateAsync], ) const isSelectTvShowForFolderPending = - applyTmdbTvShowSelectionMutation.isPending || applyTvdbTvShowSelectionMutation.isPending + recognizeFolderMutation.isPending || + applyTmdbTvShowSelectionMutation.isPending || + applyTvdbTvShowSelectionMutation.isPending return { selectTvShowForFolderMutation, diff --git a/docs/api/index.md b/docs/api/index.md index 41558dc2..7dae983c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -67,6 +67,13 @@ Source Code: apps/cli/src/route/Tmdb.ts - `POST /api/get-movie-in-tmdb` → `Core.getMovieInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. - `POST /api/get-tvshow-in-tmdb` → `Core.getTvShowInTmdb`. Body: `{ id: number, language?, host?, password?, proxy? }`. +## RecognizeFolder +Source Code: apps/cli/src/route/RecognizeFolder.ts + +1:1 Internal HTTP for `Core.recognizeFolder`. Assigns TMDB/TVDB TV show or movie metadata to an imported folder; sets `mediaFiles: []`. Response `{ data: { path } }` or `{ error }` (HTTP 200). Used by Web UI when the user selects a search result (`useSelectTvShowForFolderMutation` / `useSelectMovieForFolderMutation`) and `localStorage["smm.v3.enabled"] === "true"`. CLI equivalent: `smm recognize --db tmdb|tvdb --id `. + +- `POST /api/recognize-folder` → `Core.recognizeFolder`. Body: `{ path: string, db: "tmdb" | "tvdb", id: string }`. + ## SearchInTvdb / GetMovieInTvdb / GetTvShowInTvdb / GetTvdbLanguages Source Code: apps/cli/src/route/Tvdb.ts From 1de41f854c510b80b59996923eeb9f088f9a927e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Wed, 26 Aug 2026 19:50:09 +0800 Subject: [PATCH 152/214] feat(ui): route TVDB search through POST /api/search-in-tvdb Align the UI with the v3 TVDB design so search goes through Core via internal HTTP instead of browser-direct TVDB calls, and strengthen the SearchTvShow TVDB e2e checks for Oshi no Ko metadata and episode table. Co-authored-by: Cursor --- apps/e2e/common/tv/SearchTvShow.e2e.ts | 58 ++++++++++++++----- apps/ui/src/api/tvdbSearch.ts | 46 +++++++++++++++ apps/ui/src/api/tvdbV3.test.ts | 49 ++++++++++++++++ apps/ui/src/api/tvdbV3.ts | 57 ++++++++++++++++++ .../MediaDatabaseSearchbox.test.tsx | 33 ++++++----- .../src/components/MediaDatabaseSearchbox.tsx | 30 +++++----- apps/ui/src/hooks/useTvdbQueries.ts | 9 ++- apps/ui/src/lib/recognizeMediaFolder.ts | 7 +++ docs/dev/tvdb.md | 4 +- 9 files changed, 244 insertions(+), 49 deletions(-) create mode 100644 apps/ui/src/api/tvdbSearch.ts create mode 100644 apps/ui/src/api/tvdbV3.test.ts create mode 100644 apps/ui/src/api/tvdbV3.ts diff --git a/apps/e2e/common/tv/SearchTvShow.e2e.ts b/apps/e2e/common/tv/SearchTvShow.e2e.ts index 285b9468..a61759a9 100644 --- a/apps/e2e/common/tv/SearchTvShow.e2e.ts +++ b/apps/e2e/common/tv/SearchTvShow.e2e.ts @@ -56,6 +56,30 @@ S01E33 - - - - S01E34 - - - - S01E35 - - - -` +/** TVDB lists 5 aired-order seasons (S0–S4); empty season names render as "Season N" in zh-CN UI. */ +function buildEpisodeTableExpectation( + seasons: ReadonlyArray<{ season: number; episodes: number }>, +): string { + const lines: string[] = [] + for (const { season, episodes } of seasons) { + lines.push(`Season ${season}`) + for (let episode = 1; episode <= episodes; episode += 1) { + lines.push( + `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} - - - -`, + ) + } + } + return lines.join('\n') +} + +const OSHI_NO_KO_TVDB_EPISODE_TABLE = buildEpisodeTableExpectation([ + { season: 0, episodes: 2 }, + { season: 1, episodes: 11 }, + { season: 2, episodes: 13 }, + { season: 3, episodes: 11 }, + { season: 4, episodes: 1 }, +]) + /** * @supports local, Electron, HarmonyOS, Docker */ @@ -169,23 +193,27 @@ describe('Search TV Show', () => { await when('I search for "我推的孩子"') await when('I select search result with title "【我推的孩子】"') - await then('metadata has tvShow defined', async () => { - const folder = getStepContext()._folder as { path: string } + await then('episode table shows Oshi no Ko TVDB seasons', async () => { await browser.waitUntil(async () => { - try { - await expectMediaMetadataViaBrowser(folder.path, (obj) => { - console.log(JSON.stringify(obj)) - const mm = obj as MediaMetadata - return mm.tvShow !== undefined - }, { timeoutMs: 5_000, intervalMs: 1_000 }) - } catch { - return false - } - return true + const stateInString = await TvShowPanelCO.toString() + console.log(`${new Date().toISOString()} stateInString="${stateInString}"`) + return stateInString.includes(OSHI_NO_KO_TVDB_EPISODE_TABLE) }, { - timeout: 5 * 60 * 1000, - interval: 1000, - timeoutMsg: 'Expected to see TV show metadata in the media metadata', + timeout: 3 * 60 * 1000, + interval: 2000, + timeoutMsg: 'Expected to see TVDB Season 4 in the TV show panel within 3 minutes after TVDB select', + }) + }) + + await then('metadata is persisted with TVDB Oshi no Ko', async () => { + const folder = getStepContext()._folder as { path: string } + await expectMediaMetadataViaBrowser(folder.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.tvShow?.id).toBe('421069') + expect(mm.tvShow?.name).toBe('【我推的孩子】') + expect(mm.tvShow?.database).toBe('TVDB') + expect(mm.tvShow?.seasons?.length).toBe(5) + return true }) }) diff --git a/apps/ui/src/api/tvdbSearch.ts b/apps/ui/src/api/tvdbSearch.ts new file mode 100644 index 00000000..113157bb --- /dev/null +++ b/apps/ui/src/api/tvdbSearch.ts @@ -0,0 +1,46 @@ +import type { TVDBv4SearchResult } from '@smm/tvdb4/types' +import { isSmmV3Enabled } from '@/lib/localStorages' +import { getTVDBv4Client, type GetTVDBv4ClientOverrides } from '@/lib/TvdbUtils' +import { readUserConfig } from './readUserConfig' +import { searchInTvdb } from './tvdbV3' + +export interface SearchTvdbResponse { + results: TVDBv4SearchResult[] + error?: string +} + +export interface TvdbSearchRequestOptions { + signal?: AbortSignal + overrides?: GetTVDBv4ClientOverrides +} + +/** + * Search TVDB by keyword. When v3 is enabled, calls `POST /api/search-in-tvdb`; + * otherwise uses the legacy browser-side TVDBv4 client. + */ +export async function searchTvdb( + keyword: string, + type: 'series' | 'movie', + language?: string, + options?: TvdbSearchRequestOptions, +): Promise { + if (isSmmV3Enabled()) { + const body = await searchInTvdb({ keyword, type, language }, options?.signal) + if (body.error) { + return { results: [], error: body.error } + } + return { results: body.data ?? [] } + } + + const userConfig = await readUserConfig() + const tvdb = getTVDBv4Client({ + ...options?.overrides, + upstreamBaseURL: userConfig?.tvdb?.host?.trim() || options?.overrides?.upstreamBaseURL, + apiKey: userConfig?.tvdb?.apiKey?.trim() || options?.overrides?.apiKey, + }) + const envelope = await tvdb.search({ query: keyword.trim(), type, language }) + if (envelope.status === 'success' && Array.isArray(envelope.data)) { + return { results: envelope.data } + } + return { results: [], error: envelope.message ?? 'TVDB search failed' } +} diff --git a/apps/ui/src/api/tvdbV3.test.ts b/apps/ui/src/api/tvdbV3.test.ts new file mode 100644 index 00000000..b39dcd95 --- /dev/null +++ b/apps/ui/src/api/tvdbV3.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { searchInTvdb } from './tvdbV3' + +vi.mock('@/lib/apiFetch', () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from '@/lib/apiFetch' + +const mockApiFetch = vi.mocked(apiFetch) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('tvdbV3 Internal HTTP clients', () => { + beforeEach(() => { + mockApiFetch.mockReset() + }) + + afterEach(() => { + mockApiFetch.mockReset() + }) + + it('searchInTvdb POSTs keyword and type', async () => { + mockApiFetch.mockResolvedValue( + jsonResponse({ + data: [{ tvdb_id: '421069', name: '【我推的孩子】' }], + }), + ) + const body = await searchInTvdb({ keyword: '我推的孩子', type: 'series', language: 'zho' }) + expect(mockApiFetch).toHaveBeenCalledWith('/api/search-in-tvdb', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keyword: '我推的孩子', type: 'series', language: 'zho' }), + signal: undefined, + }) + expect(body.data?.[0]?.tvdb_id).toBe('421069') + }) + + it('throws on HTTP layer failure', async () => { + mockApiFetch.mockResolvedValue(jsonResponse({ error: 'nope' }, 500)) + await expect(searchInTvdb({ keyword: 'x', type: 'movie' })).rejects.toThrow(/HTTP Layer Error/) + }) +}) diff --git a/apps/ui/src/api/tvdbV3.ts b/apps/ui/src/api/tvdbV3.ts new file mode 100644 index 00000000..de45a9de --- /dev/null +++ b/apps/ui/src/api/tvdbV3.ts @@ -0,0 +1,57 @@ +import { apiFetch } from '@/lib/apiFetch' +import type { TVDBv4SearchResult } from '@smm/tvdb4/types' + +export interface TvdbCoreRequestOptions { + language?: string + host?: string + password?: string + proxy?: string +} + +export interface SearchInTvdbParams extends TvdbCoreRequestOptions { + keyword: string + type: 'series' | 'movie' +} + +export interface SearchInTvdbResponseBody { + data?: TVDBv4SearchResult[] + error?: string +} + +function optionalFields(options?: TvdbCoreRequestOptions): Record { + const body: Record = {} + if (options?.language) body.language = options.language + if (options?.host) body.host = options.host + if (options?.password) body.password = options.password + if (options?.proxy) body.proxy = options.proxy + return body +} + +async function postTvdb(path: string, body: unknown, signal?: AbortSignal): Promise { + const resp = await apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + return (await resp.json()) as T +} + +/** `POST /api/search-in-tvdb` → `Core.searchInTvdb`. */ +export async function searchInTvdb( + params: SearchInTvdbParams, + signal?: AbortSignal, +): Promise { + return postTvdb( + '/api/search-in-tvdb', + { + keyword: params.keyword, + type: params.type, + ...optionalFields(params), + }, + signal, + ) +} diff --git a/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx b/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx index 9a4e4fd0..388b5f76 100644 --- a/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx +++ b/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx @@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useConfig } from '@/hooks/userConfig' import { MediaDatabaseSearchbox } from './MediaDatabaseSearchbox' import localStorages from '@/lib/localStorages' -import { TVDBv4Error } from '@smm/tvdb4' const mockImmersiveSearchboxProps = { current: {} as { @@ -75,11 +74,12 @@ vi.mock('./ImmersiveSearchbox', () => ({ }), })) -const { mockSearchTmdb, mockGetTVDBv4Client, mockTvdbSearch } = vi.hoisted(() => { +const { mockSearchTmdb, mockSearchTvdb, mockGetTVDBv4Client, mockTvdbSearch } = vi.hoisted(() => { const mockSearchTmdb = vi.fn() + const mockSearchTvdb = vi.fn() const mockTvdbSearch = vi.fn() const mockGetTVDBv4Client = vi.fn(() => ({ search: mockTvdbSearch })) - return { mockSearchTmdb, mockGetTVDBv4Client, mockTvdbSearch } + return { mockSearchTmdb, mockSearchTvdb, mockGetTVDBv4Client, mockTvdbSearch } }) vi.mock('@/api/tmdb', async (importOriginal) => { @@ -87,6 +87,10 @@ vi.mock('@/api/tmdb', async (importOriginal) => { return { ...actual, searchTmdb: mockSearchTmdb } }) +vi.mock('@/api/tvdbSearch', () => ({ + searchTvdb: mockSearchTvdb, +})) + vi.mock('@/lib/TvdbUtils', () => ({ getTVDBv4Client: mockGetTVDBv4Client, })) @@ -501,7 +505,7 @@ describe('MediaDatabaseSearchbox', () => { expect(mockGetTVDBv4Client).not.toHaveBeenCalled() }) - it('handleSearch calls getTVDBv4Client().search() with type=movie when database is TVDB and mediaType is movie', async () => { + it('handleSearch calls searchTvdb with keyword, type, and language when database is TVDB and mediaType is movie', async () => { vi.mocked(useConfig).mockImplementation(() => ({ userConfig: { applicationLanguage: 'en', @@ -512,7 +516,7 @@ describe('MediaDatabaseSearchbox', () => { }, appConfig: { reverseProxyUrl: 'http://127.0.0.1:30005' }, } as any)) - mockTvdbSearch.mockResolvedValue({ status: 'success', data: [] }) + mockSearchTvdb.mockResolvedValue({ results: [] }) render( { await mockImmersiveSearchboxProps.current.onSearch?.() }) - await waitFor(() => expect(mockTvdbSearch).toHaveBeenCalledTimes(1)) - expect(mockGetTVDBv4Client).toHaveBeenCalledTimes(1) - const [searchParams] = mockTvdbSearch.mock.calls[0] as [{ query: string; type: string; language?: string }] - expect(searchParams).toMatchObject({ query: 'inception', type: 'movie' }) + await waitFor(() => expect(mockSearchTvdb).toHaveBeenCalledTimes(1)) + expect(mockSearchTvdb).toHaveBeenCalledWith('inception', 'movie', 'eng') + expect(mockGetTVDBv4Client).not.toHaveBeenCalled() expect(mockSearchTmdb).not.toHaveBeenCalled() }) @@ -553,7 +556,7 @@ describe('MediaDatabaseSearchbox', () => { }) }) - it('shows database-specific unauthorized message when TVDB login returns 401', async () => { + it('shows database-specific unauthorized message when TVDB search returns 401', async () => { vi.mocked(useConfig).mockImplementation(() => ({ userConfig: { applicationLanguage: 'en', @@ -564,12 +567,10 @@ describe('MediaDatabaseSearchbox', () => { }, appConfig: { reverseProxyUrl: 'http://127.0.0.1:30005' }, } as any)) - mockTvdbSearch.mockRejectedValue( - new TVDBv4Error('TVDB login failed: 401 Unauthorized', { - status: 401, - url: 'http://127.0.0.1:30005/login', - }), - ) + mockSearchTvdb.mockResolvedValue({ + results: [], + error: 'Error Reason: TVDB login failed: 401 Unauthorized', + }) render( (() => { @@ -235,19 +235,19 @@ export function MediaDatabaseSearchbox({ const query = searchQuery.trim() const language = searchLanguage.trim() || undefined - const tvdb = getTVDBv4Client({ - reverseProxyUrl: appConfig?.reverseProxyUrl ?? null, - upstreamBaseURL: userConfig?.tvdb?.host?.trim() || undefined, - apiKey: userConfig?.tvdb?.apiKey?.trim() || undefined, - }) - const envelope = await tvdb.search({ query, type, language }) - - if ( - envelope.status === "success" && - Array.isArray(envelope.data) && - envelope.data.length > 0 - ) { - setTvdbSearchResultsRaw(buildTvdbSearchResults(envelope.data)) + const response = await searchTvdb(query, type, language) + if (response.error) { + const errText = response.error + if (/401|API key|unauthorized/i.test(errText)) { + setSearchError(t("errors:searchFailedUnauthorizedTvdb")) + } else { + setSearchError(errText.replace(/^Error Reason:\s*/i, "")) + } + return + } + + if (response.results.length > 0) { + setTvdbSearchResultsRaw(buildTvdbSearchResults(response.results)) return } setSearchError(t("errors:searchNoResults")) diff --git a/apps/ui/src/hooks/useTvdbQueries.ts b/apps/ui/src/hooks/useTvdbQueries.ts index ca440c42..44d97cfd 100644 --- a/apps/ui/src/hooks/useTvdbQueries.ts +++ b/apps/ui/src/hooks/useTvdbQueries.ts @@ -1,5 +1,7 @@ import { useQueryClient } from "@tanstack/react-query" import { useCallback } from "react" +import { searchTvdb } from "@/api/tvdbSearch" +import { isSmmV3Enabled } from "@/lib/localStorages" import { fetchTvdbAndBuildMovieMediaMetadata, fetchTvdbAndBuildTvShowMediaMetadata, getTVDBv4Client } from "@/lib/TvdbUtils" import { tvdbArtworkTypesQueryKey, @@ -157,10 +159,15 @@ export function useTvdbQueries() { const search = useCallback( (params: TVDBv4SearchParams): Promise => { - const tvdb = getClient() return queryClient.fetchQuery({ queryKey: tvdbSearchQueryKey(params), queryFn: async () => { + if (isSmmV3Enabled()) { + const body = await searchTvdb(params.query, params.type, params.language) + if (body.error) return undefined + return body.results + } + const tvdb = getClient() const resp = await tvdb.search(params) return resp.status === "success" ? resp.data : undefined }, diff --git a/apps/ui/src/lib/recognizeMediaFolder.ts b/apps/ui/src/lib/recognizeMediaFolder.ts index 2241b582..b78d25c9 100644 --- a/apps/ui/src/lib/recognizeMediaFolder.ts +++ b/apps/ui/src/lib/recognizeMediaFolder.ts @@ -6,6 +6,8 @@ import { import { tryToRecognizeMediaFolderByTmdbIdInFolderName } from "./recognizeMediaFolderByTmdbIdInFolderName"; import { tryToRecognizeMediaFolderByTvdbIdInFolderName } from "./recognizeMediaFolderByTvdbIdInFolderName"; import type { PreferMediaLanguage, PrimaryDatabase, TmdbSearchResponseBody, TvShowMediaMetadata } from "@core/types"; +import { searchTvdb } from "@/api/tvdbSearch"; +import { isSmmV3Enabled } from "@/lib/localStorages"; import { getTVDBv4Client } from "./TvdbUtils"; import { tryToRecognizeMovieFolderBySearchingFolderNameInTVDB, @@ -64,6 +66,11 @@ export async function recognizeMediaFolder( const resolveSearchInTvdb = searchInTvdbFn ?? (async (params: TVDBv4SearchParams) => { + if (isSmmV3Enabled()) { + const body = await searchTvdb(params.query, params.type, params.language) + if (body.error) return undefined + return body.results + } const tvdb = getTVDBv4Client() const resp = await tvdb.search(params) return resp.status === "success" ? resp.data : undefined diff --git a/docs/dev/tvdb.md b/docs/dev/tvdb.md index b5e89a60..0b9aa7a2 100644 --- a/docs/dev/tvdb.md +++ b/docs/dev/tvdb.md @@ -206,7 +206,7 @@ CLI runner:`apps/cli/src/mcp/mcp.ts`(`searchInTvdb` / `getMovieInTvdb` / `ge --- -## Web UI(⏳ v3 迁移) +## Web UI(✅ v3 搜索) Web UI 的改动需要使用 localStorage 开关 `smm.v3.enabled` 控制. @@ -224,7 +224,7 @@ flowchart LR | 层 | 文件 / 接口 | |----|------| -| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` | +| UI | `apps/ui/src/components/MediaDatabaseSearchbox.tsx` · `apps/ui/src/api/tvdbSearch.ts` · `apps/ui/src/api/tvdbV3.ts` | | HTTP | `POST /api/search-in-tvdb` · `POST /api/get-movie-in-tvdb` · `POST /api/get-tvshow-in-tvdb` · `POST /api/get-tvdb-languages` | | Core | `searchInTvdb` · `getMovieInTvdb` · `getTvShowInTvdb` · `getTvdbLanguages` | | 出站 | `apps/cli/src/core/NodejsNetworkPort.ts` | From abcc1634168dab6b5c00fc07796a39a6c0563813 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 27 Aug 2026 11:38:08 +0800 Subject: [PATCH 153/214] feat(core): add config/metadata helpers and import library flow Extract UserConfigHelper and MediaMetadataHelper with validation and file locking, then wire import-library through Core, CLI, UI, and e2e tests. Co-authored-by: Cursor --- apps/cli/index.ts | 2 +- apps/cli/server.ts | 2 + apps/cli/src/cli/addlib.test.ts | 115 ++++++ apps/cli/src/cli/addlibProgress.ts | 71 ++++ apps/cli/src/cli/runCli.ts | 49 +++ apps/cli/src/route/ImportLibrary.test.ts | 81 ++++ apps/cli/src/route/ImportLibrary.ts | 59 +++ apps/core/src/Core.test.ts | 91 ++++- apps/core/src/Core.ts | 144 ++++++-- .../src/adapters/network/NetworkFsAdapter.ts | 14 + .../core/src/adapters/node/NodejsFsAdapter.ts | 11 + apps/core/src/index.ts | 19 +- apps/core/src/jobs/jobStore.ts | 8 +- apps/core/src/jobs/types.ts | 19 +- .../importFolderPipeline.integration.test.ts | 1 + .../src/pipeline/importFolderPipeline.test.ts | 1 + .../core/src/pipeline/importFolderPipeline.ts | 9 +- apps/core/src/pipeline/importLibrary.test.ts | 17 + apps/core/src/pipeline/importLibrary.ts | 7 + apps/core/src/pipeline/mcpServer.test.ts | 13 +- apps/core/src/pipeline/mcpServer.ts | 8 +- .../src/pipeline/mediaMetadataHelper.test.ts | 102 ++++++ apps/core/src/pipeline/mediaMetadataHelper.ts | 135 +++++++ .../src/pipeline/mediaMetadataValidation.ts | 88 +++++ apps/core/src/pipeline/plans.test.ts | 1 + .../core/src/pipeline/recognizeFolder.test.ts | 10 +- apps/core/src/pipeline/recognizeFolder.ts | 22 +- .../src/pipeline/renameEpisodeFile.test.ts | 19 +- apps/core/src/pipeline/renameEpisodeFile.ts | 4 +- apps/core/src/pipeline/renameFolder.ts | 28 +- .../scrape/checkScrapeCompletion.test.ts | 1 + .../scrape/downloadScrapeImage.test.ts | 3 +- .../scrape/resolveScrapeHttpProxy.test.ts | 2 +- .../pipeline/scrape/scrapeFanartTmdb.test.ts | 3 +- apps/core/src/pipeline/scrape/scrapeFolder.ts | 4 +- .../src/pipeline/scrape/scrapeNfoTmdb.test.ts | 3 +- .../pipeline/scrape/scrapePosterTmdb.test.ts | 3 +- .../scrape/scrapeThumbnailsTmdb.test.ts | 3 +- .../src/pipeline/tryToRecognizeEpisodes.ts | 4 +- apps/core/src/pipeline/tryToRenameFolder.ts | 4 +- apps/core/src/pipeline/userConfig.test.ts | 58 --- apps/core/src/pipeline/userConfigDefaults.ts | 45 +++ .../src/pipeline/userConfigHelper.test.ts | 116 ++++++ .../{userConfig.ts => userConfigHelper.ts} | 95 +++-- .../core/src/pipeline/userConfigValidation.ts | 230 ++++++++++++ apps/core/src/ports/FsPort.ts | 2 + apps/e2e/cli/import-library.test.ts | 74 ++++ apps/e2e/common/ImportLibrary.e2e.ts | 211 +++++++++++ .../steps/media-library-was-initialized.ts | 18 +- apps/ui/src/api/getJob.ts | 19 +- apps/ui/src/api/importLibrary.ts | 52 +++ apps/ui/src/api/showFolder.ts | 48 +++ .../MediaLibraryImportedEventHandler.test.ts | 346 +++++------------- .../MediaLibraryImportedEventHandler.tsx | 163 +++------ apps/ui/src/lib/importLibraryV3.ts | 47 +++ docs/dev/import-folder.md | 2 +- docs/dev/import-library.md | 96 +++++ docs/dev/v3-onboarding.md | 25 ++ 58 files changed, 2238 insertions(+), 589 deletions(-) create mode 100644 apps/cli/src/cli/addlib.test.ts create mode 100644 apps/cli/src/cli/addlibProgress.ts create mode 100644 apps/cli/src/route/ImportLibrary.test.ts create mode 100644 apps/cli/src/route/ImportLibrary.ts create mode 100644 apps/core/src/pipeline/importLibrary.test.ts create mode 100644 apps/core/src/pipeline/importLibrary.ts create mode 100644 apps/core/src/pipeline/mediaMetadataHelper.test.ts create mode 100644 apps/core/src/pipeline/mediaMetadataHelper.ts create mode 100644 apps/core/src/pipeline/mediaMetadataValidation.ts delete mode 100644 apps/core/src/pipeline/userConfig.test.ts create mode 100644 apps/core/src/pipeline/userConfigDefaults.ts create mode 100644 apps/core/src/pipeline/userConfigHelper.test.ts rename apps/core/src/pipeline/{userConfig.ts => userConfigHelper.ts} (51%) create mode 100644 apps/core/src/pipeline/userConfigValidation.ts create mode 100644 apps/e2e/cli/import-library.test.ts create mode 100644 apps/e2e/common/ImportLibrary.e2e.ts create mode 100644 apps/ui/src/api/importLibrary.ts create mode 100644 apps/ui/src/api/showFolder.ts create mode 100644 apps/ui/src/lib/importLibraryV3.ts create mode 100644 docs/dev/import-library.md create mode 100644 docs/dev/v3-onboarding.md diff --git a/apps/cli/index.ts b/apps/cli/index.ts index c4e58919..e1f0317b 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -18,7 +18,7 @@ import { logger } from './lib/logger'; applyTmdbTlsDevBypassToProcessIfEnabled(); // CLI subcommands talk to Core in-process; do not start the HTTP server. -const cliCommands = new Set(['list', 'add', 'show', 'metadata', 'rm', 'config', 'recognize', 'try-to-recognize', 'try-to-rename', 'apply', 'reject', 'plan', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) +const cliCommands = new Set(['list', 'add', 'addlib', 'show', 'metadata', 'rm', 'config', 'recognize', 'try-to-recognize', 'try-to-rename', 'apply', 'reject', 'plan', 'scrape', 'job', 'rename', 'rename-episode-file', 'hello', 'tmdb', 'tvdb', 'mcp']) const firstArg = process.argv[2] const isCliHelp = firstArg === '--help' || firstArg === '-h' if (firstArg !== undefined && (cliCommands.has(firstArg) || isCliHelp)) { diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 5b52dae6..42b097f6 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -50,6 +50,7 @@ import { handleRenameEpisodesPlan } from './src/route/RenameEpisodesPlan'; import { handleGetFolders } from './src/route/GetFolders'; import { handleUnimportFolder } from './src/route/UnimportFolder'; import { handleImportFolder } from './src/route/ImportFolder'; +import { handleImportLibrary } from './src/route/ImportLibrary'; import { handleGetJob } from './src/route/GetJob'; import { handleScrape } from './src/route/Scrape'; import { handleTmdb } from './src/route/Tmdb'; @@ -299,6 +300,7 @@ export class Server { handleGetFolders(this.app); handleUnimportFolder(this.app); handleImportFolder(this.app); + handleImportLibrary(this.app); handleGetJob(this.app); handleScrape(this.app); handleTmdb(this.app); diff --git a/apps/cli/src/cli/addlib.test.ts b/apps/cli/src/cli/addlib.test.ts new file mode 100644 index 00000000..ecd11084 --- /dev/null +++ b/apps/cli/src/cli/addlib.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { createFolderInTestFolder, musicFolder } from '@smm/test' +import { resetCoreForTests } from '../core/getCore' + +describe('smm addlib', () => { + let userDataDir: string + let libraryPath: string + let prevUserDataDir: string | undefined + let logSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-addlib-cli-')) + libraryPath = mkdtempSync(join(tmpdir(), 'smm-addlib-library-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(libraryPath, { recursive: true, force: true }) + }) + + it('imports every subfolder in a library and exits 0', async () => { + const music1 = createFolderInTestFolder(libraryPath, musicFolder) + createFolderInTestFolder(libraryPath, { + ...musicFolder, + folderName: 'SecondMusic', + }) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'addlib', libraryPath, '--type', 'music']) + + expect(code).toBe(0) + const config = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + folders: string[] + } + expect(config.folders).toContain(music1.path) + expect(config.folders.length).toBe(2) + }, 30_000) + + it('with --skip-init registers each subfolder without full initialization output', async () => { + createFolderInTestFolder(libraryPath, musicFolder) + createFolderInTestFolder(libraryPath, { + ...musicFolder, + folderName: 'SecondMusic', + }) + + const { runCli } = await import('./runCli') + const code = await runCli([ + 'node', + 'smm', + 'addlib', + libraryPath, + '--type', + 'music', + '--skip-init', + ]) + + expect(code).toBe(0) + const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')) + expect(lines.some((l) => l.includes(`importing library ${libraryPath}`))).toBe(true) + expect(lines.filter((l) => l.startsWith('imported folder ')).length).toBe(2) + expect(lines.some((l) => l === 'succeeded')).toBe(false) + expect(lines.some((l) => l.includes('recognizing'))).toBe(false) + }) + + it('exits 1 when the library path does not exist', async () => { + const missing = join(libraryPath, 'does-not-exist') + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'addlib', missing, '--type', 'music']) + + expect(code).toBe(1) + expect(errorSpy).toHaveBeenCalled() + }) + + it('treats --type anime as tvshow', async () => { + const { Core } = await import('core-app') + const importLibrary = vi + .spyOn(Core.prototype, 'importLibrary') + .mockReturnValue({ id: 'lib-job-1' }) + vi.spyOn(Core.prototype, 'getJob').mockReturnValue({ + kind: 'import-library', + id: 'lib-job-1', + libraryPath, + type: 'tvshow', + status: 'succeeded', + progress: 100, + folderPaths: [], + importedCount: 0, + totalCount: 0, + createdAt: 0, + updatedAt: 0, + }) + + const { runCli } = await import('./runCli') + const code = await runCli(['node', 'smm', 'addlib', libraryPath, '--type', 'anime']) + + expect(code).toBe(0) + expect(importLibrary).toHaveBeenCalledWith(libraryPath, 'tvshow', undefined) + }) +}) diff --git a/apps/cli/src/cli/addlibProgress.ts b/apps/cli/src/cli/addlibProgress.ts new file mode 100644 index 00000000..e5f869d4 --- /dev/null +++ b/apps/cli/src/cli/addlibProgress.ts @@ -0,0 +1,71 @@ +import type { Core, FolderType, ImportLibraryJob } from 'core-app' +import { createAddProgressState, emitAddProgress } from './addProgress' + +export async function waitUntilLibraryImportSettled( + core: Core, + id: string, + options: { + libraryPath: string + type: FolderType + timeoutMs: number + log?: (line: string) => void + /** When false, do not print progress lines. Default true. */ + progress?: boolean + skipInit?: boolean + }, +): Promise { + const log = options.log ?? console.log + const emitProgress = options.progress !== false + let announcedLibrary = false + let lastFolderJobId: string | undefined + let folderProgress = createAddProgressState() + const deadline = Date.now() + options.timeoutMs + + for (;;) { + const job = core.getJob(id) + if (job?.kind === 'import-library') { + if ( + !announcedLibrary && + (job.totalCount > 0 || (job.status !== 'running' && job.status !== 'pending')) + ) { + log(`importing library ${options.libraryPath} (${job.totalCount} folders)`) + announcedLibrary = true + } + + if (job.currentFolderJobId && job.currentFolderJobId !== lastFolderJobId) { + lastFolderJobId = job.currentFolderJobId + folderProgress = createAddProgressState() + } + + if ( + emitProgress && + !options.skipInit && + lastFolderJobId && + job.currentFolderPath + ) { + const childJob = core.getJob(lastFolderJobId) + if (childJob?.kind === 'import') { + folderProgress = emitAddProgress( + folderProgress, + childJob, + job.currentFolderPath, + options.type, + log, + ) + } + } + + if (job.status !== 'pending' && job.status !== 'running') { + if (emitProgress && job.status === 'succeeded' && !options.skipInit) { + log('succeeded') + } + return job + } + } + + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for import library job ${id}`) + } + await new Promise((resolve) => setTimeout(resolve, 20)) + } +} diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 99ccebe4..1ea329e6 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -6,6 +6,7 @@ import { isUserConfigKey, NoopLoggerAdapter } from 'core-app' import { getCore } from '../core/getCore' import { formatHelloLines } from './helloFormat' import { waitUntilImportSettled } from './addProgress' +import { waitUntilLibraryImportSettled } from './addlibProgress' import { CliLoggerAdapter } from './cliLogger' import { formatScrapeJobTaskLines } from './scrapeJobFormat' import { waitUntilScrapeSettled } from './waitScrapeJob' @@ -158,6 +159,50 @@ export async function runCli(argv: string[] = process.argv): Promise { } }) + program + .command('addlib') + .description('Import all media folders in a library directory and wait until initialization succeeds') + .argument('', 'Library directory path') + .addOption( + new Option('--type ', 'Folder type for every subfolder (anime is an alias for tvshow)') + .choices([...TYPE_CHOICES]) + .makeOptionMandatory(), + ) + .option('-v, --verbose', 'Print detailed logs') + .option('--skip-init', 'Only register subfolders in UserConfig; skip recognition and metadata') + .action(async (library: string, opts: { type: string; verbose?: boolean; skipInit?: boolean }) => { + try { + const type = resolveFolderType(opts.type) + const verbose = Boolean(opts.verbose) + const skipInit = Boolean(opts.skipInit) + const core = getCore({ + logger: verbose ? new CliLoggerAdapter(true) : new NoopLoggerAdapter(), + }) + const { id } = core.importLibrary(library, type, skipInit ? { skipInit: true } : undefined) + const job = await waitUntilLibraryImportSettled(core, id, { + libraryPath: library, + type, + timeoutMs: IMPORT_WAIT_TIMEOUT_MS, + progress: !skipInit, + skipInit, + }) + if (job.status !== 'succeeded') { + console.error(job.error ?? `Import library failed with status ${job.status}`) + exitCode = 1 + return + } + if (skipInit) { + for (const folder of job.folderPaths) { + console.log(`imported folder ${folder}`) + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + exitCode = 1 + } + }) + program .command('show') .description('Show imported folder status (UI-aligned)') @@ -533,6 +578,10 @@ export async function runCli(argv: string[] = process.argv): Promise { } return } + if (job.kind === 'import-library') { + printJson(job) + return + } printJson(job) } catch (error) { console.error(error instanceof Error ? error.message : String(error)) diff --git a/apps/cli/src/route/ImportLibrary.test.ts b/apps/cli/src/route/ImportLibrary.test.ts new file mode 100644 index 00000000..34ecd5a9 --- /dev/null +++ b/apps/cli/src/route/ImportLibrary.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import { handleImportLibrary } from './ImportLibrary' +import { handleGetJob } from './GetJob' +import { resetCoreForTests } from '../core/getCore' + +describe('POST /api/import-library', () => { + let userDataDir: string + let prevUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + prevUserDataDir = process.env.USER_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-import-library-')) + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleImportLibrary(app) + handleGetJob(app) + }) + + afterEach(() => { + resetCoreForTests() + if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = prevUserDataDir + rmSync(userDataDir, { recursive: true, force: true }) + }) + + async function post(body: unknown) { + return app.request('/api/import-library', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + async function getJob(id: string) { + return app.request('/api/get-job', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }) + } + + it('returns Error Reason when path is missing', async () => { + const res = await post({ type: 'music' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: path is required/) + }) + + it('returns Error Reason when type is missing', async () => { + const res = await post({ path: '/media/lib' }) + expect(res.status).toBe(200) + const json = (await res.json()) as { error?: string } + expect(json.error).toMatch(/^Error Reason: type is required/) + }) + + it('returns a job id for import-library', async () => { + const res = await post({ path: '/media/lib', type: 'music', skipInit: true }) + expect(res.status).toBe(200) + const json = (await res.json()) as { data?: { id: string }; error?: string } + expect(json.error).toBeUndefined() + expect(json.data?.id).toEqual(expect.any(String)) + + const deadline = Date.now() + 5000 + let status: string | undefined + while (Date.now() < deadline) { + const jobRes = await getJob(json.data!.id) + const jobJson = (await jobRes.json()) as { data?: { kind: string; status: string } } + expect(jobJson.data?.kind).toBe('import-library') + status = jobJson.data?.status + if (status === 'succeeded' || status === 'failed') break + await new Promise((r) => setTimeout(r, 50)) + } + expect(['succeeded', 'failed']).toContain(status) + }) +}) diff --git a/apps/cli/src/route/ImportLibrary.ts b/apps/cli/src/route/ImportLibrary.ts new file mode 100644 index 00000000..0e6a0efe --- /dev/null +++ b/apps/cli/src/route/ImportLibrary.ts @@ -0,0 +1,59 @@ +import type { Hono } from 'hono' +import type { FolderType } from 'core-app' +import { getCore } from '../core/getCore' +import { logger } from '../../lib/logger' + +const FOLDER_TYPES: readonly FolderType[] = ['tvshow', 'movie', 'music'] + +export interface ImportLibraryResponseBody { + data?: { id: string } + error?: string +} + +function resolveFolderType(value: unknown): FolderType | undefined { + if (value === 'anime') return 'tvshow' + if (typeof value === 'string' && (FOLDER_TYPES as readonly string[]).includes(value)) { + return value as FolderType + } + return undefined +} + +export function handleImportLibrary(app: Hono): void { + app.post('/api/import-library', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty body */ + } + const rec = typeof body === 'object' && body !== null ? (body as Record) : {} + const path = rec.path + if (typeof path !== 'string' || path.trim() === '') { + const err: ImportLibraryResponseBody = { error: 'Error Reason: path is required' } + return c.json(err, 200) + } + if (rec.type === undefined || rec.type === '') { + const err: ImportLibraryResponseBody = { error: 'Error Reason: type is required' } + return c.json(err, 200) + } + const type = resolveFolderType(rec.type) + if (type === undefined) { + const err: ImportLibraryResponseBody = { + error: `Error Reason: Invalid folder type: ${String(rec.type)}`, + } + return c.json(err, 200) + } + const skipInit = rec.skipInit === true + const { id } = getCore().importLibrary(path, type, skipInit ? { skipInit: true } : undefined) + const ok: ImportLibraryResponseBody = { data: { id } } + return c.json(ok, 200) + } catch (error) { + logger.error({ error }, '[POST /api/import-library] route error') + const err: ImportLibraryResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) +} diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 69b91481..efa25b7f 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -19,7 +19,14 @@ function inMemoryFs(seed: Record = {}): FsPort { files.set(path, content); }), writeBinaryFile: vi.fn(async () => {}), - exists: vi.fn(async (path: string) => files.has(path)), + exists: vi.fn(async (path: string) => { + if (files.has(path)) return true; + const prefix = path.endsWith("/") ? path : path + "/"; + for (const key of files.keys()) { + if (key.startsWith(prefix)) return true; + } + return false; + }), listFiles: vi.fn(async (dir: string) => { const out: string[] = []; for (const key of files.keys()) { @@ -27,6 +34,17 @@ function inMemoryFs(seed: Record = {}): FsPort { } return out; }), + listSubdirectories: vi.fn(async (dir: string) => { + const prefix = dir.endsWith("/") ? dir : dir + "/"; + const subdirs = new Set(); + for (const key of files.keys()) { + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + const slash = rest.indexOf("/"); + if (slash >= 0) subdirs.add(prefix + rest.slice(0, slash)); + } + return [...subdirs]; + }), deleteFile: vi.fn(async (path: string) => { files.delete(path); }), @@ -170,6 +188,69 @@ describe("Core", () => { }); expect(core.getJob("nope")).toBeUndefined(); }); + + it("importLibrary imports each subdirectory via importFolder", async () => { + const fs = inMemoryFs({ + "/lib/Show1/a.mp3": "", + "/lib/Show2/a.mp3": "", + }); + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importLibrary("/lib", "music"); + await waitForStatus(core, id, "succeeded"); + + const job = core.getJob(id); + expect(job?.kind).toBe("import-library"); + if (job?.kind !== "import-library") return; + expect(job.status).toBe("succeeded"); + expect(job.totalCount).toBe(2); + expect(job.importedCount).toBe(2); + + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); + expect(savedConfig.folders).toEqual(expect.arrayContaining(["/lib/Show1", "/lib/Show2"])); + }); + + it("importLibrary skips folders already in user config", async () => { + const fs = inMemoryFs({ + [userConfigPath("/data/smm")]: JSON.stringify({ folders: ["/lib/Show1"], tmdb: {}, tvdb: {} }), + "/lib/Show1/a.mp3": "", + "/lib/Show2/a.mp3": "", + }); + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importLibrary("/lib", "music"); + await waitForStatus(core, id, "succeeded"); + + const job = core.getJob(id); + if (job?.kind !== "import-library") throw new Error("expected import-library job"); + expect(job.totalCount).toBe(1); + expect(job.folderPaths).toEqual(["/lib/Show2"]); + }); + + it("importLibrary marks the job failed when the library path is missing", async () => { + const core = new Core({ + fs: inMemoryFs(), + network: emptyNetwork(), + appDataDir: "/data/smm", + }); + + const { id } = core.importLibrary("/missing/lib", "music"); + await waitForStatus(core, id, "failed"); + + const job = core.getJob(id); + expect(job?.status).toBe("failed"); + expect(job?.error).toContain("Library path not found"); + }); }); describe("getAppConfig", () => { @@ -355,7 +436,7 @@ describe("setMetadata", () => { const fs = inMemoryFs(); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - await expect(core.setMetadata({ type: "tvshow-folder" })).rejects.toThrow("Media folder path is required"); + await expect(core.setMetadata({ type: "tvshow-folder" })).rejects.toThrow("mediaFolderPath is required"); expect(fs.writeTextFile).not.toHaveBeenCalled(); }); @@ -364,7 +445,7 @@ describe("setMetadata", () => { const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); await expect(core.setMetadata({ mediaFolderPath: "", type: "tvshow-folder" })).rejects.toThrow( - "Media folder path is required", + "mediaFolderPath is required", ); expect(fs.writeTextFile).not.toHaveBeenCalled(); }); @@ -479,6 +560,7 @@ describe("unimportFolder", () => { }, rename: async () => {}, mkdir: async () => {}, + listSubdirectories: async () => [], }; const core = new Core({ fs, network: emptyNetwork(), appDataDir }); @@ -521,7 +603,7 @@ describe("renameFolder", () => { expect(await fs.exists(newCache)).toBe(true); const written = JSON.parse(await fs.readTextFile(newCache)) as typeof mm; expect(written.mediaFolderPath).toBe(to); - expect(written.files).toEqual([`${to}/S01E01.mkv`]); + expect(written.files).toBeUndefined(); expect(written.mediaFiles?.[0]?.absolutePath).toBe(`${to}/S01E01.mkv`); const folders = await core.getFolders(); @@ -950,6 +1032,7 @@ describe("scrapeFolder", () => { }), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 83d0f66c..dfa08c38 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -37,7 +37,7 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; import { TvdbClient } from "./clients/TvdbClient"; import { createBlankMediaMetadata, ImportFolderPipeline } from "./pipeline/importFolderPipeline"; -import { metadataCachePath } from "./pipeline/paths"; +import { dedupLibraryFolders } from "./pipeline/importLibrary"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; import { renameEpisodeFilePipeline, @@ -69,9 +69,11 @@ import { } from "./pipeline/scrape/scrapeFolder"; import type { ScrapeFolderResult } from "./pipeline/scrape/types"; import type { RenameRuleName } from "./pipeline/renameRules"; -import { isUserConfigKey, UserConfig } from "./pipeline/userConfig"; +import { isUserConfigKey, UserConfigHelper } from "./pipeline/userConfigHelper"; +import { MediaMetadataHelper } from "./pipeline/mediaMetadataHelper"; +import type { PersistedMediaMetadata } from "./pipeline/mediaMetadataValidation"; import { JobStore } from "./jobs/jobStore"; -import { initialScrapeTasks, type ImportJob, type Job } from "./jobs/types"; +import { initialScrapeTasks, type ImportJob, type ImportLibraryJob, type Job } from "./jobs/types"; export interface TmdbRequestOptions { /** TMDB language (CLI `--lang`). Validated offline against static primary_translations. */ @@ -151,6 +153,10 @@ export interface ImportFolderHandle { id: string; } +export interface ImportLibraryHandle { + id: string; +} + export interface ScrapeFolderHandle { id: string; } @@ -160,6 +166,11 @@ export interface ImportFolderOptions { skipInit?: boolean; } +export interface ImportLibraryOptions { + /** When true, only register each subfolder in UserConfig.folders; skip recognition and metadata. */ + skipInit?: boolean; +} + export class Core { private readonly jobs = new JobStore(); private readonly fs: FsPort; @@ -174,7 +185,8 @@ export class Core { private readonly logDir: string | undefined; private readonly platform: string | undefined; private readonly osLocale: string | undefined; - private readonly userConfig: UserConfig; + private readonly userConfig: UserConfigHelper; + private readonly mediaMetadata: MediaMetadataHelper; private readonly discover?: DiscoverPort; private readonly mcpServer?: McpServerPort; @@ -191,7 +203,8 @@ export class Core { this.logDir = options.logDir; this.platform = options.platform; this.osLocale = options.osLocale; - this.userConfig = new UserConfig(this.fs, this.appDataDir); + this.userConfig = new UserConfigHelper(this.fs, this.appDataDir); + this.mediaMetadata = new MediaMetadataHelper(this.fs, this.appDataDir); this.discover = options.discover; this.mcpServer = options.mcpServer; } @@ -256,6 +269,23 @@ export class Core { return { id: job.id }; } + /** Imports every immediate subfolder of a library directory via {@link importFolder}. */ + importLibrary(path: string, type: FolderType, options?: ImportLibraryOptions): ImportLibraryHandle { + const libraryPath = this.normalizePosix(path); + const job = this.jobs.create({ + kind: "import-library", + libraryPath, + type, + status: "running", + progress: 0, + folderPaths: [], + importedCount: 0, + totalCount: 0, + }); + void this.runImportLibrary(job, path, type, options?.skipInit === true); + return { id: job.id }; + } + getJob(id: string): Job | undefined { return this.jobs.get(id); } @@ -287,42 +317,27 @@ export class Core { return this.userConfig.read(); } - /** Updates one known UserConfig key. Rejects unknown keys without writing. */ + /** Updates one known UserConfig key. Rejects unknown keys and invalid values without writing. */ async setUserConfigKey(key: string, value: unknown): Promise { if (!isUserConfigKey(key)) { throw new Error(`Unknown config key: ${key}`); } - return this.userConfig.update((config) => ({ ...config, [key]: value })); + return this.userConfig.setKey(key, value); } async getFolders(): Promise { - return (await this.userConfig.read()).folders; + return this.userConfig.getFolders(); } /** Reads the persisted metadata cache for a folder; null when absent or corrupt. */ - async getMediaMetadata(folder: string): Promise { - const posixPath = this.normalizePosix(folder); - const cachePath = metadataCachePath(this.appDataDir, posixPath); - if (!(await this.fs.exists(cachePath))) return null; - try { - const content = await this.fs.readTextFile(cachePath); - return JSON.parse(content) as MediaMetadata; - } catch { - return null; - } + async getMediaMetadata(folder: string): Promise { + return this.mediaMetadata.read(this.normalizePosix(folder)); } - /** Writes the metadata cache for `mm.mediaFolderPath`. Strips `files`. Full replace. */ + /** Writes the metadata cache for `mm.mediaFolderPath`. Strips deprecated `files`. Full replace. */ async setMetadata(mm: MediaMetadata): Promise { - if (!mm.mediaFolderPath) { - throw new Error("Media folder path is required"); - } - const posixPath = this.normalizePosix(mm.mediaFolderPath); - const { files: _files, ...toPersist } = mm; - await this.fs.writeTextFile( - metadataCachePath(this.appDataDir, posixPath), - JSON.stringify(toPersist, null, 2), - ); + const { files: _files, ...rest } = mm; + await this.mediaMetadata.write(rest); } /** Removes a folder from the user config and deletes its metadata cache. Idempotent. */ @@ -336,15 +351,15 @@ export class Core { return { ...config, folders }; }); if (removed) { - await this.fs.deleteFile(metadataCachePath(this.appDataDir, posixPath)); + await this.mediaMetadata.delete(posixPath); } } async renameFolder(args: RenameFolderArgs): Promise { await renameFolderPipeline(args, { fs: this.fs, - appDataDir: this.appDataDir, userConfig: this.userConfig, + mediaMetadata: this.mediaMetadata, normalizePosix: (path) => this.normalizePosix(path), }); } @@ -379,6 +394,7 @@ export class Core { fs: this.fs, appDataDir: this.appDataDir, userConfig: this.userConfig, + mediaMetadata: this.mediaMetadata, normalizePosix: (p) => this.normalizePosix(p), tmdb, tvdb, @@ -399,6 +415,7 @@ export class Core { fs: this.fs, appDataDir: this.appDataDir, userConfig: this.userConfig, + mediaMetadata: this.mediaMetadata, normalizePosix: (p) => this.normalizePosix(p), tmdb, tvdb, @@ -693,10 +710,7 @@ export class Core { private async runImportSkipInit(job: ImportJob, folderPath: string): Promise { try { - await this.userConfig.update((config) => ({ - ...config, - folders: [...new Set([...config.folders, folderPath])], - })); + await this.userConfig.addFolder(folderPath); const blankMetadata = createBlankMediaMetadata(folderPath, job.type); await this.setMetadata(blankMetadata); this.jobs.update(job.id, { status: "succeeded", stage: "metadata", progress: 100 }); @@ -735,4 +749,66 @@ export class Core { }); } } + + private async runImportLibrary( + job: ImportLibraryJob, + libraryPath: string, + type: FolderType, + skipInit: boolean, + ): Promise { + try { + if (!(await this.fs.exists(libraryPath))) { + throw new Error(`Library path not found: ${libraryPath}`); + } + const subdirs = await this.fs.listSubdirectories(libraryPath); + const existing = await this.getFolders(); + const toImport = dedupLibraryFolders(subdirs, existing); + this.jobs.update(job.id, { + folderPaths: toImport, + totalCount: toImport.length, + importedCount: 0, + }); + + for (let i = 0; i < toImport.length; i++) { + const folder = toImport[i]!; + const { id: childId } = this.importFolder(folder, type, skipInit ? { skipInit: true } : undefined); + this.jobs.update(job.id, { + currentFolderPath: folder, + currentFolderJobId: childId, + progress: toImport.length === 0 ? 100 : Math.floor((i / toImport.length) * 100), + }); + await this.waitForImportJob(childId); + const childJob = this.jobs.get(childId); + if (childJob?.kind === "import" && childJob.status === "failed") { + throw new Error(childJob.error ?? `Failed to import folder: ${folder}`); + } + this.jobs.update(job.id, { + importedCount: i + 1, + progress: Math.floor(((i + 1) / toImport.length) * 100), + }); + } + + this.jobs.update(job.id, { + status: "succeeded", + progress: 100, + currentFolderPath: undefined, + currentFolderJobId: undefined, + }); + } catch (error) { + this.jobs.update(job.id, { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private async waitForImportJob(id: string): Promise { + for (;;) { + const job = this.jobs.get(id); + if (job?.kind === "import" && job.status !== "pending" && job.status !== "running") { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } } diff --git a/apps/core/src/adapters/network/NetworkFsAdapter.ts b/apps/core/src/adapters/network/NetworkFsAdapter.ts index b1988060..27201f67 100644 --- a/apps/core/src/adapters/network/NetworkFsAdapter.ts +++ b/apps/core/src/adapters/network/NetworkFsAdapter.ts @@ -66,6 +66,20 @@ export class NetworkFsAdapter implements FsPort { return (json.data?.items ?? []).map((i) => i.path); } + async listSubdirectories(dir: string): Promise { + const json = await this.post<{ + error?: string; + data?: { items: Array<{ path: string }> }; + }>("/api/listFiles", { + path: dir, + recursively: false, + onlyFolders: true, + includeHiddenFiles: false, + }); + if (json.error !== undefined) throw new Error(json.error); + return (json.data?.items ?? []).map((i) => i.path); + } + async deleteFile(path: string): Promise { const json = await this.post<{ error?: string }>("/api/deleteFile", { path }); if (json.error !== undefined) throw new Error(json.error); diff --git a/apps/core/src/adapters/node/NodejsFsAdapter.ts b/apps/core/src/adapters/node/NodejsFsAdapter.ts index 89707515..aca3f1c4 100644 --- a/apps/core/src/adapters/node/NodejsFsAdapter.ts +++ b/apps/core/src/adapters/node/NodejsFsAdapter.ts @@ -60,6 +60,17 @@ export class NodejsFsAdapter implements FsPort { return out; } + async listSubdirectories(dir: string): Promise { + const root = Path.toPlatformPath(dir); + const entries = await fsp.readdir(root, { withFileTypes: true }); + const out: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + out.push(join(root, entry.name)); + } + return out; + } + async deleteFile(path: string): Promise { try { await fsp.unlink(Path.toPlatformPath(path)); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 3850b594..b0004473 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -3,6 +3,8 @@ export { type CoreOptions, type ImportFolderHandle, type ImportFolderOptions, + type ImportLibraryHandle, + type ImportLibraryOptions, type RenameFolderArgs, type ScrapeFolderHandle, type ScrapeFolderOptions, @@ -45,7 +47,21 @@ export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; export { StaticDiscoverAdapter, STATIC_MEDIA_DATABASES } from "./adapters/StaticDiscoverAdapter"; export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; -export { UserConfig, DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } from "./pipeline/userConfig"; +export { + UserConfigHelper, + DEFAULT_USER_CONFIG, + USER_CONFIG_KEYS, + isUserConfigKey, +} from "./pipeline/userConfigHelper"; +export { validateUserConfig, validateUserConfigValue } from "./pipeline/userConfigValidation"; +export { + MediaMetadataHelper, +} from "./pipeline/mediaMetadataHelper"; +export { + validatePersistedMediaMetadata, + stripDeprecatedFiles, + type PersistedMediaMetadata, +} from "./pipeline/mediaMetadataValidation"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { tryToRecognizeEpisodesPipeline, type TryToRecognizeEpisodesDeps } from "./pipeline/tryToRecognizeEpisodes"; @@ -69,6 +85,7 @@ export type { export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; export type { ImportJob, + ImportLibraryJob, ScrapeJob, Job, JobStatus, diff --git a/apps/core/src/jobs/jobStore.ts b/apps/core/src/jobs/jobStore.ts index 3afe64c0..12d301e4 100644 --- a/apps/core/src/jobs/jobStore.ts +++ b/apps/core/src/jobs/jobStore.ts @@ -1,4 +1,4 @@ -import type { ImportJob, Job, ScrapeJob } from "./types"; +import type { ImportJob, ImportLibraryJob, Job, ScrapeJob } from "./types"; let seq = 0; @@ -8,21 +8,23 @@ export function nextJobId(): string { } type ImportJobInit = Omit; +type ImportLibraryJobInit = Omit; type ScrapeJobInit = Omit; export class JobStore { private readonly jobs = new Map(); create(init: ImportJobInit): ImportJob; + create(init: ImportLibraryJobInit): ImportLibraryJob; create(init: ScrapeJobInit): ScrapeJob; - create(init: ImportJobInit | ScrapeJobInit): Job { + create(init: ImportJobInit | ImportLibraryJobInit | ScrapeJobInit): Job { const now = Date.now(); const job = { id: nextJobId(), createdAt: now, updatedAt: now, ...init } as Job; this.jobs.set(job.id, job); return job; } - update(id: string, patch: Partial | Partial): void { + update(id: string, patch: Partial | Partial | Partial): void { const job = this.jobs.get(id); if (job === undefined) return; Object.assign(job, patch, { updatedAt: Date.now() }); diff --git a/apps/core/src/jobs/types.ts b/apps/core/src/jobs/types.ts index 73028e2c..d66c6465 100644 --- a/apps/core/src/jobs/types.ts +++ b/apps/core/src/jobs/types.ts @@ -19,6 +19,23 @@ export interface ImportJob { updatedAt: number; } +export interface ImportLibraryJob { + kind: "import-library"; + id: string; + libraryPath: string; + type: FolderType; + status: JobStatus; + progress: number; + folderPaths: string[]; + importedCount: number; + totalCount: number; + currentFolderPath?: string; + currentFolderJobId?: string; + error?: string; + createdAt: number; + updatedAt: number; +} + export type ScrapeTaskRuntimeStatus = | "pending" | "running" @@ -42,7 +59,7 @@ export interface ScrapeJob { updatedAt: number; } -export type Job = ImportJob | ScrapeJob; +export type Job = ImportJob | ImportLibraryJob | ScrapeJob; export function initialScrapeTasks(): Record { return { diff --git a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts index c2da38ef..5c99a7a8 100644 --- a/apps/core/src/pipeline/importFolderPipeline.integration.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.integration.test.ts @@ -39,6 +39,7 @@ function inMemoryFs(seed: Record = {}): FsPort { deleteFile: vi.fn(async () => {}), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index ccd76913..f4db6fbd 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -36,6 +36,7 @@ function inMemoryFs(seed: Record = {}): FsPort { deleteFile: vi.fn(async () => {}), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index d4dc71fd..861ba323 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -9,8 +9,8 @@ import { TvdbClient } from "../clients/TvdbClient"; import type { DiscoverPort } from "../ports/DiscoverPort"; import { isVideoFile, recognizeEpisodes } from "./recognizeEpisodes"; import { recognizeMediaFolder } from "./recognizeMediaFolder"; -import { metadataCachePath } from "./paths"; -import { UserConfig } from "./userConfig"; +import { UserConfigHelper } from "./userConfigHelper"; +import { MediaMetadataHelper } from "./mediaMetadataHelper"; export interface ImportFolderPipelineOptions { fs: FsPort; @@ -51,7 +51,7 @@ export class ImportFolderPipeline { const stages: JobStage[] = []; logger.info({ folderPath: posixPath, type }, "importFolder: stage=config"); - const userConfigStore = new UserConfig(fs, appDataDir); + const userConfigStore = new UserConfigHelper(fs, appDataDir); const userConfig = await userConfigStore.update((config) => ({ ...config, folders: [...new Set([...config.folders, folderPath])], @@ -118,7 +118,8 @@ export class ImportFolderPipeline { logger.info({ folderPath: posixPath }, "importFolder: stage=persist"); const { files: _files, ...mmToPersist } = mm; - await fs.writeTextFile(metadataCachePath(appDataDir, posixPath), JSON.stringify(mmToPersist, null, 2)); + const mediaMetadataStore = new MediaMetadataHelper(fs, appDataDir); + await mediaMetadataStore.write(mmToPersist); stages.push("persist"); cb.onStage?.("persist", 95); diff --git a/apps/core/src/pipeline/importLibrary.test.ts b/apps/core/src/pipeline/importLibrary.test.ts new file mode 100644 index 00000000..547947ed --- /dev/null +++ b/apps/core/src/pipeline/importLibrary.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { dedupLibraryFolders } from './importLibrary' + +describe('dedupLibraryFolders', () => { + it('removes folders already imported (POSIX comparison)', () => { + const result = dedupLibraryFolders( + ['/lib/Show1', '/lib/Show2'], + ['/lib/Show1'], + ) + expect(result).toEqual(['/lib/Show2']) + }) + + it('returns all folders when none are imported', () => { + const folders = ['/lib/A', '/lib/B'] + expect(dedupLibraryFolders(folders, [])).toEqual(folders) + }) +}) diff --git a/apps/core/src/pipeline/importLibrary.ts b/apps/core/src/pipeline/importLibrary.ts new file mode 100644 index 00000000..45265d81 --- /dev/null +++ b/apps/core/src/pipeline/importLibrary.ts @@ -0,0 +1,7 @@ +import { Path } from "@core/path"; + +/** Skip folders already present in user config (POSIX path comparison). */ +export function dedupLibraryFolders(newFolders: string[], existingFolderPaths: string[]): string[] { + const importedPosix = new Set(existingFolderPaths.map((p) => Path.posix(p))); + return newFolders.filter((folder) => !importedPosix.has(Path.posix(folder))); +} diff --git a/apps/core/src/pipeline/mcpServer.test.ts b/apps/core/src/pipeline/mcpServer.test.ts index 3b5163e6..1cb525bc 100644 --- a/apps/core/src/pipeline/mcpServer.test.ts +++ b/apps/core/src/pipeline/mcpServer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { FsPort } from "../ports/FsPort"; import type { McpServerPort, McpServerState } from "../ports/McpServerPort"; -import { UserConfig } from "./userConfig"; +import { UserConfigHelper } from "./userConfigHelper"; import { userConfigPath } from "./paths"; import { getMcpServerStatusWithConfig, @@ -28,6 +28,7 @@ function inMemoryFs(seed: Record = {}): FsPort { }), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } @@ -56,7 +57,7 @@ describe("mcpServer pipeline", () => { it("startMcpServerWithConfig persists enableMcpServer and host/port on success", async () => { const fs = inMemoryFs(); const appDataDir = "/data"; - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const port = createMockPort(); const state = await startMcpServerWithConfig( @@ -79,7 +80,7 @@ describe("mcpServer pipeline", () => { it("startMcpServerWithConfig does not persist when start fails", async () => { const fs = inMemoryFs(); const appDataDir = "/data"; - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const port = createMockPort(); port.start.mockRejectedValueOnce(new Error("port in use")); @@ -93,7 +94,7 @@ describe("mcpServer pipeline", () => { it("startMcpServerWithConfig skips config write when persistUserConfig is false", async () => { const fs = inMemoryFs(); const appDataDir = "/data"; - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const port = createMockPort(); await startMcpServerWithConfig(port, userConfig, {}, { persistUserConfig: false }); @@ -109,7 +110,7 @@ describe("mcpServer pipeline", () => { configPath, JSON.stringify({ folders: [], enableMcpServer: true, mcpHost: "127.0.0.1", mcpPort: 30001 }), ); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const port = createMockPort({ status: "running", host: "127.0.0.1", port: 30001 }); const state = await stopMcpServerWithConfig(port, userConfig, { persistUserConfig: true }); @@ -127,7 +128,7 @@ describe("mcpServer pipeline", () => { configPath, JSON.stringify({ folders: [], enableMcpServer: true }), ); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const port = createMockPort({ status: "stopped" }); const state = await getMcpServerStatusWithConfig(port, userConfig); diff --git a/apps/core/src/pipeline/mcpServer.ts b/apps/core/src/pipeline/mcpServer.ts index 02276944..652f3b65 100644 --- a/apps/core/src/pipeline/mcpServer.ts +++ b/apps/core/src/pipeline/mcpServer.ts @@ -1,6 +1,6 @@ import type { UserConfig as UserConfigData } from "@smm/core"; import type { McpServerPort, McpServerState } from "../ports/McpServerPort"; -import type { UserConfig } from "./userConfig"; +import type { UserConfigHelper } from "./userConfigHelper"; export const DEFAULT_MCP_HOST = "127.0.0.1"; export const DEFAULT_MCP_PORT = 30001; @@ -27,7 +27,7 @@ export function resolveMcpStartOptions( export async function startMcpServerWithConfig( mcpServer: McpServerPort, - userConfig: UserConfig, + userConfig: UserConfigHelper, options?: StartMcpServerOptions, operation?: McpServerOperationOptions, ): Promise { @@ -50,7 +50,7 @@ export async function startMcpServerWithConfig( export async function stopMcpServerWithConfig( mcpServer: McpServerPort, - userConfig: UserConfig, + userConfig: UserConfigHelper, operation?: McpServerOperationOptions, ): Promise { try { @@ -69,7 +69,7 @@ export async function stopMcpServerWithConfig( export async function getMcpServerStatusWithConfig( mcpServer: McpServerPort, - userConfig: UserConfig, + userConfig: UserConfigHelper, ): Promise { const state = mcpServer.getState(); diff --git a/apps/core/src/pipeline/mediaMetadataHelper.test.ts b/apps/core/src/pipeline/mediaMetadataHelper.test.ts new file mode 100644 index 00000000..e3aa71e0 --- /dev/null +++ b/apps/core/src/pipeline/mediaMetadataHelper.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { MediaMetadataHelper } from "./mediaMetadataHelper"; +import { metadataCachePath } from "./paths"; +import { validatePersistedMediaMetadata } from "./mediaMetadataValidation"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }, + writeTextFile: async (path: string, content: string) => { + files.set(path, content); + }, + writeBinaryFile: async () => {}, + exists: async (path: string) => files.has(path), + listFiles: async () => [], + deleteFile: async (path: string) => { + files.delete(path); + }, + rename: async () => {}, + mkdir: async () => {}, + listSubdirectories: async () => [], + }; +} + +describe("MediaMetadataHelper", () => { + const appDataDir = "/data/smm"; + const folder = "/m/Show"; + const cache = metadataCachePath(appDataDir, folder); + + it("write strips files and read never returns files", async () => { + const fs = inMemoryFs(); + const helper = new MediaMetadataHelper(fs, appDataDir); + + await helper.write({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [], + }); + + const saved = JSON.parse((await fs.readTextFile(cache)) as string) as Record; + expect(saved.files).toBeUndefined(); + expect(await helper.read(folder)).toEqual({ + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [], + }); + }); + + it("rejects metadata without mediaFolderPath", async () => { + const helper = new MediaMetadataHelper(inMemoryFs(), appDataDir); + await expect( + helper.write({ type: "tvshow-folder" } as Parameters[0]), + ).rejects.toThrow("mediaFolderPath is required"); + }); + + it("delete is idempotent", async () => { + const fs = inMemoryFs({ + [cache]: JSON.stringify({ mediaFolderPath: folder, type: "tvshow-folder" }), + }); + const helper = new MediaMetadataHelper(fs, appDataDir); + + await helper.delete(folder); + await helper.delete(folder); + expect(await fs.exists(cache)).toBe(false); + }); + + it("move renames cache file under write lock", async () => { + const fs = inMemoryFs({ + [cache]: JSON.stringify({ mediaFolderPath: folder, type: "tvshow-folder" }), + }); + const helper = new MediaMetadataHelper(fs, appDataDir); + const toFolder = "/m/ShowRenamed"; + const newCache = metadataCachePath(appDataDir, toFolder); + + await helper.move(folder, toFolder, { + mediaFolderPath: toFolder, + type: "tvshow-folder", + }); + + expect(await fs.exists(cache)).toBe(false); + expect(await fs.exists(newCache)).toBe(true); + }); +}); + +describe("validatePersistedMediaMetadata", () => { + it("strips deprecated files field", () => { + const validated = validatePersistedMediaMetadata({ + mediaFolderPath: "/m/Show", + type: "tvshow-folder", + files: ["/m/Show/a.mkv"], + }); + expect(validated).toEqual({ + mediaFolderPath: "/m/Show", + type: "tvshow-folder", + }); + }); +}); diff --git a/apps/core/src/pipeline/mediaMetadataHelper.ts b/apps/core/src/pipeline/mediaMetadataHelper.ts new file mode 100644 index 00000000..fe6ce73f --- /dev/null +++ b/apps/core/src/pipeline/mediaMetadataHelper.ts @@ -0,0 +1,135 @@ +import { Path } from "@core/path"; +import { Mutex } from "es-toolkit"; +import type { FsPort } from "../ports/FsPort"; +import { metadataCachePath } from "./paths"; +import { + stripDeprecatedFiles, + validatePersistedMediaMetadata, + type PersistedMediaMetadata, +} from "./mediaMetadataValidation"; + +const writeMutexByPath = new Map(); + +function writeMutexFor(path: string): Mutex { + const existing = writeMutexByPath.get(path); + if (existing) return existing; + const created = new Mutex(); + writeMutexByPath.set(path, created); + return created; +} + +async function withWriteLocks(paths: string[], fn: () => Promise): Promise { + const uniqueSorted = [...new Set(paths)].sort(); + const acquired: Mutex[] = []; + try { + for (const path of uniqueSorted) { + const mutex = writeMutexFor(path); + await mutex.acquire(); + acquired.push(mutex); + } + await fn(); + } finally { + for (let i = acquired.length - 1; i >= 0; i--) { + acquired[i]!.release(); + } + } +} + +/** Write-locked reader/writer for `{appDataDir}/metadata/*.json` cache files. */ +export class MediaMetadataHelper { + constructor( + private readonly fs: FsPort, + private readonly appDataDir: string, + ) {} + + cachePath(folder: string): string { + return metadataCachePath(this.appDataDir, this.normalizeFolder(folder)); + } + + /** Reads metadata for a folder; null when absent or corrupt. Never returns `files`. */ + async read(folder: string): Promise { + const cachePath = this.cachePath(folder); + if (!(await this.fs.exists(cachePath))) return null; + try { + const content = await this.fs.readTextFile(cachePath); + const parsed = JSON.parse(content) as PersistedMediaMetadata; + return stripDeprecatedFiles(parsed); + } catch { + return null; + } + } + + /** Validates and replaces the metadata cache for `metadata.mediaFolderPath`. */ + async write(metadata: PersistedMediaMetadata): Promise { + const validated = validatePersistedMediaMetadata(metadata); + const cachePath = this.cachePath(validated.mediaFolderPath!); + await withWriteLocks([cachePath], async () => { + await this.fs.writeTextFile(cachePath, JSON.stringify(validated, null, 2)); + }); + } + + async update( + folder: string, + mutator: (current: PersistedMediaMetadata) => PersistedMediaMetadata, + ): Promise { + const cachePath = this.cachePath(folder); + let result!: PersistedMediaMetadata; + await withWriteLocks([cachePath], async () => { + const current = (await this.readUnlocked(folder)) ?? { + mediaFolderPath: this.normalizeFolder(folder), + }; + const next = mutator(current); + if (next === current) { + result = current; + return; + } + const validated = validatePersistedMediaMetadata(next); + await this.fs.writeTextFile(cachePath, JSON.stringify(validated, null, 2)); + result = validated; + }); + return result; + } + + /** Deletes the metadata cache for a folder. Idempotent. */ + async delete(folder: string): Promise { + const cachePath = this.cachePath(folder); + if (!(await this.fs.exists(cachePath))) return; + await withWriteLocks([cachePath], async () => { + if (await this.fs.exists(cachePath)) { + await this.fs.deleteFile(cachePath); + } + }); + } + + /** Moves metadata from one cache file to another under write lock. */ + async move(fromFolder: string, toFolder: string, metadata: PersistedMediaMetadata): Promise { + const fromPath = this.cachePath(fromFolder); + const toPath = this.cachePath(toFolder); + const validated = validatePersistedMediaMetadata(metadata); + await withWriteLocks([fromPath, toPath], async () => { + await this.fs.writeTextFile(toPath, JSON.stringify(validated, null, 2)); + if (fromPath !== toPath && (await this.fs.exists(fromPath))) { + await this.fs.deleteFile(fromPath); + } + }); + } + + private normalizeFolder(folder: string): string { + try { + return Path.posix(folder); + } catch { + return folder; + } + } + + private async readUnlocked(folder: string): Promise { + const cachePath = this.cachePath(folder); + if (!(await this.fs.exists(cachePath))) return null; + try { + const content = await this.fs.readTextFile(cachePath); + return stripDeprecatedFiles(JSON.parse(content) as PersistedMediaMetadata); + } catch { + return null; + } + } +} diff --git a/apps/core/src/pipeline/mediaMetadataValidation.ts b/apps/core/src/pipeline/mediaMetadataValidation.ts new file mode 100644 index 00000000..2f1bf157 --- /dev/null +++ b/apps/core/src/pipeline/mediaMetadataValidation.ts @@ -0,0 +1,88 @@ +import type { MediaFileMetadata, MediaMetadata } from "@smm/core"; + +const MEDIA_FOLDER_TYPES = ["music-folder", "tvshow-folder", "movie-folder"] as const; + +/** Media metadata persisted on disk; {@link MediaMetadata.files} is never stored. */ +export type PersistedMediaMetadata = Omit; + +function assertObject(value: unknown, field: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as Record; +} + +function validateMediaFileMetadata(value: unknown, index: number): MediaFileMetadata { + const obj = assertObject(value, `mediaFiles[${index}]`); + if (typeof obj.absolutePath !== "string" || !obj.absolutePath.trim()) { + throw new Error(`mediaFiles[${index}].absolutePath must be a non-empty string`); + } + const entry: MediaFileMetadata = { absolutePath: obj.absolutePath }; + if (obj.seasonNumber !== undefined) { + if (typeof obj.seasonNumber !== "number" || !Number.isInteger(obj.seasonNumber)) { + throw new Error(`mediaFiles[${index}].seasonNumber must be an integer`); + } + entry.seasonNumber = obj.seasonNumber; + } + if (obj.episodeNumber !== undefined) { + if (typeof obj.episodeNumber !== "number" || !Number.isInteger(obj.episodeNumber)) { + throw new Error(`mediaFiles[${index}].episodeNumber must be an integer`); + } + entry.episodeNumber = obj.episodeNumber; + } + return entry; +} + +/** Validates metadata before persist; strips deprecated `files` when present. */ +export function validatePersistedMediaMetadata(value: unknown): PersistedMediaMetadata { + const obj = assertObject(value, "MediaMetadata"); + + const mediaFolderPath = obj.mediaFolderPath; + if (typeof mediaFolderPath !== "string" || !mediaFolderPath.trim()) { + throw new Error("mediaFolderPath is required"); + } + + let type: PersistedMediaMetadata["type"]; + if (obj.type !== undefined) { + if (typeof obj.type !== "string" || !(MEDIA_FOLDER_TYPES as readonly string[]).includes(obj.type)) { + throw new Error(`type must be one of: ${MEDIA_FOLDER_TYPES.join(", ")}`); + } + type = obj.type as PersistedMediaMetadata["type"]; + } + + let mediaFiles: MediaFileMetadata[] | undefined; + if (obj.mediaFiles !== undefined) { + if (!Array.isArray(obj.mediaFiles)) { + throw new Error("mediaFiles must be an array"); + } + mediaFiles = obj.mediaFiles.map((entry, index) => validateMediaFileMetadata(entry, index)); + } + + const result: PersistedMediaMetadata = { + mediaFolderPath, + ...(type !== undefined ? { type } : {}), + ...(mediaFiles !== undefined ? { mediaFiles } : {}), + }; + + if (obj.tvShow !== undefined) { + if (obj.tvShow === null || typeof obj.tvShow !== "object" || Array.isArray(obj.tvShow)) { + throw new Error("tvShow must be an object"); + } + result.tvShow = obj.tvShow as PersistedMediaMetadata["tvShow"]; + } + + if (obj.movie !== undefined) { + if (obj.movie === null || typeof obj.movie !== "object" || Array.isArray(obj.movie)) { + throw new Error("movie must be an object"); + } + result.movie = obj.movie as PersistedMediaMetadata["movie"]; + } + + return result; +} + +/** Removes deprecated `files` from loaded metadata. */ +export function stripDeprecatedFiles(mm: MediaMetadata): PersistedMediaMetadata { + const { files: _files, ...rest } = mm; + return rest; +} diff --git a/apps/core/src/pipeline/plans.test.ts b/apps/core/src/pipeline/plans.test.ts index ebc7a46d..fb9ddb17 100644 --- a/apps/core/src/pipeline/plans.test.ts +++ b/apps/core/src/pipeline/plans.test.ts @@ -28,6 +28,7 @@ function inMemoryFs(): FsPort & { raw: Map } { }), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/recognizeFolder.test.ts b/apps/core/src/pipeline/recognizeFolder.test.ts index e51a8f42..fb5f3309 100644 --- a/apps/core/src/pipeline/recognizeFolder.test.ts +++ b/apps/core/src/pipeline/recognizeFolder.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { FsPort } from "../ports/FsPort"; import { metadataCachePath, userConfigPath } from "./paths"; -import { UserConfig } from "./userConfig"; +import { UserConfigHelper } from "./userConfigHelper"; +import { MediaMetadataHelper } from "./mediaMetadataHelper"; import { recognizeFolderPipeline, tryToRecognizeFolderPipeline, @@ -27,13 +28,16 @@ function inMemoryFs(seed: Record = {}): FsPort { }), rename: vi.fn(async () => {}), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } function deps(partial: Partial & { fs: FsPort; appDataDir: string }): RecognizeFolderDeps { - const userConfig = new UserConfig(partial.fs, partial.appDataDir); + const userConfig = new UserConfigHelper(partial.fs, partial.appDataDir); + const mediaMetadata = new MediaMetadataHelper(partial.fs, partial.appDataDir); return { userConfig, + mediaMetadata, normalizePosix: (p) => p.replace(/\\/g, "/"), language: "en-US", tmdb: { @@ -83,7 +87,7 @@ describe("tryToRecognizeFolderPipeline", () => { const writes = (fs.writeTextFile as ReturnType).mock.calls.filter( ([p]) => p === cachePath, ); - expect(writes).toHaveLength(1); // only the seed write above + expect(writes).toHaveLength(1); }); it("throws when unmanaged", async () => { diff --git a/apps/core/src/pipeline/recognizeFolder.ts b/apps/core/src/pipeline/recognizeFolder.ts index 1e384835..6470e334 100644 --- a/apps/core/src/pipeline/recognizeFolder.ts +++ b/apps/core/src/pipeline/recognizeFolder.ts @@ -14,8 +14,8 @@ import { type TmdbRecognitionClient, type TvdbRecognitionClient, } from "./recognizeMediaFolder"; -import { metadataCachePath } from "./paths"; -import type { UserConfig } from "./userConfig"; +import type { UserConfigHelper } from "./userConfigHelper"; +import type { MediaMetadataHelper } from "./mediaMetadataHelper"; export type RecognizeFolderDb = "tmdb" | "tvdb"; @@ -30,7 +30,8 @@ export interface RecognizeFolderCandidate { export interface RecognizeFolderDeps { fs: FsPort; appDataDir: string; - userConfig: UserConfig; + userConfig: UserConfigHelper; + mediaMetadata: MediaMetadataHelper; normalizePosix: (path: string) => string; tmdb: TmdbRecognitionClient; tvdb: TvdbRecognitionClient; @@ -56,14 +57,8 @@ async function loadManagedMediaMetadata( if (!isManaged(config.folders ?? [], path)) { throw new Error(`${posixPath} is not managed by SMM`); } - const cachePath = metadataCachePath(deps.appDataDir, posixPath); - if (!(await deps.fs.exists(cachePath))) { - throw new Error(`Media metadata not found: ${path}`); - } - let mm: MediaMetadata; - try { - mm = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; - } catch { + const mm = await deps.mediaMetadata.read(posixPath); + if (!mm) { throw new Error(`Media metadata not found: ${path}`); } if (mm.type !== "tvshow-folder" && mm.type !== "movie-folder") { @@ -166,8 +161,5 @@ export async function recognizeFolderPipeline( mediaFiles: [], ...(isTv ? { tvShow } : { movie }), }; - await deps.fs.writeTextFile( - metadataCachePath(deps.appDataDir, posixPath), - JSON.stringify(next, null, 2), - ); + await deps.mediaMetadata.write(next); } diff --git a/apps/core/src/pipeline/renameEpisodeFile.test.ts b/apps/core/src/pipeline/renameEpisodeFile.test.ts index 1c9e9e0e..b7d02740 100644 --- a/apps/core/src/pipeline/renameEpisodeFile.test.ts +++ b/apps/core/src/pipeline/renameEpisodeFile.test.ts @@ -3,7 +3,7 @@ import type { MediaMetadata } from "@smm/core"; import type { FsPort } from "../ports/FsPort"; import { metadataCachePath, userConfigPath } from "./paths"; import { renameEpisodeFilePipeline } from "./renameEpisodeFile"; -import { UserConfig } from "./userConfig"; +import { UserConfigHelper } from "./userConfigHelper"; const appDataDir = "/app"; const folder = "/m/Show"; @@ -75,6 +75,7 @@ function createFs(seed: Record): FsPort & { textFiles.set(to, content); }), mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), }; } @@ -84,12 +85,12 @@ function managedFs(extraFiles: Record = {}, mm: MediaMetadata = [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), ...extraFiles, }); - return { fs, userConfig: new UserConfig(fs, appDataDir), mm }; + return { fs, userConfig: new UserConfigHelper(fs, appDataDir), mm }; } function baseDeps( fs: FsPort, - userConfig: UserConfig, + userConfig: UserConfigHelper, mm: MediaMetadata | null, setMetadata: (next: MediaMetadata) => Promise = async () => {}, ) { @@ -113,7 +114,7 @@ describe("renameEpisodeFilePipeline", () => { "/m/Show/S01E01.srt": "sub", "/m/Show/S01E01.en.srt": "en", }); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const result = await renameEpisodeFilePipeline( { @@ -155,7 +156,7 @@ describe("renameEpisodeFilePipeline", () => { [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), "/m/Show/orphan.mp4": "x", }); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); await expect( renameEpisodeFilePipeline( @@ -183,7 +184,7 @@ describe("renameEpisodeFilePipeline", () => { [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), "/m/Show/movie.mkv": "x", }); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); await expect( renameEpisodeFilePipeline( @@ -211,7 +212,7 @@ describe("renameEpisodeFilePipeline", () => { [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), "/m/Show/S01E01.mp4": "video", }); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); await expect( renameEpisodeFilePipeline( @@ -240,7 +241,7 @@ describe("renameEpisodeFilePipeline", () => { "/m/Show/S01E01.mp4": "video", "/m/Show/S01E01_renamed.mp4": "already", }); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const rename = vi.fn(fs.rename); await expect( @@ -271,7 +272,7 @@ describe("renameEpisodeFilePipeline", () => { [userConfigPath(appDataDir)]: configWith([folder]), [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), }); - const userConfig = new UserConfig(fs, appDataDir); + const userConfig = new UserConfigHelper(fs, appDataDir); const rename = vi.fn(fs.rename); await expect( diff --git a/apps/core/src/pipeline/renameEpisodeFile.ts b/apps/core/src/pipeline/renameEpisodeFile.ts index 9c35664d..f7581c07 100644 --- a/apps/core/src/pipeline/renameEpisodeFile.ts +++ b/apps/core/src/pipeline/renameEpisodeFile.ts @@ -7,7 +7,7 @@ import type { FsPort } from "../ports/FsPort"; import { expandAssociatedFileRenames } from "./expandAssociatedFileRenames"; import { mediaFilePathEqual } from "./mediaFilePathEqual"; import { dirname } from "./paths"; -import type { UserConfig } from "./userConfig"; +import type { UserConfigHelper } from "./userConfigHelper"; function renameFileExistenceProbe(fs: FsPort): RenameFileExistenceProbe { return { @@ -34,7 +34,7 @@ export interface RenameEpisodeFileResult { export interface RenameEpisodeFileDeps { fs: FsPort; appDataDir: string; - userConfig: UserConfig; + userConfig: UserConfigHelper; normalizePosix: (path: string) => string; getMediaMetadata: (folder: string) => Promise; setMetadata: (mm: MediaMetadata) => Promise; diff --git a/apps/core/src/pipeline/renameFolder.ts b/apps/core/src/pipeline/renameFolder.ts index 6c1737cc..fe1ccaf0 100644 --- a/apps/core/src/pipeline/renameFolder.ts +++ b/apps/core/src/pipeline/renameFolder.ts @@ -1,10 +1,9 @@ import { Path } from "@core/path"; import { renameFolderInMediaMetadata } from "@core/mediaMetadata"; import { renameFolderInUserConfig } from "@core/userConfig"; -import type { MediaMetadata } from "@smm/core"; +import type { MediaMetadataHelper } from "./mediaMetadataHelper"; import type { FsPort } from "../ports/FsPort"; -import { metadataCachePath } from "./paths"; -import type { UserConfig } from "./userConfig"; +import type { UserConfigHelper } from "./userConfigHelper"; export interface RenameFolderArgs { from: string; @@ -13,8 +12,8 @@ export interface RenameFolderArgs { export interface RenameFolderDeps { fs: FsPort; - appDataDir: string; - userConfig: UserConfig; + userConfig: UserConfigHelper; + mediaMetadata: MediaMetadataHelper; normalizePosix: (path: string) => string; } @@ -40,14 +39,8 @@ export async function renameFolderPipeline( throw new Error(`${fromAsPosix} is not managed by SMM`); } - const cachePath = metadataCachePath(deps.appDataDir, fromAsPosix); - if (!(await deps.fs.exists(cachePath))) { - throw new Error(`Media metadata not found: ${args.from}`); - } - let mediaMetadata: MediaMetadata; - try { - mediaMetadata = JSON.parse(await deps.fs.readTextFile(cachePath)) as MediaMetadata; - } catch { + const mediaMetadata = await deps.mediaMetadata.read(fromAsPosix); + if (!mediaMetadata) { throw new Error(`Media metadata not found: ${args.from}`); } @@ -55,9 +48,12 @@ export async function renameFolderPipeline( if (!updatedMetadata.mediaFolderPath) { throw new Error("Media folder path is required"); } - const newCachePath = metadataCachePath(deps.appDataDir, Path.posix(updatedMetadata.mediaFolderPath)); - await deps.fs.writeTextFile(newCachePath, JSON.stringify(updatedMetadata, null, 2)); - await deps.fs.deleteFile(cachePath); + + await deps.mediaMetadata.move( + fromAsPosix, + Path.posix(updatedMetadata.mediaFolderPath), + updatedMetadata, + ); await deps.userConfig.update((current) => renameFolderInUserConfig(current, fromAsPosix, toAsPosix), diff --git a/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts b/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts index ff2a9236..89ec4368 100644 --- a/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts +++ b/apps/core/src/pipeline/scrape/checkScrapeCompletion.test.ts @@ -18,6 +18,7 @@ function createFs(listFiles: string[] | (() => never)): FsPort { deleteFile: vi.fn(), rename: vi.fn(), mkdir: vi.fn(), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts index 29769d99..9db54aeb 100644 --- a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts @@ -2,7 +2,7 @@ import type { MediaMetadata, UserConfig } from "@smm/core"; import { describe, expect, it, vi } from "vitest"; import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; -import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { downloadScrapeImage } from "./downloadScrapeImage"; function createInMemoryFs(): FsPort & { @@ -26,6 +26,7 @@ function createInMemoryFs(): FsPort & { mkdir: vi.fn(async (path) => { mkdirCalls.push(path); }), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts b/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts index 92bb6117..b91ecc62 100644 --- a/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts +++ b/apps/core/src/pipeline/scrape/resolveScrapeHttpProxy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { MediaMetadata, UserConfig } from "@smm/core"; -import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { resolveMediaDatabaseHttpProxy, resolveScrapeHttpProxy, diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts index 2dbe4fa2..1d71bd5d 100644 --- a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; import type { FsPort } from "../../ports/FsPort"; import type { NetworkPort } from "../../ports/NetworkPort"; -import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { scrapeFanartTmdb } from "./scrapeFanartTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; import type { TvdbClient } from "../../clients/TvdbClient"; @@ -36,6 +36,7 @@ function createInMemoryFs(): FsPort & { binaryFiles: Map } { deleteFile: vi.fn(), rename: vi.fn(), mkdir: vi.fn(), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/scrape/scrapeFolder.ts b/apps/core/src/pipeline/scrape/scrapeFolder.ts index 307debd4..8356a812 100644 --- a/apps/core/src/pipeline/scrape/scrapeFolder.ts +++ b/apps/core/src/pipeline/scrape/scrapeFolder.ts @@ -6,7 +6,7 @@ import type { DiscoverPort } from "../../ports/DiscoverPort"; import type { FsPort } from "../../ports/FsPort"; import type { NetworkPort } from "../../ports/NetworkPort"; import { metadataCachePath } from "../paths"; -import type { UserConfig as UserConfigHandle } from "../userConfig"; +import type { UserConfigHelper } from "../userConfigHelper"; import type { UserConfig as UserConfigData } from "@smm/core"; import { checkScrapeCompletion } from "./checkScrapeCompletion"; import { scrapeFanartTmdb } from "./scrapeFanartTmdb"; @@ -25,7 +25,7 @@ export interface ScrapeFolderDeps { fs: FsPort; network: NetworkPort; appDataDir: string; - userConfig: UserConfigHandle; + userConfig: UserConfigHelper; normalizePosix: (path: string) => string; discover?: DiscoverPort; reverseProxyUrl?: string | null; diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts index 1e615bfa..a0150ccc 100644 --- a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; import type { FsPort } from "../../ports/FsPort"; import type { NetworkPort } from "../../ports/NetworkPort"; -import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { scrapeNfoTmdb } from "./scrapeNfoTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; @@ -87,6 +87,7 @@ function createInMemoryFs(initialText: Record = {}): FsPort & { deleteFile: vi.fn(), rename: vi.fn(), mkdir: vi.fn(), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts index 31f5de43..aabfe7d6 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; -import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { resolvePosterUrl, scrapePosterTmdb } from "./scrapePosterTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; import type { TvdbClient } from "../../clients/TvdbClient"; @@ -73,6 +73,7 @@ function createInMemoryFs(initialFiles: Record = {} deleteFile: vi.fn(), rename: vi.fn(), mkdir: vi.fn(), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts index 5641896c..113cd152 100644 --- a/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; import type { FsPort } from "../../ports/FsPort"; import type { NetworkPort } from "../../ports/NetworkPort"; -import { DEFAULT_USER_CONFIG } from "../userConfig"; +import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; import { scrapeThumbnailsTmdb } from "./scrapeThumbnailsTmdb"; import type { ScrapeTaskDeps } from "./scrapeTaskDeps"; @@ -61,6 +61,7 @@ function createInMemoryFs(): FsPort & { binaryFiles: Map } { deleteFile: vi.fn(), rename: vi.fn(), mkdir: vi.fn(), + listSubdirectories: vi.fn(async () => []), }; } diff --git a/apps/core/src/pipeline/tryToRecognizeEpisodes.ts b/apps/core/src/pipeline/tryToRecognizeEpisodes.ts index 2b2f0435..43ceba72 100644 --- a/apps/core/src/pipeline/tryToRecognizeEpisodes.ts +++ b/apps/core/src/pipeline/tryToRecognizeEpisodes.ts @@ -6,12 +6,12 @@ import type { FsPort } from "../ports/FsPort"; import { metadataCachePath } from "./paths"; import { writePlan } from "./plans"; import { recognizeEpisodes } from "./recognizeEpisodes"; -import type { UserConfig } from "./userConfig"; +import type { UserConfigHelper } from "./userConfigHelper"; export interface TryToRecognizeEpisodesDeps { fs: FsPort; appDataDir: string; - userConfig: UserConfig; + userConfig: UserConfigHelper; normalizePosix: (path: string) => string; /** Injected for tests; default `randomUUID` from `node:crypto`. */ createId?: () => string; diff --git a/apps/core/src/pipeline/tryToRenameFolder.ts b/apps/core/src/pipeline/tryToRenameFolder.ts index d41f8d80..9b98bf1a 100644 --- a/apps/core/src/pipeline/tryToRenameFolder.ts +++ b/apps/core/src/pipeline/tryToRenameFolder.ts @@ -7,12 +7,12 @@ import { buildTvShowRenamePlanFileEntries } from "./buildTvShowRenamePlanFileEnt import { metadataCachePath } from "./paths"; import { writePlan } from "./plans"; import type { RenameRuleName } from "./renameRules"; -import type { UserConfig } from "./userConfig"; +import type { UserConfigHelper } from "./userConfigHelper"; export interface TryToRenameFolderDeps { fs: FsPort; appDataDir: string; - userConfig: UserConfig; + userConfig: UserConfigHelper; normalizePosix: (path: string) => string; /** Injected for tests; default `randomUUID` from `node:crypto`. */ createId?: () => string; diff --git a/apps/core/src/pipeline/userConfig.test.ts b/apps/core/src/pipeline/userConfig.test.ts deleted file mode 100644 index cf7136fe..00000000 --- a/apps/core/src/pipeline/userConfig.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { FsPort } from "../ports/FsPort"; -import { UserConfig } from "./userConfig"; -import { userConfigPath } from "./paths"; - -function delayedFs(seed: Record, delayMs = 15): FsPort { - const files = new Map(Object.entries(seed)); - const pause = () => new Promise((r) => setTimeout(r, delayMs)); - return { - readTextFile: async (path: string) => { - await pause(); - const v = files.get(path); - if (v === undefined) throw new Error("ENOENT: " + path); - return v; - }, - writeTextFile: async (path: string, content: string) => { - await pause(); - files.set(path, content); - }, - writeBinaryFile: async () => {}, - exists: async (path: string) => { - await pause(); - return files.has(path); - }, - listFiles: async () => [], - deleteFile: async (path: string) => { - files.delete(path); - }, - rename: async () => {}, - mkdir: async () => {}, - }; -} - -describe("UserConfig", () => { - it("keeps both folders when two instances update the same smm.json concurrently", async () => { - const appDataDir = "/data/smm-userconfig-mutex"; - const fs = delayedFs({ - [userConfigPath(appDataDir)]: JSON.stringify({ - folders: [], - tmdb: {}, - tvdb: {}, - renameRules: [], - dryRun: false, - selectedRenameRule: "plex", - }), - }); - const a = new UserConfig(fs, appDataDir); - const b = new UserConfig(fs, appDataDir); - - await Promise.all([ - a.update((config) => ({ ...config, folders: [...config.folders, "/m/A"] })), - b.update((config) => ({ ...config, folders: [...config.folders, "/m/B"] })), - ]); - - const saved = await a.read(); - expect([...saved.folders].sort()).toEqual(["/m/A", "/m/B"]); - }); -}); diff --git a/apps/core/src/pipeline/userConfigDefaults.ts b/apps/core/src/pipeline/userConfigDefaults.ts new file mode 100644 index 00000000..967dcef8 --- /dev/null +++ b/apps/core/src/pipeline/userConfigDefaults.ts @@ -0,0 +1,45 @@ +import type { UserConfig } from "@smm/core"; + +export const DEFAULT_USER_CONFIG: UserConfig = { + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", +}; + +/** Exhaustive UserConfig keys; adding a field to UserConfig without updating this fails typecheck. */ +export const USER_CONFIG_KEY_FLAGS = { + applicationLanguage: true, + tmdb: true, + tvdb: true, + primaryDatabase: true, + preferMediaLanguage: true, + folders: true, + selectedFolder: true, + renameRules: true, + dryRun: true, + ai: true, + selectedAI: true, + aiProviders: true, + selectedAIProvider: true, + selectedTMDBIntance: true, + selectedRenameRule: true, + enableMcpServer: true, + mcpHost: true, + mcpPort: true, + anonymousTelemetryConsent: true, + ytdlpExecutablePath: true, + ytdlpProxy: true, + ffmpegExecutablePath: true, + videoCaptionerExecutablePath: true, + useBundledFfmpegForVideoCaptioner: true, + quickjsExecutablePath: true, +} as const satisfies { [K in keyof UserConfig]: true }; + +export const USER_CONFIG_KEYS = Object.keys(USER_CONFIG_KEY_FLAGS) as (keyof UserConfig)[]; + +export function isUserConfigKey(key: string): key is keyof UserConfig { + return Object.prototype.hasOwnProperty.call(USER_CONFIG_KEY_FLAGS, key); +} diff --git a/apps/core/src/pipeline/userConfigHelper.test.ts b/apps/core/src/pipeline/userConfigHelper.test.ts new file mode 100644 index 00000000..24a467ec --- /dev/null +++ b/apps/core/src/pipeline/userConfigHelper.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { UserConfigHelper } from "./userConfigHelper"; +import { userConfigPath } from "./paths"; +import { validateUserConfigValue } from "./userConfigValidation"; + +function delayedFs(seed: Record, delayMs = 15): FsPort { + const files = new Map(Object.entries(seed)); + const pause = () => new Promise((r) => setTimeout(r, delayMs)); + return { + readTextFile: async (path: string) => { + await pause(); + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }, + writeTextFile: async (path: string, content: string) => { + await pause(); + files.set(path, content); + }, + writeBinaryFile: async () => {}, + exists: async (path: string) => { + await pause(); + return files.has(path); + }, + listFiles: async () => [], + deleteFile: async (path: string) => { + files.delete(path); + }, + rename: async () => {}, + mkdir: async () => {}, + listSubdirectories: async () => [], + }; +} + +describe("UserConfigHelper", () => { + it("keeps both folders when two instances update the same smm.json concurrently", async () => { + const appDataDir = "/data/smm-userconfig-mutex"; + const fs = delayedFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ + folders: [], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const a = new UserConfigHelper(fs, appDataDir); + const b = new UserConfigHelper(fs, appDataDir); + + await Promise.all([ + a.update((config) => ({ ...config, folders: [...config.folders, "/m/A"] })), + b.update((config) => ({ ...config, folders: [...config.folders, "/m/B"] })), + ]); + + const saved = await a.read(); + expect([...saved.folders].sort()).toEqual(["/m/A", "/m/B"]); + }); + + it("setKey validates dryRun as boolean", async () => { + const fs = delayedFs({}); + const store = new UserConfigHelper(fs, "/data/smm"); + + await expect(store.setKey("dryRun", "yes")).rejects.toThrow("dryRun must be a boolean"); + const updated = await store.setKey("dryRun", true); + expect(updated.dryRun).toBe(true); + }); + + it("setKey rejects unknown keys", async () => { + const fs = delayedFs({}); + const store = new UserConfigHelper(fs, "/data/smm"); + + await expect(store.setKey("notAKey" as "dryRun", true)).rejects.toThrow("Unknown config key"); + }); + + it("getKey reads a single field", async () => { + const appDataDir = "/data/smm"; + const fs = delayedFs({ + [userConfigPath(appDataDir)]: JSON.stringify({ + folders: ["/m/A"], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: "plex", + }), + }); + const store = new UserConfigHelper(fs, appDataDir); + + expect(await store.getKey("folders")).toEqual(["/m/A"]); + expect(await store.getFolders()).toEqual(["/m/A"]); + }); + + it("addFolder deduplicates paths", async () => { + const fs = delayedFs({}); + const store = new UserConfigHelper(fs, "/data/smm"); + + await store.addFolder("/m/A"); + const updated = await store.addFolder("/m/A"); + + expect(updated.folders).toEqual(["/m/A"]); + }); +}); + +describe("validateUserConfigValue", () => { + it("accepts valid primaryDatabase values", () => { + expect(validateUserConfigValue("primaryDatabase", "TMDB")).toBe("TMDB"); + expect(validateUserConfigValue("primaryDatabase", "TVDB")).toBe("TVDB"); + }); + + it("rejects invalid mcpPort", () => { + expect(() => validateUserConfigValue("mcpPort", 0)).toThrow("mcpPort must be an integer"); + expect(() => validateUserConfigValue("mcpPort", "30001")).toThrow("mcpPort must be an integer"); + }); +}); diff --git a/apps/core/src/pipeline/userConfig.ts b/apps/core/src/pipeline/userConfigHelper.ts similarity index 51% rename from apps/core/src/pipeline/userConfig.ts rename to apps/core/src/pipeline/userConfigHelper.ts index 5dec343d..01d7cda3 100644 --- a/apps/core/src/pipeline/userConfig.ts +++ b/apps/core/src/pipeline/userConfigHelper.ts @@ -2,50 +2,10 @@ import { Mutex } from "es-toolkit" import type { UserConfig as UserConfigData } from "@smm/core" import type { FsPort } from "../ports/FsPort" import { userConfigPath } from "./paths" +import { DEFAULT_USER_CONFIG, isUserConfigKey } from "./userConfigDefaults" +import { validateUserConfig, validateUserConfigValue } from "./userConfigValidation" -export const DEFAULT_USER_CONFIG: UserConfigData = { - folders: [], - tmdb: {}, - tvdb: {}, - renameRules: [], - dryRun: false, - selectedRenameRule: "plex", -} - -/** Exhaustive UserConfig keys; adding a field to UserConfig without updating this fails typecheck. */ -const USER_CONFIG_KEY_FLAGS = { - applicationLanguage: true, - tmdb: true, - tvdb: true, - primaryDatabase: true, - preferMediaLanguage: true, - folders: true, - selectedFolder: true, - renameRules: true, - dryRun: true, - ai: true, - selectedAI: true, - aiProviders: true, - selectedAIProvider: true, - selectedTMDBIntance: true, - selectedRenameRule: true, - enableMcpServer: true, - mcpHost: true, - mcpPort: true, - anonymousTelemetryConsent: true, - ytdlpExecutablePath: true, - ytdlpProxy: true, - ffmpegExecutablePath: true, - videoCaptionerExecutablePath: true, - useBundledFfmpegForVideoCaptioner: true, - quickjsExecutablePath: true, -} as const satisfies { [K in keyof UserConfigData]: true } - -export const USER_CONFIG_KEYS = Object.keys(USER_CONFIG_KEY_FLAGS) as (keyof UserConfigData)[] - -export function isUserConfigKey(key: string): key is keyof UserConfigData { - return Object.prototype.hasOwnProperty.call(USER_CONFIG_KEY_FLAGS, key) -} +export { DEFAULT_USER_CONFIG, USER_CONFIG_KEYS, isUserConfigKey } from "./userConfigDefaults" const mutexByPath = new Map() @@ -58,7 +18,7 @@ function mutexFor(path: string): Mutex { } /** Locked reader/writer for `{appDataDir}/smm.json`. Instances that share a path share one mutex. */ -export class UserConfig { +export class UserConfigHelper { private readonly path: string private readonly mutex: Mutex @@ -80,23 +40,62 @@ export class UserConfig { } async write(config: UserConfigData): Promise { + const validated = validateUserConfig(config) await this.mutex.acquire() try { - await this.persistUnlocked(config) + await this.persistUnlocked(validated) } finally { this.mutex.release() } } + /** Reads a single field under the file lock. */ + async getKey(key: K): Promise { + const config = await this.read() + return config[key] + } + + /** Validates and persists one field; returns the updated config. */ + async setKey( + key: K, + value: unknown, + ): Promise { + if (!isUserConfigKey(key)) { + throw new Error(`Unknown config key: ${String(key)}`) + } + const validated = validateUserConfigValue(key, value) + return this.update((config) => ({ ...config, [key]: validated })) + } + + async getFolders(): Promise { + return this.getKey("folders") + } + + async setFolders(folders: string[]): Promise { + return this.setKey("folders", folders) + } + + async addFolder(folderPath: string): Promise { + if (typeof folderPath !== "string" || !folderPath.trim()) { + throw new Error("folder path must be a non-empty string") + } + return this.update((config) => ({ + ...config, + folders: [...new Set([...config.folders, folderPath])], + })) + } + async update(mutator: (config: UserConfigData) => UserConfigData): Promise { await this.mutex.acquire() try { const current = await this.loadUnlocked() const next = mutator(current) - if (next !== current) { - await this.persistUnlocked(next) + if (next === current) { + return current } - return next + const validated = validateUserConfig(next) + await this.persistUnlocked(validated) + return validated } finally { this.mutex.release() } diff --git a/apps/core/src/pipeline/userConfigValidation.ts b/apps/core/src/pipeline/userConfigValidation.ts new file mode 100644 index 00000000..c5cf6167 --- /dev/null +++ b/apps/core/src/pipeline/userConfigValidation.ts @@ -0,0 +1,230 @@ +import { isPreferMediaLanguage } from "@core/locale"; +import type { + LanguageCode, + OpenAICompatibleConfig, + PrimaryDatabase, + TMDBConfig, + TMDBInstance, + TVDBConfig, + UserConfig, +} from "@smm/core"; +import { DEFAULT_USER_CONFIG, USER_CONFIG_KEYS } from "./userConfigDefaults"; + +const LANGUAGE_CODES = ["zh-CN", "zh-HK", "zh-TW", "en"] as const satisfies readonly LanguageCode[]; +const PRIMARY_DATABASES = ["TMDB", "TVDB"] as const satisfies readonly PrimaryDatabase[]; +const TMDB_INSTANCES = ["public", "customized"] as const satisfies readonly TMDBInstance[]; + +function assertObject(value: unknown, field: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${field} must be an object`); + } + return value as Record; +} + +function assertOptionalString(value: unknown, field: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + throw new Error(`${field} must be a string`); + } + return value; +} + +function validateTmdbConfig(value: unknown): TMDBConfig { + const obj = assertObject(value, "tmdb"); + return { + host: assertOptionalString(obj.host, "tmdb.host"), + apiKey: assertOptionalString(obj.apiKey, "tmdb.apiKey"), + httpProxy: assertOptionalString(obj.httpProxy, "tmdb.httpProxy"), + }; +} + +function validateTvdbConfig(value: unknown): TVDBConfig { + const obj = assertObject(value, "tvdb"); + return { + host: assertOptionalString(obj.host, "tvdb.host"), + apiKey: assertOptionalString(obj.apiKey, "tvdb.apiKey"), + httpProxy: assertOptionalString(obj.httpProxy, "tvdb.httpProxy"), + }; +} + +function validateAiProvider(value: unknown, index: number): OpenAICompatibleConfig { + const obj = assertObject(value, `aiProviders[${index}]`); + return { + name: assertOptionalString(obj.name, `aiProviders[${index}].name`), + baseURL: assertOptionalString(obj.baseURL, `aiProviders[${index}].baseURL`), + apiKey: assertOptionalString(obj.apiKey, `aiProviders[${index}].apiKey`), + model: assertOptionalString(obj.model, `aiProviders[${index}].model`), + }; +} + +function validateAiRecord(value: unknown): Record { + const obj = assertObject(value, "ai"); + const result: Record = {}; + for (const [name, entry] of Object.entries(obj)) { + result[name] = validateAiProvider(entry, 0); + } + return result; +} + +function validatePort(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) { + throw new Error(`${field} must be an integer between 1 and 65535`); + } + return value; +} + +function validateStringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`${field} must be an array`); + } + for (let i = 0; i < value.length; i++) { + if (typeof value[i] !== "string") { + throw new Error(`${field}[${i}] must be a string`); + } + } + return value as string[]; +} + +/** Validates a single UserConfig field value; returns the normalized value. */ +export function validateUserConfigValue( + key: K, + value: unknown, +): UserConfig[K] { + switch (key) { + case "applicationLanguage": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string" || !(LANGUAGE_CODES as readonly string[]).includes(value)) { + throw new Error(`applicationLanguage must be one of: ${LANGUAGE_CODES.join(", ")}`); + } + return value as UserConfig[K]; + } + case "tmdb": + return validateTmdbConfig(value) as UserConfig[K]; + case "tvdb": + return validateTvdbConfig(value) as UserConfig[K]; + case "primaryDatabase": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string" || !(PRIMARY_DATABASES as readonly string[]).includes(value)) { + throw new Error(`primaryDatabase must be one of: ${PRIMARY_DATABASES.join(", ")}`); + } + return value as UserConfig[K]; + } + case "preferMediaLanguage": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string" || !isPreferMediaLanguage(value)) { + throw new Error("preferMediaLanguage must be one of: zh-CN, en-US, ja-JP"); + } + return value as UserConfig[K]; + } + case "folders": + return validateStringArray(value, "folders") as UserConfig[K]; + case "selectedFolder": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string") { + throw new Error("selectedFolder must be a string"); + } + return value as UserConfig[K]; + } + case "renameRules": + return validateStringArray(value, "renameRules") as UserConfig[K]; + case "dryRun": + if (typeof value !== "boolean") { + throw new Error("dryRun must be a boolean"); + } + return value as UserConfig[K]; + case "ai": { + if (value === undefined) return undefined as UserConfig[K]; + return validateAiRecord(value) as UserConfig[K]; + } + case "selectedAI": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string") { + throw new Error("selectedAI must be a string"); + } + return value as UserConfig[K]; + } + case "aiProviders": { + if (value === undefined) return undefined as UserConfig[K]; + if (!Array.isArray(value)) { + throw new Error("aiProviders must be an array"); + } + return value.map((entry, index) => validateAiProvider(entry, index)) as UserConfig[K]; + } + case "selectedAIProvider": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string") { + throw new Error("selectedAIProvider must be a string"); + } + return value as UserConfig[K]; + } + case "selectedTMDBIntance": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string" || !(TMDB_INSTANCES as readonly string[]).includes(value)) { + throw new Error(`selectedTMDBIntance must be one of: ${TMDB_INSTANCES.join(", ")}`); + } + return value as UserConfig[K]; + } + case "selectedRenameRule": + if (typeof value !== "string") { + throw new Error("selectedRenameRule must be a string"); + } + return value as UserConfig[K]; + case "enableMcpServer": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "boolean") { + throw new Error("enableMcpServer must be a boolean"); + } + return value as UserConfig[K]; + } + case "mcpHost": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string" || !value.trim()) { + throw new Error("mcpHost must be a non-empty string"); + } + return value as UserConfig[K]; + } + case "mcpPort": { + if (value === undefined) return undefined as UserConfig[K]; + return validatePort(value, "mcpPort") as UserConfig[K]; + } + case "anonymousTelemetryConsent": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "boolean") { + throw new Error("anonymousTelemetryConsent must be a boolean"); + } + return value as UserConfig[K]; + } + case "ytdlpExecutablePath": + case "ytdlpProxy": + case "ffmpegExecutablePath": + case "videoCaptionerExecutablePath": + case "quickjsExecutablePath": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "string") { + throw new Error(`${key} must be a string`); + } + return value as UserConfig[K]; + } + case "useBundledFfmpegForVideoCaptioner": { + if (value === undefined) return undefined as UserConfig[K]; + if (typeof value !== "boolean") { + throw new Error("useBundledFfmpegForVideoCaptioner must be a boolean"); + } + return value as UserConfig[K]; + } + default: { + const _exhaustive: never = key; + throw new Error(`Unknown config key: ${String(_exhaustive)}`); + } + } +} + +/** Validates every known field and strips unknown keys before persisting. */ +export function validateUserConfig(config: UserConfig): UserConfig { + const merged = { ...DEFAULT_USER_CONFIG, ...config }; + const validated = { ...DEFAULT_USER_CONFIG }; + for (const key of USER_CONFIG_KEYS) { + (validated as Record)[key] = validateUserConfigValue(key, merged[key]); + } + return validated; +} diff --git a/apps/core/src/ports/FsPort.ts b/apps/core/src/ports/FsPort.ts index e9e01ace..d61e3d89 100644 --- a/apps/core/src/ports/FsPort.ts +++ b/apps/core/src/ports/FsPort.ts @@ -12,6 +12,8 @@ export interface FsPort { isFile?(path: string): Promise; /** Recursively list all files under `dir` (not directories). */ listFiles(dir: string): Promise; + /** List immediate child directories under `dir` (non-recursive; hidden entries excluded). */ + listSubdirectories(dir: string): Promise; /** Delete a file; missing files count as success (idempotent). */ deleteFile(path: string): Promise; /** Rename/move a file or directory. Paths are POSIX. Missing source should reject. */ diff --git a/apps/e2e/cli/import-library.test.ts b/apps/e2e/cli/import-library.test.ts new file mode 100644 index 00000000..352aa723 --- /dev/null +++ b/apps/e2e/cli/import-library.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createFolderInTestFolder, folder1, folder2, musicFolder } from '@smm/test' +import { setup, cleanup, bin } from './base' +import { $ } from 'bun' + +const FIVE_MINUTES_MS = 5 * 60 * 1000 + +describe('import library', () => { + let libraryPath: string + + beforeEach(async () => { + libraryPath = mkdtempSync(join(tmpdir(), 'smm-cli-library-')) + await setup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + }) + + afterEach(async () => { + await cleanup({ + binary: bin, + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + resetUserConfig: true, + }) + rmSync(libraryPath, { recursive: true, force: true }) + }) + + it('import TV show library', async () => { + const show1 = createFolderInTestFolder(libraryPath, folder1) + const show2 = createFolderInTestFolder(libraryPath, { + ...folder1, + folderName: 'UnknownFolder', + files: ['S01E01.mkv'], + }) + + const ret = await $`${bin} addlib ${libraryPath} --type tvshow --verbose +${bin} list + `.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain(show1.path!) + expect(ret.text()).toContain(show2.path!) + }, FIVE_MINUTES_MS) + + it('import movie library', async () => { + createFolderInTestFolder(libraryPath, folder2) + + const ret = await $`${bin} addlib ${libraryPath} --type movie --verbose`.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain('importing library') + expect(ret.text()).toContain('succeeded') + }, FIVE_MINUTES_MS) + + it('import music library with --skip-init', async () => { + const music = createFolderInTestFolder(libraryPath, musicFolder) + + const ret = await $`${bin} addlib ${libraryPath} --type music --skip-init +${bin} list + `.nothrow() + + expect(ret.exitCode).toBe(0) + expect(ret.text()).toContain(music.path!) + expect(ret.text()).toContain(`imported folder ${music.path}`) + }, FIVE_MINUTES_MS) +}) diff --git a/apps/e2e/common/ImportLibrary.e2e.ts b/apps/e2e/common/ImportLibrary.e2e.ts new file mode 100644 index 00000000..b2a97ce0 --- /dev/null +++ b/apps/e2e/common/ImportLibrary.e2e.ts @@ -0,0 +1,211 @@ +import { expect } from '@wdio/globals' +import { + setup, + cleanup, + expectMediaMetadataViaBrowser, +} from 'test/lib/testbed' +import { + clearFolderViaBrowser, + resolveSmmTestFolderViaBrowser, +} from 'test/lib/browser-fs' +import { delay } from 'es-toolkit' +import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' +import 'test/steps' +import type { MediaMetadata } from '@smm/core/types' +import { Path } from '@smm/core' + +import { testbedOs } from 'test/lib/e2e-platform' + +/** + * UC1: import tvshow/movie/music libraries via Core import-library (Web UI / Electron / ohos). + * @supports local, Electron, HarmonyOS, Docker + */ +describe('Import Library', () => { + let testFolder = '' + + beforeEach(async () => { + resetStepContext() + await setup({ + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + removeDirInSidebar: true, + openBrowserPage: true, + resetUserConfig: (config) => { + config.primaryDatabase = 'TMDB' + config.preferMediaLanguage = 'zh-CN' + }, + os: testbedOs, + }) + + const { default: Page } = await import('test/pageobjects/page') + await Page.refresh() + + testFolder = await resolveSmmTestFolderViaBrowser() + await clearFolderViaBrowser(testFolder) + }) + + afterEach(async () => { + await cleanup({ + removeMetadataDir: true, + removePlansDir: true, + removeMediaFolders: true, + removeDirInSidebar: true, + resetUserConfig: true, + os: testbedOs, + }) + if (testFolder) { + await clearFolderViaBrowser(testFolder) + } + }) + + it('Import TV Show Library', async function () { + this.timeout(6 * 60 * 1000) + + await given('Media library was imported with TV show folders', { + base: testFolder, + }) + + await delay(30 * 1000) + + const folders = getStepContext()._folders as Array<{ + folderName: string + path: string + type: string + }> + + await then('unknown folder has no tvshow metadata', async () => { + const f = folders.find((x) => x.folderName === 'UnknownFolder')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow).toBeUndefined() + return true + }) + }) + + await then('folder recognized by name has TMDB tvshow metadata', async () => { + const { folder1 } = await import('test/actions/import-folders') + const f = folders.find((x) => x.folderName === folder1.folderName)! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by tmdbid has TMDB tvshow metadata', async () => { + const f = folders.find((x) => x.folderName === '{tmdbid=84666}')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by NFO has TMDB tvshow metadata', async () => { + const f = folders.find((x) => x.folderName === 'FolderContainsTvShowNfo')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('tvshow-folder') + expect(mm.tvShow?.database).toBe('TMDB') + return true + }) + }) + }) + + it('Import Movie Library', async function () { + this.timeout(6 * 60 * 1000) + + await given('Media library was imported with movie folders', { + base: testFolder, + }) + + await delay(30 * 1000) + + const folders = getStepContext()._folders as Array<{ + folderName: string + path: string + type: string + }> + + await then('unknown folder has no movie metadata', async () => { + const f = folders.find((x) => x.folderName === 'UnknownFolder')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie).toBeUndefined() + return true + }) + }) + + await then('folder recognized by name has TMDB movie metadata', async () => { + const { folder2 } = await import('test/actions/import-folders') + const f = folders.find((x) => x.folderName === folder2.folderName)! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by tmdbid has TMDB movie metadata', async () => { + const f = folders.find((x) => x.folderName === '{tmdbid=1539104}')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie?.database).toBe('TMDB') + return true + }) + }) + + await then('folder recognized by NFO has TMDB movie metadata', async () => { + const f = folders.find((x) => x.folderName === 'FolderContainsMovieNfo')! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('movie-folder') + expect(mm.movie?.database).toBe('TMDB') + return true + }) + }) + }) + + it('Import Music Library', async function () { + this.timeout(3 * 60 * 1000) + + await given('Media library was imported with music folders', { + base: testFolder, + }) + + await delay(15 * 1000) + + const folders = getStepContext()._folders as Array<{ + folderName: string + path: string + type: string + }> + + await then('music folder has music-folder metadata with listed files', async () => { + const { musicFolder } = await import('test/actions/import-folders') + const f = folders.find((x) => x.folderName === musicFolder.folderName)! + await expectMediaMetadataViaBrowser(f.path, (obj) => { + const mm = obj as MediaMetadata + expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) + expect(mm.type).toBe('music-folder') + expect(mm.files?.some((file) => file.endsWith('01.mp3'))).toBe(true) + return true + }) + }) + }) +}) diff --git a/apps/e2e/test/steps/media-library-was-initialized.ts b/apps/e2e/test/steps/media-library-was-initialized.ts index c719096c..2f3ad780 100644 --- a/apps/e2e/test/steps/media-library-was-initialized.ts +++ b/apps/e2e/test/steps/media-library-was-initialized.ts @@ -4,7 +4,7 @@ import { joinPlatformPath, writeFileViaBrowser, } from '../lib/browser-fs' -import { folder1, folder2 } from 'test/actions/import-folders' +import { folder1, folder2, musicFolder } from 'test/actions/import-folders' import { importMediaLibrary } from 'test/actions/events' interface InitializedFolder { @@ -112,3 +112,19 @@ registerStep('Media library was imported with movie folders', async (ctx) => { ctx._folders = folders }) + +registerStep('Media library was imported with music folders', async (ctx) => { + const base = requireLibraryBase(ctx) + const folders: InitializedFolder[] = [] + + const musicPath = await createTestFolderViaBrowser(base, musicFolder) + folders.push({ folderName: musicFolder.folderName, path: musicPath, type: 'music-folder' }) + + await importMediaLibrary({ + libraryPathInPlatformFormat: base, + type: 'music', + traceId: 'e2e:ImportLibrary:music', + }) + + ctx._folders = folders +}) diff --git a/apps/ui/src/api/getJob.ts b/apps/ui/src/api/getJob.ts index 936f0b7d..ee8b8f79 100644 --- a/apps/ui/src/api/getJob.ts +++ b/apps/ui/src/api/getJob.ts @@ -40,7 +40,24 @@ export interface ImportJob { updatedAt: number } -export type Job = ImportJob | ScrapeJob +export interface ImportLibraryJob { + kind: 'import-library' + id: string + libraryPath: string + type: string + status: JobStatus + progress: number + folderPaths: string[] + importedCount: number + totalCount: number + currentFolderPath?: string + currentFolderJobId?: string + error?: string + createdAt: number + updatedAt: number +} + +export type Job = ImportJob | ImportLibraryJob | ScrapeJob export interface GetJobResponseBody { data?: Job diff --git a/apps/ui/src/api/importLibrary.ts b/apps/ui/src/api/importLibrary.ts new file mode 100644 index 00000000..28989277 --- /dev/null +++ b/apps/ui/src/api/importLibrary.ts @@ -0,0 +1,52 @@ +import { apiFetch } from '@/lib/apiFetch' +import type { FolderType } from '@core/types' + +export interface ImportLibraryParams { + path: string + type: FolderType | 'anime' + skipInit?: boolean +} + +export interface ImportLibraryResponseBody { + data?: { id: string } + error?: string +} + +/** Layer-2 import library via Core (`POST /api/import-library`). */ +export async function importLibrary( + params: ImportLibraryParams, + signal?: AbortSignal, +): Promise { + const body: Record = { + path: params.path, + type: params.type, + } + if (params.skipInit === true) { + body.skipInit = true + } + + const resp = await apiFetch('/api/import-library', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as ImportLibraryResponseBody +} + +/** Throws on business error; returns job id. */ +export async function importLibraryViaCore(params: ImportLibraryParams): Promise { + const data = await importLibrary(params) + if (data.error) { + throw new Error(data.error) + } + if (!data.data?.id) { + throw new Error('Error Reason: import-library job id missing') + } + return data.data.id +} diff --git a/apps/ui/src/api/showFolder.ts b/apps/ui/src/api/showFolder.ts new file mode 100644 index 00000000..f7f1097f --- /dev/null +++ b/apps/ui/src/api/showFolder.ts @@ -0,0 +1,48 @@ +import { apiFetch } from '@/lib/apiFetch' +import type { MediaMetadata } from '@core/types' +import type { UIMediaFolderStatus } from '@/types/UIMediaFolder' + +export type ShowFolderStatus = Extract< + UIMediaFolderStatus, + 'ok' | 'folder_not_found' | 'error_loading_metadata' +> + +export interface ShowFolderResult { + path: string + status: ShowFolderStatus + type?: MediaMetadata['type'] + title?: string +} + +export interface ShowFolderResponseBody { + data?: ShowFolderResult + error?: string +} + +/** Resolve folder display status via Core (`POST /api/show-folder`). */ +export async function showFolder(path: string, signal?: AbortSignal): Promise { + const resp = await apiFetch('/api/show-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as ShowFolderResponseBody +} + +/** Throws on business error; returns show-folder payload. */ +export async function showFolderViaCore(path: string, signal?: AbortSignal): Promise { + const body = await showFolder(path, signal) + if (body.error) { + throw new Error(body.error) + } + if (!body.data) { + throw new Error('Error Reason: show-folder result missing') + } + return body.data +} diff --git a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts index 784e9c1d..30e8d492 100644 --- a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts +++ b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts @@ -1,23 +1,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { createElement } from "react" import { render } from "@testing-library/react" -import type { ListFilesResponseBody } from "@core/types" -import { Path } from "@core/path" - -const { listFilesMock } = vi.hoisted(() => ({ - listFilesMock: vi.fn(), -})) +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" const { persistHarmonyOSFileAccessMock } = vi.hoisted(() => ({ persistHarmonyOSFileAccessMock: vi.fn(), })) -const { initializeImportedMediaFolderMock } = vi.hoisted(() => ({ - initializeImportedMediaFolderMock: vi.fn().mockResolvedValue(undefined), +const { importLibraryViaCoreMock } = vi.hoisted(() => ({ + importLibraryViaCoreMock: vi.fn(), +})) + +const { pollImportLibraryJobMock, buildUiFoldersFromShowFolderMock } = vi.hoisted(() => ({ + pollImportLibraryJobMock: vi.fn(), + buildUiFoldersFromShowFolderMock: vi.fn(), })) -const { saveUserConfigMock } = vi.hoisted(() => ({ - saveUserConfigMock: vi.fn().mockResolvedValue(undefined), +const { refreshUserConfigMock } = vi.hoisted(() => ({ + refreshUserConfigMock: vi.fn().mockResolvedValue(undefined), })) const { addJobMock, updateJobMock } = vi.hoisted(() => ({ @@ -25,36 +25,25 @@ const { addJobMock, updateJobMock } = vi.hoisted(() => ({ updateJobMock: vi.fn(), })) -const { sortPathsBySidebarDisplayOrderMock } = vi.hoisted(() => ({ - sortPathsBySidebarDisplayOrderMock: vi.fn((paths: string[]) => [...paths]), -})) - -vi.mock("@/api/listFiles", () => ({ - listFiles: listFilesMock, -})) - vi.mock("@/lib/persistHarmonyOSFileAccess", () => ({ persistHarmonyOSFileAccess: persistHarmonyOSFileAccessMock, })) -vi.mock("@/hooks/initialization/useInitializeImportedMediaFolder", () => ({ - useInitializeImportedMediaFolder: () => ({ - initializeImportedMediaFolder: initializeImportedMediaFolderMock, - }), +vi.mock("@/api/importLibrary", () => ({ + importLibraryViaCore: importLibraryViaCoreMock, })) +vi.mock("@/lib/importLibraryV3", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + pollImportLibraryJob: pollImportLibraryJobMock, + buildUiFoldersFromShowFolder: buildUiFoldersFromShowFolderMock, + } +}) + vi.mock("@/hooks/userConfig", () => ({ - useConfig: () => ({ - userConfig: { - folders: ["/media/existing-folder-A"], - }, - appConfig: {}, - isLoading: false, - isUserConfigLoaded: true, - }), - useSaveUserConfigMutation: () => ({ - mutateAsync: saveUserConfigMock, - }), + useRefreshUserConfig: () => refreshUserConfigMock, })) vi.mock("@/hooks/useJobManager", () => ({ @@ -64,163 +53,34 @@ vi.mock("@/hooks/useJobManager", () => ({ }), })) -vi.mock("@/stores/sidebarStore", () => ({ - sortPathsBySidebarDisplayOrder: sortPathsBySidebarDisplayOrderMock, -})) - -import { - _dedupFolders, - _listFolders, - listLibraryFoldersWithAccess, - MediaLibraryImportedEventHandler, -} from "./MediaLibraryImportedEventHandler" +import { MediaLibraryImportedEventHandler } from "./MediaLibraryImportedEventHandler" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { UI_MediaLibraryImportedEvent, type OnMediaLibraryImportedEventData } from "@/types/eventTypes" -describe("_listFolders", () => { - beforeEach(() => { - listFilesMock.mockReset() - persistHarmonyOSFileAccessMock.mockReset() - }) - - it("calls listFiles with only folders, no hidden files, and the given path", async () => { - const libraryPath = "/media/library" - listFilesMock.mockResolvedValue({ - data: { path: libraryPath, items: [], size: 0 }, - } satisfies ListFilesResponseBody) - - await _listFolders(libraryPath) - - expect(listFilesMock).toHaveBeenCalledTimes(1) - expect(listFilesMock).toHaveBeenCalledWith({ - path: libraryPath, - onlyFolders: true, - includeHiddenFiles: false, +describe("MediaLibraryImportedEventHandler", () => { + function renderHandler() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, }) - }) - - it("returns folder paths from response items", async () => { - listFilesMock.mockResolvedValue({ - data: { - path: "/media/library", - size: 2, - items: [ - { path: "/media/library/a", size: 0, mtime: 0, isDirectory: true }, - { path: "/media/library/b", size: 0, mtime: 0, isDirectory: true }, - ], - }, - } satisfies ListFilesResponseBody) - - await expect(_listFolders("/media/library")).resolves.toEqual(["/media/library/a", "/media/library/b"]) - }) - - it("returns empty array when response has no data", async () => { - listFilesMock.mockResolvedValue({} satisfies ListFilesResponseBody) - - await expect(_listFolders("/media/library")).resolves.toEqual([]) - }) - - it("returns empty array when data.items is empty", async () => { - listFilesMock.mockResolvedValue({ - data: { path: "/media/library", items: [], size: 0 }, - } satisfies ListFilesResponseBody) - - await expect(_listFolders("/media/library")).resolves.toEqual([]) - }) -}) - -describe("listLibraryFoldersWithAccess", () => { - beforeEach(() => { - listFilesMock.mockReset() - persistHarmonyOSFileAccessMock.mockReset() - persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) - }) - - it("persists library access before listing folders", async () => { - const libraryPath = "file://docs/storage/media-library" - listFilesMock.mockResolvedValue({ - data: { - path: libraryPath, - size: 1, - items: [{ path: `${libraryPath}/show-a`, size: 0, mtime: 0, isDirectory: true }], - }, - } satisfies ListFilesResponseBody) - - const callOrder: string[] = [] - persistHarmonyOSFileAccessMock.mockImplementation(async () => { - callOrder.push("persist") - }) - listFilesMock.mockImplementation(async () => { - callOrder.push("listFiles") - return { - data: { - path: libraryPath, - size: 1, - items: [{ path: `${libraryPath}/show-a`, size: 0, mtime: 0, isDirectory: true }], - }, - } satisfies ListFilesResponseBody - }) - - await listLibraryFoldersWithAccess(libraryPath) - - expect(persistHarmonyOSFileAccessMock).toHaveBeenCalledWith([libraryPath]) - expect(listFilesMock).toHaveBeenCalledTimes(1) - expect(callOrder).toEqual(["persist", "listFiles"]) - }) - - it("does not list folders when persist fails", async () => { - persistHarmonyOSFileAccessMock.mockRejectedValue( - new Error("无法持久化文件夹访问权限"), + render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(MediaLibraryImportedEventHandler), + ), ) + } - await expect( - listLibraryFoldersWithAccess("file://docs/storage/media-library"), - ).rejects.toThrow("无法持久化文件夹访问权限") - - expect(listFilesMock).not.toHaveBeenCalled() - }) -}) - -describe("_dedupFolders", () => { - it("returns all new folders when there is no existing metadata", () => { - const incoming = ["/media/show-a", "/media/show-b"] - expect(_dedupFolders(incoming, [])).toEqual(incoming) - }) - - it("returns empty when every new folder is already imported", () => { - expect(_dedupFolders(["/media/show-a", "/media/show-b"], ["/media/show-a", "/media/show-b"])).toEqual([]) - }) - - it("keeps only folders that are not already imported", () => { - expect(_dedupFolders(["/media/show-a", "/media/show-b"], ["/media/show-a"])).toEqual(["/media/show-b"]) - }) - - it("matches paths after normalizing with Path.posix so platform paths dedupe against existing folder paths", () => { - const winPath = "D:\\library\\TV\\MyShow" - const incoming = [winPath, "D:\\library\\TV\\OtherShow"] - const result = _dedupFolders(incoming, [winPath]) - expect(result).toEqual(["D:\\library\\TV\\OtherShow"]) - expect(Path.posix(result[0]!)).toBe(Path.posix("D:\\library\\TV\\OtherShow")) - }) - - it("ignores entries without a usable folder path", () => { - expect(_dedupFolders(["/media/keep", "/media/new"], [""])).toEqual(["/media/keep", "/media/new"]) - }) -}) - -describe("MediaLibraryImportedEventHandler integration", () => { beforeEach(() => { - listFilesMock.mockReset() + importLibraryViaCoreMock.mockReset() + pollImportLibraryJobMock.mockReset() + buildUiFoldersFromShowFolderMock.mockReset() + refreshUserConfigMock.mockReset() + refreshUserConfigMock.mockResolvedValue(undefined) persistHarmonyOSFileAccessMock.mockReset() - initializeImportedMediaFolderMock.mockReset() - saveUserConfigMock.mockReset() + persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) addJobMock.mockReset() updateJobMock.mockReset() - sortPathsBySidebarDisplayOrderMock.mockReset() - sortPathsBySidebarDisplayOrderMock.mockImplementation((paths: string[]) => [...paths]) - persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) - initializeImportedMediaFolderMock.mockResolvedValue(undefined) - saveUserConfigMock.mockResolvedValue(undefined) useUIMediaFolderStore.setState({ folders: [], selectedFolder: "", @@ -228,101 +88,87 @@ describe("MediaLibraryImportedEventHandler integration", () => { }) }) - it("preserves already-imported folders when a media library is imported (regression)", async () => { - // Pre-populate the store with a previously-imported folder A. - const folderA = "/media/existing-folder-A" - useUIMediaFolderStore.setState({ - folders: [{ path: folderA, status: "ok" }], - selectedFolder: "", - selectedFolders: [], - }) - - // Mock the library listing to return folder B (a new folder inside the library). + it("imports media library via Core and updates folder store", async () => { const folderB = "/media/library/library-folder-B" const libraryPath = "/media/library" - listFilesMock.mockResolvedValue({ - data: { - path: libraryPath, - size: 1, - items: [{ path: folderB, size: 0, mtime: 0, isDirectory: true }], - }, - } satisfies ListFilesResponseBody) + importLibraryViaCoreMock.mockResolvedValue("core-job-1") + pollImportLibraryJobMock.mockResolvedValue({ + kind: "import-library", + id: "core-job-1", + libraryPath, + type: "tvshow", + status: "succeeded", + progress: 100, + folderPaths: [folderB], + importedCount: 1, + totalCount: 1, + createdAt: 0, + updatedAt: 0, + }) + buildUiFoldersFromShowFolderMock.mockResolvedValue([ + { path: folderB, status: "ok", type: "tvshow-folder" }, + ]) - render(createElement(MediaLibraryImportedEventHandler)) + renderHandler() - const eventData: OnMediaLibraryImportedEventData = { - libraryPathInPlatformFormat: libraryPath, - type: "tvshow", - traceId: "test-trace", - } document.dispatchEvent( - new CustomEvent(UI_MediaLibraryImportedEvent, { detail: eventData }), + new CustomEvent(UI_MediaLibraryImportedEvent, { + detail: { + libraryPathInPlatformFormat: libraryPath, + type: "tvshow", + traceId: "test-trace", + } satisfies OnMediaLibraryImportedEventData, + }), ) - // Wait for the async handler to finish (it must call initializeImportedMediaFolder for B). await vi.waitFor(() => { - expect(initializeImportedMediaFolderMock).toHaveBeenCalled() + expect(importLibraryViaCoreMock).toHaveBeenCalledWith({ + path: libraryPath, + type: "tvshow", + }) }) - const folders = useUIMediaFolderStore.getState().folders - const paths = folders.map((f) => f.path) - - // The bug was: importing a media library wiped out folder A. - // After fix: both A and B must be present in the store. - expect(paths).toContain(folderA) - expect(paths).toContain(folderB) - expect(folders).toHaveLength(2) + expect(persistHarmonyOSFileAccessMock).toHaveBeenCalledWith([libraryPath]) + expect(pollImportLibraryJobMock).toHaveBeenCalled() + expect(refreshUserConfigMock).toHaveBeenCalled() + expect(buildUiFoldersFromShowFolderMock).toHaveBeenCalledWith([folderB]) - // userConfig must also be saved with both folders appended. - expect(saveUserConfigMock).toHaveBeenCalledTimes(1) - const savedConfig = saveUserConfigMock.mock.calls[0]![0].config - expect(savedConfig.folders).toEqual([folderA, folderB]) + const folders = useUIMediaFolderStore.getState().folders + expect(folders.map((f) => f.path)).toContain(folderB) + expect(updateJobMock).toHaveBeenCalledWith("job-1", { status: "succeeded", progress: 100 }) }) - it("does not duplicate folders already present in the store", async () => { - // Folder A is already in both the store AND userConfig. - const folderA = "/media/existing-folder-A" - useUIMediaFolderStore.setState({ - folders: [{ path: folderA, status: "ok" }], - selectedFolder: "", - selectedFolders: [], - }) - - // The library contains folder A (already imported) and folder B (new). - const folderB = "/media/library/library-folder-B" + it("marks background job failed when Core import-library fails", async () => { const libraryPath = "/media/library" - listFilesMock.mockResolvedValue({ - data: { - path: libraryPath, - size: 2, - items: [ - { path: folderA, size: 0, mtime: 0, isDirectory: true }, - { path: folderB, size: 0, mtime: 0, isDirectory: true }, - ], - }, - } satisfies ListFilesResponseBody) + importLibraryViaCoreMock.mockResolvedValue("core-job-1") + pollImportLibraryJobMock.mockResolvedValue({ + kind: "import-library", + id: "core-job-1", + libraryPath, + type: "movie", + status: "failed", + progress: 0, + folderPaths: [], + importedCount: 0, + totalCount: 1, + error: "Library path not found", + createdAt: 0, + updatedAt: 0, + }) - render(createElement(MediaLibraryImportedEventHandler)) + renderHandler() document.dispatchEvent( new CustomEvent(UI_MediaLibraryImportedEvent, { detail: { libraryPathInPlatformFormat: libraryPath, - type: "tvshow", - traceId: "test-trace", + type: "movie", } satisfies OnMediaLibraryImportedEventData, }), ) await vi.waitFor(() => { - expect(initializeImportedMediaFolderMock).toHaveBeenCalled() + expect(updateJobMock).toHaveBeenCalledWith("job-1", { status: "failed" }) }) - - const folders = useUIMediaFolderStore.getState().folders - const paths = folders.map((f) => f.path) - // A must remain, B must be added, and there must be exactly one of each. - expect(paths.filter((p) => p === folderA)).toHaveLength(1) - expect(paths.filter((p) => p === folderB)).toHaveLength(1) - expect(folders).toHaveLength(2) }) }) diff --git a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx index 6304b5d2..d59959ba 100644 --- a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx +++ b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx @@ -1,152 +1,86 @@ import { useEffect, useRef } from "react" -import { useLatest, useMount, useUnmount } from "react-use" -import { listFiles } from "@/api/listFiles" +import { useMount, useUnmount } from "react-use" +import { useQueryClient } from "@tanstack/react-query" +import { importLibraryViaCore } from "@/api/importLibrary" import { useJobManager } from "@/hooks/useJobManager" import { nextTraceId } from "@/lib/utils" -import { Path } from "@core/path" -import { basename } from "@/lib/path" -import { sortPathsBySidebarDisplayOrder } from "@/stores/sidebarStore" -import { UI_MediaFolderImportedEvent, UI_MediaLibraryImportedEvent, type OnMediaLibraryImportedEventData } from "@/types/eventTypes" -import { isEqual, isNotNil } from "es-toolkit" +import { UI_MediaLibraryImportedEvent, type OnMediaLibraryImportedEventData } from "@/types/eventTypes" import debug from "debug" -import { useInitializeImportedMediaFolder } from "@/hooks/initialization/useInitializeImportedMediaFolder" -import { useUIMediaFolderStore, useUIMediaFolderStoreState } from "@/stores/uiMediaFolderStore" -import { useConfig, useSaveUserConfigMutation } from "@/hooks/userConfig" -import type { UIMediaFolder } from "@/types/UIMediaFolder" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" +import { useRefreshUserConfig } from "@/hooks/userConfig" import { persistHarmonyOSFileAccess } from "@/lib/persistHarmonyOSFileAccess" -import { logger } from "@/lib/log" import { toast } from "sonner" import { useTranslation } from "@/lib/i18n" - -/** - * folders - the absolute folder paths in platform format - */ -// eslint-disable-next-line react-refresh/only-export-components -export function _dedupFolders(newFolders: string[], existingFolderPaths: string[]): string[] { - const importedFolders: string[] = existingFolderPaths - .filter((path) => path && Path.toPlatformPath(path)) - .filter(isNotNil) - - const importedPosix = new Set(importedFolders.map((p) => Path.posix(p))) - return newFolders.filter((folder) => !importedPosix.has(Path.posix(folder))) -} - -// eslint-disable-next-line react-refresh/only-export-components -export async function _listFolders(path: string): Promise { - const listFilesResponse = await listFiles({ - path, - onlyFolders: true, - includeHiddenFiles: false, - }) - return listFilesResponse.data?.items.map((item) => item.path) ?? [] -} - -// eslint-disable-next-line react-refresh/only-export-components -export async function listLibraryFoldersWithAccess(libraryPath: string): Promise { - await persistHarmonyOSFileAccess([libraryPath]) - return _listFolders(libraryPath) -} +import { foldersQueryKey } from "@/hooks/folders/foldersQueryKeys" +import { + buildUiFoldersFromShowFolder, + folderTypeToMediaType, + pollImportLibraryJob, +} from "@/lib/importLibraryV3" export function MediaLibraryImportedEventHandler() { - const { t: tComponents } = useTranslation("components") const tRef = useRef(tComponents) useEffect(() => { tRef.current = tComponents }, [tComponents]) const eventListener = useRef<((event: Event) => void) | null>(null) - const { folders } = useUIMediaFolderStoreState() - const setFolders = useUIMediaFolderStore((s) => s.setFolders) - const latestFolders = useLatest(folders) - const { initializeImportedMediaFolder } = useInitializeImportedMediaFolder(); - const { mutateAsync: saveUserConfig } = useSaveUserConfigMutation() - const { userConfig } = useConfig() - const latestUserConfig = useLatest(userConfig) + const refreshUserConfig = useRefreshUserConfig() + const queryClient = useQueryClient() + const upsertFolder = useUIMediaFolderStore((s) => s.upsertFolder) const { addJob, updateJob } = useJobManager() - const doImportMediaLibrary = async (event: Event) => { - - const data = (event as CustomEvent).detail + const doImportMediaLibrary = async (data: OnMediaLibraryImportedEventData) => { const { libraryPathInPlatformFormat, type } = data - console.log(`start to initialize media library: ${JSON.stringify(data)}`) - debug(`start to process ${UI_MediaLibraryImportedEvent} event: ${JSON.stringify(data)}`) const traceIdBase = data.traceId || `MediaLibraryImportedEventHandler:${nextTraceId()}` + console.log(`[${traceIdBase}] start import media library: ${JSON.stringify(data)}`) + debug(`start ${UI_MediaLibraryImportedEvent}: ${JSON.stringify(data)}`) + + await persistHarmonyOSFileAccess([libraryPathInPlatformFormat]) const jobId = addJob(tRef.current("statusBar.backgroundJobs.jobNames.importMediaLibrary")) + updateJob(jobId, { status: "running", progress: 0 }) try { - - const foldersInLibrary = await listLibraryFoldersWithAccess(libraryPathInPlatformFormat) - debug(`listed ${foldersInLibrary.length} folders in library`) - const foldersToImport = _dedupFolders( - foldersInLibrary, - latestFolders.current.map((folder) => folder.path), - ) - debug(`deduped ${foldersToImport.length} folders, ${foldersToImport.length} folders need to import`) - - let progress = 0 - const updateProgress = () => { - const delta = ~~(100 / foldersToImport.length) - progress += delta - updateJob(jobId, { progress: progress }) + const coreJobId = await importLibraryViaCore({ + path: libraryPathInPlatformFormat, + type, + }) + + const finalJob = await pollImportLibraryJob(coreJobId, (job) => { + updateJob(jobId, { progress: job.progress }) + if (job.currentFolderPath) { + upsertFolder({ + path: job.currentFolderPath, + status: "initializing", + type: folderTypeToMediaType(type), + }) + } + }) + + if (finalJob.status !== "succeeded") { + throw new Error(finalJob.error ?? "Import media library failed") } - const uiMediaFolders = foldersToImport.map((folder) => ({ - path: folder, - status: "pending_for_initialization", - } as UIMediaFolder)) - - // Merge with already-imported folders so importing a media library does not - // remove folders that were imported previously. _dedupFolders guarantees - // no path overlap between `latestFolders.current` and `uiMediaFolders`. - setFolders([...latestFolders.current, ...uiMediaFolders]) - - await saveUserConfig({ traceId: traceIdBase, config: { - ...latestUserConfig.current, - folders: [...latestUserConfig.current.folders, ...foldersToImport], - } }) - - // Use Sidebar store sort order so initialization runs top-to-bottom as shown in Sidebar - const foldersInSidebarOrder = sortPathsBySidebarDisplayOrder(foldersToImport, (path) => basename(path) ?? "") - - /** - * Double check foldersInSidebarOrder contains all element in foldersToImport - * In case sortPathsBySidebarDisplayOrder was implemented wrongly - * And return array with missing folders - */ - const foldersToInitialize = isEqual(foldersToImport, foldersInSidebarOrder) ? foldersInSidebarOrder : foldersToImport - - for (const folder of foldersToInitialize) { - logger.info(`start to initialize folder ${folder}`) - await initializeImportedMediaFolder(new CustomEvent(UI_MediaFolderImportedEvent, { - detail: { - type: type, - folderPathInPlatformFormat: folder, - skipOptimisticUpdate: true, - traceId: traceIdBase, - }, - })) - logger.info(`initialized folder ${folder}`) - updateProgress() + await refreshUserConfig() + const importedFolders = await buildUiFoldersFromShowFolder(finalJob.folderPaths) + for (const folder of importedFolders) { + upsertFolder(folder) } + void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) updateJob(jobId, { status: "succeeded", progress: 100 }) - } catch (error) { - console.error(`[${traceIdBase}] Import media library failed:`, error) + console.error(`[${traceIdBase}] import media library failed:`, error) updateJob(jobId, { status: "failed" }) - debug(`failed to import media library: ${error instanceof Error ? error.message : String(error)}`) - toast.error( - error instanceof Error ? error.message : "导入媒体库失败", - ) + toast.error(error instanceof Error ? error.message : "导入媒体库失败") } } useMount(() => { eventListener.current = (event) => { debug(`received ${UI_MediaLibraryImportedEvent} event`) - ; (async () => { - doImportMediaLibrary(event) - })() + const data = (event as CustomEvent).detail + void doImportMediaLibrary(data) } document.addEventListener(UI_MediaLibraryImportedEvent, eventListener.current) @@ -160,4 +94,3 @@ export function MediaLibraryImportedEventHandler() { return <> } - diff --git a/apps/ui/src/lib/importLibraryV3.ts b/apps/ui/src/lib/importLibraryV3.ts new file mode 100644 index 00000000..1fa116b6 --- /dev/null +++ b/apps/ui/src/lib/importLibraryV3.ts @@ -0,0 +1,47 @@ +import { getJobViaCore, type ImportLibraryJob } from '@/api/getJob' +import { showFolderViaCore } from '@/api/showFolder' +import type { UIMediaFolder } from '@/types/UIMediaFolder' +import type { FolderType } from '@core/types' + +const POLL_INTERVAL_MS = 1000 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function folderTypeToMediaType(type: FolderType): NonNullable { + if (type === 'tvshow') return 'tvshow-folder' + if (type === 'movie') return 'movie-folder' + return 'music-folder' +} + +export async function pollImportLibraryJob( + jobId: string, + onUpdate: (job: ImportLibraryJob) => void, + signal?: AbortSignal, +): Promise { + for (;;) { + const job = await getJobViaCore(jobId, signal) + if (job.kind !== 'import-library') { + throw new Error(`Error Reason: unexpected job kind: ${job.kind}`) + } + onUpdate(job) + if (job.status === 'succeeded' || job.status === 'failed' || job.status === 'aborted') { + return job + } + await sleep(POLL_INTERVAL_MS) + } +} + +export async function buildUiFoldersFromShowFolder(paths: string[]): Promise { + const folders: UIMediaFolder[] = [] + for (const path of paths) { + const show = await showFolderViaCore(path) + folders.push({ + path: show.path, + status: show.status, + ...(show.type !== undefined ? { type: show.type } : {}), + }) + } + return folders +} diff --git a/docs/dev/import-folder.md b/docs/dev/import-folder.md index d672e65b..4518eaf1 100644 --- a/docs/dev/import-folder.md +++ b/docs/dev/import-folder.md @@ -99,7 +99,7 @@ See [apps/core](#appscore) for pipeline stages and persistence details. ## CLI -``` +```bash smm add --type tvshow|movie|music|anime [--verbose] [--skip-init] smm list smm show diff --git a/docs/dev/import-library.md b/docs/dev/import-library.md new file mode 100644 index 00000000..e25ad203 --- /dev/null +++ b/docs/dev/import-library.md @@ -0,0 +1,96 @@ +# Import Library + +**Supported Platform** Web UI, CLI, Electron, ohos +**Status** wip + +## Core + +The function of importing library reuses the function of [importing folder](./import-folder.md). + +```mermaid +sequenceDiagram + participant U as Upstream + participant C as C + participant UC as UserConfigHelper + participant M as MediaMetadataHelper + + U->>C: importLibrary(path, type) + C->>C: create job + C->>U: job id + C->>C: read folders in library + loop #1: for each folder + C->>M: create blank metadata file + C->>UC: upsert folder in UserConfig + end + loop import folders + C->>C: importFolder(jobId) + end + C->>C: update job status +``` + +> #1: this loop supports Web UI to render folder list immediately after user import library. +> Web UI first get folder list from user config. And then get folder status from media metadata. +> So we need to create metadata file before upsert folder in UserConfig +> To avoid Web UI find new folder but fail to get its metadata + + +## CLI + +```bash +smm addlib "" --type --type tvshow|movie|music|anime --skip-init +``` + +```mermaid +sequenceDiagram + participant U as User + participant S as CLI + participant C as Core + + U->>S: smm addlib "" + S->>C: importLibrary(path, type) + C->>S: job id + loop looping job status + S->>C: getJob(jobId) + end + S->>U: print result +``` + +## Web UI, Electron and ohos + +TODO: There is bug +The folder list was not updated before importFolder job completed. +UI should show all folders at the first step +and then run import job to trigger init process + +```mermaid +sequenceDiagram + participant U as User + participant W as Web UI + participant S as Server + participant C as Core + + U->>W: import library + W->>S: POST /api/import-library + S->>C: importLibrary(path, type) + C->>S: job id + S->>W: job id + W->>U: display job in running status + W->>W: invalidate useUserConfigQuery + W->>W: display folders in Sidebar + loop looping job status + W->>S: /api/get-job + S->>C: getJob(jobId) + C->>S: return + S->>W: return + end + W->>U: display job in success/failure status +``` + +## Testing + +| Use Case | Platform | File | +|--|--|--| +|UC1|Web UI, Electron, ohos| apps/e2e/common/ImportLibrary.e2e.ts| +|UC1|CLI| apps/e2e/cli/import-library.test.ts| + +### UC1: import tvshow/movie/music libraries \ No newline at end of file diff --git a/docs/dev/v3-onboarding.md b/docs/dev/v3-onboarding.md new file mode 100644 index 00000000..2661bff7 --- /dev/null +++ b/docs/dev/v3-onboarding.md @@ -0,0 +1,25 @@ +# The process of onboarding v3 + +## Prepare for Testing + +添加 "E2E_SMM_V3" 环境变量 +当 E2E_SMM_V3 为 true 时, apps/e2e 下的 wdio 测试开始前, 注入 localStorage "smm.v3.enabled". + +## Features + +V: passed +X: failed + +### Import Folders + +| Web UI | Electron | ohos | CLI | +|--|--|--|--| +|V|V||V| + +## Checklist + +[x] MCP tests + +[ ] All e2e tests pass when v3 is enabled +[ ] All test/mcp tests pass + From a9c087d4f707e7a7237a76e3b7a47db38c34c145 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 27 Aug 2026 12:19:38 +0800 Subject: [PATCH 154/214] feat(core): two-phase importLibrary with batch folder registration Register blank metadata for all subfolders before a single UserConfig upsert, then run importFolder with skipRegistration so the UI can show the full folder list immediately. Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 57 ++++++++++++++++++ apps/core/src/Core.ts | 60 +++++++++++++++---- .../src/pipeline/importFolderPipeline.test.ts | 34 +++++++++++ .../core/src/pipeline/importFolderPipeline.ts | 41 +++++++++---- apps/core/src/pipeline/importLibrary.test.ts | 34 ++++++++++- apps/core/src/pipeline/importLibrary.ts | 24 ++++++++ docs/dev/import-library.md | 4 +- 7 files changed, 225 insertions(+), 29 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index efa25b7f..8e336ca0 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -215,6 +215,63 @@ describe("Core", () => { expect(savedConfig.folders).toEqual(expect.arrayContaining(["/lib/Show1", "/lib/Show2"])); }); + it("importLibrary registers blank metadata before UserConfig upsert", async () => { + const writeOrder: string[] = []; + const baseFs = inMemoryFs({ + "/lib/Show1/a.mp3": "", + "/lib/Show2/a.mp3": "", + }); + const fs: FsPort = { + ...baseFs, + writeTextFile: vi.fn(async (path: string, content: string) => { + if (path === userConfigPath("/data/smm")) { + writeOrder.push("config"); + } else if (path.includes("/metadata/")) { + writeOrder.push(`metadata:${path}`); + } + await baseFs.writeTextFile(path, content); + }), + }; + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importLibrary("/lib", "music"); + await waitForStatus(core, id, "succeeded"); + + const configIndex = writeOrder.indexOf("config"); + expect(configIndex).toBeGreaterThan(-1); + expect(writeOrder.slice(0, configIndex).every((entry) => entry.startsWith("metadata:"))).toBe(true); + expect(writeOrder.slice(0, configIndex).length).toBe(2); + }); + + it("importLibrary with skipInit only registers folders without running importFolder init", async () => { + const fs = inMemoryFs({ + "/lib/Show1/a.mp3": "", + "/lib/Show2/a.mp3": "", + }); + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + + const { id } = core.importLibrary("/lib", "music", { skipInit: true }); + await waitForStatus(core, id, "succeeded"); + + const jobs = [core.getJob(id)]; + expect(jobs.filter((job) => job?.kind === "import")).toHaveLength(0); + + expect(await fs.exists(metadataCachePath("/data/smm", "/lib/Show1"))).toBe(true); + expect(await fs.exists(metadataCachePath("/data/smm", "/lib/Show2"))).toBe(true); + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); + expect(savedConfig.folders).toEqual(expect.arrayContaining(["/lib/Show1", "/lib/Show2"])); + }); + it("importLibrary skips folders already in user config", async () => { const fs = inMemoryFs({ [userConfigPath("/data/smm")]: JSON.stringify({ folders: ["/lib/Show1"], tmdb: {}, tvdb: {} }), diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index dfa08c38..f4d6e3a9 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -37,7 +37,7 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; import { TvdbClient } from "./clients/TvdbClient"; import { createBlankMediaMetadata, ImportFolderPipeline } from "./pipeline/importFolderPipeline"; -import { dedupLibraryFolders } from "./pipeline/importLibrary"; +import { dedupLibraryFolders, prepareLibraryFoldersForImport } from "./pipeline/importLibrary"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; import { renameEpisodeFilePipeline, @@ -164,6 +164,8 @@ export interface ScrapeFolderHandle { export interface ImportFolderOptions { /** When true, only register the path in UserConfig.folders; skip recognition and metadata. */ skipInit?: boolean; + /** When true, folder is already registered (import-library prep); run init from listFiles. */ + skipRegistration?: boolean; } export interface ImportLibraryOptions { @@ -258,13 +260,15 @@ export class Core { folderPath, type, status: "running", - stage: "config", + stage: options?.skipRegistration === true ? "listFiles" : "config", progress: 0, }); if (options?.skipInit === true) { void this.runImportSkipInit(job, path); } else { - void this.runImport(job, path, type); + void this.runImport(job, path, type, { + skipRegistration: options?.skipRegistration === true, + }); } return { id: job.id }; } @@ -722,7 +726,12 @@ export class Core { } } - private async runImport(job: ImportJob, folderPath: string, type: FolderType): Promise { + private async runImport( + job: ImportJob, + folderPath: string, + type: FolderType, + options?: { skipRegistration?: boolean }, + ): Promise { try { const pipeline = new ImportFolderPipeline({ fs: this.fs, @@ -732,15 +741,20 @@ export class Core { discover: this.discover, reverseProxyUrl: this.reverseProxyUrl, }); - await pipeline.run(folderPath, type, { - onStage: (stage, progress, detail) => { - this.jobs.update(job.id, { - stage, - progress, - ...(detail?.title !== undefined ? { recognizedTitle: detail.title } : {}), - }); + await pipeline.run( + folderPath, + type, + { + onStage: (stage, progress, detail) => { + this.jobs.update(job.id, { + stage, + progress, + ...(detail?.title !== undefined ? { recognizedTitle: detail.title } : {}), + }); + }, }, - }); + { skipRegistration: options?.skipRegistration === true }, + ); this.jobs.update(job.id, { status: "succeeded", stage: null, progress: 100 }); } catch (error) { this.jobs.update(job.id, { @@ -769,9 +783,29 @@ export class Core { importedCount: 0, }); + await prepareLibraryFoldersForImport(toImport, type, { + writeBlankMetadata: (metadata) => this.mediaMetadata.write(metadata), + upsertFolders: async (folders) => { + await this.userConfig.update((config) => ({ + ...config, + folders: [...new Set([...config.folders, ...folders])], + })); + }, + }); + + if (skipInit) { + this.jobs.update(job.id, { + status: "succeeded", + progress: 100, + currentFolderPath: undefined, + currentFolderJobId: undefined, + }); + return; + } + for (let i = 0; i < toImport.length; i++) { const folder = toImport[i]!; - const { id: childId } = this.importFolder(folder, type, skipInit ? { skipInit: true } : undefined); + const { id: childId } = this.importFolder(folder, type, { skipRegistration: true }); this.jobs.update(job.id, { currentFolderPath: folder, currentFolderJobId: childId, diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index f4db6fbd..8c6ee1e3 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -80,6 +80,40 @@ describe("ImportFolderPipeline", () => { expect(cached.type).toBe("music-folder"); }); + it("skipRegistration does not rewrite userConfig but still lists files and persists metadata", async () => { + const mediaDir = "/m/My.Show"; + const { fs, pipeline, appDataDir } = makePipeline({ + seed: { + "/m/My.Show/S01E01.mkv": "", + [userConfigPath("/data/smm")]: JSON.stringify({ folders: [mediaDir], tmdb: {}, tvdb: {} }), + [metadataCachePath("/data/smm", mediaDir)]: JSON.stringify({ + mediaFolderPath: mediaDir, + type: "music-folder", + mediaFiles: [], + }), + }, + }); + + const stages: string[] = []; + await pipeline.run( + mediaDir, + "music", + { + onStage: (stage) => { + stages.push(stage ?? "null"); + }, + }, + { skipRegistration: true }, + ); + + expect(stages).toEqual(["listFiles", "persist"]); + const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath(appDataDir))) as string); + expect(savedConfig.folders).toEqual([mediaDir]); + const cached = JSON.parse((await fs.readTextFile(metadataCachePath(appDataDir, mediaDir))) as string); + expect(cached.mediaFiles).toEqual([]); + expect(cached.type).toBe("music-folder"); + }); + it("dedupes an already-present folder in userConfig", async () => { const mediaDir = "/m/My.Show"; const { fs, pipeline, appDataDir } = makePipeline({ diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index 861ba323..94e8e515 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -27,6 +27,11 @@ export interface ImportFolderPipelineCallbacks { onStage?: (stage: JobStage, progress: number, detail?: { title?: string }) => void; } +export interface ImportFolderPipelineRunOptions { + /** Folder already has blank metadata and is listed in UserConfig (import-library prep). */ + skipRegistration?: boolean; +} + function mediaMetadataType(type: FolderType): MediaMetadata["type"] { return type === "tvshow" ? "tvshow-folder" : type === "movie" ? "movie-folder" : "music-folder"; } @@ -45,24 +50,36 @@ export function createBlankMediaMetadata(folderPath: string, type: FolderType): export class ImportFolderPipeline { constructor(private readonly options: ImportFolderPipelineOptions) {} - async run(folderPath: string, type: FolderType, cb: ImportFolderPipelineCallbacks = {}): Promise { + async run( + folderPath: string, + type: FolderType, + cb: ImportFolderPipelineCallbacks = {}, + runOptions: ImportFolderPipelineRunOptions = {}, + ): Promise { const { fs, logger, appDataDir, network, discover, reverseProxyUrl } = this.options; const posixPath = Path.posix(folderPath); const stages: JobStage[] = []; + const skipRegistration = runOptions.skipRegistration === true; - logger.info({ folderPath: posixPath, type }, "importFolder: stage=config"); const userConfigStore = new UserConfigHelper(fs, appDataDir); - const userConfig = await userConfigStore.update((config) => ({ - ...config, - folders: [...new Set([...config.folders, folderPath])], - })); - stages.push("config"); - cb.onStage?.("config", 10); - - logger.info({ folderPath: posixPath, type }, "importFolder: stage=metadata"); + let userConfig; + if (skipRegistration) { + userConfig = await userConfigStore.read(); + } else { + logger.info({ folderPath: posixPath, type }, "importFolder: stage=config"); + userConfig = await userConfigStore.update((config) => ({ + ...config, + folders: [...new Set([...config.folders, folderPath])], + })); + stages.push("config"); + cb.onStage?.("config", 10); + + logger.info({ folderPath: posixPath, type }, "importFolder: stage=metadata"); + stages.push("metadata"); + cb.onStage?.("metadata", 25); + } + const mm = createBlankMediaMetadata(folderPath, type); - stages.push("metadata"); - cb.onStage?.("metadata", 25); logger.info({ folderPath: posixPath, type }, "importFolder: stage=listFiles"); const listed = await fs.listFiles(posixPath); diff --git a/apps/core/src/pipeline/importLibrary.test.ts b/apps/core/src/pipeline/importLibrary.test.ts index 547947ed..f9243310 100644 --- a/apps/core/src/pipeline/importLibrary.test.ts +++ b/apps/core/src/pipeline/importLibrary.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { dedupLibraryFolders } from './importLibrary' +import { describe, expect, it, vi } from 'vitest' +import { dedupLibraryFolders, prepareLibraryFoldersForImport } from './importLibrary' describe('dedupLibraryFolders', () => { it('removes folders already imported (POSIX comparison)', () => { @@ -15,3 +15,33 @@ describe('dedupLibraryFolders', () => { expect(dedupLibraryFolders(folders, [])).toEqual(folders) }) }) + +describe('prepareLibraryFoldersForImport', () => { + it('writes blank metadata for each folder before batch upserting UserConfig', async () => { + const order: string[] = [] + const writeBlankMetadata = vi.fn(async (metadata: { mediaFolderPath?: string }) => { + order.push(`metadata:${metadata.mediaFolderPath}`) + }) + const upsertFolders = vi.fn(async (folders: string[]) => { + order.push(`config:${folders.join(',')}`) + }) + + await prepareLibraryFoldersForImport(['/lib/A', '/lib/B'], 'music', { + writeBlankMetadata, + upsertFolders, + }) + + expect(order).toEqual(['metadata:/lib/A', 'metadata:/lib/B', 'config:/lib/A,/lib/B']) + expect(writeBlankMetadata).toHaveBeenCalledTimes(2) + expect(upsertFolders).toHaveBeenCalledWith(['/lib/A', '/lib/B']) + }) + + it('skips UserConfig upsert when there are no folders', async () => { + const upsertFolders = vi.fn() + await prepareLibraryFoldersForImport([], 'music', { + writeBlankMetadata: vi.fn(), + upsertFolders, + }) + expect(upsertFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/core/src/pipeline/importLibrary.ts b/apps/core/src/pipeline/importLibrary.ts index 45265d81..2aae1cf0 100644 --- a/apps/core/src/pipeline/importLibrary.ts +++ b/apps/core/src/pipeline/importLibrary.ts @@ -1,7 +1,31 @@ import { Path } from "@core/path"; +import type { FolderType } from "@smm/core"; +import { createBlankMediaMetadata } from "./importFolderPipeline"; +import type { PersistedMediaMetadata } from "./mediaMetadataValidation"; /** Skip folders already present in user config (POSIX path comparison). */ export function dedupLibraryFolders(newFolders: string[], existingFolderPaths: string[]): string[] { const importedPosix = new Set(existingFolderPaths.map((p) => Path.posix(p))); return newFolders.filter((folder) => !importedPosix.has(Path.posix(folder))); } + +export interface PrepareLibraryFoldersDeps { + writeBlankMetadata: (metadata: PersistedMediaMetadata) => Promise; + upsertFolders: (folders: string[]) => Promise; +} + +/** Loop #1: blank metadata per folder, then batch upsert folders in UserConfig. */ +export async function prepareLibraryFoldersForImport( + folders: string[], + type: FolderType, + deps: PrepareLibraryFoldersDeps, +): Promise { + for (const folder of folders) { + const blank = createBlankMediaMetadata(folder, type); + const { files: _files, ...toPersist } = blank; + await deps.writeBlankMetadata(toPersist); + } + if (folders.length > 0) { + await deps.upsertFolders(folders); + } +} diff --git a/docs/dev/import-library.md b/docs/dev/import-library.md index e25ad203..993c686c 100644 --- a/docs/dev/import-library.md +++ b/docs/dev/import-library.md @@ -10,7 +10,7 @@ The function of importing library reuses the function of [importing folder](./im ```mermaid sequenceDiagram participant U as Upstream - participant C as C + participant C as Core participant UC as UserConfigHelper participant M as MediaMetadataHelper @@ -20,8 +20,8 @@ sequenceDiagram C->>C: read folders in library loop #1: for each folder C->>M: create blank metadata file - C->>UC: upsert folder in UserConfig end + C->>UC: upsert folders in UserConfig at at once loop import folders C->>C: importFolder(jobId) end From 4f2f03e7ae7229005d9e12f06bf35a1b2d272590 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 27 Aug 2026 14:58:11 +0800 Subject: [PATCH 155/214] feat: import-library per-folder tasks with live UI metadata refresh Return ImportLibraryJob.tasks from get-job so the Sidebar can show each folder's status during import. Core emits mediaMetadataUpdated after importFolder persist; CLI forwards via Socket.IO for panel refetch. Split apps/cli e2e tests into pnpm run test:e2e so pnpm test stays unit-only. Co-authored-by: Cursor --- apps/cli/package.json | 1 + apps/cli/src/cli/addlib.test.ts | 4 +- apps/cli/src/cli/addlibProgress.ts | 19 +- apps/cli/src/cli/runCli.ts | 4 +- apps/cli/src/core/getCore.ts | 2 + apps/cli/src/core/wireCoreEvents.test.ts | 52 +++++ apps/cli/src/core/wireCoreEvents.ts | 13 ++ apps/cli/src/route/ImportLibrary.test.ts | 11 +- apps/cli/src/route/ImportLibrary.ts | 4 + apps/cli/vitest.config.ts | 4 +- apps/cli/vitest.e2e.config.ts | 36 ++++ apps/core/src/Core.test.ts | 53 ++++- apps/core/src/Core.ts | 105 +++++++--- apps/core/src/coreEvents.ts | 33 ++++ apps/core/src/index.ts | 7 + apps/core/src/jobs/types.ts | 21 +- apps/core/src/pipeline/importLibrary.test.ts | 28 ++- apps/core/src/pipeline/importLibrary.ts | 27 +++ apps/ui/src/api/getJob.ts | 20 +- apps/ui/src/api/importLibrary.ts | 22 ++- .../MediaLibraryImportedEventHandler.test.ts | 184 +++++++++++++++--- .../MediaLibraryImportedEventHandler.tsx | 75 +++++-- ...MediaMetadataUpdatedEventListener.test.tsx | 90 +++++++++ .../MediaMetadataUpdatedEventListener.tsx | 13 ++ apps/ui/src/lib/importLibraryLog.ts | 22 +++ apps/ui/src/lib/importLibraryV3.test.ts | 82 ++++++++ apps/ui/src/lib/importLibraryV3.ts | 145 +++++++++++++- docs/dev/import-library.md | 84 +++----- package.json | 1 + packages/core/job/ImportLibraryJob.ts | 25 +++ 30 files changed, 1002 insertions(+), 185 deletions(-) create mode 100644 apps/cli/src/core/wireCoreEvents.test.ts create mode 100644 apps/cli/src/core/wireCoreEvents.ts create mode 100644 apps/cli/vitest.e2e.config.ts create mode 100644 apps/core/src/coreEvents.ts create mode 100644 apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.test.tsx create mode 100644 apps/ui/src/lib/importLibraryLog.ts create mode 100644 apps/ui/src/lib/importLibraryV3.test.ts create mode 100644 packages/core/job/ImportLibraryJob.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 1572591d..2b24e44f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -53,6 +53,7 @@ "build": "bun run scripts/build.ts", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", "test:mcp": "bun test ./test/test-mcp.e2e.ts" diff --git a/apps/cli/src/cli/addlib.test.ts b/apps/cli/src/cli/addlib.test.ts index ecd11084..34ba9fe6 100644 --- a/apps/cli/src/cli/addlib.test.ts +++ b/apps/cli/src/cli/addlib.test.ts @@ -99,9 +99,7 @@ describe('smm addlib', () => { type: 'tvshow', status: 'succeeded', progress: 100, - folderPaths: [], - importedCount: 0, - totalCount: 0, + tasks: [], createdAt: 0, updatedAt: 0, }) diff --git a/apps/cli/src/cli/addlibProgress.ts b/apps/cli/src/cli/addlibProgress.ts index e5f869d4..25b857b1 100644 --- a/apps/cli/src/cli/addlibProgress.ts +++ b/apps/cli/src/cli/addlibProgress.ts @@ -17,7 +17,7 @@ export async function waitUntilLibraryImportSettled( const log = options.log ?? console.log const emitProgress = options.progress !== false let announcedLibrary = false - let lastFolderJobId: string | undefined + let lastImportJobId: string | undefined let folderProgress = createAddProgressState() const deadline = Date.now() + options.timeoutMs @@ -26,29 +26,30 @@ export async function waitUntilLibraryImportSettled( if (job?.kind === 'import-library') { if ( !announcedLibrary && - (job.totalCount > 0 || (job.status !== 'running' && job.status !== 'pending')) + (job.tasks.length > 0 || (job.status !== 'running' && job.status !== 'pending')) ) { - log(`importing library ${options.libraryPath} (${job.totalCount} folders)`) + log(`importing library ${options.libraryPath} (${job.tasks.length} folders)`) announcedLibrary = true } - if (job.currentFolderJobId && job.currentFolderJobId !== lastFolderJobId) { - lastFolderJobId = job.currentFolderJobId + const runningTask = job.tasks.find((task) => task.status === 'running') + if (runningTask?.importJobId && runningTask.importJobId !== lastImportJobId) { + lastImportJobId = runningTask.importJobId folderProgress = createAddProgressState() } if ( emitProgress && !options.skipInit && - lastFolderJobId && - job.currentFolderPath + lastImportJobId && + runningTask ) { - const childJob = core.getJob(lastFolderJobId) + const childJob = core.getJob(lastImportJobId) if (childJob?.kind === 'import') { folderProgress = emitAddProgress( folderProgress, childJob, - job.currentFolderPath, + runningTask.path, options.type, log, ) diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index 1ea329e6..b66e892d 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -192,8 +192,8 @@ export async function runCli(argv: string[] = process.argv): Promise { return } if (skipInit) { - for (const folder of job.folderPaths) { - console.log(`imported folder ${folder}`) + for (const task of job.tasks) { + console.log(`imported folder ${task.path}`) } } } catch (error) { diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index df8e1057..6ed521d5 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -10,6 +10,7 @@ import { getUserDataDir, getAppDataDir, getTmpDir, getLogDir } from '@/utils/con import { APP_VERSION } from '@/version' import { getBunMcpServerPort } from '@/mcp/BunMcpServerPort' import { NodejsNetworkPort } from './NodejsNetworkPort' +import { wireCoreEvents } from './wireCoreEvents' let instance: Core | undefined @@ -36,6 +37,7 @@ export function getCore(options?: GetCoreOptions): Core { discover: new StaticDiscoverAdapter(), mcpServer: getBunMcpServerPort(), }) + wireCoreEvents(instance) } return instance } diff --git a/apps/cli/src/core/wireCoreEvents.test.ts b/apps/cli/src/core/wireCoreEvents.test.ts new file mode 100644 index 00000000..9687adec --- /dev/null +++ b/apps/cli/src/core/wireCoreEvents.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { MEDIA_METADATA_UPDATED_EVENT } from 'core-app' + +const { broadcastMock } = vi.hoisted(() => ({ + broadcastMock: vi.fn(), +})) + +vi.mock('@/utils/socketIO', () => ({ + broadcast: broadcastMock, +})) + +import { wireCoreEvents } from './wireCoreEvents' + +describe('wireCoreEvents', () => { + beforeEach(() => { + broadcastMock.mockReset() + }) + + it('broadcasts mediaMetadataUpdated when Core emits the event', () => { + const listeners: Array<(data: { folderPath?: string }) => void> = [] + const core = { + on: vi.fn((_event: string, listener: (data: { folderPath?: string }) => void) => { + listeners.push(listener) + }), + } + + wireCoreEvents(core as never) + + expect(core.on).toHaveBeenCalledWith(MEDIA_METADATA_UPDATED_EVENT, expect.any(Function)) + + listeners[0]?.({ folderPath: '/media/ShowA' }) + + expect(broadcastMock).toHaveBeenCalledWith({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: '/media/ShowA' }, + }) + }) + + it('skips broadcast when folderPath is missing', () => { + const listeners: Array<(data: { folderPath?: string }) => void> = [] + const core = { + on: vi.fn((_event: string, listener: (data: { folderPath?: string }) => void) => { + listeners.push(listener) + }), + } + + wireCoreEvents(core as never) + listeners[0]?.({}) + + expect(broadcastMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/core/wireCoreEvents.ts b/apps/cli/src/core/wireCoreEvents.ts new file mode 100644 index 00000000..c2360cb6 --- /dev/null +++ b/apps/cli/src/core/wireCoreEvents.ts @@ -0,0 +1,13 @@ +import { MEDIA_METADATA_UPDATED_EVENT, type Core } from 'core-app' +import { broadcast } from '@/utils/socketIO' + +/** Forwards Core events to Socket.IO (Web UI / Electron / ohos). */ +export function wireCoreEvents(core: Core): void { + core.on(MEDIA_METADATA_UPDATED_EVENT, (data) => { + if (!data.folderPath) return + broadcast({ + event: MEDIA_METADATA_UPDATED_EVENT, + data: { folderPath: data.folderPath }, + }) + }) +} diff --git a/apps/cli/src/route/ImportLibrary.test.ts b/apps/cli/src/route/ImportLibrary.test.ts index 34ecd5a9..2776d413 100644 --- a/apps/cli/src/route/ImportLibrary.test.ts +++ b/apps/cli/src/route/ImportLibrary.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -9,12 +9,15 @@ import { resetCoreForTests } from '../core/getCore' describe('POST /api/import-library', () => { let userDataDir: string + let libraryPath: string let prevUserDataDir: string | undefined let app: Hono beforeEach(() => { + vi.restoreAllMocks() prevUserDataDir = process.env.USER_DATA_DIR userDataDir = mkdtempSync(join(tmpdir(), 'smm-import-library-')) + libraryPath = mkdtempSync(join(tmpdir(), 'smm-import-library-path-')) process.env.USER_DATA_DIR = userDataDir resetCoreForTests() app = new Hono() @@ -23,10 +26,12 @@ describe('POST /api/import-library', () => { }) afterEach(() => { + vi.restoreAllMocks() resetCoreForTests() if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR else process.env.USER_DATA_DIR = prevUserDataDir rmSync(userDataDir, { recursive: true, force: true }) + rmSync(libraryPath, { recursive: true, force: true }) }) async function post(body: unknown) { @@ -60,7 +65,7 @@ describe('POST /api/import-library', () => { }) it('returns a job id for import-library', async () => { - const res = await post({ path: '/media/lib', type: 'music', skipInit: true }) + const res = await post({ path: libraryPath, type: 'music', skipInit: true }) expect(res.status).toBe(200) const json = (await res.json()) as { data?: { id: string }; error?: string } expect(json.error).toBeUndefined() @@ -76,6 +81,6 @@ describe('POST /api/import-library', () => { if (status === 'succeeded' || status === 'failed') break await new Promise((r) => setTimeout(r, 50)) } - expect(['succeeded', 'failed']).toContain(status) + expect(status).toBe('succeeded') }) }) diff --git a/apps/cli/src/route/ImportLibrary.ts b/apps/cli/src/route/ImportLibrary.ts index 0e6a0efe..4f3596ca 100644 --- a/apps/cli/src/route/ImportLibrary.ts +++ b/apps/cli/src/route/ImportLibrary.ts @@ -46,6 +46,10 @@ export function handleImportLibrary(app: Hono): void { } const skipInit = rec.skipInit === true const { id } = getCore().importLibrary(path, type, skipInit ? { skipInit: true } : undefined) + logger.info( + { path, type, skipInit, jobId: id }, + '[POST /api/import-library] Core importLibrary started', + ) const ok: ImportLibraryResponseBody = { data: { id } } return c.json(ok, 200) } catch (error) { diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index babe4a3c..325865d7 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -6,8 +6,8 @@ const coreRoot = resolve(__dirname, '../../packages/core') export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'test/**/*.e2e.ts'], - exclude: ['test/test-mcp.e2e.ts'], + // Unit tests only. E2E lives in test/*.e2e.ts — run via `pnpm run test:e2e`. + include: ['src/**/*.test.ts', 'test/helpers/**/*.test.ts'], }, resolve: { alias: [ diff --git a/apps/cli/vitest.e2e.config.ts b/apps/cli/vitest.e2e.config.ts new file mode 100644 index 00000000..ea886b35 --- /dev/null +++ b/apps/cli/vitest.e2e.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'path' + +const coreRoot = resolve(__dirname, '../../packages/core') + +/** Network/integration CLI tests — not part of default `pnpm test`. */ +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.e2e.ts'], + exclude: ['test/test-mcp.e2e.ts'], + testTimeout: 10 * 60 * 1000, + }, + resolve: { + alias: [ + { find: '@', replacement: resolve(__dirname, './src') }, + { find: '@core', replacement: coreRoot }, + { + find: /^@smm\/core\/(.+)$/, + replacement: `${coreRoot}/$1`, + }, + { + find: '@smm/core', + replacement: resolve(coreRoot, 'types.ts'), + }, + { + find: '@smm/test', + replacement: resolve(__dirname, '../../packages/test/src/index.ts'), + }, + { + find: 'core-app', + replacement: resolve(__dirname, '../core/src/index.ts'), + }, + ], + }, +}) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 8e336ca0..d6423212 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -208,8 +208,8 @@ describe("Core", () => { expect(job?.kind).toBe("import-library"); if (job?.kind !== "import-library") return; expect(job.status).toBe("succeeded"); - expect(job.totalCount).toBe(2); - expect(job.importedCount).toBe(2); + expect(job.tasks).toHaveLength(2); + expect(job.tasks.every((task) => task.status === "succeeded")).toBe(true); const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); expect(savedConfig.folders).toEqual(expect.arrayContaining(["/lib/Show1", "/lib/Show2"])); @@ -290,8 +290,8 @@ describe("Core", () => { const job = core.getJob(id); if (job?.kind !== "import-library") throw new Error("expected import-library job"); - expect(job.totalCount).toBe(1); - expect(job.folderPaths).toEqual(["/lib/Show2"]); + expect(job.tasks).toHaveLength(1); + expect(job.tasks[0]?.path).toBe("/lib/Show2"); }); it("importLibrary marks the job failed when the library path is missing", async () => { @@ -308,6 +308,51 @@ describe("Core", () => { expect(job?.status).toBe("failed"); expect(job?.error).toContain("Library path not found"); }); + + it("importFolder emits mediaMetadataUpdated after persist (not on skipInit blank metadata)", async () => { + const fs = inMemoryFs({ "/m/Show/ep.mkv": "" }); + const updated: string[] = []; + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + core.on("mediaMetadataUpdated", (data) => { + if (data.folderPath) updated.push(data.folderPath); + }); + + const { id: fullId } = core.importFolder("/m/Show", "music"); + await waitForStatus(core, fullId, "succeeded"); + expect(updated).toEqual(["/m/Show"]); + + updated.length = 0; + const { id: skipId } = core.importFolder("/m/Deferred", "tvshow", { skipInit: true }); + await waitForStatus(core, skipId, "succeeded"); + expect(updated).toEqual([]); + }); + + it("importLibrary emits mediaMetadataUpdated once per folder after importFolder persist", async () => { + const fs = inMemoryFs({ + "/lib/Show1/a.mp3": "", + "/lib/Show2/a.mp3": "", + }); + const updated: string[] = []; + const core = new Core({ + fs, + network: emptyNetwork(), + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + }); + core.on("mediaMetadataUpdated", (data) => { + if (data.folderPath) updated.push(data.folderPath); + }); + + const { id } = core.importLibrary("/lib", "music"); + await waitForStatus(core, id, "succeeded"); + + expect(updated.sort()).toEqual(["/lib/Show1", "/lib/Show2"].sort()); + }); }); describe("getAppConfig", () => { diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index f4d6e3a9..3fee3e68 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -26,6 +26,12 @@ import type { NetworkPort } from "./ports/NetworkPort"; import type { LoggerPort } from "./ports/LoggerPort"; import type { DiscoverPort } from "./ports/DiscoverPort"; import type { McpServerPort, McpServerState } from "./ports/McpServerPort"; +import { + CoreEventBus, + MEDIA_METADATA_UPDATED_EVENT, + type CoreEventMap, + type CoreEventName, +} from "./coreEvents"; import { getMcpServerStatusWithConfig, startMcpServerWithConfig, @@ -37,7 +43,7 @@ import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; import { TvdbClient } from "./clients/TvdbClient"; import { createBlankMediaMetadata, ImportFolderPipeline } from "./pipeline/importFolderPipeline"; -import { dedupLibraryFolders, prepareLibraryFoldersForImport } from "./pipeline/importLibrary"; +import { dedupLibraryFolders, prepareLibraryFoldersForImport, createImportLibraryTasks, patchImportLibraryTask, importLibraryJobProgress } from "./pipeline/importLibrary"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; import { renameEpisodeFilePipeline, @@ -191,6 +197,7 @@ export class Core { private readonly mediaMetadata: MediaMetadataHelper; private readonly discover?: DiscoverPort; private readonly mcpServer?: McpServerPort; + private readonly eventBus = new CoreEventBus(); constructor(options: CoreOptions) { this.fs = options.fs; @@ -211,6 +218,24 @@ export class Core { this.mcpServer = options.mcpServer; } + on(event: E, listener: (data: CoreEventMap[E]) => void): void { + this.eventBus.on(event, listener); + } + + off(event: E, listener: (data: CoreEventMap[E]) => void): void { + this.eventBus.off(event, listener); + } + + once(event: E, listener: (data: CoreEventMap[E]) => void): void { + this.eventBus.once(event, listener); + } + + private notifyMediaMetadataUpdated(folderPath: string): void { + this.eventBus.emit(MEDIA_METADATA_UPDATED_EVENT, { + folderPath: this.normalizePosix(folderPath), + }); + } + private requireMcpServer(): McpServerPort { if (!this.mcpServer) { throw new Error("MCP server port is not configured"); @@ -280,13 +305,15 @@ export class Core { kind: "import-library", libraryPath, type, - status: "running", + status: "pending", progress: 0, - folderPaths: [], - importedCount: 0, - totalCount: 0, + tasks: [], }); void this.runImportLibrary(job, path, type, options?.skipInit === true); + this.logger.info( + { jobId: job.id, libraryPath, type, skipInit: options?.skipInit === true }, + "importLibrary: job created", + ); return { id: job.id }; } @@ -756,6 +783,7 @@ export class Core { { skipRegistration: options?.skipRegistration === true }, ); this.jobs.update(job.id, { status: "succeeded", stage: null, progress: 100 }); + this.notifyMediaMetadataUpdated(folderPath); } catch (error) { this.jobs.update(job.id, { status: "failed", @@ -777,11 +805,12 @@ export class Core { const subdirs = await this.fs.listSubdirectories(libraryPath); const existing = await this.getFolders(); const toImport = dedupLibraryFolders(subdirs, existing); - this.jobs.update(job.id, { - folderPaths: toImport, - totalCount: toImport.length, - importedCount: 0, - }); + const tasks = createImportLibraryTasks(job.id, toImport); + this.jobs.update(job.id, { tasks }); + this.logger.info( + { jobId: job.id, libraryPath, folderCount: toImport.length, folderPaths: toImport }, + "importLibrary: folders discovered", + ); await prepareLibraryFoldersForImport(toImport, type, { writeBlankMetadata: (metadata) => this.mediaMetadata.write(metadata), @@ -792,46 +821,74 @@ export class Core { })); }, }); + this.logger.info( + { jobId: job.id, folderCount: toImport.length }, + "importLibrary: folder registration complete (metadata + UserConfig)", + ); if (skipInit) { + const succeededTasks = tasks.map((task) => ({ + ...task, + status: "succeeded" as const, + importJobId: undefined, + })); this.jobs.update(job.id, { status: "succeeded", progress: 100, - currentFolderPath: undefined, - currentFolderJobId: undefined, + tasks: succeededTasks, }); return; } - for (let i = 0; i < toImport.length; i++) { - const folder = toImport[i]!; - const { id: childId } = this.importFolder(folder, type, { skipRegistration: true }); + this.jobs.update(job.id, { status: "running" }); + let currentTasks = tasks; + + for (const task of tasks) { + currentTasks = patchImportLibraryTask(currentTasks, task.id, { + status: "running", + }); this.jobs.update(job.id, { - currentFolderPath: folder, - currentFolderJobId: childId, - progress: toImport.length === 0 ? 100 : Math.floor((i / toImport.length) * 100), + tasks: currentTasks, + progress: importLibraryJobProgress(currentTasks), }); + + const { id: childId } = this.importFolder(task.path, type, { skipRegistration: true }); + currentTasks = patchImportLibraryTask(currentTasks, task.id, { importJobId: childId }); + this.jobs.update(job.id, { tasks: currentTasks }); + await this.waitForImportJob(childId); const childJob = this.jobs.get(childId); if (childJob?.kind === "import" && childJob.status === "failed") { - throw new Error(childJob.error ?? `Failed to import folder: ${folder}`); + currentTasks = patchImportLibraryTask(currentTasks, task.id, { + status: "failed", + importJobId: undefined, + }); + this.jobs.update(job.id, { tasks: currentTasks }); + throw new Error(childJob.error ?? `Failed to import folder: ${task.path}`); } + + currentTasks = patchImportLibraryTask(currentTasks, task.id, { + status: "succeeded", + importJobId: undefined, + }); this.jobs.update(job.id, { - importedCount: i + 1, - progress: Math.floor(((i + 1) / toImport.length) * 100), + tasks: currentTasks, + progress: importLibraryJobProgress(currentTasks), }); } this.jobs.update(job.id, { status: "succeeded", progress: 100, - currentFolderPath: undefined, - currentFolderJobId: undefined, + tasks: currentTasks, }); + this.logger.info({ jobId: job.id, taskCount: tasks.length }, "importLibrary: job succeeded"); } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ jobId: job.id, libraryPath, error: message }, "importLibrary: job failed"); this.jobs.update(job.id, { status: "failed", - error: error instanceof Error ? error.message : String(error), + error: message, }); } } diff --git a/apps/core/src/coreEvents.ts b/apps/core/src/coreEvents.ts new file mode 100644 index 00000000..54afc6f8 --- /dev/null +++ b/apps/core/src/coreEvents.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from "node:events"; +import { + MEDIA_METADATA_UPDATED_EVENT, + type MediaMetadataUpdatedEventData, +} from "@core/event-types"; + +export { MEDIA_METADATA_UPDATED_EVENT }; + +export interface CoreEventMap { + [MEDIA_METADATA_UPDATED_EVENT]: MediaMetadataUpdatedEventData; +} + +export type CoreEventName = keyof CoreEventMap; + +export class CoreEventBus { + private readonly emitter = new EventEmitter(); + + on(event: E, listener: (data: CoreEventMap[E]) => void): void { + this.emitter.on(event, listener); + } + + off(event: E, listener: (data: CoreEventMap[E]) => void): void { + this.emitter.off(event, listener); + } + + once(event: E, listener: (data: CoreEventMap[E]) => void): void { + this.emitter.once(event, listener); + } + + emit(event: E, data: CoreEventMap[E]): void { + this.emitter.emit(event, data); + } +} diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index b0004473..8d3874da 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -19,6 +19,12 @@ export { export type { FsPort } from "./ports/FsPort"; export type { NetworkPort, FetchInit, HttpResponse } from "./ports/NetworkPort"; export type { LoggerPort } from "./ports/LoggerPort"; +export { + CoreEventBus, + MEDIA_METADATA_UPDATED_EVENT, + type CoreEventMap, + type CoreEventName, +} from "./coreEvents"; export type { DiscoverPort, DiscoverConfig, @@ -86,6 +92,7 @@ export { parseNfo, type ParsedNfo } from "./pipeline/nfo"; export type { ImportJob, ImportLibraryJob, + ImportLibraryJobTask, ScrapeJob, Job, JobStatus, diff --git a/apps/core/src/jobs/types.ts b/apps/core/src/jobs/types.ts index d66c6465..ee2f3b64 100644 --- a/apps/core/src/jobs/types.ts +++ b/apps/core/src/jobs/types.ts @@ -1,6 +1,12 @@ import type { FolderType } from "@smm/core"; +import type { + ImportLibraryJob as ImportLibraryJobPayload, + ImportLibraryJobTask, +} from "@smm/core/job/ImportLibraryJob"; import type { ScrapeTaskId } from "../pipeline/scrape/types"; +export type { ImportLibraryJobTask } from "@smm/core/job/ImportLibraryJob"; + export type JobStatus = "pending" | "running" | "succeeded" | "failed" | "aborted"; export type JobStage = "config" | "metadata" | "listFiles" | "recognize" | "episodes" | "persist" | null; @@ -19,21 +25,8 @@ export interface ImportJob { updatedAt: number; } -export interface ImportLibraryJob { +export interface ImportLibraryJob extends ImportLibraryJobPayload { kind: "import-library"; - id: string; - libraryPath: string; - type: FolderType; - status: JobStatus; - progress: number; - folderPaths: string[]; - importedCount: number; - totalCount: number; - currentFolderPath?: string; - currentFolderJobId?: string; - error?: string; - createdAt: number; - updatedAt: number; } export type ScrapeTaskRuntimeStatus = diff --git a/apps/core/src/pipeline/importLibrary.test.ts b/apps/core/src/pipeline/importLibrary.test.ts index f9243310..9cddab10 100644 --- a/apps/core/src/pipeline/importLibrary.test.ts +++ b/apps/core/src/pipeline/importLibrary.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { dedupLibraryFolders, prepareLibraryFoldersForImport } from './importLibrary' +import { + createImportLibraryTasks, + dedupLibraryFolders, + importLibraryJobProgress, + prepareLibraryFoldersForImport, +} from './importLibrary' describe('dedupLibraryFolders', () => { it('removes folders already imported (POSIX comparison)', () => { @@ -45,3 +50,24 @@ describe('prepareLibraryFoldersForImport', () => { expect(upsertFolders).not.toHaveBeenCalled() }) }) + +describe('createImportLibraryTasks', () => { + it('creates pending tasks for each folder path', () => { + const tasks = createImportLibraryTasks('job-1', ['/lib/A', '/lib/B']) + expect(tasks).toEqual([ + { id: 'job-1-task-0', path: '/lib/A', status: 'pending' }, + { id: 'job-1-task-1', path: '/lib/B', status: 'pending' }, + ]) + }) +}) + +describe('importLibraryJobProgress', () => { + it('returns percentage of completed tasks', () => { + expect( + importLibraryJobProgress([ + { id: '1', path: '/a', status: 'succeeded' }, + { id: '2', path: '/b', status: 'pending' }, + ]), + ).toBe(50) + }) +}) diff --git a/apps/core/src/pipeline/importLibrary.ts b/apps/core/src/pipeline/importLibrary.ts index 2aae1cf0..763ed46e 100644 --- a/apps/core/src/pipeline/importLibrary.ts +++ b/apps/core/src/pipeline/importLibrary.ts @@ -1,5 +1,6 @@ import { Path } from "@core/path"; import type { FolderType } from "@smm/core"; +import type { ImportLibraryJobTask } from "../jobs/types"; import { createBlankMediaMetadata } from "./importFolderPipeline"; import type { PersistedMediaMetadata } from "./mediaMetadataValidation"; @@ -9,6 +10,32 @@ export function dedupLibraryFolders(newFolders: string[], existingFolderPaths: s return newFolders.filter((folder) => !importedPosix.has(Path.posix(folder))); } +export function createImportLibraryTasks(jobId: string, folderPaths: string[]): ImportLibraryJobTask[] { + return folderPaths.map((path, index) => ({ + id: `${jobId}-task-${index}`, + path, + status: "pending" as const, + })); +} + +export function patchImportLibraryTask( + tasks: ImportLibraryJobTask[], + taskId: string, + patch: Partial, +): ImportLibraryJobTask[] { + return tasks.map((task) => (task.id === taskId ? { ...task, ...patch } : task)); +} + +export function importLibraryJobProgress(tasks: ImportLibraryJobTask[]): number { + if (tasks.length === 0) return 100; + const completed = tasks.filter((task) => task.status === "succeeded" || task.status === "failed").length; + return Math.floor((completed / tasks.length) * 100); +} + +export function importLibraryTaskPaths(tasks: ImportLibraryJobTask[]): string[] { + return tasks.map((task) => task.path); +} + export interface PrepareLibraryFoldersDeps { writeBlankMetadata: (metadata: PersistedMediaMetadata) => Promise; upsertFolders: (folders: string[]) => Promise; diff --git a/apps/ui/src/api/getJob.ts b/apps/ui/src/api/getJob.ts index ee8b8f79..7f137197 100644 --- a/apps/ui/src/api/getJob.ts +++ b/apps/ui/src/api/getJob.ts @@ -1,6 +1,11 @@ import { apiFetch } from '@/lib/apiFetch' +import type { + ImportLibraryJob as ImportLibraryJobBody, +} from '@smm/core/job/ImportLibraryJob' import type { ScrapeTaskId } from '@/lib/scrapeDialog' +export type { ImportLibraryJobTask } from '@smm/core/job/ImportLibraryJob' + export type JobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted' export type ScrapeTaskRuntimeStatus = @@ -40,21 +45,8 @@ export interface ImportJob { updatedAt: number } -export interface ImportLibraryJob { +export interface ImportLibraryJob extends ImportLibraryJobBody { kind: 'import-library' - id: string - libraryPath: string - type: string - status: JobStatus - progress: number - folderPaths: string[] - importedCount: number - totalCount: number - currentFolderPath?: string - currentFolderJobId?: string - error?: string - createdAt: number - updatedAt: number } export type Job = ImportJob | ImportLibraryJob | ScrapeJob diff --git a/apps/ui/src/api/importLibrary.ts b/apps/ui/src/api/importLibrary.ts index 28989277..7d63ce2f 100644 --- a/apps/ui/src/api/importLibrary.ts +++ b/apps/ui/src/api/importLibrary.ts @@ -5,6 +5,8 @@ export interface ImportLibraryParams { path: string type: FolderType | 'anime' skipInit?: boolean + /** Correlates client logs for import-library flow. */ + traceId?: string } export interface ImportLibraryResponseBody { @@ -17,14 +19,19 @@ export async function importLibrary( params: ImportLibraryParams, signal?: AbortSignal, ): Promise { + const { traceId, path, type, skipInit } = params const body: Record = { - path: params.path, - type: params.type, + path, + type, } - if (params.skipInit === true) { + if (skipInit === true) { body.skipInit = true } + if (traceId) { + console.log(`[${traceId}] import-library: POST /api/import-library`, { path, type, skipInit: skipInit === true }) + } + const resp = await apiFetch('/api/import-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -36,7 +43,14 @@ export async function importLibrary( throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) } - return (await resp.json()) as ImportLibraryResponseBody + const data = (await resp.json()) as ImportLibraryResponseBody + if (traceId) { + console.log(`[${traceId}] import-library: POST /api/import-library response`, { + jobId: data.data?.id, + error: data.error, + }) + } + return data } /** Throws on business error; returns job id. */ diff --git a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts index 30e8d492..9430a5d6 100644 --- a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts +++ b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.test.ts @@ -11,15 +11,28 @@ const { importLibraryViaCoreMock } = vi.hoisted(() => ({ importLibraryViaCoreMock: vi.fn(), })) -const { pollImportLibraryJobMock, buildUiFoldersFromShowFolderMock } = vi.hoisted(() => ({ +const { getJobViaCoreMock } = vi.hoisted(() => ({ + getJobViaCoreMock: vi.fn(), +})) + +const { + pollImportLibraryJobMock, + buildUiFoldersFromShowFolderMock, + waitForLibraryFoldersRegisteredMock, +} = vi.hoisted(() => ({ pollImportLibraryJobMock: vi.fn(), buildUiFoldersFromShowFolderMock: vi.fn(), + waitForLibraryFoldersRegisteredMock: vi.fn(), })) const { refreshUserConfigMock } = vi.hoisted(() => ({ refreshUserConfigMock: vi.fn().mockResolvedValue(undefined), })) +const { invalidateFoldersQueryIfV3Mock } = vi.hoisted(() => ({ + invalidateFoldersQueryIfV3Mock: vi.fn(), +})) + const { addJobMock, updateJobMock } = vi.hoisted(() => ({ addJobMock: vi.fn(() => "job-1"), updateJobMock: vi.fn(), @@ -33,12 +46,17 @@ vi.mock("@/api/importLibrary", () => ({ importLibraryViaCore: importLibraryViaCoreMock, })) +vi.mock("@/api/getJob", () => ({ + getJobViaCore: getJobViaCoreMock, +})) + vi.mock("@/lib/importLibraryV3", async (importOriginal) => { const actual = await importOriginal() return { ...actual, pollImportLibraryJob: pollImportLibraryJobMock, buildUiFoldersFromShowFolder: buildUiFoldersFromShowFolderMock, + waitForLibraryFoldersRegistered: waitForLibraryFoldersRegisteredMock, } }) @@ -46,6 +64,10 @@ vi.mock("@/hooks/userConfig", () => ({ useRefreshUserConfig: () => refreshUserConfigMock, })) +vi.mock("@/hooks/folders", () => ({ + invalidateFoldersQueryIfV3: invalidateFoldersQueryIfV3Mock, +})) + vi.mock("@/hooks/useJobManager", () => ({ useJobManager: () => ({ addJob: addJobMock, @@ -57,6 +79,31 @@ import { MediaLibraryImportedEventHandler } from "./MediaLibraryImportedEventHan import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { UI_MediaLibraryImportedEvent, type OnMediaLibraryImportedEventData } from "@/types/eventTypes" +function makeImportLibraryJob( + overrides: Partial<{ + id: string + libraryPath: string + type: "tvshow" | "music" | "movie" + status: "pending" | "running" | "succeeded" | "failed" + progress: number + tasks: Array<{ id: string; path: string; status: "pending" | "running" | "succeeded" | "failed" }> + error?: string + }> = {}, +) { + return { + kind: "import-library" as const, + id: "core-job-1", + libraryPath: "/media/library", + type: "tvshow" as const, + status: "succeeded" as const, + progress: 100, + tasks: [], + createdAt: 0, + updatedAt: 0, + ...overrides, + } +} + describe("MediaLibraryImportedEventHandler", () => { function renderHandler() { const queryClient = new QueryClient({ @@ -69,14 +116,18 @@ describe("MediaLibraryImportedEventHandler", () => { createElement(MediaLibraryImportedEventHandler), ), ) + return queryClient } beforeEach(() => { importLibraryViaCoreMock.mockReset() + getJobViaCoreMock.mockReset() pollImportLibraryJobMock.mockReset() buildUiFoldersFromShowFolderMock.mockReset() + waitForLibraryFoldersRegisteredMock.mockReset() refreshUserConfigMock.mockReset() refreshUserConfigMock.mockResolvedValue(undefined) + invalidateFoldersQueryIfV3Mock.mockReset() persistHarmonyOSFileAccessMock.mockReset() persistHarmonyOSFileAccessMock.mockResolvedValue(undefined) addJobMock.mockReset() @@ -88,28 +139,30 @@ describe("MediaLibraryImportedEventHandler", () => { }) }) - it("imports media library via Core and updates folder store", async () => { + it("refreshes sidebar after registration and before polling the import job", async () => { const folderB = "/media/library/library-folder-B" const libraryPath = "/media/library" importLibraryViaCoreMock.mockResolvedValue("core-job-1") - pollImportLibraryJobMock.mockResolvedValue({ - kind: "import-library", - id: "core-job-1", - libraryPath, - type: "tvshow", - status: "succeeded", - progress: 100, - folderPaths: [folderB], - importedCount: 1, - totalCount: 1, - createdAt: 0, - updatedAt: 0, - }) + waitForLibraryFoldersRegisteredMock.mockResolvedValue([folderB]) + getJobViaCoreMock.mockResolvedValue( + makeImportLibraryJob({ + libraryPath, + tasks: [{ id: "t0", path: folderB, status: "pending" }], + status: "running", + progress: 0, + }), + ) + pollImportLibraryJobMock.mockResolvedValue( + makeImportLibraryJob({ + libraryPath, + tasks: [{ id: "t0", path: folderB, status: "succeeded" }], + }), + ) buildUiFoldersFromShowFolderMock.mockResolvedValue([ { path: folderB, status: "ok", type: "tvshow-folder" }, ]) - renderHandler() + const queryClient = renderHandler() document.dispatchEvent( new CustomEvent(UI_MediaLibraryImportedEvent, { @@ -125,36 +178,103 @@ describe("MediaLibraryImportedEventHandler", () => { expect(importLibraryViaCoreMock).toHaveBeenCalledWith({ path: libraryPath, type: "tvshow", + traceId: "test-trace", }) }) expect(persistHarmonyOSFileAccessMock).toHaveBeenCalledWith([libraryPath]) - expect(pollImportLibraryJobMock).toHaveBeenCalled() + expect(waitForLibraryFoldersRegisteredMock).toHaveBeenCalledWith("core-job-1", { traceId: "test-trace" }) expect(refreshUserConfigMock).toHaveBeenCalled() - expect(buildUiFoldersFromShowFolderMock).toHaveBeenCalledWith([folderB]) + expect(invalidateFoldersQueryIfV3Mock).toHaveBeenCalledWith(queryClient) + expect(getJobViaCoreMock).toHaveBeenCalledWith("core-job-1") + + const refreshOrder = refreshUserConfigMock.mock.invocationCallOrder[0]! + const pollOrder = pollImportLibraryJobMock.mock.invocationCallOrder[0]! + expect(refreshOrder).toBeLessThan(pollOrder) + + expect(pollImportLibraryJobMock).toHaveBeenCalled() + expect(buildUiFoldersFromShowFolderMock).toHaveBeenCalledWith([folderB], { traceId: "test-trace" }) const folders = useUIMediaFolderStore.getState().folders expect(folders.map((f) => f.path)).toContain(folderB) expect(updateJobMock).toHaveBeenCalledWith("job-1", { status: "succeeded", progress: 100 }) }) - it("marks background job failed when Core import-library fails", async () => { + it("syncs per-folder status from import-library job tasks", async () => { + const folderA = "/media/library/A" const libraryPath = "/media/library" importLibraryViaCoreMock.mockResolvedValue("core-job-1") - pollImportLibraryJobMock.mockResolvedValue({ - kind: "import-library", - id: "core-job-1", - libraryPath, - type: "movie", - status: "failed", - progress: 0, - folderPaths: [], - importedCount: 0, - totalCount: 1, - error: "Library path not found", - createdAt: 0, - updatedAt: 0, + waitForLibraryFoldersRegisteredMock.mockResolvedValue([folderA]) + getJobViaCoreMock.mockResolvedValue( + makeImportLibraryJob({ + libraryPath, + type: "music", + tasks: [{ id: "t0", path: folderA, status: "running" }], + status: "running", + progress: 0, + }), + ) + + let resolvePoll!: (value: unknown) => void + const pollDeferred = new Promise((resolve) => { + resolvePoll = resolve + }) + pollImportLibraryJobMock.mockImplementation(async () => { + await pollDeferred + return makeImportLibraryJob({ + libraryPath, + type: "music", + tasks: [{ id: "t0", path: folderA, status: "succeeded" }], + }) }) + buildUiFoldersFromShowFolderMock.mockResolvedValue([ + { path: folderA, status: "ok", type: "music-folder" }, + ]) + + renderHandler() + + document.dispatchEvent( + new CustomEvent(UI_MediaLibraryImportedEvent, { + detail: { + libraryPathInPlatformFormat: libraryPath, + type: "music", + } satisfies OnMediaLibraryImportedEventData, + }), + ) + + await vi.waitFor(() => { + const folder = useUIMediaFolderStore.getState().folders.find((f) => f.path === folderA) + expect(folder?.status).toBe("initializing") + }) + + resolvePoll(undefined) + await vi.waitFor(() => { + expect(updateJobMock).toHaveBeenCalledWith("job-1", { status: "succeeded", progress: 100 }) + }) + }) + + it("marks background job failed when Core import-library fails", async () => { + const libraryPath = "/media/library" + importLibraryViaCoreMock.mockResolvedValue("core-job-1") + waitForLibraryFoldersRegisteredMock.mockResolvedValue([]) + getJobViaCoreMock.mockResolvedValue( + makeImportLibraryJob({ + libraryPath, + type: "movie", + status: "failed", + progress: 0, + error: "Library path not found", + }), + ) + pollImportLibraryJobMock.mockResolvedValue( + makeImportLibraryJob({ + libraryPath, + type: "movie", + status: "failed", + progress: 0, + error: "Library path not found", + }), + ) renderHandler() diff --git a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx index d59959ba..bd747030 100644 --- a/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx +++ b/apps/ui/src/components/eventlisteners/MediaLibraryImportedEventHandler.tsx @@ -2,20 +2,24 @@ import { useEffect, useRef } from "react" import { useMount, useUnmount } from "react-use" import { useQueryClient } from "@tanstack/react-query" import { importLibraryViaCore } from "@/api/importLibrary" +import { getJobViaCore } from "@/api/getJob" import { useJobManager } from "@/hooks/useJobManager" import { nextTraceId } from "@/lib/utils" import { UI_MediaLibraryImportedEvent, type OnMediaLibraryImportedEventData } from "@/types/eventTypes" import debug from "debug" import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" import { useRefreshUserConfig } from "@/hooks/userConfig" +import { invalidateFoldersQueryIfV3 } from "@/hooks/folders" import { persistHarmonyOSFileAccess } from "@/lib/persistHarmonyOSFileAccess" import { toast } from "sonner" import { useTranslation } from "@/lib/i18n" -import { foldersQueryKey } from "@/hooks/folders/foldersQueryKeys" +import { importLibraryLog } from "@/lib/importLibraryLog" import { buildUiFoldersFromShowFolder, folderTypeToMediaType, pollImportLibraryJob, + syncSidebarFromImportLibraryJob, + waitForLibraryFoldersRegistered, } from "@/lib/importLibraryV3" export function MediaLibraryImportedEventHandler() { @@ -31,46 +35,78 @@ export function MediaLibraryImportedEventHandler() { const doImportMediaLibrary = async (data: OnMediaLibraryImportedEventData) => { const { libraryPathInPlatformFormat, type } = data - const traceIdBase = data.traceId || `MediaLibraryImportedEventHandler:${nextTraceId()}` - console.log(`[${traceIdBase}] start import media library: ${JSON.stringify(data)}`) + const traceId = data.traceId || `MediaLibraryImportedEventHandler:${nextTraceId()}` + const trace = { traceId } + const mediaType = folderTypeToMediaType(type) + + importLibraryLog(trace, "user import library", { + libraryPath: libraryPathInPlatformFormat, + type, + }) debug(`start ${UI_MediaLibraryImportedEvent}: ${JSON.stringify(data)}`) await persistHarmonyOSFileAccess([libraryPathInPlatformFormat]) + importLibraryLog(trace, "HarmonyOS file access persisted") const jobId = addJob(tRef.current("statusBar.backgroundJobs.jobNames.importMediaLibrary")) updateJob(jobId, { status: "running", progress: 0 }) + importLibraryLog(trace, "background job running", { uiJobId: jobId }) try { const coreJobId = await importLibraryViaCore({ path: libraryPathInPlatformFormat, type, + traceId, }) + importLibraryLog(trace, "received core job id", { coreJobId, uiJobId: jobId }) - const finalJob = await pollImportLibraryJob(coreJobId, (job) => { - updateJob(jobId, { progress: job.progress }) - if (job.currentFolderPath) { - upsertFolder({ - path: job.currentFolderPath, - status: "initializing", - type: folderTypeToMediaType(type), - }) - } + const registeredPaths = await waitForLibraryFoldersRegistered(coreJobId, trace) + await refreshUserConfig() + invalidateFoldersQueryIfV3(queryClient) + importLibraryLog(trace, "sidebar folder list refreshed", { + folderCount: registeredPaths.length, + folderPaths: registeredPaths, }) + const prepJob = await getJobViaCore(coreJobId) + if (prepJob.kind === "import-library") { + syncSidebarFromImportLibraryJob(prepJob, upsertFolder, mediaType) + } + + const finalJob = await pollImportLibraryJob( + coreJobId, + (job) => { + updateJob(jobId, { progress: job.progress }) + syncSidebarFromImportLibraryJob(job, upsertFolder, mediaType) + }, + trace, + ) + if (finalJob.status !== "succeeded") { throw new Error(finalJob.error ?? "Import media library failed") } - await refreshUserConfig() - const importedFolders = await buildUiFoldersFromShowFolder(finalJob.folderPaths) + const importedFolders = await buildUiFoldersFromShowFolder( + finalJob.tasks.map((task) => task.path), + trace, + ) for (const folder of importedFolders) { upsertFolder(folder) } - void queryClient.invalidateQueries({ queryKey: foldersQueryKey }) + await refreshUserConfig() + invalidateFoldersQueryIfV3(queryClient) + importLibraryLog(trace, "sidebar folder status synced after import", { + folderCount: importedFolders.length, + }) updateJob(jobId, { status: "succeeded", progress: 100 }) + importLibraryLog(trace, "background job succeeded", { uiJobId: jobId, coreJobId }) } catch (error) { - console.error(`[${traceIdBase}] import media library failed:`, error) + console.error(`[${traceId}] import-library: failed`, error) + importLibraryLog(trace, "background job failed", { + uiJobId: jobId, + error: error instanceof Error ? error.message : String(error), + }) updateJob(jobId, { status: "failed" }) toast.error(error instanceof Error ? error.message : "导入媒体库失败") } @@ -78,8 +114,13 @@ export function MediaLibraryImportedEventHandler() { useMount(() => { eventListener.current = (event) => { - debug(`received ${UI_MediaLibraryImportedEvent} event`) const data = (event as CustomEvent).detail + importLibraryLog( + data.traceId ? { traceId: data.traceId } : undefined, + "received import library event", + { libraryPath: data.libraryPathInPlatformFormat, type: data.type }, + ) + debug(`received ${UI_MediaLibraryImportedEvent} event`) void doImportMediaLibrary(data) } diff --git a/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.test.tsx b/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.test.tsx new file mode 100644 index 00000000..9ba629ef --- /dev/null +++ b/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act } from "@testing-library/react" +import { useEffect } from "react" +import { MEDIA_METADATA_UPDATED_EVENT } from "@core/event-types" +import { MediaMetadataUpdatedEventListener } from "./MediaMetadataUpdatedEventListener" +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore" + +const fetchMediaMetadataMock = vi.fn() +const refreshUserConfigMock = vi.fn() + +vi.mock("@/hooks/mediaMetadata/useFetchMediaMetadataMutation", () => ({ + useFetchMediaMetadataMutation: () => ({ + mutateAsync: fetchMediaMetadataMock, + }), +})) + +vi.mock("@/hooks/userConfig", () => ({ + useConfig: () => ({ + refreshUserConfig: refreshUserConfigMock, + }), +})) + +vi.mock("react-use", () => ({ + useMount: (cb: () => void) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(cb, []) + }, + useUnmount: (cb: () => void) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => () => cb(), []) + }, +})) + +describe("MediaMetadataUpdatedEventListener", () => { + beforeEach(() => { + fetchMediaMetadataMock.mockReset() + refreshUserConfigMock.mockReset() + fetchMediaMetadataMock.mockResolvedValue(undefined) + useUIMediaFolderStore.setState({ + folders: [], + selectedFolder: "", + selectedFolders: [], + }) + }) + + it("refetches metadata when the event folder matches the selected sidebar folder", async () => { + useUIMediaFolderStore.setState({ selectedFolder: "/media/ShowA" }) + render() + + await act(async () => { + document.dispatchEvent( + new CustomEvent(`socket.io_${MEDIA_METADATA_UPDATED_EVENT}`, { + detail: { folderPath: "/media/ShowA" }, + }), + ) + }) + + expect(fetchMediaMetadataMock).toHaveBeenCalledWith({ path: "/media/ShowA" }) + }) + + it("ignores metadata updates for folders that are not selected", async () => { + useUIMediaFolderStore.setState({ selectedFolder: "/media/ShowA" }) + render() + + await act(async () => { + document.dispatchEvent( + new CustomEvent(`socket.io_${MEDIA_METADATA_UPDATED_EVENT}`, { + detail: { folderPath: "/media/ShowB" }, + }), + ) + }) + + expect(fetchMediaMetadataMock).not.toHaveBeenCalled() + }) + + it("refreshes user config when folderPath is missing", async () => { + render() + + await act(async () => { + document.dispatchEvent( + new CustomEvent(`socket.io_${MEDIA_METADATA_UPDATED_EVENT}`, { + detail: {}, + }), + ) + }) + + expect(fetchMediaMetadataMock).not.toHaveBeenCalled() + expect(refreshUserConfigMock).toHaveBeenCalled() + }) +}) diff --git a/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx b/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx index 5f58ebc2..a5a17279 100644 --- a/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx +++ b/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx @@ -3,10 +3,15 @@ import { useMount, useUnmount } from "react-use"; import { MEDIA_METADATA_UPDATED_EVENT } from "@core/event-types"; import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation"; import { useConfig } from "@/hooks/userConfig"; +import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys"; +import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore"; export function MediaMetadataUpdatedEventListener() { const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation(); const { refreshUserConfig } = useConfig(); + const selectedFolder = useUIMediaFolderStore((s) => s.selectedFolder); + const selectedFolderRef = useRef(selectedFolder); + selectedFolderRef.current = selectedFolder; // eslint-disable-next-line @typescript-eslint/no-explicit-any const eventListener = useRef<((event: any) => void) | null>(null); @@ -14,6 +19,14 @@ export function MediaMetadataUpdatedEventListener() { eventListener.current = async (event) => { const folderPath = event.detail?.folderPath; if (folderPath) { + const selected = selectedFolderRef.current?.trim(); + if ( + !selected || + normalizeMediaFolderPathForQuery(selected) !== + normalizeMediaFolderPathForQuery(folderPath) + ) { + return; + } console.log(`[MediaMetadataUpdatedEventListener] Received mediaMetadataUpdated event for folder: ${folderPath}`); try { await fetchMediaMetadata({ path: folderPath }); diff --git a/apps/ui/src/lib/importLibraryLog.ts b/apps/ui/src/lib/importLibraryLog.ts new file mode 100644 index 00000000..e2a6d5c3 --- /dev/null +++ b/apps/ui/src/lib/importLibraryLog.ts @@ -0,0 +1,22 @@ +import { logger } from '@/lib/log' + +export interface ImportLibraryTrace { + traceId: string +} + +/** Structured + console logs for import-library flow (grep by traceId). */ +export function importLibraryLog( + trace: ImportLibraryTrace | undefined, + step: string, + details?: Record, +): void { + if (!trace) return + const message = `import-library: ${step}` + if (details !== undefined) { + console.log(`[${trace.traceId}] ${message}`, details) + logger.info({ traceId: trace.traceId, step, ...details }, message) + } else { + console.log(`[${trace.traceId}] ${message}`) + logger.info({ traceId: trace.traceId, step }, message) + } +} diff --git a/apps/ui/src/lib/importLibraryV3.test.ts b/apps/ui/src/lib/importLibraryV3.test.ts new file mode 100644 index 00000000..826a63ce --- /dev/null +++ b/apps/ui/src/lib/importLibraryV3.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { getJobViaCoreMock, getFoldersMock } = vi.hoisted(() => ({ + getJobViaCoreMock: vi.fn(), + getFoldersMock: vi.fn(), +})) + +vi.mock('@/api/getJob', () => ({ + getJobViaCore: getJobViaCoreMock, +})) + +vi.mock('@/api/getFolders', () => ({ + getFolders: getFoldersMock, +})) + +import { + importLibraryTaskStatusToUiStatus, + waitForLibraryFoldersRegistered, +} from './importLibraryV3' + +describe('importLibraryTaskStatusToUiStatus', () => { + it('maps task status to sidebar folder status', () => { + expect(importLibraryTaskStatusToUiStatus('pending')).toBe('pending_for_initialization') + expect(importLibraryTaskStatusToUiStatus('running')).toBe('initializing') + expect(importLibraryTaskStatusToUiStatus('succeeded')).toBe('ok') + expect(importLibraryTaskStatusToUiStatus('failed')).toBe('error_loading_metadata') + }) +}) + +describe('waitForLibraryFoldersRegistered', () => { + beforeEach(() => { + getJobViaCoreMock.mockReset() + getFoldersMock.mockReset() + }) + + it('returns folder paths once they appear in UserConfig', async () => { + getJobViaCoreMock + .mockResolvedValueOnce({ + kind: 'import-library', + tasks: [{ id: 't0', path: '/lib/A', status: 'pending' }], + status: 'pending', + }) + .mockResolvedValue({ + kind: 'import-library', + tasks: [{ id: 't0', path: '/lib/A', status: 'pending' }], + status: 'pending', + }) + getFoldersMock + .mockResolvedValueOnce({ data: { folders: [] } }) + .mockResolvedValue({ data: { folders: ['/lib/A'] } }) + + const paths = await waitForLibraryFoldersRegistered('job-1') + + expect(paths).toEqual(['/lib/A']) + expect(getFoldersMock).toHaveBeenCalled() + }) + + it('returns empty paths when the job fails before registration', async () => { + getJobViaCoreMock.mockResolvedValue({ + kind: 'import-library', + tasks: [], + status: 'failed', + }) + + const paths = await waitForLibraryFoldersRegistered('job-1') + + expect(paths).toEqual([]) + expect(getFoldersMock).not.toHaveBeenCalled() + }) + + it('returns empty paths when the library has no subfolders', async () => { + getJobViaCoreMock.mockResolvedValue({ + kind: 'import-library', + tasks: [], + status: 'succeeded', + }) + + const paths = await waitForLibraryFoldersRegistered('job-1') + + expect(paths).toEqual([]) + }) +}) diff --git a/apps/ui/src/lib/importLibraryV3.ts b/apps/ui/src/lib/importLibraryV3.ts index 1fa116b6..e922e1d3 100644 --- a/apps/ui/src/lib/importLibraryV3.ts +++ b/apps/ui/src/lib/importLibraryV3.ts @@ -1,7 +1,10 @@ -import { getJobViaCore, type ImportLibraryJob } from '@/api/getJob' +import { Path } from '@core/path' +import { getFolders } from '@/api/getFolders' +import { getJobViaCore, type ImportLibraryJob, type ImportLibraryJobTask } from '@/api/getJob' import { showFolderViaCore } from '@/api/showFolder' -import type { UIMediaFolder } from '@/types/UIMediaFolder' +import type { UIMediaFolder, UIMediaFolderStatus } from '@/types/UIMediaFolder' import type { FolderType } from '@core/types' +import { importLibraryLog, type ImportLibraryTrace } from '@/lib/importLibraryLog' const POLL_INTERVAL_MS = 1000 @@ -15,25 +18,159 @@ export function folderTypeToMediaType(type: FolderType): NonNullable void, + mediaType: NonNullable, +): void { + for (const task of job.tasks) { + upsertFolder({ + path: task.path, + status: importLibraryTaskStatusToUiStatus(task.status), + type: mediaType, + }) + } +} + +function importLibraryTaskPaths(job: ImportLibraryJob): string[] { + return job.tasks.map((task) => task.path) +} + +function foldersContainAll(folderPaths: string[], registered: string[]): boolean { + const registeredPosix = new Set(registered.map((p) => Path.posix(p))) + return folderPaths.every((p) => registeredPosix.has(Path.posix(p))) +} + +async function foldersRegisteredInUserConfig( + folderPaths: string[], + signal?: AbortSignal, +): Promise { + const resp = await getFolders(signal) + if (resp.error) { + throw new Error(resp.error) + } + const folders = resp.data?.folders ?? [] + return foldersContainAll(folderPaths, folders) +} + +/** + * Waits until Core has finished Loop #1 (blank metadata + batch UserConfig upsert). + * Returns folder paths from the import-library job tasks. + */ +export async function waitForLibraryFoldersRegistered( + coreJobId: string, + trace?: ImportLibraryTrace, + signal?: AbortSignal, +): Promise { + importLibraryLog(trace, 'wait for folder registration started', { coreJobId }) + let loggedWaitingForUserConfig = false + + for (;;) { + const job = await getJobViaCore(coreJobId, signal) + if (job.kind !== 'import-library') { + throw new Error(`Error Reason: unexpected job kind: ${job.kind}`) + } + + const folderPaths = importLibraryTaskPaths(job) + + if (job.status === 'failed') { + importLibraryLog(trace, 'folder registration skipped (job ended early)', { + coreJobId, + status: job.status, + folderPaths, + error: job.error, + }) + return folderPaths + } + + if (folderPaths.length > 0) { + const registered = await foldersRegisteredInUserConfig(folderPaths, signal) + if (registered) { + importLibraryLog(trace, 'folder registration ready', { + coreJobId, + folderCount: folderPaths.length, + folderPaths, + }) + return folderPaths + } + if (!loggedWaitingForUserConfig) { + importLibraryLog(trace, 'waiting for UserConfig folders after Core prep', { + coreJobId, + folderPaths, + }) + loggedWaitingForUserConfig = true + } + } else if (job.status === 'succeeded') { + importLibraryLog(trace, 'folder registration ready (empty library)', { coreJobId }) + return folderPaths + } + + await sleep(POLL_INTERVAL_MS) + } +} + export async function pollImportLibraryJob( jobId: string, onUpdate: (job: ImportLibraryJob) => void, + trace?: ImportLibraryTrace, signal?: AbortSignal, ): Promise { + importLibraryLog(trace, 'poll import-library job started', { coreJobId: jobId }) + let lastLoggedProgress = -1 + let lastLoggedStatus = '' + for (;;) { const job = await getJobViaCore(jobId, signal) if (job.kind !== 'import-library') { throw new Error(`Error Reason: unexpected job kind: ${job.kind}`) } + + if (job.progress !== lastLoggedProgress || job.status !== lastLoggedStatus) { + importLibraryLog(trace, 'poll import-library job status', { + coreJobId: jobId, + status: job.status, + progress: job.progress, + taskCount: job.tasks.length, + tasks: job.tasks.map((task) => ({ path: task.path, status: task.status })), + }) + lastLoggedProgress = job.progress + lastLoggedStatus = job.status + } + onUpdate(job) - if (job.status === 'succeeded' || job.status === 'failed' || job.status === 'aborted') { + if (job.status === 'succeeded' || job.status === 'failed') { + importLibraryLog(trace, 'poll import-library job finished', { + coreJobId: jobId, + status: job.status, + progress: job.progress, + error: job.error, + }) return job } await sleep(POLL_INTERVAL_MS) } } -export async function buildUiFoldersFromShowFolder(paths: string[]): Promise { +export async function buildUiFoldersFromShowFolder( + paths: string[], + trace?: ImportLibraryTrace, +): Promise { + importLibraryLog(trace, 'resolve folder display status', { folderCount: paths.length }) const folders: UIMediaFolder[] = [] for (const path of paths) { const show = await showFolderViaCore(path) diff --git a/docs/dev/import-library.md b/docs/dev/import-library.md index 993c686c..80f48eb4 100644 --- a/docs/dev/import-library.md +++ b/docs/dev/import-library.md @@ -1,11 +1,11 @@ # Import Library -**Supported Platform** Web UI, CLI, Electron, ohos +**Supported Platform** Web UI, CLI, Electron, ohos **Status** wip ## Core -The function of importing library reuses the function of [importing folder](./import-folder.md). +Reuses [import folder](./import-folder.md). Job shape: [ImportLibraryJob](../../packages/core/job/ImportLibraryJob.ts). ```mermaid sequenceDiagram @@ -13,84 +13,64 @@ sequenceDiagram participant C as Core participant UC as UserConfigHelper participant M as MediaMetadataHelper + participant E as Core events U->>C: importLibrary(path, type) - C->>C: create job + C->>C: create job (pending) + tasks (pending) C->>U: job id - C->>C: read folders in library - loop #1: for each folder - C->>M: create blank metadata file + loop #1 prep + C->>M: blank metadata per folder end - C->>UC: upsert folders in UserConfig at at once - loop import folders - C->>C: importFolder(jobId) + C->>UC: batch upsert folders + C->>C: job → running + loop #2 import each folder + C->>C: task → running → importFolder + C->>C: task → succeeded | failed + C->>E: emit mediaMetadataUpdated end - C->>C: update job status + C->>C: job → succeeded | failed ``` -> #1: this loop supports Web UI to render folder list immediately after user import library. -> Web UI first get folder list from user config. And then get folder status from media metadata. -> So we need to create metadata file before upsert folder in UserConfig -> To avoid Web UI find new folder but fail to get its metadata - +- **Loop #1**: Sidebar can list folders early; blank metadata must exist before UserConfig upsert. +- **Loop #2**: Each `importFolder` persist completion emits `mediaMetadataUpdated` (not Loop #1, not `skipInit` blank writes). Host subscribes via `core.on(...)` and forwards to Socket.IO. ## CLI ```bash -smm addlib "" --type --type tvshow|movie|music|anime --skip-init +smm addlib "" --type tvshow|movie|music|anime [--skip-init] ``` -```mermaid -sequenceDiagram - participant U as User - participant S as CLI - participant C as Core - - U->>S: smm addlib "" - S->>C: importLibrary(path, type) - C->>S: job id - loop looping job status - S->>C: getJob(jobId) - end - S->>U: print result -``` +Poll `getJob(jobId)` until the import-library job settles. ## Web UI, Electron and ohos -TODO: There is bug -The folder list was not updated before importFolder job completed. -UI should show all folders at the first step -and then run import job to trigger init process - ```mermaid sequenceDiagram - participant U as User participant W as Web UI participant S as Server participant C as Core - U->>W: import library W->>S: POST /api/import-library - S->>C: importLibrary(path, type) - C->>S: job id + S->>C: importLibrary S->>W: job id - W->>U: display job in running status - W->>W: invalidate useUserConfigQuery - W->>W: display folders in Sidebar - loop looping job status - W->>S: /api/get-job - S->>C: getJob(jobId) - C->>S: return - S->>W: return + W->>W: refresh Sidebar (UserConfig + job tasks) + par poll job + W->>S: POST /api/get-job + and metadata push + C->>S: core.on → emit mediaMetadataUpdated + S->>W: Socket.IO mediaMetadataUpdated + W->>W: fetchMediaMetadata (selected folder) end - W->>U: display job in success/failure status ``` -## Testing +- Sidebar status: `ImportLibraryJob.tasks[]` via get-job poll. +- Panel content (TvShowPanel, etc.): existing `MediaMetadataUpdatedEventListener` refetches metadata when the selected folder’s import completes. + +## Testing | Use Case | Platform | File | |--|--|--| -|UC1|Web UI, Electron, ohos| apps/e2e/common/ImportLibrary.e2e.ts| -|UC1|CLI| apps/e2e/cli/import-library.test.ts| +| UC1 | Web UI, Electron, ohos | `apps/e2e/common/ImportLibrary.e2e.ts` | +| UC1 | CLI | `apps/e2e/cli/import-library.test.ts` | -### UC1: import tvshow/movie/music libraries \ No newline at end of file +### UC1: import tvshow/movie/music libraries diff --git a/package.json b/package.json index 25d6aa6f..a571d737 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:electron-common": "cd packages/electron-common && pnpm test", "test:core-app": "cd apps/core && pnpm test", "test:cli": "cd apps/cli && pnpm test", + "test:cli:e2e": "cd apps/cli && pnpm run test:e2e", "test:ui": "cd apps/ui && pnpm test", "test:e2e": "cd apps/e2e && pnpm run test", "e2e:ai": "bun ci/run-e2e-test.ts --spec \"./test/specs/ai/*.e2e.ts\"", diff --git a/packages/core/job/ImportLibraryJob.ts b/packages/core/job/ImportLibraryJob.ts new file mode 100644 index 00000000..75bbf556 --- /dev/null +++ b/packages/core/job/ImportLibraryJob.ts @@ -0,0 +1,25 @@ +export interface ImportLibraryJobTask { + id: string + /** + * Media folder path + */ + path: string + status: 'pending' | 'running' | 'succeeded' | 'failed' + /** Active child import-folder job id while status is running. */ + importJobId?: string +} + +export interface ImportLibraryJob { + id: string + libraryPath: string + type: 'music' | 'tvshow' | 'movie' + status: 'pending' | 'running' | 'succeeded' | 'failed' + createdAt: number + updatedAt: number + /** + * The progress of the job, from 0 to 100 + */ + progress: number + tasks: ImportLibraryJobTask[] + error?: string +} From 34f3d8a1605e770a5dc084be9f3e01d6620b9370 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 27 Aug 2026 17:23:02 +0800 Subject: [PATCH 156/214] fix(e2e): wait for import-library job before metadata assertions Replace fixed delays with background job polling so multi-folder imports finish before checks, and verify music files via listFiles instead of persisted metadata cache fields. Co-authored-by: Cursor --- apps/e2e/common/ImportLibrary.e2e.ts | 11 +-- .../common/movie/ImportMovieLibrary.e2e.ts | 3 - apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts | 3 - apps/e2e/test/actions/import-library.ts | 94 +++++++++++++++++++ .../steps/media-library-was-initialized.ts | 14 +-- 5 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 apps/e2e/test/actions/import-library.ts diff --git a/apps/e2e/common/ImportLibrary.e2e.ts b/apps/e2e/common/ImportLibrary.e2e.ts index b2a97ce0..db96a7b1 100644 --- a/apps/e2e/common/ImportLibrary.e2e.ts +++ b/apps/e2e/common/ImportLibrary.e2e.ts @@ -7,8 +7,8 @@ import { import { clearFolderViaBrowser, resolveSmmTestFolderViaBrowser, + listFilesViaBrowser, } from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' import 'test/steps' import type { MediaMetadata } from '@smm/core/types' @@ -66,8 +66,6 @@ describe('Import Library', () => { base: testFolder, }) - await delay(30 * 1000) - const folders = getStepContext()._folders as Array<{ folderName: string path: string @@ -127,8 +125,6 @@ describe('Import Library', () => { base: testFolder, }) - await delay(30 * 1000) - const folders = getStepContext()._folders as Array<{ folderName: string path: string @@ -188,8 +184,6 @@ describe('Import Library', () => { base: testFolder, }) - await delay(15 * 1000) - const folders = getStepContext()._folders as Array<{ folderName: string path: string @@ -203,9 +197,10 @@ describe('Import Library', () => { const mm = obj as MediaMetadata expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) expect(mm.type).toBe('music-folder') - expect(mm.files?.some((file) => file.endsWith('01.mp3'))).toBe(true) return true }) + const listed = await listFilesViaBrowser(f.path, { recursively: true, onlyFiles: true }) + expect(listed.some((file) => file.path.endsWith('01.mp3'))).toBe(true) }) }) }) diff --git a/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts b/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts index 25eb68c2..7b56837c 100644 --- a/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts +++ b/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts @@ -8,7 +8,6 @@ import { clearFolderViaBrowser, resolveSmmTestFolderViaBrowser, } from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' import 'test/steps' import type { MediaMetadata } from '@smm/core/types' @@ -68,8 +67,6 @@ describe('Import Movie Library', () => { base: testFolder, }) - await delay(30 * 1000) - const folders = getStepContext()._folders as Array<{ folderName: string path: string diff --git a/apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts b/apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts index fc45a38c..f57b2dde 100644 --- a/apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts +++ b/apps/e2e/common/tv/ImportTvShowLibrary.e2e.ts @@ -8,7 +8,6 @@ import { clearFolderViaBrowser, resolveSmmTestFolderViaBrowser, } from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' import 'test/steps' import type { MediaMetadata } from '@smm/core/types' @@ -67,8 +66,6 @@ describe('Import TV Show Library', () => { base: testFolder, }) - await delay(30 * 1000) - const folders = getStepContext()._folders as Array<{ folderName: string path: string diff --git a/apps/e2e/test/actions/import-library.ts b/apps/e2e/test/actions/import-library.ts new file mode 100644 index 00000000..ba7d8cc2 --- /dev/null +++ b/apps/e2e/test/actions/import-library.ts @@ -0,0 +1,94 @@ +import StatusBar from 'test/componentobjects/StatusBar' +import { + importMediaLibrary, + type OnMediaLibraryImportedEventData, +} from 'test/actions/events' + +const IMPORT_MEDIA_LIBRARY_JOB_NAMES = [ + 'Import Media Library', + '导入媒体库', + '匯入媒體庫', +] as const + +const JOB_SUCCEEDED_BADGES = ['succeeded', '成功'] as const + +async function findImportMediaLibraryJobId(): Promise { + return browser.execute((jobNames: readonly string[]) => { + const matches: Array<{ id: string; status: string }> = [] + for (const el of document.querySelectorAll('[data-testid$="-name"]')) { + const testId = el.getAttribute('data-testid') + if (!testId?.startsWith('background-job-') || !testId.endsWith('-name')) { + continue + } + const name = el.textContent?.trim() ?? '' + if (!jobNames.includes(name)) { + continue + } + const id = testId.replace('background-job-', '').replace('-name', '') + const badge = document.querySelector( + `[data-testid="background-job-${id}-status-badge"]`, + ) + matches.push({ + id, + status: badge?.textContent?.trim().toLowerCase() ?? '', + }) + } + const running = matches.find((job) => job.status === 'running') + if (running) { + return running.id + } + const pending = matches.find((job) => job.status === 'pending') + if (pending) { + return pending.id + } + return matches.at(-1)?.id ?? null + }, IMPORT_MEDIA_LIBRARY_JOB_NAMES) +} + +/** Waits until the UI background job for import-library reaches succeeded (or throws on failed). */ +export async function waitForImportMediaLibraryJob(options?: { + timeoutMs?: number + startTimeoutMs?: number +}): Promise { + const timeoutMs = options?.timeoutMs ?? 5 * 60 * 1000 + const startTimeoutMs = options?.startTimeoutMs ?? 30 * 1000 + + let jobId: string | null = null + await browser.waitUntil( + async () => { + jobId = await findImportMediaLibraryJobId() + return jobId !== null + }, + { + timeout: startTimeoutMs, + interval: 500, + timeoutMsg: 'Import Media Library background job did not start', + }, + ) + + await browser.waitUntil( + async () => { + const badge = (await StatusBar.backgroundJobStatusBadge(jobId!).getText()).trim().toLowerCase() + if (badge === 'failed' || badge.includes('failed') || badge.includes('失败')) { + throw new Error(`Import Media Library background job failed (status: ${badge})`) + } + return JOB_SUCCEEDED_BADGES.some( + (success) => badge === success || badge.includes(success), + ) + }, + { + timeout: timeoutMs, + interval: 1000, + timeoutMsg: `Import Media Library background job did not succeed within ${timeoutMs}ms`, + }, + ) +} + +/** Dispatches ui.mediaLibraryImported and waits for Core import-library to finish. */ +export async function importMediaLibraryAndWait( + data: OnMediaLibraryImportedEventData, + options?: { timeoutMs?: number }, +): Promise { + await importMediaLibrary(data) + await waitForImportMediaLibraryJob({ timeoutMs: options?.timeoutMs }) +} diff --git a/apps/e2e/test/steps/media-library-was-initialized.ts b/apps/e2e/test/steps/media-library-was-initialized.ts index 2f3ad780..35edc1f4 100644 --- a/apps/e2e/test/steps/media-library-was-initialized.ts +++ b/apps/e2e/test/steps/media-library-was-initialized.ts @@ -5,7 +5,7 @@ import { writeFileViaBrowser, } from '../lib/browser-fs' import { folder1, folder2, musicFolder } from 'test/actions/import-folders' -import { importMediaLibrary } from 'test/actions/events' +import { importMediaLibraryAndWait } from 'test/actions/import-library' interface InitializedFolder { folderName: string @@ -63,11 +63,11 @@ registerStep('Media library was imported with TV show folders', async (ctx) => { await writeFileViaBrowser(joinPlatformPath(byNfoPath, 'tvshow.nfo'), nfoXml) folders.push({ folderName: 'FolderContainsTvShowNfo', path: byNfoPath, type: 'tvshow-folder' }) - await importMediaLibrary({ + await importMediaLibraryAndWait({ libraryPathInPlatformFormat: base, type: 'tvshow', traceId: 'e2e:Import Media Library:Import TV Show Library', - }) + }, { timeoutMs: 5 * 60 * 1000 }) ctx._folders = folders }) @@ -104,11 +104,11 @@ registerStep('Media library was imported with movie folders', async (ctx) => { await writeFileViaBrowser(joinPlatformPath(byNfoPath, 'movie.nfo'), nfoXml) folders.push({ folderName: 'FolderContainsMovieNfo', path: byNfoPath, type: 'movie-folder' }) - await importMediaLibrary({ + await importMediaLibraryAndWait({ libraryPathInPlatformFormat: base, type: 'movie', traceId: 'e2e:Import Media Library:Import Movie Library', - }) + }, { timeoutMs: 5 * 60 * 1000 }) ctx._folders = folders }) @@ -120,11 +120,11 @@ registerStep('Media library was imported with music folders', async (ctx) => { const musicPath = await createTestFolderViaBrowser(base, musicFolder) folders.push({ folderName: musicFolder.folderName, path: musicPath, type: 'music-folder' }) - await importMediaLibrary({ + await importMediaLibraryAndWait({ libraryPathInPlatformFormat: base, type: 'music', traceId: 'e2e:ImportLibrary:music', - }) + }, { timeoutMs: 2 * 60 * 1000 }) ctx._folders = folders }) From e01b642f79d062f0dea97ade2a527b2611661c25 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 27 Aug 2026 18:03:44 +0800 Subject: [PATCH 157/214] feat(core): move TMDB asset scrape failover into core with unit tests Implement discover-based image URL failover in downloadScrapeImage and replace the browser ScrapeFailover e2e with apps/core unit test coverage. Co-authored-by: Cursor --- .../pipeline/scrape/assetImageUrls.test.ts | 110 +++++++++++++ .../src/pipeline/scrape/assetImageUrls.ts | 78 +++++++++ .../scrape/downloadScrapeImage.test.ts | 152 ++++++++++++++++++ .../pipeline/scrape/downloadScrapeImage.ts | 73 +++++++-- .../src/pipeline/scrape/scrapeFanartTmdb.ts | 4 +- apps/core/src/pipeline/scrape/scrapeFolder.ts | 1 + .../pipeline/scrape/scrapePosterTmdb.test.ts | 53 ++++++ .../src/pipeline/scrape/scrapePosterTmdb.ts | 4 +- .../src/pipeline/scrape/scrapeTaskDeps.ts | 2 + .../pipeline/scrape/scrapeThumbnailsTmdb.ts | 1 + apps/e2e/common/tv/ScrapeFailover.e2e.ts | 100 ------------ apps/e2e/scenarios/check-log.ts | 124 -------------- .../scenarios/scrape-failover-tmdb.test.json | 45 ------ package.json | 1 - 14 files changed, 460 insertions(+), 288 deletions(-) create mode 100644 apps/core/src/pipeline/scrape/assetImageUrls.test.ts create mode 100644 apps/core/src/pipeline/scrape/assetImageUrls.ts delete mode 100644 apps/e2e/common/tv/ScrapeFailover.e2e.ts delete mode 100644 apps/e2e/scenarios/check-log.ts delete mode 100644 apps/e2e/scenarios/scrape-failover-tmdb.test.json diff --git a/apps/core/src/pipeline/scrape/assetImageUrls.test.ts b/apps/core/src/pipeline/scrape/assetImageUrls.test.ts new file mode 100644 index 00000000..a65e4b34 --- /dev/null +++ b/apps/core/src/pipeline/scrape/assetImageUrls.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { buildAssetUrlCandidates } from "./assetImageUrls"; +import type { DiscoverConfig } from "../../ports/DiscoverPort"; + +const emptyConfig: DiscoverConfig = { mediaDatabases: [], reverseProxies: [] }; + +const configWithAssets: DiscoverConfig = { + mediaDatabases: [ + { type: "tmdb", url: "https://api.example/tmdb", authorizationMethod: "none" }, + { type: "tmdb-asset", url: "https://tmdb-mirror.example", authorizationMethod: "none" }, + { type: "tmdb-asset", url: "https://tmdb-mirror-2.example/", authorizationMethod: "none" }, + { type: "tvdb-asset", url: "https://tvdb-mirror.example", authorizationMethod: "none" }, + ], + reverseProxies: [], +}; + +describe("buildAssetUrlCandidates", () => { + it("puts official TMDB URL first, then host-swapped tmdb-asset mirrors", () => { + const url = "https://image.tmdb.org/t/p/original/poster.jpg"; + expect(buildAssetUrlCandidates(url, configWithAssets)).toEqual([ + "https://image.tmdb.org/t/p/original/poster.jpg", + "https://tmdb-mirror.example/t/p/original/poster.jpg", + "https://tmdb-mirror-2.example/t/p/original/poster.jpg", + ]); + }); + + it("puts official TVDB artwork URL first, then tvdb-asset mirrors", () => { + const url = "https://artworks.thetvdb.com/banners/v4/series/1/posters/abc.jpg"; + expect(buildAssetUrlCandidates(url, configWithAssets)).toEqual([ + "https://artworks.thetvdb.com/banners/v4/series/1/posters/abc.jpg", + "https://tvdb-mirror.example/banners/v4/series/1/posters/abc.jpg", + ]); + }); + + it("normalizes protocol-relative URLs before building candidates", () => { + const url = "//image.tmdb.org/t/p/w500/a.jpg"; + expect(buildAssetUrlCandidates(url, configWithAssets)[0]).toBe( + "https://image.tmdb.org/t/p/w500/a.jpg", + ); + }); + + it("returns only the original URL for unknown hosts", () => { + expect( + buildAssetUrlCandidates("https://cdn.example.com/pic.jpg", configWithAssets), + ).toEqual(["https://cdn.example.com/pic.jpg"]); + }); + + it("returns only the original URL when discover has no matching assets", () => { + expect( + buildAssetUrlCandidates("https://image.tmdb.org/t/p/original/x.jpg", emptyConfig), + ).toEqual(["https://image.tmdb.org/t/p/original/x.jpg"]); + }); + + it("dedupes when a mirror origin matches the original host", () => { + const config: DiscoverConfig = { + mediaDatabases: [ + { + type: "tmdb-asset", + url: "https://image.tmdb.org", + authorizationMethod: "none", + }, + ], + reverseProxies: [], + }; + expect( + buildAssetUrlCandidates("https://image.tmdb.org/t/p/original/x.jpg", config), + ).toEqual(["https://image.tmdb.org/t/p/original/x.jpg"]); + }); + + it("skips invalid asset base URLs", () => { + const config: DiscoverConfig = { + mediaDatabases: [ + { type: "tmdb-asset", url: "not a url", authorizationMethod: "none" }, + { type: "tmdb-asset", url: "https://good.example", authorizationMethod: "none" }, + ], + reverseProxies: [], + }; + expect( + buildAssetUrlCandidates("https://image.tmdb.org/t/p/original/x.jpg", config), + ).toEqual([ + "https://image.tmdb.org/t/p/original/x.jpg", + "https://good.example/t/p/original/x.jpg", + ]); + }); + + it("replaces the first TMDB CDN host when overrideDefaultTmdbAssetServerHost is set", () => { + const url = "https://image.tmdb.org/t/p/original/poster.jpg"; + expect( + buildAssetUrlCandidates(url, configWithAssets, { + overrideDefaultTmdbAssetServerHost: "wronghost.tmdb.local", + }), + ).toEqual([ + "https://wronghost.tmdb.local/t/p/original/poster.jpg", + "https://tmdb-mirror.example/t/p/original/poster.jpg", + "https://tmdb-mirror-2.example/t/p/original/poster.jpg", + ]); + }); + + it("ignores override for non-TMDB asset hosts", () => { + const url = "https://artworks.thetvdb.com/banners/v4/series/1/posters/abc.jpg"; + expect( + buildAssetUrlCandidates(url, configWithAssets, { + overrideDefaultTmdbAssetServerHost: "wronghost.tmdb.local", + }), + ).toEqual([ + "https://artworks.thetvdb.com/banners/v4/series/1/posters/abc.jpg", + "https://tvdb-mirror.example/banners/v4/series/1/posters/abc.jpg", + ]); + }); +}); diff --git a/apps/core/src/pipeline/scrape/assetImageUrls.ts b/apps/core/src/pipeline/scrape/assetImageUrls.ts new file mode 100644 index 00000000..ca8a0d1e --- /dev/null +++ b/apps/core/src/pipeline/scrape/assetImageUrls.ts @@ -0,0 +1,78 @@ +import type { DiscoverConfig, MediaDatabaseType } from "../../ports/DiscoverPort"; + +export const TMDB_IMAGE_HOSTS = new Set(["image.tmdb.org"]); +export const TVDB_ARTWORK_HOSTS = new Set(["artworks.thetvdb.com"]); + +export interface BuildAssetUrlCandidatesOptions { + /** Test hook: replace the first TMDB CDN candidate host to simulate outage. */ + overrideDefaultTmdbAssetServerHost?: string | null; +} + +function normalizeImageUrl(url: string): string { + if (url.startsWith("//")) return `https:${url}`; + return url; +} + +function hostSwap(originalUrl: string, assetBaseUrl: string): string | null { + try { + const original = new URL(originalUrl); + const base = new URL(assetBaseUrl); + const swapped = new URL(original.href); + swapped.protocol = base.protocol; + swapped.host = base.host; + const basePath = base.pathname.replace(/\/$/, ""); + if (basePath && basePath !== "") { + swapped.pathname = `${basePath}${original.pathname}`; + } + return swapped.href; + } catch { + return null; + } +} + +function assetTypeForHost(hostname: string): MediaDatabaseType | null { + if (TMDB_IMAGE_HOSTS.has(hostname)) return "tmdb-asset"; + if (TVDB_ARTWORK_HOSTS.has(hostname)) return "tvdb-asset"; + return null; +} + +/** + * Build ordered image URL candidates: official CDN first, then discover asset mirrors (host-swap). + */ +export function buildAssetUrlCandidates( + url: string, + config: DiscoverConfig, + options: BuildAssetUrlCandidatesOptions = {}, +): string[] { + const normalized = normalizeImageUrl(url); + let hostname = ""; + try { + hostname = new URL(normalized).hostname; + } catch { + return [url]; + } + + const assetType = assetTypeForHost(hostname); + const candidates: string[] = [normalized]; + if (!assetType) return candidates; + + for (const entry of config.mediaDatabases) { + if (entry.type !== assetType) continue; + const swapped = hostSwap(normalized, entry.url); + if (!swapped) continue; + if (!candidates.includes(swapped)) candidates.push(swapped); + } + + const overrideHost = options.overrideDefaultTmdbAssetServerHost; + if (overrideHost && assetType === "tmdb-asset" && candidates.length > 0) { + try { + const overridden = new URL(candidates[0]); + overridden.host = overrideHost; + candidates[0] = overridden.href; + } catch { + // ignore invalid override host + } + } + + return candidates; +} diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts index 9db54aeb..32567bd9 100644 --- a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts @@ -1,5 +1,6 @@ import type { MediaMetadata, UserConfig } from "@smm/core"; import { describe, expect, it, vi } from "vitest"; +import type { DiscoverConfig, DiscoverPort } from "../../ports/DiscoverPort"; import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; @@ -196,4 +197,155 @@ describe("downloadScrapeImage", () => { expect(fetch).toHaveBeenCalledOnce(); }); + + it("failovers to discover tmdb-asset mirror when the default CDN host fails", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([0xff, 0xd8, 0xff]); + const urls: string[] = []; + const network: NetworkPort = { + fetch: vi.fn(async (url: string) => { + urls.push(url); + if (url.includes("wronghost.tmdb.local")) { + return fakeImageResponse(new Uint8Array(), false); + } + if (url.includes("tmdb-mirror.example")) { + return fakeImageResponse(fakeBytes); + } + throw new Error(`unexpected url: ${url}`); + }), + }; + + const discoverConfig: DiscoverConfig = { + mediaDatabases: [ + { + type: "tmdb-asset", + url: "https://tmdb-mirror.example", + authorizationMethod: "none", + }, + ], + reverseProxies: [], + }; + const discover: DiscoverPort = { + getDiscoverConfig: vi.fn(async () => discoverConfig), + }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/t/p/original/poster.jpg", + "/media/Fight Club/poster.jpg", + userConfig(), + fs, + network, + { + discover, + overrideDefaultTmdbAssetServerHost: "wronghost.tmdb.local", + }, + ); + + expect(urls).toEqual([ + "https://wronghost.tmdb.local/t/p/original/poster.jpg", + "https://tmdb-mirror.example/t/p/original/poster.jpg", + ]); + expect(fs.binaryFiles.get("/media/Fight Club/poster.jpg")).toEqual(fakeBytes); + }); + + it("failovers when the first candidate throws a network error", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([1, 2, 3]); + const network: NetworkPort = { + fetch: vi.fn(async (url: string) => { + if (url.includes("image.tmdb.org")) { + throw new Error("network down"); + } + return fakeImageResponse(fakeBytes); + }), + }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { + type: "tmdb-asset", + url: "https://tmdb-mirror.example", + authorizationMethod: "none", + }, + ], + reverseProxies: [], + }), + }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/t/p/original/poster.jpg", + "/media/poster.jpg", + userConfig(), + fs, + network, + { discover }, + ); + + expect(fs.binaryFiles.get("/media/poster.jpg")).toEqual(fakeBytes); + expect(network.fetch).toHaveBeenCalledTimes(2); + }); + + it("throws the last error when all candidates fail", async () => { + const fs = createInMemoryFs(); + const network: NetworkPort = { + fetch: vi.fn(async () => fakeImageResponse(new Uint8Array(), false)), + }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { + type: "tmdb-asset", + url: "https://tmdb-mirror.example", + authorizationMethod: "none", + }, + ], + reverseProxies: [], + }), + }; + + await expect( + downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/t/p/original/poster.jpg", + "/media/poster.jpg", + userConfig(), + fs, + network, + { discover }, + ), + ).rejects.toThrow("HTTP error! status: 503"); + + expect(network.fetch).toHaveBeenCalledTimes(2); + }); + + it("downloads from the official CDN when discover is unavailable", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([9]); + const fetch = vi.fn(async () => fakeImageResponse(fakeBytes)); + const network: NetworkPort = { fetch }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => { + throw new Error("discover offline"); + }, + }; + + await downloadScrapeImage( + movieMetadata, + "https://image.tmdb.org/t/p/original/poster.jpg", + "/media/poster.jpg", + userConfig(), + fs, + network, + { discover }, + ); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith( + "https://image.tmdb.org/t/p/original/poster.jpg", + expect.any(Object), + ); + expect(fs.binaryFiles.get("/media/poster.jpg")).toEqual(fakeBytes); + }); }); diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.ts index 12c8d097..e0a3eb87 100644 --- a/apps/core/src/pipeline/scrape/downloadScrapeImage.ts +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.ts @@ -1,8 +1,9 @@ -import { isEmpty } from "es-toolkit/compat"; import type { MediaMetadata, UserConfig } from "@smm/core"; +import type { DiscoverConfig, DiscoverPort } from "../../ports/DiscoverPort"; import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; import { dirname } from "../paths"; +import { buildAssetUrlCandidates } from "./assetImageUrls"; import { resolveScrapeHttpProxy } from "./resolveScrapeHttpProxy"; /** Browser-like request headers for remote TMDB/TVDB image fetches. */ @@ -17,7 +18,12 @@ export const REMOTE_IMAGE_REQUEST_HEADERS: Record = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }; +const EMPTY_DISCOVER_CONFIG: DiscoverConfig = { mediaDatabases: [], reverseProxies: [] }; + export interface DownloadScrapeImageDeps { + discover?: DiscoverPort; + /** Test hook: simulate TMDB CDN outage (see buildAssetUrlCandidates). */ + overrideDefaultTmdbAssetServerHost?: string | null; /** * Optional fetch override (e.g. proxied). Receives the resolved httpProxy * when a custom media-database host + proxy pair is configured. @@ -32,9 +38,22 @@ function normalizeImageUrl(url: string): string { return url; } +async function fetchImageResponse( + url: string, + init: FetchInit, + httpProxy: string | undefined, + network: NetworkPort, + customFetch: DownloadScrapeImageDeps["fetch"], +): Promise { + if (customFetch) { + return customFetch(url, init, httpProxy); + } + return network.fetch(url, init); +} + /** - * Download one scrape image: resolve HTTP proxy from metadata, fetch via - * NetworkPort, mkdir parent, write bytes with FsPort.writeBinaryFile. + * Download one scrape image with TMDB/TVDB asset-server failover: try official CDN + * URL first, then discover-configured asset mirrors (host-swap). */ export async function downloadScrapeImage( mediaMetadata: MediaMetadata, @@ -57,25 +76,47 @@ export async function downloadScrapeImage( ); } + const discoverConfig = deps.discover + ? await deps.discover.getDiscoverConfig().catch(() => EMPTY_DISCOVER_CONFIG) + : EMPTY_DISCOVER_CONFIG; + + const candidates = buildAssetUrlCandidates(normalizedUrl, discoverConfig, { + overrideDefaultTmdbAssetServerHost: deps.overrideDefaultTmdbAssetServerHost, + }); + const init: FetchInit = { method: "GET", headers: REMOTE_IMAGE_REQUEST_HEADERS, }; - const response = deps.fetch - ? await deps.fetch(normalizedUrl, init, httpProxy) - : await network.fetch(normalizedUrl, init); + let lastError: Error | undefined; + for (const candidate of candidates) { + try { + const response = await fetchImageResponse( + candidate, + init, + httpProxy, + network, + deps.fetch, + ); + if (!response.ok) { + lastError = new Error(`HTTP error! status: ${response.status}`); + continue; + } - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + const arrayBuffer = await response.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); - const arrayBuffer = await response.arrayBuffer(); - const data = new Uint8Array(arrayBuffer); - - const parentDir = dirname(filePath); - if (parentDir !== "/") { - await fs.mkdir(parentDir); + const parentDir = dirname(filePath); + if (parentDir !== "/") { + await fs.mkdir(parentDir); + } + await fs.writeBinaryFile(filePath, data); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } } - await fs.writeBinaryFile(filePath, data); + + throw lastError ?? new Error("Failed to download image: no candidates"); } diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts index c083fd95..2cd54c0b 100644 --- a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts @@ -126,7 +126,9 @@ export async function scrapeFanart(deps: ScrapeTaskDeps): Promise; diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts index aabfe7d6..2cf418ea 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -1,6 +1,7 @@ import type { MediaMetadata, TmdbSeasonDetails, TmdbSeriesDetails, UserConfig } from "@smm/core"; import { describe, expect, it, vi } from "vitest"; import type { TmdbClient } from "../../clients/TmdbClient"; +import type { DiscoverPort } from "../../ports/DiscoverPort"; import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; import { DEFAULT_USER_CONFIG } from "../userConfigHelper"; @@ -94,6 +95,7 @@ function createDeps( network: NetworkPort, tmdb: Partial, mediaMetadata: MediaMetadata = tvShowMetadata, + discover?: DiscoverPort, ): ScrapeTaskDeps { return { fs, @@ -103,6 +105,7 @@ function createDeps( mediaMetadata, language: "en-US", userConfig: DEFAULT_USER_CONFIG, + discover, }; } @@ -160,4 +163,54 @@ describe("scrapePosterTmdb", () => { expect(result.status).toBe("failed"); expect(result.error).toContain("No poster available"); }); + + it("failovers to a discover tmdb-asset mirror when the default CDN host is unreachable", async () => { + const fs = createInMemoryFs(); + const fakeBytes = new Uint8Array([0xff, 0xd8, 0xff]); + const urls: string[] = []; + const network: NetworkPort = { + fetch: vi.fn(async (url: string) => { + urls.push(url); + if (url.includes("wronghost.tmdb.local")) { + return { + ok: false, + status: 503, + statusText: "Service Unavailable", + headers: {}, + text: () => Promise.resolve(""), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }; + } + if (url.includes("tmdb-mirror.example")) { + return fakeImageResponse(fakeBytes); + } + throw new Error(`unexpected url: ${url}`); + }), + }; + const tmdb = { getTvShowById: vi.fn().mockResolvedValue(seriesDetails) }; + const discover: DiscoverPort = { + getDiscoverConfig: async () => ({ + mediaDatabases: [ + { + type: "tmdb-asset", + url: "https://tmdb-mirror.example", + authorizationMethod: "none", + }, + ], + reverseProxies: [], + }), + }; + + const result = await scrapePosterTmdb( + createDeps(fs, network, tmdb, tvShowMetadata, discover), + ); + + expect(result).toEqual({ status: "completed" }); + expect(urls).toEqual([ + "https://image.tmdb.org/t/p/original/poster.jpg", + "https://tmdb-mirror.example/t/p/original/poster.jpg", + ]); + expect(fs.binaryFiles.get("/media/Test Show/poster.jpg")).toEqual(fakeBytes); + }); }); diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts index ee85f4fa..96bf623e 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts @@ -108,7 +108,9 @@ export async function scrapePoster(deps: ScrapeTaskDeps): Promise { - const items = await listFilesViaBrowser(folderPath) - const match = items.find((item) => { - if (item.isDirectory) return false - const name = basenamePlatformPath(item.path) - return ( - name.startsWith(`${prefix}.`) && - IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext)) - ) - }) - return match?.path -} - -/** - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Scrape Failover', () => { - let testFolder = '' - - beforeEach(async () => { - resetStepContext() - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - openBrowserPage: true, - os: testbedOs, - }) - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('failover to another TMDB asset server', async function () { - this.timeout(60 * 1000) - - await given('prefer media language is "zh-CN"') - await given('TV show folder with TMDB id 84666 and one episode was imported') - await when('folder from context was selected') - await when('debug override default tmdb asset server host is "wronghost.tmdb.local"') - await when('I click "Scrape" button in TV show panel') - await then('scrape dialog shows all tasks pending') - await when('I start scrape') - await then('scrape dialog shows all TV show tasks completed') - await when('I close scrape dialog') - - await then('TMDB scrape outputs are written to disk with failover', async () => { - const folder = getStepContext()._folder as { path: string } - const thumbnailPath = joinPlatformPath(folder.path, 'S01E01.jpg') - expect(await fileExistsViaBrowser(thumbnailPath)).toBe(true) - expect(await getFileSizeViaBrowser(thumbnailPath)).toBeGreaterThan(0) - - const posterPath = await getImagePathWithPrefix(folder.path, 'poster') - const fanartPath = await getImagePathWithPrefix(folder.path, 'fanart') - expect(posterPath).toBeDefined() - expect(fanartPath).toBeDefined() - expect(await getFileSizeViaBrowser(posterPath!)).toBeGreaterThan(0) - expect(await getFileSizeViaBrowser(fanartPath!)).toBeGreaterThan(0) - - const tvshowNfoPath = joinPlatformPath(folder.path, 'tvshow.nfo') - expect(await fileExistsViaBrowser(tvshowNfoPath)).toBe(true) - expect(await readFileViaBrowser(tvshowNfoPath)).toContain('天使降临到我身边') - }) - }) -}) diff --git a/apps/e2e/scenarios/check-log.ts b/apps/e2e/scenarios/check-log.ts deleted file mode 100644 index 83c9cb2c..00000000 --- a/apps/e2e/scenarios/check-log.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * onArtifactsReady hook: assert ScrapeFailover.e2e.ts logs contain - * evidence of TMDB asset server failover. - * - * Verifies two groups of log lines in cli.log: - * 1. Download attempts to wronghost.tmdb.local (the overridden host) — proves - * the debug override was in effect. - * 2. Download attempts to a different host — proves the frontend fell back - * to the configured/default asset server after the first host failed. - * - * Env (from apps/cicd onArtifactsReady): - * CICD_ARTIFACT_DIR — run artifact directory (e.g. artifacts/cicd/) - */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -const TASK_NAME = 'ScrapeFailover.e2e.ts'; -const LOG_PREFIX = '[DownloadImageAsFile]'; -const WRONG_HOST = 'wronghost.tmdb.local'; - -interface HostCount { - wronghost: number; - failover: number; - byHost: Record; -} - -function countHosts(lines: string[]): HostCount { - let wronghost = 0; - const byHost: Record = {}; - - for (const line of lines) { - if (!line.includes(LOG_PREFIX)) continue; - - const match = line.match(/Downloading image from https?:\/\/([^/]+)/); - if (!match) continue; - - const host = match[1]; - if (!host) continue; - byHost[host] = (byHost[host] ?? 0) + 1; - - if (host === WRONG_HOST) { - wronghost++; - } - } - - // "failover" count = downloads to any host that is NOT wronghost.tmdb.local - const failover = Object.entries(byHost) - .filter(([h]) => h !== WRONG_HOST) - .reduce((sum, [, c]) => sum + c, 0); - - return { wronghost, failover, byHost }; -} - -function main(): number { - const artifactDir = process.env.CICD_ARTIFACT_DIR; - if (!artifactDir) { - console.error('[check-log] missing CICD_ARTIFACT_DIR'); - return 1; - } - - const mainLog = path.join(artifactDir, TASK_NAME, 'main.log'); - if (!fs.existsSync(mainLog)) { - console.error(`[check-log] expected log not found: ${mainLog}`); - return 1; - } - - const body = fs.readFileSync(mainLog, 'utf8'); - const EXPECTED_SNIPPET = 'RUNNING in chrome'; - if (!body.includes(EXPECTED_SNIPPET)) { - console.error( - `[check-log] expected "${EXPECTED_SNIPPET}" in ${mainLog}, but it was not found`, - ); - return 1; - } - - console.log(`[check-log] ok: found "${EXPECTED_SNIPPET}" in ${mainLog}`); - - // --- Failover verification --- - - const cliLog = path.join(artifactDir, TASK_NAME, 'cli.log'); - if (!fs.existsSync(cliLog)) { - console.error(`[check-log] expected log not found: ${cliLog}`); - return 1; - } - - const cliLines = fs.readFileSync(cliLog, 'utf8').split('\n'); - const counts = countHosts(cliLines); - - console.log('[check-log] image download host distribution:', counts.byHost); - - if (counts.wronghost === 0) { - console.error( - `[check-log] no downloads to "${WRONG_HOST}" found in ${cliLog} — ` + - 'the debug override may not have taken effect', - ); - return 1; - } - - if (counts.failover === 0) { - console.error( - `[check-log] all ${LOG_PREFIX} lines target "${WRONG_HOST}" — ` + - 'no evidence of failover to another asset server in ' + cliLog, - ); - return 1; - } - - // Every image should have at least one wronghost attempt AND one failover - // attempt — warn if the distribution looks suspicious. - if (counts.wronghost !== counts.failover) { - console.warn( - `[check-log] warning: wronghost downloads (${counts.wronghost}) ` + - `don't match failover downloads (${counts.failover}) — ` + - 'some images may have been served without failing over', - ); - } - - console.log( - `[check-log] ok: ${counts.wronghost} attempts to "${WRONG_HOST}" + ` + - `${counts.failover} failover downloads to other host(s)`, - ); - return 0; -} - -process.exit(main()); diff --git a/apps/e2e/scenarios/scrape-failover-tmdb.test.json b/apps/e2e/scenarios/scrape-failover-tmdb.test.json deleted file mode 100644 index 8aa83bc8..00000000 --- a/apps/e2e/scenarios/scrape-failover-tmdb.test.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "smm-e2e", - "outputDir": "./artifacts/cicd", - "env": { - "BROWSER_LOG_ENABLED": "true", - "NETWORK_LOG_ENABLED": "true", - "SMM_AUTH_TOKEN": "ChangeMe123" - }, - "background": [ - { - "name": "cli", - "command": "pnpm e2e:cli" - }, - { - "name": "ui", - "command": "pnpm dev:ui" - } - ], - "tasks": [ - { - "name": "wait-ready", - "command": "bun ci/wait-for-e2e-ready.ts" - }, - { - "name": "ScrapeFailover.e2e.ts", - "command": "pnpm wdio --spec ./common/tv/ScrapeFailover.e2e.ts", - "cwd": "apps/e2e" - } - ], - "afterEach": [ - { - "name": "collect-wdio-report", - "command": "bun ci/collect-wdio-report.ts" - } - ], - "onArtifactsReady": [ - { - "name": "check-log", - "command": "bun ./apps/e2e/scenarios/check-log.ts", - "when": "success" - } - ], - "stopOnFailure": false, - "keepRawTimeline": true -} diff --git a/package.json b/package.json index a571d737..b8fc19af 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "e2e:ohos:tv": "bun ci/run-e2e-test.ts --platform ohos --spec \"./common/tv/*.e2e.ts\"", "e2e:electron:tv": "bun ci/run-e2e-test.ts --platform electron --spec \"./common/tv/*.e2e.ts\"", "e2e:failover": "cross-env EXTERNAL_CONFIG_FILE_URL=\"http://localhost:8000/config.json\" bun ci/run-e2e-test.ts --spec ./common/tv/TmdbHostFailover.e2e.ts", - "e2e:scrape-failover": "bun apps/cicd/run.ts -f apps/e2e/scenarios/scrape-failover-tmdb.test.json", "e2e:http-proxy": "bun apps/cicd/run.ts -f apps/e2e/scenarios/http-proxy-reverse-proxy.test.json", "typecheck": "pnpm run typecheck:core && pnpm run typecheck:core-routes && pnpm run typecheck:electron-common && pnpm run typecheck:cli && pnpm run typecheck:ui && pnpm run typecheck:e2e && pnpm run typecheck:electron && pnpm run typecheck:core-app", "typecheck:core": "cd packages/core && pnpm run typecheck", From 9351fc5cdb8ea84539b8970e0a23c5ebefa3de6f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Thu, 27 Aug 2026 19:24:48 +0800 Subject: [PATCH 158/214] feat(core): add TMDB/TVDB host speed tests, selection, and TCP-only failover Rank API and asset CDN hosts by startup speed tests, prefer the last successful host, and only fail over on transport errors so HTTP 4xx/5xx stop retrying as designed. Co-authored-by: Cursor --- apps/cli/server.ts | 5 + apps/core/src/Core.hostSpeedTest.test.ts | 78 +++++++++++++ apps/core/src/Core.ts | 39 +++++++ .../src/adapters/StaticDiscoverAdapter.ts | 20 ++++ apps/core/src/clients/TmdbClient.test.ts | 23 ++-- apps/core/src/clients/TmdbClient.ts | 5 + apps/core/src/clients/TvdbClient.ts | 4 + apps/core/src/clients/hostPerformance.test.ts | 98 +++++++++++++++++ apps/core/src/clients/hostPerformance.ts | 104 ++++++++++++++++++ apps/core/src/clients/hostSpeedTest.test.ts | 72 ++++++++++++ apps/core/src/clients/hostSpeedTest.ts | 43 ++++++++ .../clients/mediaDatabaseTransport.test.ts | 83 +++++++++++--- .../src/clients/mediaDatabaseTransport.ts | 92 +++++++++------- apps/core/src/index.ts | 5 + .../src/pipeline/scrape/assetImageUrls.ts | 4 +- .../scrape/downloadScrapeImage.test.ts | 4 +- .../pipeline/scrape/downloadScrapeImage.ts | 78 ++++++++++--- .../src/pipeline/scrape/scrapeFanartTmdb.ts | 1 + apps/core/src/pipeline/scrape/scrapeFolder.ts | 5 + .../pipeline/scrape/scrapePosterTmdb.test.ts | 12 +- .../src/pipeline/scrape/scrapePosterTmdb.ts | 1 + .../src/pipeline/scrape/scrapeTaskDeps.ts | 2 + .../pipeline/scrape/scrapeThumbnailsTmdb.ts | 2 +- .../tmdb-tvdb-host-selection-and-failover.md | 45 ++++++++ 24 files changed, 728 insertions(+), 97 deletions(-) create mode 100644 apps/core/src/Core.hostSpeedTest.test.ts create mode 100644 apps/core/src/clients/hostPerformance.test.ts create mode 100644 apps/core/src/clients/hostPerformance.ts create mode 100644 apps/core/src/clients/hostSpeedTest.test.ts create mode 100644 apps/core/src/clients/hostSpeedTest.ts create mode 100644 docs/dev/tmdb-tvdb-host-selection-and-failover.md diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 42b097f6..d51937f6 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -71,6 +71,7 @@ import { handleSpeedtest } from './src/route/speedtest'; import { handleDiscover } from './src/route/discover'; import { handleShutdown, setShutdownRequestIPResolver } from './src/route/shutdown'; import { applyMcpConfig } from '@/mcp/mcpServerManager'; +import { getCore } from '@/core/getCore'; import { requestId } from 'hono/request-id'; import { logger } from './lib/logger'; import { @@ -417,6 +418,10 @@ export class Server { ); applyMcpConfig().catch((err) => logger.error({ err }, "Failed to apply MCP config on startup")); + + getCore().runHostSpeedTests().catch((err) => + logger.error({ err }, "Failed to run TMDB/TVDB host speed tests"), + ); } async stop(): Promise { diff --git a/apps/core/src/Core.hostSpeedTest.test.ts b/apps/core/src/Core.hostSpeedTest.test.ts new file mode 100644 index 00000000..7519e7a7 --- /dev/null +++ b/apps/core/src/Core.hostSpeedTest.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { StaticDiscoverAdapter } from "./adapters/StaticDiscoverAdapter"; +import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; +import { Core } from "./Core"; +import type { FsPort } from "./ports/FsPort"; +import type { HttpResponse, NetworkPort } from "./ports/NetworkPort"; + +function inMemoryFs(): FsPort { + const files = new Map(); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + listSubdirectories: vi.fn(async () => []), + mkdir: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + deleteFile: vi.fn(async () => {}), + }; +} + +function jsonOk(): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve("{}"), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }; +} + +describe("Core host speed tests", () => { + it("leaves performance lists empty when speed tests are not run (CLI)", async () => { + const fetch = vi.fn(async () => jsonOk()); + const core = new Core({ + fs: inMemoryFs(), + network: { fetch }, + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + discover: new StaticDiscoverAdapter(), + }); + + expect(core.getHostPerformanceList("tmdb")).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("records reachable hosts and omits TCP failures after runHostSpeedTests", async () => { + const network: NetworkPort = { + fetch: async (url) => { + if (url.includes("tencentscf.com")) throw new Error("ECONNREFUSED"); + return jsonOk(); + }, + }; + const core = new Core({ + fs: inMemoryFs(), + network, + logger: new NoopLoggerAdapter(), + appDataDir: "/data/smm", + discover: new StaticDiscoverAdapter(), + }); + + await core.runHostSpeedTests(); + + const tmdb = core.getHostPerformanceList("tmdb"); + expect(tmdb.length).toBeGreaterThan(0); + expect(tmdb.every((entry) => !entry.host.includes("tencentscf.com"))).toBe(true); + expect(tmdb.every((entry) => entry.score >= 0)).toBe(true); + }); +}); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 3fee3e68..dabc8208 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -42,6 +42,14 @@ import { import { NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; import { TmdbClient } from "./clients/TmdbClient"; import { TvdbClient } from "./clients/TvdbClient"; +import { + HostPerformanceStore, + mergeHostUrls, + type HostPerformanceEntry, + type HostPerformanceKind, +} from "./clients/hostPerformance"; +import { speedTestHosts } from "./clients/hostSpeedTest"; +import { STATIC_MEDIA_DATABASES } from "./adapters/StaticDiscoverAdapter"; import { createBlankMediaMetadata, ImportFolderPipeline } from "./pipeline/importFolderPipeline"; import { dedupLibraryFolders, prepareLibraryFoldersForImport, createImportLibraryTasks, patchImportLibraryTask, importLibraryJobProgress } from "./pipeline/importLibrary"; import { renameFolderPipeline, type RenameFolderArgs } from "./pipeline/renameFolder"; @@ -151,6 +159,8 @@ export interface CoreOptions { osLocale?: string; /** Discover hosts for TMDB/TVDB failover. */ discover?: DiscoverPort; + /** When true, run host speed tests in the background after construction. */ + enableHostSpeedTest?: boolean; /** MCP HTTP runtime (injected by CLI / OHOS host). */ mcpServer?: McpServerPort; } @@ -198,6 +208,7 @@ export class Core { private readonly discover?: DiscoverPort; private readonly mcpServer?: McpServerPort; private readonly eventBus = new CoreEventBus(); + private readonly hostPerformance = new HostPerformanceStore(); constructor(options: CoreOptions) { this.fs = options.fs; @@ -216,6 +227,9 @@ export class Core { this.mediaMetadata = new MediaMetadataHelper(this.fs, this.appDataDir); this.discover = options.discover; this.mcpServer = options.mcpServer; + if (options.enableHostSpeedTest === true) { + void this.runHostSpeedTests(); + } } on(event: E, listener: (data: CoreEventMap[E]) => void): void { @@ -230,6 +244,28 @@ export class Core { this.eventBus.once(event, listener); } + /** + * Measure TMDB/TVDB (and asset CDN) hosts after app start-up. + * CLI one-shot commands skip this so the performance list stays empty. + */ + async runHostSpeedTests(): Promise { + const remote = this.discover + ? await this.discover.getDiscoverConfig().catch(() => ({ mediaDatabases: [], reverseProxies: [] })) + : { mediaDatabases: [], reverseProxies: [] }; + const kinds: HostPerformanceKind[] = ["tmdb", "tvdb", "tmdb-asset", "tvdb-asset"]; + for (const kind of kinds) { + const staticUrls = STATIC_MEDIA_DATABASES.filter((entry) => entry.type === kind).map((entry) => entry.url); + const remoteUrls = remote.mediaDatabases.filter((entry) => entry.type === kind).map((entry) => entry.url); + const hosts = mergeHostUrls(staticUrls, remoteUrls); + const results = await speedTestHosts(this.network, hosts); + this.hostPerformance.set(kind, results); + } + } + + getHostPerformanceList(kind: HostPerformanceKind): readonly HostPerformanceEntry[] { + return this.hostPerformance.get(kind); + } + private notifyMediaMetadataUpdated(folderPath: string): void { this.eventBus.emit(MEDIA_METADATA_UPDATED_EVENT, { folderPath: this.normalizePosix(folderPath), @@ -645,6 +681,7 @@ export class Core { httpProxy, reverseProxyUrl: null, discover: this.discover, + hostPerformance: this.hostPerformance, }); return { client, language }; @@ -674,6 +711,7 @@ export class Core { httpProxy, reverseProxyUrl: null, discover: this.discover, + hostPerformance: this.hostPerformance, }); return { client, language }; @@ -696,6 +734,7 @@ export class Core { normalizePosix: (p: string) => this.normalizePosix(p), discover: this.discover, reverseProxyUrl: this.reverseProxyUrl, + hostPerformance: this.hostPerformance, }; } diff --git a/apps/core/src/adapters/StaticDiscoverAdapter.ts b/apps/core/src/adapters/StaticDiscoverAdapter.ts index dda4a1fd..4f6bd73b 100644 --- a/apps/core/src/adapters/StaticDiscoverAdapter.ts +++ b/apps/core/src/adapters/StaticDiscoverAdapter.ts @@ -25,6 +25,26 @@ export const STATIC_MEDIA_DATABASES: MediaDatabaseEntry[] = [ url: "https://1255396852-24lotax0vl.ap-hongkong.tencentscf.com", authorizationMethod: "none", }, + { + type: "tmdb-asset", + url: "https://image.tmdb.org", + authorizationMethod: "none", + }, + { + type: "tmdb-asset", + url: "https://1255396852-19bqcvs6wn.ap-hongkong.tencentscf.com", + authorizationMethod: "none", + }, + { + type: "tvdb-asset", + url: "https://artworks.thetvdb.com", + authorizationMethod: "none", + }, + { + type: "tvdb-asset", + url: "https://1255396852-2gz8ynvtkt.ap-hongkong.tencentscf.com", + authorizationMethod: "none", + }, ]; export class StaticDiscoverAdapter implements DiscoverPort { diff --git a/apps/core/src/clients/TmdbClient.test.ts b/apps/core/src/clients/TmdbClient.test.ts index 9d963d06..9681a696 100644 --- a/apps/core/src/clients/TmdbClient.test.ts +++ b/apps/core/src/clients/TmdbClient.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { HostPerformanceStore } from "./hostPerformance"; import { buildTvShowMediaMetadata, TmdbClient } from "./TmdbClient"; function jsonResponse(body: unknown): HttpResponse { @@ -112,20 +113,18 @@ describe("TmdbClient", () => { fetch: async (url) => { urls.push(url); if (url.includes("dead.example")) throw new Error("down"); - return jsonResponse({ results: [{ id: 9, name: "X" }], page: 1, total_pages: 1, total_results: 1 }); + if (url.includes("live.example")) { + return jsonResponse({ results: [{ id: 9, name: "X" }], page: 1, total_pages: 1, total_results: 1 }); + } + throw new Error("unexpected: " + url); }, }; - const client = new TmdbClient(network, { - discover: { - getDiscoverConfig: async () => ({ - mediaDatabases: [ - { type: "tmdb", url: "https://dead.example/api/tmdb", authorizationMethod: "none" }, - { type: "tmdb", url: "https://live.example/api/tmdb", authorizationMethod: "none" }, - ], - reverseProxies: [], - }), - }, - }); + const hostPerformance = new HostPerformanceStore(); + hostPerformance.set("tmdb", [ + { host: "https://dead.example/api/tmdb", score: 0.2 }, + { host: "https://live.example/api/tmdb", score: 1.1 }, + ]); + const client = new TmdbClient(network, { hostPerformance }); const body = await client.search("X", "tv", "en-US"); expect(body.results[0]?.id).toBe(9); diff --git a/apps/core/src/clients/TmdbClient.ts b/apps/core/src/clients/TmdbClient.ts index c67ea781..6f8c6475 100644 --- a/apps/core/src/clients/TmdbClient.ts +++ b/apps/core/src/clients/TmdbClient.ts @@ -12,6 +12,7 @@ import type { } from "@smm/core"; import type { DiscoverPort } from "../ports/DiscoverPort"; import type { NetworkPort } from "../ports/NetworkPort"; +import type { HostPerformanceStore } from "./hostPerformance"; import { fetchMediaDatabase, SMM_TMDB_DEFAULT_UPSTREAM, @@ -25,6 +26,7 @@ export interface TmdbClientOptions { httpProxy?: string; reverseProxyUrl?: string | null; discover?: DiscoverPort; + hostPerformance?: HostPerformanceStore; } export class TmdbClient { @@ -33,6 +35,7 @@ export class TmdbClient { private readonly httpProxy?: string; private readonly reverseProxyUrl?: string | null; private readonly discover?: DiscoverPort; + private readonly hostPerformance?: HostPerformanceStore; constructor( private readonly network: NetworkPort, @@ -43,6 +46,7 @@ export class TmdbClient { this.httpProxy = options.httpProxy?.trim() || undefined; this.reverseProxyUrl = options.reverseProxyUrl; this.discover = options.discover; + this.hostPerformance = options.hostPerformance; } private async request(urlPath: string): Promise { @@ -54,6 +58,7 @@ export class TmdbClient { httpProxy: this.httpProxy, reverseProxyUrl: this.reverseProxyUrl, discover: this.discover, + hostPerformance: this.hostPerformance, }); if (!resp.ok) { throw new Error(`TMDB request failed: ${resp.status} ${resp.statusText}`); diff --git a/apps/core/src/clients/TvdbClient.ts b/apps/core/src/clients/TvdbClient.ts index a4e4ef18..c834e577 100644 --- a/apps/core/src/clients/TvdbClient.ts +++ b/apps/core/src/clients/TvdbClient.ts @@ -9,6 +9,7 @@ import type { import type { MovieMediaMetadata, TvShowMediaMetadata } from "@smm/core"; import type { DiscoverPort } from "../ports/DiscoverPort"; import type { NetworkPort } from "../ports/NetworkPort"; +import type { HostPerformanceStore } from "./hostPerformance"; import { fetchMediaDatabase, SMM_TVDB_DEFAULT_UPSTREAM, @@ -36,6 +37,7 @@ export interface TvdbClientOptions { httpProxy?: string; reverseProxyUrl?: string | null; discover?: DiscoverPort; + hostPerformance?: HostPerformanceStore; } /** Sentinel base URL; real routing goes through {@link fetchMediaDatabase}. */ @@ -50,6 +52,7 @@ export class TvdbClient { const httpProxy = options.httpProxy?.trim() || undefined; const reverseProxyUrl = options.reverseProxyUrl; const discover = options.discover; + const hostPerformance = options.hostPerformance; this.client = new TVDBv4({ baseUrl: TVDB_TRANSPORT_BASE, @@ -67,6 +70,7 @@ export class TvdbClient { httpProxy, reverseProxyUrl, discover, + hostPerformance, }); }, }); diff --git a/apps/core/src/clients/hostPerformance.test.ts b/apps/core/src/clients/hostPerformance.test.ts new file mode 100644 index 00000000..960139ef --- /dev/null +++ b/apps/core/src/clients/hostPerformance.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + HostPerformanceStore, + mergeHostUrls, + selectCandidateHosts, +} from "./hostPerformance"; + +describe("mergeHostUrls", () => { + it("keeps static hosts first and appends unique remote hosts", () => { + expect( + mergeHostUrls( + ["https://static.example/api/tmdb/", "https://static-2.example"], + ["https://remote.example", "https://static.example/api/tmdb"], + ), + ).toEqual([ + "https://static.example/api/tmdb", + "https://static-2.example", + "https://remote.example", + ]); + }); +}); + +describe("selectCandidateHosts", () => { + const defaultUpstream = "https://mediadb.vercel.app/api/tmdb"; + + it("uses only the custom host and disables failover", () => { + const result = selectCandidateHosts({ + customHost: "https://api.themoviedb.org/3", + defaultUpstream, + performanceList: [{ host: "https://fast.example", score: 1 }], + fallbackHosts: ["https://a.example", "https://b.example"], + }); + + expect(result).toEqual({ + hosts: ["https://api.themoviedb.org/3"], + allowFailover: false, + }); + }); + + it("uses fallback host order when the performance list is empty", () => { + const result = selectCandidateHosts({ + defaultUpstream, + performanceList: [], + fallbackHosts: ["https://static.example", "https://remote.example"], + }); + + expect(result).toEqual({ + hosts: ["https://static.example", "https://remote.example"], + allowFailover: true, + }); + }); + + it("uses performance-list order when speed test results exist", () => { + const result = selectCandidateHosts({ + defaultUpstream, + performanceList: [ + { host: "https://slow.example", score: 2.5 }, + { host: "https://fast.example", score: 0.4 }, + ], + fallbackHosts: ["https://static.example"], + }); + + expect(result).toEqual({ + hosts: ["https://slow.example", "https://fast.example"], + allowFailover: true, + }); + }); +}); + +describe("HostPerformanceStore", () => { + it("promotes a successful host to the top with score 0", () => { + const store = new HostPerformanceStore(); + store.set("tmdb", [ + { host: "https://a.example", score: 1.2 }, + { host: "https://b.example", score: 0.8 }, + ]); + + store.promoteToTop("tmdb", "https://b.example/"); + + expect(store.get("tmdb")).toEqual([ + { host: "https://b.example", score: 0 }, + { host: "https://a.example", score: 1.2 }, + ]); + }); + + it("matches a successful host by hostname when the URL includes a path", () => { + const store = new HostPerformanceStore(); + store.set("tmdb-asset", [{ host: "https://cdn.example/assets", score: 1 }]); + store.promoteToTop("tmdb-asset", "https://cdn.example/t/p/original/x.jpg"); + expect(store.get("tmdb-asset")[0]).toEqual({ host: "https://cdn.example/assets", score: 0 }); + }); + + it("does not promote when the performance list is empty", () => { + const store = new HostPerformanceStore(); + store.promoteToTop("tmdb", "https://a.example"); + expect(store.get("tmdb")).toEqual([]); + }); +}); diff --git a/apps/core/src/clients/hostPerformance.ts b/apps/core/src/clients/hostPerformance.ts new file mode 100644 index 00000000..15aa5d25 --- /dev/null +++ b/apps/core/src/clients/hostPerformance.ts @@ -0,0 +1,104 @@ +export interface HostPerformanceEntry { + host: string; + score: number; +} + +export type HostPerformanceKind = "tmdb" | "tvdb" | "tmdb-asset" | "tvdb-asset"; + +export function normalizeHostUrl(url: string): string { + return url.trim().replace(/\/+$/, ""); +} + +export function mergeHostUrls(...lists: string[][]): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const list of lists) { + for (const url of list) { + const normalized = normalizeHostUrl(url); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + } + return merged; +} + +export function isCustomHost(host: string | undefined, defaultUpstream: string): boolean { + const trimmed = host?.trim() ?? ""; + if (!trimmed) return false; + try { + // eslint-disable-next-line no-new + new URL(trimmed); + } catch { + return false; + } + return normalizeHostUrl(trimmed) !== normalizeHostUrl(defaultUpstream); +} + +export interface SelectCandidateHostsOptions { + customHost?: string; + defaultUpstream: string; + performanceList: readonly HostPerformanceEntry[]; + fallbackHosts: string[]; +} + +export interface CandidateHostSelection { + hosts: string[]; + allowFailover: boolean; +} + +/** Pick TMDB/TVDB hosts: custom (no failover) → performance order → static+remote fallback. */ +export function selectCandidateHosts(options: SelectCandidateHostsOptions): CandidateHostSelection { + if (isCustomHost(options.customHost, options.defaultUpstream)) { + return { hosts: [normalizeHostUrl(options.customHost!)], allowFailover: false }; + } + if (options.performanceList.length === 0) { + return { hosts: [...options.fallbackHosts], allowFailover: true }; + } + return { + hosts: options.performanceList.map((entry) => normalizeHostUrl(entry.host)), + allowFailover: true, + }; +} + +export class HostPerformanceStore { + private readonly lists: Record = { + tmdb: [], + tvdb: [], + "tmdb-asset": [], + "tvdb-asset": [], + }; + + get(kind: HostPerformanceKind): readonly HostPerformanceEntry[] { + return this.lists[kind]; + } + + set(kind: HostPerformanceKind, entries: HostPerformanceEntry[]): void { + this.lists[kind] = entries.map((entry) => ({ + host: normalizeHostUrl(entry.host), + score: entry.score, + })); + } + + /** + * Move a reachable host to the front with score 0 so the next request prefers it. + * No-op while the list is empty (speed test running, skipped, or failed). + */ + promoteToTop(kind: HostPerformanceKind, host: string): void { + const list = this.lists[kind]; + if (list.length === 0) return; + + const normalized = normalizeHostUrl(host); + const index = list.findIndex((entry) => { + if (normalizeHostUrl(entry.host) === normalized) return true; + try { + return new URL(entry.host).host === new URL(normalized).host; + } catch { + return false; + } + }); + const promoted: HostPerformanceEntry = { host: index === -1 ? normalized : list[index]!.host, score: 0 }; + const rest = index === -1 ? list : list.filter((_, i) => i !== index); + this.lists[kind] = [promoted, ...rest]; + } +} diff --git a/apps/core/src/clients/hostSpeedTest.test.ts b/apps/core/src/clients/hostSpeedTest.test.ts new file mode 100644 index 00000000..0bd47787 --- /dev/null +++ b/apps/core/src/clients/hostSpeedTest.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { speedTestHosts } from "./hostSpeedTest"; + +function jsonOk(): HttpResponse { + return { + ok: true, + status: 200, + statusText: "OK", + headers: {}, + text: () => Promise.resolve("{}"), + json: () => Promise.resolve({} as T), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }; +} + +describe("speedTestHosts", () => { + it("scores reachable hosts by duration in seconds and sorts lower first", async () => { + let nowMs = 0; + const network: NetworkPort = { + fetch: async (url) => { + nowMs += url.includes("slow") ? 2500 : 400; + return jsonOk(); + }, + }; + + const results = await speedTestHosts( + network, + ["https://slow.example/api/tmdb", "https://fast.example/api/tmdb"], + { now: () => nowMs }, + ); + + expect(results.map((entry) => entry.host)).toEqual([ + "https://fast.example/api/tmdb", + "https://slow.example/api/tmdb", + ]); + expect(results[0]!.score).toBe(0.4); + expect(results[1]!.score).toBe(2.5); + }); + + it("omits hosts that fail at TCP layer so an all-fail run leaves the list empty", async () => { + const network: NetworkPort = { + fetch: async () => { + throw new Error("ECONNREFUSED"); + }, + }; + + const results = await speedTestHosts(network, ["https://dead.example", "https://also-dead.example"]); + + expect(results).toEqual([]); + }); + + it("keeps HTTP 4xx/5xx hosts because they are reachable", async () => { + const network: NetworkPort = { + fetch: async () => ({ + ok: false, + status: 500, + statusText: "ERR", + headers: {}, + text: () => Promise.resolve(""), + json: async () => { + throw new Error("no json"); + }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }), + }; + + const results = await speedTestHosts(network, ["https://http-error.example"], { now: () => 0 }); + + expect(results).toEqual([{ host: "https://http-error.example", score: 0 }]); + }); +}); diff --git a/apps/core/src/clients/hostSpeedTest.ts b/apps/core/src/clients/hostSpeedTest.ts new file mode 100644 index 00000000..46ca4bb1 --- /dev/null +++ b/apps/core/src/clients/hostSpeedTest.ts @@ -0,0 +1,43 @@ +import type { NetworkPort } from "../ports/NetworkPort"; +import { normalizeHostUrl, type HostPerformanceEntry } from "./hostPerformance"; + +export const HOST_SPEED_TEST_TIMEOUT_MS = 5_000; + +export interface SpeedTestHostsOptions { + now?: () => number; + timeoutMs?: number; +} + +/** + * Dummy-GET each host and score it by duration in seconds (lower is better). + * TCP-or-below failures are omitted; HTTP 4xx/5xx still count as reachable. + */ +export async function speedTestHosts( + network: NetworkPort, + hosts: string[], + options: SpeedTestHostsOptions = {}, +): Promise { + const now = options.now ?? (() => performance.now()); + const timeoutMs = options.timeoutMs ?? HOST_SPEED_TEST_TIMEOUT_MS; + const entries: HostPerformanceEntry[] = []; + + for (const host of hosts) { + const normalized = normalizeHostUrl(host); + if (!normalized) continue; + const start = now(); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + await network.fetch(normalized, { method: "GET", signal: controller.signal }); + } finally { + clearTimeout(timer); + } + entries.push({ host: normalized, score: (now() - start) / 1000 }); + } catch { + // Unreachable at TCP or below — skip so an all-fail run leaves the list empty. + } + } + + return entries.sort((a, b) => a.score - b.score); +} diff --git a/apps/core/src/clients/mediaDatabaseTransport.test.ts b/apps/core/src/clients/mediaDatabaseTransport.test.ts index c54e3811..eb7b1073 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.test.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { DiscoverPort } from "../ports/DiscoverPort"; import type { HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { HostPerformanceStore } from "./hostPerformance"; import { _resetTvdbTokenCacheForTests, fetchMediaDatabase, @@ -39,9 +40,8 @@ describe("fetchMediaDatabase", () => { const network: NetworkPort = { fetch: async (url) => { urls.push(url); - if (url.includes("dead.example")) throw new Error("network down"); if (url.includes("live.example")) return jsonOk({ results: [] }); - throw new Error("unexpected: " + url); + throw new Error("network down"); }, }; const discover: DiscoverPort = { @@ -61,8 +61,7 @@ describe("fetchMediaDatabase", () => { }); expect(resp.ok).toBe(true); - expect(urls[0]).toContain("https://dead.example/api/tmdb/search/tv"); - expect(urls[1]).toContain("https://live.example/api/tmdb/search/tv"); + expect(urls.some((url) => url.includes("https://live.example/api/tmdb/search/tv"))).toBe(true); }); it("routes custom host via local reverse proxy with X-SMM-Proxy-Upstream-BaseURL", async () => { @@ -114,7 +113,7 @@ describe("fetchMediaDatabase", () => { ).rejects.toBeInstanceOf(MediaDatabaseFailoverExhaustedError); }); - it("failovers on HTTP non-OK for default upstream hosts", async () => { + it("treats HTTP 5xx as success and does not failover", async () => { const urls: string[] = []; const network: NetworkPort = { fetch: async (url) => { @@ -123,23 +122,20 @@ describe("fetchMediaDatabase", () => { return jsonOk({}); }, }; - const discover: DiscoverPort = { - getDiscoverConfig: async () => ({ - mediaDatabases: [ - { type: "tmdb", url: "https://a.example", authorizationMethod: "none" }, - { type: "tmdb", url: "https://b.example", authorizationMethod: "none" }, - ], - reverseProxies: [], - }), - }; + const hostPerformance = new HostPerformanceStore(); + hostPerformance.set("tmdb", [ + { host: "https://a.example", score: 1 }, + { host: "https://b.example", score: 2 }, + ]); const resp = await fetchMediaDatabase(network, { kind: "tmdb", path: "/tv/1", - discover, + hostPerformance, }); - expect(resp.ok).toBe(true); - expect(urls).toHaveLength(2); + expect(resp.status).toBe(502); + expect(urls).toEqual(["https://a.example/tv/1"]); + expect(hostPerformance.get("tmdb")[0]).toEqual({ host: "https://a.example", score: 0 }); }); it("passes httpProxy to NetworkPort on direct custom-host fetch", async () => { @@ -161,6 +157,59 @@ describe("fetchMediaDatabase", () => { expect(proxies[0]).toBe("socks5://127.0.0.1:1080"); }); + + it("tries hosts in performance-list order and failovers only on TCP failure", async () => { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url) => { + urls.push(url); + if (url.includes("slow.example")) throw new Error("ECONNREFUSED"); + if (url.includes("fast.example")) return jsonOk({ results: [] }); + throw new Error("unexpected: " + url); + }, + }; + const hostPerformance = new HostPerformanceStore(); + hostPerformance.set("tmdb", [ + { host: "https://slow.example/api/tmdb", score: 0.2 }, + { host: "https://fast.example/api/tmdb", score: 1.5 }, + ]); + + const resp = await fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + hostPerformance, + }); + + expect(resp.ok).toBe(true); + expect(urls[0]).toContain("https://slow.example/api/tmdb/search/tv"); + expect(urls[1]).toContain("https://fast.example/api/tmdb/search/tv"); + expect(hostPerformance.get("tmdb")[0]).toEqual({ + host: "https://fast.example/api/tmdb", + score: 0, + }); + }); + + it("does not failover a custom host when it fails at TCP layer", async () => { + const urls: string[] = []; + const network: NetworkPort = { + fetch: async (url) => { + urls.push(url); + throw new Error("ECONNREFUSED"); + }, + }; + const hostPerformance = new HostPerformanceStore(); + hostPerformance.set("tmdb", [{ host: "https://fast.example", score: 0.1 }]); + + await expect( + fetchMediaDatabase(network, { + kind: "tmdb", + path: "/search/tv?query=x", + configuredHost: "https://api.themoviedb.org/3", + hostPerformance, + }), + ).rejects.toBeInstanceOf(MediaDatabaseFailoverExhaustedError); + expect(urls).toEqual(["https://api.themoviedb.org/3/search/tv?query=x"]); + }); }); describe("TVDB custom-host JWT login", () => { diff --git a/apps/core/src/clients/mediaDatabaseTransport.ts b/apps/core/src/clients/mediaDatabaseTransport.ts index 53ac1799..885d59bd 100644 --- a/apps/core/src/clients/mediaDatabaseTransport.ts +++ b/apps/core/src/clients/mediaDatabaseTransport.ts @@ -1,5 +1,13 @@ +import { STATIC_MEDIA_DATABASES } from "../adapters/StaticDiscoverAdapter"; import type { DiscoverPort, MediaDatabaseAuthorizationMethod, ReverseProxyEntry } from "../ports/DiscoverPort"; import type { FetchInit, HttpResponse, NetworkPort } from "../ports/NetworkPort"; +import { + isCustomHost, + mergeHostUrls, + normalizeHostUrl, + selectCandidateHosts, + type HostPerformanceStore, +} from "./hostPerformance"; export const SMM_TMDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tmdb"; export const SMM_TVDB_DEFAULT_UPSTREAM = "https://mediadb.vercel.app/api/tvdb"; @@ -27,30 +35,16 @@ export interface FetchMediaDatabaseOptions { /** Local SMM reverse proxy base URL (required for reliable custom-host routing). */ reverseProxyUrl?: string | null; discover?: DiscoverPort; -} - -function normalizeBase(url: string): string { - return url.trim().replace(/\/+$/, ""); + /** In-memory speed-test results; empty means use static+remote fallback order. */ + hostPerformance?: HostPerformanceStore; } function joinUrl(base: string, path: string): string { - const cleanBase = normalizeBase(base); + const cleanBase = normalizeHostUrl(base); const cleanPath = path.startsWith("/") ? path : `/${path}`; return cleanBase + cleanPath; } -function isCustomHost(host: string | undefined, defaultUpstream: string): boolean { - const trimmed = host?.trim() ?? ""; - if (!trimmed) return false; - try { - // eslint-disable-next-line no-new - new URL(trimmed); - } catch { - return false; - } - return normalizeBase(trimmed) !== normalizeBase(defaultUpstream); -} - function dateToken(now = new Date()): string { const y = now.getUTCFullYear(); const m = String(now.getUTCMonth() + 1).padStart(2, "0"); @@ -88,7 +82,7 @@ async function ensureTvdbToken( apiKey: string | undefined, proxy: string | undefined, ): Promise { - const key = normalizeBase(host); + const key = normalizeHostUrl(host); const cached = tvdbTokenCache.get(key); if (cached && Date.now() < cached.expiresAt - TVDB_TOKEN_REFRESH_BUFFER_MS) { return cached.token; @@ -121,7 +115,7 @@ function generalProxyHeaders( const headers: Record = { Accept: "application/json", ...authHeaders(apiKey), - "X-Upstream-Base-Url": normalizeBase(upstreamBaseURL), + "X-Upstream-Base-Url": normalizeHostUrl(upstreamBaseURL), }; if (authorizationMethod === "date-token") { headers["X-Proxy-Authorization"] = `Bearer ${dateToken()}`; @@ -137,13 +131,17 @@ function localProxyHeaders( const headers: Record = { Accept: "application/json", ...authHeaders(apiKey), - "X-SMM-Proxy-Upstream-BaseURL": normalizeBase(upstreamBaseURL), + "X-SMM-Proxy-Upstream-BaseURL": normalizeHostUrl(upstreamBaseURL), }; const proxy = httpProxy?.trim(); if (proxy) headers["X-Http-Proxy"] = proxy; return headers; } +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + async function tryFetch( network: NetworkPort, url: string, @@ -154,18 +152,25 @@ async function tryFetch( const init: FetchInit = { method: "GET", headers }; const trimmedProxy = proxy?.trim(); if (trimmedProxy) init.proxy = trimmedProxy; - const resp = await network.fetch(url, init); - if (resp.ok) return resp; - return undefined; - } catch { + // Any HTTP status means the host is reachable at TCP layer. + return await network.fetch(url, init); + } catch (error) { + if (isAbortError(error)) throw error; return undefined; } } +function fallbackHostsForKind(kind: MediaDatabaseKind, remoteUrls: string[], defaultUpstream: string): string[] { + const staticUrls = STATIC_MEDIA_DATABASES.filter((entry) => entry.type === kind).map((entry) => entry.url); + const merged = mergeHostUrls(staticUrls, remoteUrls); + return merged.length > 0 ? merged : [normalizeHostUrl(defaultUpstream)]; +} + /** - * Fetch a TMDB/TVDB API path with the same routing policy as UI `fetchTmdb` / `fetchTvdb`: - * - custom host → local reverse proxy (or direct if no reverseProxyUrl, for tests) - * - default → discover hosts, each tried direct then via general reverse proxies + * Fetch a TMDB/TVDB API path: + * - custom host → no failover + * - empty performance list → static+remote order + * - otherwise performance-list order; TCP-or-below failure tries the next host */ export async function fetchMediaDatabase( network: NetworkPort, @@ -177,7 +182,7 @@ export async function fetchMediaDatabase( const attempted: string[] = []; if (isCustomHost(options.configuredHost, defaultUpstream)) { - const upstream = normalizeBase(options.configuredHost!); + const upstream = normalizeHostUrl(options.configuredHost!); const localProxy = options.reverseProxyUrl?.trim(); if (localProxy) { const url = joinUrl(localProxy, path); @@ -194,7 +199,7 @@ export async function fetchMediaDatabase( // (httpProxy is passed as NetworkPort `proxy`, not X-Http-Proxy). const url = joinUrl(upstream, path); attempted.push(url); - let headers: Record = { Accept: "application/json" }; + const headers: Record = { Accept: "application/json" }; if (options.kind === "tvdb") { const token = await ensureTvdbToken(network, upstream, options.apiKey, options.httpProxy); headers.Authorization = `Bearer ${token}`; @@ -210,14 +215,15 @@ export async function fetchMediaDatabase( ? await options.discover.getDiscoverConfig() : { mediaDatabases: [], reverseProxies: [] as ReverseProxyEntry[] }; - let hosts = config.mediaDatabases - .filter((e) => e.type === options.kind) - .map((e) => normalizeBase(e.url)) - .filter(Boolean); - - if (hosts.length === 0) { - hosts = [normalizeBase(defaultUpstream)]; - } + const remoteUrls = config.mediaDatabases + .filter((entry) => entry.type === options.kind) + .map((entry) => entry.url); + const { hosts, allowFailover } = selectCandidateHosts({ + customHost: options.configuredHost, + defaultUpstream, + performanceList: options.hostPerformance?.get(options.kind) ?? [], + fallbackHosts: fallbackHostsForKind(options.kind, remoteUrls, defaultUpstream), + }); const proxies = config.reverseProxies.filter((p) => { try { @@ -241,7 +247,10 @@ export async function fetchMediaDatabase( }, options.httpProxy, ); - if (direct !== undefined) return direct; + if (direct !== undefined) { + options.hostPerformance?.promoteToTop(options.kind, host); + return direct; + } for (const proxy of proxies) { const proxyUrl = joinUrl(proxy.url, path); @@ -251,8 +260,13 @@ export async function fetchMediaDatabase( proxyUrl, generalProxyHeaders(host, proxy.authorizationMethod, options.apiKey), ); - if (viaProxy !== undefined) return viaProxy; + if (viaProxy !== undefined) { + options.hostPerformance?.promoteToTop(options.kind, host); + return viaProxy; + } } + + if (!allowFailover) break; } throw new MediaDatabaseFailoverExhaustedError(attempted); diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 8d3874da..85bc2ae7 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -52,6 +52,11 @@ export { NetworkFsAdapter, type NetworkFsAdapterOptions } from "./adapters/netwo export { FetchNetworkAdapter } from "./adapters/FetchNetworkAdapter"; export { ConsoleLoggerAdapter, NoopLoggerAdapter } from "./adapters/ConsoleLoggerAdapter"; export { StaticDiscoverAdapter, STATIC_MEDIA_DATABASES } from "./adapters/StaticDiscoverAdapter"; +export { + HostPerformanceStore, + type HostPerformanceEntry, + type HostPerformanceKind, +} from "./clients/hostPerformance"; export { ImportFolderPipeline, type ImportFolderPipelineCallbacks, type ImportFolderPipelineOptions } from "./pipeline/importFolderPipeline"; export { UserConfigHelper, diff --git a/apps/core/src/pipeline/scrape/assetImageUrls.ts b/apps/core/src/pipeline/scrape/assetImageUrls.ts index ca8a0d1e..337d4c69 100644 --- a/apps/core/src/pipeline/scrape/assetImageUrls.ts +++ b/apps/core/src/pipeline/scrape/assetImageUrls.ts @@ -13,7 +13,7 @@ function normalizeImageUrl(url: string): string { return url; } -function hostSwap(originalUrl: string, assetBaseUrl: string): string | null { +export function hostSwap(originalUrl: string, assetBaseUrl: string): string | null { try { const original = new URL(originalUrl); const base = new URL(assetBaseUrl); @@ -30,7 +30,7 @@ function hostSwap(originalUrl: string, assetBaseUrl: string): string | null { } } -function assetTypeForHost(hostname: string): MediaDatabaseType | null { +export function assetTypeForHost(hostname: string): MediaDatabaseType | null { if (TMDB_IMAGE_HOSTS.has(hostname)) return "tmdb-asset"; if (TVDB_ARTWORK_HOSTS.has(hostname)) return "tvdb-asset"; return null; diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts index 32567bd9..ff191b44 100644 --- a/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.test.ts @@ -206,7 +206,7 @@ describe("downloadScrapeImage", () => { fetch: vi.fn(async (url: string) => { urls.push(url); if (url.includes("wronghost.tmdb.local")) { - return fakeImageResponse(new Uint8Array(), false); + throw new Error("ECONNREFUSED"); } if (url.includes("tmdb-mirror.example")) { return fakeImageResponse(fakeBytes); @@ -317,7 +317,7 @@ describe("downloadScrapeImage", () => { ), ).rejects.toThrow("HTTP error! status: 503"); - expect(network.fetch).toHaveBeenCalledTimes(2); + expect(network.fetch).toHaveBeenCalledTimes(1); }); it("downloads from the official CDN when discover is unavailable", async () => { diff --git a/apps/core/src/pipeline/scrape/downloadScrapeImage.ts b/apps/core/src/pipeline/scrape/downloadScrapeImage.ts index e0a3eb87..6267104a 100644 --- a/apps/core/src/pipeline/scrape/downloadScrapeImage.ts +++ b/apps/core/src/pipeline/scrape/downloadScrapeImage.ts @@ -1,9 +1,10 @@ import type { MediaMetadata, UserConfig } from "@smm/core"; +import type { HostPerformanceStore } from "../../clients/hostPerformance"; import type { DiscoverConfig, DiscoverPort } from "../../ports/DiscoverPort"; import type { FetchInit, HttpResponse, NetworkPort } from "../../ports/NetworkPort"; import type { FsPort } from "../../ports/FsPort"; import { dirname } from "../paths"; -import { buildAssetUrlCandidates } from "./assetImageUrls"; +import { assetTypeForHost, buildAssetUrlCandidates, hostSwap } from "./assetImageUrls"; import { resolveScrapeHttpProxy } from "./resolveScrapeHttpProxy"; /** Browser-like request headers for remote TMDB/TVDB image fetches. */ @@ -22,6 +23,7 @@ const EMPTY_DISCOVER_CONFIG: DiscoverConfig = { mediaDatabases: [], reverseProxi export interface DownloadScrapeImageDeps { discover?: DiscoverPort; + hostPerformance?: HostPerformanceStore; /** Test hook: simulate TMDB CDN outage (see buildAssetUrlCandidates). */ overrideDefaultTmdbAssetServerHost?: string | null; /** @@ -80,9 +82,10 @@ export async function downloadScrapeImage( ? await deps.discover.getDiscoverConfig().catch(() => EMPTY_DISCOVER_CONFIG) : EMPTY_DISCOVER_CONFIG; - const candidates = buildAssetUrlCandidates(normalizedUrl, discoverConfig, { + const fallbackCandidates = buildAssetUrlCandidates(normalizedUrl, discoverConfig, { overrideDefaultTmdbAssetServerHost: deps.overrideDefaultTmdbAssetServerHost, }); + const candidates = orderAssetCandidates(normalizedUrl, fallbackCandidates, deps.hostPerformance); const init: FetchInit = { method: "GET", @@ -99,20 +102,23 @@ export async function downloadScrapeImage( network, deps.fetch, ); - if (!response.ok) { - lastError = new Error(`HTTP error! status: ${response.status}`); - continue; - } - - const arrayBuffer = await response.arrayBuffer(); - const data = new Uint8Array(arrayBuffer); + if (response.ok) { + const arrayBuffer = await response.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); - const parentDir = dirname(filePath); - if (parentDir !== "/") { - await fs.mkdir(parentDir); + const parentDir = dirname(filePath); + if (parentDir !== "/") { + await fs.mkdir(parentDir); + } + await fs.writeBinaryFile(filePath, data); + promoteAssetHost(normalizedUrl, candidate, deps.hostPerformance); + return; } - await fs.writeBinaryFile(filePath, data); - return; + + lastError = new Error(`HTTP error! status: ${response.status}`); + promoteAssetHost(normalizedUrl, candidate, deps.hostPerformance); + // HTTP-layer failure means the host is reachable — stop failover. + break; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } @@ -120,3 +126,47 @@ export async function downloadScrapeImage( throw lastError ?? new Error("Failed to download image: no candidates"); } + +function orderAssetCandidates( + originalUrl: string, + fallbackCandidates: string[], + hostPerformance: HostPerformanceStore | undefined, +): string[] { + let hostname = ""; + try { + hostname = new URL(originalUrl).hostname; + } catch { + return fallbackCandidates; + } + const assetType = assetTypeForHost(hostname); + const list = assetType ? hostPerformance?.get(assetType) : undefined; + if (!list || list.length === 0) return fallbackCandidates; + + const ordered: string[] = []; + for (const entry of list) { + const swapped = hostSwap(originalUrl, entry.host); + if (swapped && !ordered.includes(swapped)) ordered.push(swapped); + } + return ordered.length > 0 ? ordered : fallbackCandidates; +} + +function promoteAssetHost( + originalUrl: string, + succeededUrl: string, + hostPerformance: HostPerformanceStore | undefined, +): void { + if (!hostPerformance) return; + let hostname = ""; + try { + hostname = new URL(originalUrl).hostname; + } catch { + return; + } + const assetType = assetTypeForHost(hostname); + if (!assetType) return; + try { + hostPerformance.promoteToTop(assetType, new URL(succeededUrl).origin); + } catch { + // ignore invalid URLs + } +} diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts index 2cd54c0b..27185fdf 100644 --- a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.ts @@ -128,6 +128,7 @@ export async function scrapeFanart(deps: ScrapeTaskDeps): Promise string; discover?: DiscoverPort; reverseProxyUrl?: string | null; + hostPerformance?: HostPerformanceStore; } export interface ScrapeFolderProgress { @@ -135,11 +137,13 @@ export async function runPreparedScrape( ...config.tmdb, discover: deps.discover, reverseProxyUrl: deps.reverseProxyUrl, + hostPerformance: deps.hostPerformance, }); const tvdb = new TvdbClient(deps.network, { ...config.tvdb, discover: deps.discover, reverseProxyUrl: deps.reverseProxyUrl, + hostPerformance: deps.hostPerformance, }); const completion = await checkScrapeCompletion(mediaMetadata, deps.fs); @@ -154,6 +158,7 @@ export async function runPreparedScrape( userConfig: config, reverseProxyUrl: deps.reverseProxyUrl ?? undefined, discover: deps.discover, + hostPerformance: deps.hostPerformance, }; const tasks = {} as Record; diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts index 2cf418ea..fb8144d5 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -171,16 +171,8 @@ describe("scrapePosterTmdb", () => { const network: NetworkPort = { fetch: vi.fn(async (url: string) => { urls.push(url); - if (url.includes("wronghost.tmdb.local")) { - return { - ok: false, - status: 503, - statusText: "Service Unavailable", - headers: {}, - text: () => Promise.resolve(""), - json: () => Promise.resolve({} as T), - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - }; + if (url.includes("image.tmdb.org")) { + throw new Error("ECONNREFUSED"); } if (url.includes("tmdb-mirror.example")) { return fakeImageResponse(fakeBytes); diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts index 96bf623e..fdbf5cf4 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.ts @@ -110,6 +110,7 @@ export async function scrapePoster(deps: ScrapeTaskDeps): Promise Date: Thu, 27 Aug 2026 19:36:33 +0800 Subject: [PATCH 159/214] fix(e2e): deprecate removeDirInSidebar option in setup function Add a deprecation notice for the removeDirInSidebar option in the setup function, indicating that it is no longer necessary to remove folders in the sidebar due to the resetUserConfig functionality. This change aims to clarify the usage of the setup options for better maintainability. --- apps/e2e/test/lib/testbed.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/e2e/test/lib/testbed.ts b/apps/e2e/test/lib/testbed.ts index ff71b85b..fb4b1ef5 100644 --- a/apps/e2e/test/lib/testbed.ts +++ b/apps/e2e/test/lib/testbed.ts @@ -114,10 +114,17 @@ async function applyResetUserConfig(option: ResetUserConfigOption): Promise Date: Thu, 27 Aug 2026 23:23:54 +0800 Subject: [PATCH 160/214] fix(ui): map preferMediaLanguage to ISO 639-3 for TVDB v3 search Core.searchInTvdb rejects BCP 47 codes like zh-CN; convert them in the tvdbV3 client so folder-name TVDB initialization works under SMM v3. Co-authored-by: Cursor --- apps/ui/src/api/tvdbV3.test.ts | 29 ++++++++++++++++++++++++++++- apps/ui/src/api/tvdbV3.ts | 17 ++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/api/tvdbV3.test.ts b/apps/ui/src/api/tvdbV3.test.ts index b39dcd95..fac710c3 100644 --- a/apps/ui/src/api/tvdbV3.test.ts +++ b/apps/ui/src/api/tvdbV3.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { searchInTvdb } from './tvdbV3' +import { searchInTvdb, toTvdbApiLanguage } from './tvdbV3' vi.mock('@/lib/apiFetch', () => ({ apiFetch: vi.fn(), @@ -26,6 +26,33 @@ describe('tvdbV3 Internal HTTP clients', () => { mockApiFetch.mockReset() }) + it('toTvdbApiLanguage maps preferMediaLanguage to ISO 639-3', () => { + expect(toTvdbApiLanguage('zh-CN')).toBe('zho') + expect(toTvdbApiLanguage('en-US')).toBe('eng') + expect(toTvdbApiLanguage('ja-JP')).toBe('jpn') + expect(toTvdbApiLanguage('zho')).toBe('zho') + expect(toTvdbApiLanguage(undefined)).toBeUndefined() + }) + + it('searchInTvdb maps zh-CN to zho for Core.searchInTvdb', async () => { + mockApiFetch.mockResolvedValue( + jsonResponse({ + data: [{ tvdb_id: '355969', name: '天使降临到了我身边!' }], + }), + ) + await searchInTvdb({ keyword: '天使降临到我身边', type: 'series', language: 'zh-CN' }) + expect(mockApiFetch).toHaveBeenCalledWith('/api/search-in-tvdb', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keyword: '天使降临到我身边', + type: 'series', + language: 'zho', + }), + signal: undefined, + }) + }) + it('searchInTvdb POSTs keyword and type', async () => { mockApiFetch.mockResolvedValue( jsonResponse({ diff --git a/apps/ui/src/api/tvdbV3.ts b/apps/ui/src/api/tvdbV3.ts index de45a9de..7b003068 100644 --- a/apps/ui/src/api/tvdbV3.ts +++ b/apps/ui/src/api/tvdbV3.ts @@ -1,6 +1,20 @@ import { apiFetch } from '@/lib/apiFetch' +import { mediaLanguageToTvdbCode } from '@core/locale' +import type { PreferMediaLanguage } from '@core/types' import type { TVDBv4SearchResult } from '@smm/tvdb4/types' +const PREFER_MEDIA_LANGUAGES = new Set(['zh-CN', 'en-US', 'ja-JP']) + +/** Map UI media language (BCP 47) to TVDB ISO 639-3 before calling Core routes. */ +export function toTvdbApiLanguage(language?: string): string | undefined { + const trimmed = language?.trim() + if (!trimmed) return undefined + if (PREFER_MEDIA_LANGUAGES.has(trimmed)) { + return mediaLanguageToTvdbCode(trimmed as PreferMediaLanguage) + } + return trimmed +} + export interface TvdbCoreRequestOptions { language?: string host?: string @@ -20,7 +34,8 @@ export interface SearchInTvdbResponseBody { function optionalFields(options?: TvdbCoreRequestOptions): Record { const body: Record = {} - if (options?.language) body.language = options.language + const language = toTvdbApiLanguage(options?.language) + if (language) body.language = language if (options?.host) body.host = options.host if (options?.password) body.password = options.password if (options?.proxy) body.proxy = options.proxy From b1468787b1c5ebde5511491e0b23a2b0cf2d0bde Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 10:54:25 +0800 Subject: [PATCH 161/214] ci: add GitHub Actions job for CLI bun:test e2e Run apps/e2e/cli tests against the built CLI binary with embedded HTTP proxy and TMDB/TVDB API secrets. Co-authored-by: Cursor --- .github/workflows/ci.yml | 68 +++++++++++++++++++++++++++++++++ ci/run-cli-e2e-test.ts | 82 ++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 151 insertions(+) create mode 100644 ci/run-cli-e2e-test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17510b61..abb9d5cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ name: CI # Required check names (RELEASE_REQUIRED_CHECKS) must stay stable: # Run Unit Tests, Lint UI, Typecheck, Build UI, Build CLI, # host-e2e / gate, docker-e2e / gate, http-proxy-e2e / gate +# (cli-e2e / gate is optional for release until added to RELEASE_REQUIRED_CHECKS) on: push: branches: [ main, develop ] @@ -556,3 +557,70 @@ jobs: exit 1 fi echo "All HTTP Proxy E2E jobs passed." + + # ------------------------------------------------------------------ + # CLI E2E (bun:test under apps/e2e/cli) + # ------------------------------------------------------------------ + e2e-cli: + name: E2E Tests for CLI + needs: [test, typecheck, build-cli] + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} + TVDB_API_KEY: ${{ secrets.TVDB_API_KEY }} + TMDB_HOST: https://api.themoviedb.org/3 + TVDB_HOST: https://api4.thetvdb.com/v4 + USE_EMBEDDED_HTTP_PROXY: 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install all dependencies + run: pnpm install --frozen-lockfile + + - name: Download CLI build + uses: actions/download-artifact@v4 + with: + name: cli-dist + path: apps/cli/dist + + - name: Require API key secrets + run: | + missing=0 + if [ -z "${TMDB_API_KEY:-}" ]; then + echo "::error::Secret TMDB_API_KEY is empty. Add it under Settings → Secrets and variables → Actions." + missing=1 + fi + if [ -z "${TVDB_API_KEY:-}" ]; then + echo "::error::Secret TVDB_API_KEY is empty. Add it under Settings → Secrets and variables → Actions." + missing=1 + fi + exit "$missing" + + - name: Run CLI E2E tests + run: bun ci/run-cli-e2e-test.ts + + e2e-cli-gate: + name: cli-e2e / gate + needs: [e2e-cli] + if: always() + runs-on: ubuntu-latest + steps: + - name: Require CLI E2E job passed + run: | + set -euo pipefail + if [ "${{ needs.e2e-cli.result }}" != "success" ]; then + echo "::error::cli e2e: ${{ needs.e2e-cli.result }}" + exit 1 + fi + echo "CLI E2E job passed." diff --git a/ci/run-cli-e2e-test.ts b/ci/run-cli-e2e-test.ts new file mode 100644 index 00000000..2fe0281a --- /dev/null +++ b/ci/run-cli-e2e-test.ts @@ -0,0 +1,82 @@ +/** + * CLI e2e runner: `bun test` under `apps/e2e/cli` against `apps/cli/dist/cli`. + * + * Usage (from repo root): + * bun ci/run-cli-e2e-test.ts + * bun ci/run-cli-e2e-test.ts ./cli/tmdb.test.ts + */ +import { $ } from 'bun'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + DEFAULT_EMBEDDED_PROXY_ADDRESS, + startEmbeddedHttpProxy, + stopEmbeddedHttpProxy, + useEmbeddedHttpProxy, +} from '../apps/e2e/test/lib/httpProxyServer.ts'; +import { loadEnvLocal } from './load-env-local.ts'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const E2E_DIR = path.join(ROOT, 'apps/e2e'); +const CLI_BIN = path.join( + ROOT, + 'apps/cli/dist', + process.platform === 'win32' ? 'cli.exe' : 'cli', +); + +const DEFAULTS: Record = { + TMDB_HOST: 'https://api.themoviedb.org/3', + TVDB_HOST: 'https://api4.thetvdb.com/v4', +}; + +function applyDefaultEnv(): void { + for (const [key, value] of Object.entries(DEFAULTS)) { + if (!process.env[key]?.trim()) { + process.env[key] = value; + } + } +} + +function ensureCliBinary(): void { + if (!fs.existsSync(CLI_BIN)) { + console.error(`CLI binary not found: ${CLI_BIN}`); + console.error('Run: cd apps/cli && pnpm run build'); + process.exit(1); + } +} + +async function main(): Promise { + loadEnvLocal(ROOT); + applyDefaultEnv(); + ensureCliBinary(); + + const embedded = useEmbeddedHttpProxy(); + if (embedded) { + await startEmbeddedHttpProxy(DEFAULT_EMBEDDED_PROXY_ADDRESS); + if (!process.env.TMDB_HTTP_PROXY?.trim()) { + process.env.TMDB_HTTP_PROXY = DEFAULT_EMBEDDED_PROXY_ADDRESS; + } + if (!process.env.TVDB_HTTP_PROXY?.trim()) { + process.env.TVDB_HTTP_PROXY = DEFAULT_EMBEDDED_PROXY_ADDRESS; + } + } + + try { + const extraArgs = process.argv.slice(2); + const testArgs = extraArgs.length > 0 ? extraArgs : ['./cli/']; + + const result = await $`bun test ${testArgs}`.cwd(E2E_DIR).env(process.env).nothrow(); + return result.exitCode; + } finally { + if (embedded) { + await stopEmbeddedHttpProxy(); + } + } +} + +main() + .then((exitCode) => process.exit(exitCode)) + .catch((error) => { + console.error('failed:', error); + process.exit(1); + }); diff --git a/package.json b/package.json index b8fc19af..bf23f839 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:core-app": "cd apps/core && pnpm test", "test:cli": "cd apps/cli && pnpm test", "test:cli:e2e": "cd apps/cli && pnpm run test:e2e", + "e2e:cli:bun": "bun ci/run-cli-e2e-test.ts", "test:ui": "cd apps/ui && pnpm test", "test:e2e": "cd apps/e2e && pnpm run test", "e2e:ai": "bun ci/run-e2e-test.ts --spec \"./test/specs/ai/*.e2e.ts\"", From f475609d87917181f7a6f0bed30cdbdc675ec257 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 11:35:03 +0800 Subject: [PATCH 162/214] ci: extract CLI e2e into reusable workflow with standalone dispatch Allow manual runs via "E2E Tests for CLI" while CI continues to call the same reusable job after build-cli. Co-authored-by: Cursor --- .github/workflows/_e2e-cli.yml | 88 ++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 76 +++++------------------------ .github/workflows/e2e-cli.yml | 55 +++++++++++++++++++++ 3 files changed, 155 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/_e2e-cli.yml create mode 100644 .github/workflows/e2e-cli.yml diff --git a/.github/workflows/_e2e-cli.yml b/.github/workflows/_e2e-cli.yml new file mode 100644 index 00000000..3fc3dd35 --- /dev/null +++ b/.github/workflows/_e2e-cli.yml @@ -0,0 +1,88 @@ +name: E2E Tests for CLI (reusable) + +# Bun:test suites under apps/e2e/cli against apps/cli/dist/cli. +# Called by: +# - CI (full run, after build-cli) +# - E2E Tests for CLI (manual workflow_dispatch, after build-cli) +# Expects artifact `cli-dist` from the caller. + +on: + workflow_call: + inputs: + test_filter: + description: 'Optional bun test path under apps/e2e/cli (e.g. ./cli/tmdb.test.ts). Empty runs all CLI e2e tests.' + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + e2e-cli: + name: E2E Tests for CLI + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} + TVDB_API_KEY: ${{ secrets.TVDB_API_KEY }} + TMDB_HOST: https://api.themoviedb.org/3 + TVDB_HOST: https://api4.thetvdb.com/v4 + USE_EMBEDDED_HTTP_PROXY: 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install all dependencies + run: pnpm install --frozen-lockfile + + - name: Download CLI build + uses: actions/download-artifact@v4 + with: + name: cli-dist + path: apps/cli/dist + + - name: Require API key secrets + run: | + missing=0 + if [ -z "${TMDB_API_KEY:-}" ]; then + echo "::error::Secret TMDB_API_KEY is empty. Add it under Settings → Secrets and variables → Actions." + missing=1 + fi + if [ -z "${TVDB_API_KEY:-}" ]; then + echo "::error::Secret TVDB_API_KEY is empty. Add it under Settings → Secrets and variables → Actions." + missing=1 + fi + exit "$missing" + + - name: Run CLI E2E tests + run: | + if [ -n "${{ inputs.test_filter }}" ]; then + bun ci/run-cli-e2e-test.ts "${{ inputs.test_filter }}" + else + bun ci/run-cli-e2e-test.ts + fi + + e2e-cli-gate: + name: cli-e2e / gate + needs: [e2e-cli] + if: always() + runs-on: ubuntu-latest + steps: + - name: Require CLI E2E job passed + run: | + set -euo pipefail + if [ "${{ needs.e2e-cli.result }}" != "success" ]; then + echo "::error::cli e2e: ${{ needs.e2e-cli.result }}" + exit 1 + fi + echo "CLI E2E job passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abb9d5cc..050da374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: CI # Merged from build.yml + e2e-test.yml + e2e-docker.yml + e2e-http-proxy.yml. # E2E groups run only after the build pipeline (test / lint / typecheck / build) succeeds. +# +# CLI bun:test e2e: reusable _e2e-cli.yml (also callable via "E2E Tests for CLI" workflow). +# # Required check names (RELEASE_REQUIRED_CHECKS) must stay stable: # Run Unit Tests, Lint UI, Typecheck, Build UI, Build CLI, # host-e2e / gate, docker-e2e / gate, http-proxy-e2e / gate @@ -16,6 +19,8 @@ on: - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' - '.github/workflows/ci.yml' + - '.github/workflows/_e2e-cli.yml' + - '.github/workflows/e2e-cli.yml' pull_request: branches: [ main, develop ] paths: @@ -25,6 +30,8 @@ on: - 'pnpm-workspace.yaml' - 'pnpm-lock.yaml' - '.github/workflows/ci.yml' + - '.github/workflows/_e2e-cli.yml' + - '.github/workflows/e2e-cli.yml' workflow_dispatch: permissions: @@ -320,7 +327,7 @@ jobs: e2e-docker: name: ${{ matrix.name }} - needs: build-image + needs: [build-image] runs-on: ubuntu-latest timeout-minutes: 90 env: @@ -559,68 +566,9 @@ jobs: echo "All HTTP Proxy E2E jobs passed." # ------------------------------------------------------------------ - # CLI E2E (bun:test under apps/e2e/cli) + # CLI E2E (bun:test under apps/e2e/cli) — reusable _e2e-cli.yml # ------------------------------------------------------------------ e2e-cli: - name: E2E Tests for CLI - needs: [test, typecheck, build-cli] - runs-on: ubuntu-latest - timeout-minutes: 30 - env: - TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} - TVDB_API_KEY: ${{ secrets.TVDB_API_KEY }} - TMDB_HOST: https://api.themoviedb.org/3 - TVDB_HOST: https://api4.thetvdb.com/v4 - USE_EMBEDDED_HTTP_PROXY: 'true' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install all dependencies - run: pnpm install --frozen-lockfile - - - name: Download CLI build - uses: actions/download-artifact@v4 - with: - name: cli-dist - path: apps/cli/dist - - - name: Require API key secrets - run: | - missing=0 - if [ -z "${TMDB_API_KEY:-}" ]; then - echo "::error::Secret TMDB_API_KEY is empty. Add it under Settings → Secrets and variables → Actions." - missing=1 - fi - if [ -z "${TVDB_API_KEY:-}" ]; then - echo "::error::Secret TVDB_API_KEY is empty. Add it under Settings → Secrets and variables → Actions." - missing=1 - fi - exit "$missing" - - - name: Run CLI E2E tests - run: bun ci/run-cli-e2e-test.ts - - e2e-cli-gate: - name: cli-e2e / gate - needs: [e2e-cli] - if: always() - runs-on: ubuntu-latest - steps: - - name: Require CLI E2E job passed - run: | - set -euo pipefail - if [ "${{ needs.e2e-cli.result }}" != "success" ]; then - echo "::error::cli e2e: ${{ needs.e2e-cli.result }}" - exit 1 - fi - echo "CLI E2E job passed." + needs: [build-cli] + uses: ./.github/workflows/_e2e-cli.yml + secrets: inherit diff --git a/.github/workflows/e2e-cli.yml b/.github/workflows/e2e-cli.yml new file mode 100644 index 00000000..7f827327 --- /dev/null +++ b/.github/workflows/e2e-cli.yml @@ -0,0 +1,55 @@ +name: E2E Tests for CLI + +# Manual-only entry for CLI bun:test e2e (apps/e2e/cli). +# Builds CLI, then calls reusable _e2e-cli.yml (same job used by CI). +# +# For full CI (unit + host/docker/http-proxy + CLI e2e), use the CI workflow. + +on: + workflow_dispatch: + inputs: + test_filter: + description: 'Optional bun test path under apps/e2e/cli (e.g. ./cli/tmdb.test.ts). Empty runs all CLI e2e tests.' + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + build-cli: + name: Build CLI + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install all dependencies + run: pnpm install --frozen-lockfile + + - name: Build CLI + run: cd apps/cli && pnpm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: cli-dist + path: apps/cli/dist + retention-days: 7 + + e2e-cli: + needs: [build-cli] + uses: ./.github/workflows/_e2e-cli.yml + with: + test_filter: ${{ inputs.test_filter }} + secrets: inherit From 3a0bd9f26dc30e44382d5090cf81c80d49aa1f12 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 11:41:33 +0800 Subject: [PATCH 163/214] fix(ui): resolve eslint react-hooks lint errors - Move selectedFolderRef update into useEffect in MediaMetadataUpdatedEventListener - Derive telemetry consent dialog visibility from config state instead of effect - Remove unused mockTvdbSearch and stale eslint-disable directives --- .../MediaDatabaseSearchbox.test.tsx | 4 +-- .../MediaMetadataUpdatedEventListener.tsx | 6 ++-- .../AnonymousTelemetryConsentGate.tsx | 30 ++++++------------- .../ui/settings/useMediaDatabaseSettings.ts | 2 -- 4 files changed, 15 insertions(+), 27 deletions(-) diff --git a/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx b/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx index 388b5f76..a253be58 100644 --- a/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx +++ b/apps/ui/src/components/MediaDatabaseSearchbox.test.tsx @@ -74,12 +74,12 @@ vi.mock('./ImmersiveSearchbox', () => ({ }), })) -const { mockSearchTmdb, mockSearchTvdb, mockGetTVDBv4Client, mockTvdbSearch } = vi.hoisted(() => { +const { mockSearchTmdb, mockSearchTvdb, mockGetTVDBv4Client } = vi.hoisted(() => { const mockSearchTmdb = vi.fn() const mockSearchTvdb = vi.fn() const mockTvdbSearch = vi.fn() const mockGetTVDBv4Client = vi.fn(() => ({ search: mockTvdbSearch })) - return { mockSearchTmdb, mockSearchTvdb, mockGetTVDBv4Client, mockTvdbSearch } + return { mockSearchTmdb, mockSearchTvdb, mockGetTVDBv4Client } }) vi.mock('@/api/tmdb', async (importOriginal) => { diff --git a/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx b/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx index a5a17279..a5731887 100644 --- a/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx +++ b/apps/ui/src/components/eventlisteners/MediaMetadataUpdatedEventListener.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useMount, useUnmount } from "react-use"; import { MEDIA_METADATA_UPDATED_EVENT } from "@core/event-types"; import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation"; @@ -11,7 +11,9 @@ export function MediaMetadataUpdatedEventListener() { const { refreshUserConfig } = useConfig(); const selectedFolder = useUIMediaFolderStore((s) => s.selectedFolder); const selectedFolderRef = useRef(selectedFolder); - selectedFolderRef.current = selectedFolder; + useEffect(() => { + selectedFolderRef.current = selectedFolder; + }, [selectedFolder]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const eventListener = useRef<((event: any) => void) | null>(null); diff --git a/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx b/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx index 91886d24..50e7aad6 100644 --- a/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx +++ b/apps/ui/src/components/initialization/AnonymousTelemetryConsentGate.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react" +import { useCallback } from "react" import { toast } from "sonner" import { useConfig } from "@/hooks/userConfig" import { nextTraceId } from "@/lib/utils" @@ -16,36 +16,24 @@ export function AnonymousTelemetryConsentGate() { isUserConfigLoaded, setAndSaveUserConfig, } = useConfig() - const [isOpen, setIsOpen] = useState(false) - - useEffect(() => { - if (isLoading || !isUserConfigLoaded) return - if (shouldShowAnonymousTelemetryConsent(userConfig.anonymousTelemetryConsent)) { - setIsOpen(true) - } else { - setIsOpen(false) - } - }, [ - isLoading, - isUserConfigLoaded, - userConfig.anonymousTelemetryConsent, - ]) + // Dialog visibility is derived from config state: it stays open while consent + // is still undefined (including after a failed save) and closes once a choice + // has been persisted. + const isOpen = + !isLoading && + isUserConfigLoaded && + shouldShowAnonymousTelemetryConsent(userConfig.anonymousTelemetryConsent) const persist = useCallback( async (value: boolean) => { - const previous = userConfig.anonymousTelemetryConsent const next = { ...userConfig, anonymousTelemetryConsent: value } const traceId = `AnonymousTelemetryConsent-${nextTraceId()}` - setIsOpen(false) try { await setAndSaveUserConfig(traceId, next) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(message) - // Re-open if still unset after failure so the user can retry - if (previous === undefined) { - setIsOpen(true) - } + // If consent is still unset after failure the dialog stays open so the user can retry } }, [setAndSaveUserConfig, userConfig], diff --git a/apps/ui/src/components/ui/settings/useMediaDatabaseSettings.ts b/apps/ui/src/components/ui/settings/useMediaDatabaseSettings.ts index 1a816280..4cea9e76 100644 --- a/apps/ui/src/components/ui/settings/useMediaDatabaseSettings.ts +++ b/apps/ui/src/components/ui/settings/useMediaDatabaseSettings.ts @@ -72,7 +72,6 @@ export function useMediaDatabaseSettings(): MediaDatabasesSettingsProps { const [primaryDatabase, setPrimaryDatabase] = useState(initialValues.primaryDatabase) useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect */ setTmdbHost(initialValues.tmdbHost) setTmdbApiKey(initialValues.tmdbApiKey) setTmdbProxy(initialValues.tmdbProxy) @@ -80,7 +79,6 @@ export function useMediaDatabaseSettings(): MediaDatabasesSettingsProps { setTvdbApiKey(initialValues.tvdbApiKey) setTvdbProxy(initialValues.tvdbProxy) setPrimaryDatabase(initialValues.primaryDatabase) - /* eslint-enable react-hooks/set-state-in-effect */ }, [initialValues]) const hasChanges = useMemo(() => { From 57029ec065f502860e5b09493f0d225db4981120 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 12:01:56 +0800 Subject: [PATCH 164/214] fix: make pnpm run typecheck pass across all packages core-routes: - tmdb.ts: bridge typed TMDB results to record-based AI tool output schemas (interfaces lack index signatures, so use unknown-casts) - tmdb.test.ts: type mock runners with the concrete TMDB response types - mcpServerConfig/mcpServerRpcRoute tests: provide full HelloOptions + allowlist - proxiedFetch.ts: cast proxy agents to node:http Agent (http vs node:http mismatch) - nodeRenameFileExistenceProbe.ts: guard stats before isFile core-app: - tsconfig: add @smm/core/* path mapping (resolves types/RecognizeMediaFilePlan etc.) - Core.test.ts: narrow Job union for stage access, fix TMDB details cast, ArrayBuffer - jobStore.test.ts: narrow Job union for folderPath access - applyPlan.ts: capture task before narrowing to avoid never property access - scrape tests: unknown-casts for TMDB fixtures, complete required season/episode fields - renameEpisodeFile.test.ts: type setMetadata mock with MediaMetadata param - assetImageUrls.ts: non-null assert candidates[0] (guarded by length check) cli: - cli/*.test.ts: type console spies as MockInstance instead of ReturnType - NodejsNetworkPort.ts: pass socks proxy URL as string - CoreFetch.test.ts: Promise.resolve(pending) before .then - FolderMetadata.ts: use mm directly (files already stripped by PersistedMediaMetadata) - RecognizeFolder.ts: import RecognizeFolderDb from core-app (moved from @smm/core) - Scrape.test.ts: add required seasons field - validateDestFileNotExist.ts: guard stats before isFile e2e: - cli/helpers.ts: replace unexported bun ShellOutput with local structural type - import-library.ts: Array.from for NodeListOf iteration --- apps/cli/src/cli/add.test.ts | 5 +++-- apps/cli/src/cli/addlib.test.ts | 5 +++-- apps/cli/src/cli/list.test.ts | 5 +++-- apps/cli/src/cli/metadata.test.ts | 5 +++-- apps/cli/src/cli/rm.test.ts | 5 +++-- apps/cli/src/cli/show.test.ts | 5 +++-- apps/cli/src/core/NodejsNetworkPort.ts | 2 +- apps/cli/src/route/CoreFetch.test.ts | 2 +- apps/cli/src/route/FolderMetadata.ts | 3 +-- apps/cli/src/route/RecognizeFolder.ts | 2 +- apps/cli/src/route/Scrape.test.ts | 1 + .../validations/validateDestFileNotExist.ts | 2 +- apps/core/src/Core.test.ts | 10 +++++++--- apps/core/src/jobs/jobStore.test.ts | 3 ++- apps/core/src/pipeline/applyPlan.ts | 7 ++++--- .../src/pipeline/renameEpisodeFile.test.ts | 4 ++-- .../src/pipeline/scrape/assetImageUrls.ts | 2 +- .../scrape/buildTvShowNfoTmdb.test.ts | 2 +- .../pipeline/scrape/scrapeFanartTmdb.test.ts | 2 +- .../src/pipeline/scrape/scrapeNfoTmdb.test.ts | 2 +- .../pipeline/scrape/scrapePosterTmdb.test.ts | 2 +- .../scrape/scrapeThumbnailsTmdb.test.ts | 5 +++++ apps/core/tsconfig.json | 1 + apps/e2e/cli/helpers.ts | 11 ++++++++-- apps/e2e/test/actions/import-library.ts | 2 +- .../src/mcp/mcpServerConfig.test.ts | 12 ++++++++++- .../src/nodeRenameFileExistenceProbe.ts | 2 +- packages/core-routes/src/proxiedFetch.ts | 7 ++++--- .../src/routes/mcpServerRpcRoute.test.ts | 12 ++++++++++- packages/core-routes/src/tools/tmdb.test.ts | 20 +++++++++++++++---- packages/core-routes/src/tools/tmdb.ts | 10 +++++++--- 31 files changed, 110 insertions(+), 48 deletions(-) diff --git a/apps/cli/src/cli/add.test.ts b/apps/cli/src/cli/add.test.ts index bdc3a7e0..254900e1 100644 --- a/apps/cli/src/cli/add.test.ts +++ b/apps/cli/src/cli/add.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -8,8 +9,8 @@ describe('smm add', () => { let userDataDir: string let mediaFolder: string let prevUserDataDir: string | undefined - let logSpy: ReturnType - let errorSpy: ReturnType + let logSpy: MockInstance<(...args: any[]) => void> + let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { prevUserDataDir = process.env.USER_DATA_DIR diff --git a/apps/cli/src/cli/addlib.test.ts b/apps/cli/src/cli/addlib.test.ts index 34ba9fe6..5a0673e8 100644 --- a/apps/cli/src/cli/addlib.test.ts +++ b/apps/cli/src/cli/addlib.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' @@ -9,8 +10,8 @@ describe('smm addlib', () => { let userDataDir: string let libraryPath: string let prevUserDataDir: string | undefined - let logSpy: ReturnType - let errorSpy: ReturnType + let logSpy: MockInstance<(...args: any[]) => void> + let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { prevUserDataDir = process.env.USER_DATA_DIR diff --git a/apps/cli/src/cli/list.test.ts b/apps/cli/src/cli/list.test.ts index e24eae93..eb479a94 100644 --- a/apps/cli/src/cli/list.test.ts +++ b/apps/cli/src/cli/list.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -7,8 +8,8 @@ import { resetCoreForTests } from '../core/getCore' describe('smm list', () => { let userDataDir: string let prevUserDataDir: string | undefined - let logSpy: ReturnType - let errorSpy: ReturnType + let logSpy: MockInstance<(...args: any[]) => void> + let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { prevUserDataDir = process.env.USER_DATA_DIR diff --git a/apps/cli/src/cli/metadata.test.ts b/apps/cli/src/cli/metadata.test.ts index 68ae6604..f2d8a9ea 100644 --- a/apps/cli/src/cli/metadata.test.ts +++ b/apps/cli/src/cli/metadata.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -8,8 +9,8 @@ describe('smm metadata', () => { let userDataDir: string let mediaFolder: string let prevUserDataDir: string | undefined - let logSpy: ReturnType - let errorSpy: ReturnType + let logSpy: MockInstance<(...args: any[]) => void> + let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { prevUserDataDir = process.env.USER_DATA_DIR diff --git a/apps/cli/src/cli/rm.test.ts b/apps/cli/src/cli/rm.test.ts index 19ab123b..129fe889 100644 --- a/apps/cli/src/cli/rm.test.ts +++ b/apps/cli/src/cli/rm.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -8,8 +9,8 @@ describe('smm rm', () => { let userDataDir: string let mediaFolder: string let prevUserDataDir: string | undefined - let logSpy: ReturnType - let errorSpy: ReturnType + let logSpy: MockInstance<(...args: any[]) => void> + let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { prevUserDataDir = process.env.USER_DATA_DIR diff --git a/apps/cli/src/cli/show.test.ts b/apps/cli/src/cli/show.test.ts index c89b3496..556866e9 100644 --- a/apps/cli/src/cli/show.test.ts +++ b/apps/cli/src/cli/show.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -8,8 +9,8 @@ describe('smm show', () => { let userDataDir: string let mediaFolder: string let prevUserDataDir: string | undefined - let logSpy: ReturnType - let errorSpy: ReturnType + let logSpy: MockInstance<(...args: any[]) => void> + let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { prevUserDataDir = process.env.USER_DATA_DIR diff --git a/apps/cli/src/core/NodejsNetworkPort.ts b/apps/cli/src/core/NodejsNetworkPort.ts index 49a6ee85..33cadbe8 100644 --- a/apps/cli/src/core/NodejsNetworkPort.ts +++ b/apps/cli/src/core/NodejsNetworkPort.ts @@ -178,7 +178,7 @@ function agentForProxy(proxyUrl: string, targetUrl: string): Agent { // (`socks5://`). `socks5h://` lets the proxy resolve the hostname (same as curl --socks5-hostname). const socks = new URL(proxyUrl) if (socks.protocol === 'socks5:') socks.protocol = 'socks5h:' - return new SocksProxyAgent(socks) as unknown as Agent + return new SocksProxyAgent(socks.toString()) as unknown as Agent } // https:// target → CONNECT tunnel; http:// target → absolute-URL forward. diff --git a/apps/cli/src/route/CoreFetch.test.ts b/apps/cli/src/route/CoreFetch.test.ts index 5c0ac48a..31a43189 100644 --- a/apps/cli/src/route/CoreFetch.test.ts +++ b/apps/cli/src/route/CoreFetch.test.ts @@ -119,7 +119,7 @@ describe('POST /api/core/fetch', () => { controller.abort() // Hono may surface abort as a rejected fetch or an error Response. - const result = await pending.then( + const result = await Promise.resolve(pending).then( (res) => ({ type: 'response' as const, res }), (err: unknown) => ({ type: 'reject' as const, err }), ) diff --git a/apps/cli/src/route/FolderMetadata.ts b/apps/cli/src/route/FolderMetadata.ts index 76554fe6..a2d0ea3a 100644 --- a/apps/cli/src/route/FolderMetadata.ts +++ b/apps/cli/src/route/FolderMetadata.ts @@ -39,8 +39,7 @@ export function handleFolderMetadata(app: Hono): void { } return c.json(err, 200) } - const { files: _files, ...rest } = mm - const ok: FolderMetadataResponseBody = { data: rest } + const ok: FolderMetadataResponseBody = { data: mm } return c.json(ok, 200) } catch (error) { logger.error({ error }, '[POST /api/folder-metadata] route error') diff --git a/apps/cli/src/route/RecognizeFolder.ts b/apps/cli/src/route/RecognizeFolder.ts index ed233f3d..e2b14030 100644 --- a/apps/cli/src/route/RecognizeFolder.ts +++ b/apps/cli/src/route/RecognizeFolder.ts @@ -1,5 +1,5 @@ import type { Hono } from 'hono' -import type { RecognizeFolderDb } from '@smm/core' +import type { RecognizeFolderDb } from 'core-app' import { getCore } from '../core/getCore' import { logger } from '../../lib/logger' diff --git a/apps/cli/src/route/Scrape.test.ts b/apps/cli/src/route/Scrape.test.ts index 09173542..b509b8b8 100644 --- a/apps/cli/src/route/Scrape.test.ts +++ b/apps/cli/src/route/Scrape.test.ts @@ -93,6 +93,7 @@ describe('POST /api/scrape', () => { database: 'TMDB', id: '1', name: 'Test Show', + seasons: [], }, } await getCore().setMetadata(metadata) diff --git a/apps/cli/src/validations/validateDestFileNotExist.ts b/apps/cli/src/validations/validateDestFileNotExist.ts index a38f2bc4..06a96564 100644 --- a/apps/cli/src/validations/validateDestFileNotExist.ts +++ b/apps/cli/src/validations/validateDestFileNotExist.ts @@ -39,7 +39,7 @@ export async function validateDestFileNotExist( const platformPath = Path.toPlatformPath(task.to); const stats = await statWithTimeout(platformPath); // Only check for files, not directories - if (stats.isFile()) { + if (stats?.isFile()) { existingFiles.push(task.to); } } catch (error) { diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index d6423212..418ace22 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -168,7 +168,8 @@ describe("Core", () => { await waitForStatus(core, id, "succeeded"); expect(core.getJob(id)?.status).toBe("succeeded"); - expect(core.getJob(id)?.stage).toBe("metadata"); + const job = core.getJob(id); + expect(job?.kind === "import" ? job.stage : undefined).toBe("metadata"); const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath("/data/smm"))) as string); expect(savedConfig.folders).toContain("/m/Deferred"); expect(fs.listFiles).not.toHaveBeenCalled(); @@ -1057,7 +1058,7 @@ describe("scrapeFolder", () => { last_air_date: "2021-03-26", networks: [], production_companies: [], - } as TmdbSeriesDetails; + } as unknown as TmdbSeriesDetails; const seasonDetails: TmdbSeasonDetails = { id: 1, @@ -1146,7 +1147,10 @@ describe("scrapeFolder", () => { headers: { "content-type": "image/jpeg" }, text: () => Promise.resolve(""), json: () => Promise.resolve({} as T), - arrayBuffer: () => Promise.resolve(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), + arrayBuffer: () => + Promise.resolve( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + ), }; } diff --git a/apps/core/src/jobs/jobStore.test.ts b/apps/core/src/jobs/jobStore.test.ts index 04b347e2..e0f3268c 100644 --- a/apps/core/src/jobs/jobStore.test.ts +++ b/apps/core/src/jobs/jobStore.test.ts @@ -17,7 +17,8 @@ describe("JobStore", () => { expect(job.kind).toBe("import"); expect(job.createdAt).toBeGreaterThan(0); expect(job.updatedAt).toBeGreaterThanOrEqual(job.createdAt); - expect(store.get(job.id)?.folderPath).toBe("/m/My.Show"); + const stored = store.get(job.id); + expect(stored?.kind === "import" && stored.folderPath).toBe("/m/My.Show"); }); it("update patches fields and bumps updatedAt", async () => { diff --git a/apps/core/src/pipeline/applyPlan.ts b/apps/core/src/pipeline/applyPlan.ts index a93ab8a2..2b5be5c5 100644 --- a/apps/core/src/pipeline/applyPlan.ts +++ b/apps/core/src/pipeline/applyPlan.ts @@ -14,13 +14,14 @@ export interface ApplyPlanDeps { /** Dispatches apply by plan task (recognize-media-file or rename-files). */ export async function applyPlanPipeline(plan: Plan, deps: ApplyPlanDeps): Promise { - if (plan.task === "recognize-media-file") { + const task = plan.task; + if (task === "recognize-media-file") { return applyRecognizeMediaFilePlanPipeline(plan, deps); } - if (plan.task === "rename-files") { + if (task === "rename-files") { return applyRenameFilesPlanPipeline(plan, deps); } - throw new Error(`Unsupported plan task: ${plan.task}`); + throw new Error(`Unsupported plan task: ${task}`); } /** Applies a pending recognize-media-file plan: merge mediaFiles, persist, delete plan. */ diff --git a/apps/core/src/pipeline/renameEpisodeFile.test.ts b/apps/core/src/pipeline/renameEpisodeFile.test.ts index b7d02740..daff7e8e 100644 --- a/apps/core/src/pipeline/renameEpisodeFile.test.ts +++ b/apps/core/src/pipeline/renameEpisodeFile.test.ts @@ -478,7 +478,7 @@ describe("renameEpisodeFilePipeline", () => { "/m/Show/S01E01.mp4": "video", "/m/Show/S01E01.srt": "sub", }); - const setMetadata = vi.fn(async () => {}); + const setMetadata = vi.fn(async (_mm: MediaMetadata) => {}); const rename = vi.fn(async (from: string, to: string) => { if (from.endsWith(".srt")) { throw new Error("EACCES: subtitle locked"); @@ -511,7 +511,7 @@ describe("renameEpisodeFilePipeline", () => { it("does not call setMetadata when every rename fails", async () => { const { fs, userConfig, mm } = managedFs({ "/m/Show/S01E01.mp4": "video" }); - const setMetadata = vi.fn(async () => {}); + const setMetadata = vi.fn(async (_mm: MediaMetadata) => {}); const rename = vi.fn(async () => { throw new Error("EIO: disk error"); }); diff --git a/apps/core/src/pipeline/scrape/assetImageUrls.ts b/apps/core/src/pipeline/scrape/assetImageUrls.ts index 337d4c69..09e050dc 100644 --- a/apps/core/src/pipeline/scrape/assetImageUrls.ts +++ b/apps/core/src/pipeline/scrape/assetImageUrls.ts @@ -66,7 +66,7 @@ export function buildAssetUrlCandidates( const overrideHost = options.overrideDefaultTmdbAssetServerHost; if (overrideHost && assetType === "tmdb-asset" && candidates.length > 0) { try { - const overridden = new URL(candidates[0]); + const overridden = new URL(candidates[0]!); overridden.host = overrideHost; candidates[0] = overridden.href; } catch { diff --git a/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts index 14ed7c55..2f580418 100644 --- a/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/buildTvShowNfoTmdb.test.ts @@ -157,7 +157,7 @@ describe("buildTvShowEpisodeNfo", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-19T12:00:00.000Z")); - const episode = seasonDetails.episodes![0]; + const episode = seasonDetails.episodes![0]!; const nfo = buildTvShowEpisodeNfo(seriesDetails, seasonDetails, episode); expect(nfo.id).toBe("8415207"); diff --git a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts index 1d71bd5d..462a8cb2 100644 --- a/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeFanartTmdb.test.ts @@ -14,7 +14,7 @@ const seriesDetails: TmdbSeriesDetails = { poster_path: "/poster.jpg", backdrop_path: "/fanart.jpg", seasons: [], -} as TmdbSeriesDetails; +} as unknown as TmdbSeriesDetails; const tvShowMetadata: MediaMetadata = { type: "tvshow-folder", diff --git a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts index a0150ccc..73e98824 100644 --- a/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeNfoTmdb.test.ts @@ -30,7 +30,7 @@ const seriesDetails: TmdbSeriesDetails = { networks: [], production_companies: [{ id: 1, name: "Studio Alpha", logo_path: null }], genres: [{ id: 16, name: "Animation" }], -} as TmdbSeriesDetails; +} as unknown as TmdbSeriesDetails; const seasonDetails: TmdbSeasonDetails = { id: 1, diff --git a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts index fb8144d5..ee4442bb 100644 --- a/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapePosterTmdb.test.ts @@ -35,7 +35,7 @@ const seriesDetails: TmdbSeriesDetails = { last_air_date: "2021-03-26", networks: [], production_companies: [], -} as TmdbSeriesDetails; +} as unknown as TmdbSeriesDetails; const tvShowMetadata: MediaMetadata = { type: "tvshow-folder", diff --git a/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts index 113cd152..d6cad048 100644 --- a/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts +++ b/apps/core/src/pipeline/scrape/scrapeThumbnailsTmdb.test.ts @@ -16,13 +16,18 @@ const seriesDetails: TmdbSeriesDetails = { const seasonDetails: TmdbSeasonDetails = { id: 1, name: "Season 1", + overview: "Season overview", + poster_path: null, season_number: 1, + air_date: "2021-01-15", episode_count: 1, episodes: [ { id: 8415207, name: "Episode One", + overview: "Episode overview", still_path: "/still.jpg", + air_date: "2021-01-15", episode_number: 1, season_number: 1, runtime: 24, diff --git a/apps/core/tsconfig.json b/apps/core/tsconfig.json index d53fe381..38970008 100644 --- a/apps/core/tsconfig.json +++ b/apps/core/tsconfig.json @@ -25,6 +25,7 @@ "paths": { "@core/*": ["../../packages/core/*"], "@smm/core": ["../../packages/core/types.ts"], + "@smm/core/*": ["../../packages/core/*"], "@smm/tvdb4": ["../../packages/tvdb4/src/index.ts"], "@smm/tvdb4/*": ["../../packages/tvdb4/src/*"] } diff --git a/apps/e2e/cli/helpers.ts b/apps/e2e/cli/helpers.ts index 89efb667..cb76769a 100644 --- a/apps/e2e/cli/helpers.ts +++ b/apps/e2e/cli/helpers.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { $, type ShellOutput } from 'bun' +import { $ } from 'bun' import { Path } from '@smm/core' import type { MediaMetadata } from '@smm/core/types' import type { TestFolder } from '@smm/test' @@ -21,8 +21,15 @@ export function requiredEnv(name: string): string { return value } +/** Shape of an awaited `$` subprocess result (bun-types does not export ShellOutput). */ +interface CliSubprocessResult { + stdout: Buffer + stderr: Buffer + exitCode: number +} + /** Combine stdout and stderr from a CLI subprocess result. */ -export function cliOutput(result: ShellOutput): string { +export function cliOutput(result: CliSubprocessResult): string { const stdout = result.stdout.toString() const stderr = result.stderr.toString() if (!stdout) return stderr diff --git a/apps/e2e/test/actions/import-library.ts b/apps/e2e/test/actions/import-library.ts index ba7d8cc2..07d4ff95 100644 --- a/apps/e2e/test/actions/import-library.ts +++ b/apps/e2e/test/actions/import-library.ts @@ -15,7 +15,7 @@ const JOB_SUCCEEDED_BADGES = ['succeeded', '成功'] as const async function findImportMediaLibraryJobId(): Promise { return browser.execute((jobNames: readonly string[]) => { const matches: Array<{ id: string; status: string }> = [] - for (const el of document.querySelectorAll('[data-testid$="-name"]')) { + for (const el of Array.from(document.querySelectorAll('[data-testid$="-name"]'))) { const testId = el.getAttribute('data-testid') if (!testId?.startsWith('background-job-') || !testId.endsWith('-name')) { continue diff --git a/packages/core-routes/src/mcp/mcpServerConfig.test.ts b/packages/core-routes/src/mcp/mcpServerConfig.test.ts index 45e47b91..21e240db 100644 --- a/packages/core-routes/src/mcp/mcpServerConfig.test.ts +++ b/packages/core-routes/src/mcp/mcpServerConfig.test.ts @@ -54,7 +54,17 @@ describe("mcpServerConfig persistence", () => { "utf-8", ); routesConfig = { - hello: { userDataDir: tmpDir }, + allowlist: [], + hello: { + version: "test", + userDataDir: tmpDir, + appDataDir: tmpDir, + logDir: tmpDir, + tmpDir: tmpDir, + reverseProxyUrl: null, + osLocale: "en-US", + coreRoutesPort: 3001, + }, appDataDir: tmpDir, }; manager = createGateManager(); diff --git a/packages/core-routes/src/nodeRenameFileExistenceProbe.ts b/packages/core-routes/src/nodeRenameFileExistenceProbe.ts index aab69068..3a4fabe8 100644 --- a/packages/core-routes/src/nodeRenameFileExistenceProbe.ts +++ b/packages/core-routes/src/nodeRenameFileExistenceProbe.ts @@ -28,7 +28,7 @@ export function createNodeRenameFileExistenceProbe( async isFile(posixPath: string): Promise { try { const stats = await statWithTimeout(Path.toPlatformPath(posixPath), timeoutMs); - return stats.isFile(); + return stats?.isFile() ?? false; } catch { return false; } diff --git a/packages/core-routes/src/proxiedFetch.ts b/packages/core-routes/src/proxiedFetch.ts index f12352d7..b912c1e9 100644 --- a/packages/core-routes/src/proxiedFetch.ts +++ b/packages/core-routes/src/proxiedFetch.ts @@ -177,9 +177,10 @@ function httpProxyAgentRequest(request: Request, proxyUrl: string): Promise { return requestViaAgent( request, - new SocksProxyAgent(proxyUrl), + new SocksProxyAgent(proxyUrl) as unknown as Agent, "SOCKS5 proxy request timeout", ); } diff --git a/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts b/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts index 65e6bf5b..a49f75f3 100644 --- a/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts +++ b/packages/core-routes/src/routes/mcpServerRpcRoute.test.ts @@ -80,7 +80,17 @@ describe("MCP RPC route handlers", () => { ); manager = createGateManager(); config = { - hello: { userDataDir: tmpDir }, + allowlist: [], + hello: { + version: "test", + userDataDir: tmpDir, + appDataDir: tmpDir, + logDir: tmpDir, + tmpDir: tmpDir, + reverseProxyUrl: null, + osLocale: "en-US", + coreRoutesPort: 3001, + }, appDataDir: tmpDir, mcp: { manager }, }; diff --git a/packages/core-routes/src/tools/tmdb.test.ts b/packages/core-routes/src/tools/tmdb.test.ts index 970f12bd..6f5de7fa 100644 --- a/packages/core-routes/src/tools/tmdb.test.ts +++ b/packages/core-routes/src/tools/tmdb.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it, vi } from "vitest"; +import type { + TmdbMovieDetails, + TmdbSearchResponseBody, + TmdbSeriesDetails, + TMDBTVShow, +} from "@smm/core/types"; import { executeTmdbGetMovie, executeTmdbGetTvShow, @@ -15,8 +21,8 @@ describe("executeTmdbSearch", () => { }); it("maps keyword and type to Core runner", async () => { - const runner = vi.fn(async () => ({ - results: [{ id: 1, name: "Show" }], + const runner = vi.fn(async (): Promise => ({ + results: [{ id: 1, name: "Show" } as TMDBTVShow], page: 1, total_pages: 1, total_results: 1, @@ -53,7 +59,10 @@ describe("executeTmdbGetMovie", () => { }); it("returns movie details on success", async () => { - const runner = vi.fn(async () => ({ id: 550, title: "Fight Club" })); + const runner = vi.fn(async (): Promise => ({ + id: 550, + title: "Fight Club", + } as TmdbMovieDetails)); const result = await executeTmdbGetMovie({ id: 550, language: "en-US" }, runner); expect(result.title).toBe("Fight Club"); expect(runner).toHaveBeenCalledWith(550, { language: "en-US", host: undefined }); @@ -67,7 +76,10 @@ describe("executeTmdbGetTvShow", () => { }); it("returns series details on success", async () => { - const runner = vi.fn(async () => ({ id: 31917, name: "Show" })); + const runner = vi.fn(async (): Promise => ({ + id: 31917, + name: "Show", + } as TmdbSeriesDetails)); const result = await executeTmdbGetTvShow({ id: 31917 }, runner); expect(result.name).toBe("Show"); }); diff --git a/packages/core-routes/src/tools/tmdb.ts b/packages/core-routes/src/tools/tmdb.ts index 7f412a42..102f597f 100644 --- a/packages/core-routes/src/tools/tmdb.ts +++ b/packages/core-routes/src/tools/tmdb.ts @@ -92,7 +92,7 @@ export async function executeTmdbSearch( return { error: body.error }; } return { - results: body.results, + results: body.results as unknown as Record[], page: body.page, total_pages: body.total_pages, total_results: body.total_results, @@ -119,7 +119,9 @@ export async function executeTmdbGetMovie( try { const details = await runner(params.id, toTmdbCoreOptions(params)); - return details as TmdbGetMovieOutput; + // Core TMDB types are interfaces without index signatures, while the tool + // output schema describes plain records for AI function calling. + return details as unknown as TmdbGetMovieOutput; } catch (error) { return { error: formatTmdbToolError(error) }; } @@ -142,7 +144,9 @@ export async function executeTmdbGetTvShow( try { const details = await runner(params.id, toTmdbCoreOptions(params)); - return details as TmdbGetTvShowOutput; + // Core TMDB types are interfaces without index signatures, while the tool + // output schema describes plain records for AI function calling. + return details as unknown as TmdbGetTvShowOutput; } catch (error) { return { error: formatTmdbToolError(error) }; } From ce49e6cb590ac73712ca63fac4349b9b68b9e0f2 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 14:13:00 +0800 Subject: [PATCH 165/214] fix(docker): copy apps/core into cli build context apps/cli depends on core-app (workspace:*) at runtime (Core singleton via getCore.ts), but cli.Dockerfile only copied packages/core, core-routes, tvdb4 and apps/cli, so bun build failed to resolve node_modules/core-app. Verified: pnpm run typecheck and bun run scripts/build.ts pass locally. --- apps/docker/cli.Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docker/cli.Dockerfile b/apps/docker/cli.Dockerfile index 8c0d6ed4..7ab4560d 100644 --- a/apps/docker/cli.Dockerfile +++ b/apps/docker/cli.Dockerfile @@ -21,6 +21,8 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY packages/core packages/core COPY packages/core-routes packages/core-routes COPY packages/tvdb4 packages/tvdb4 +# core-app (apps/core) is a runtime dependency of apps/cli (Core singleton) +COPY apps/core apps/core COPY apps/cli apps/cli # pnpm --frozen-lockfile tolerates missing workspace members: only packages From 057706e04cf38c6c0d8f530a13878b5cf88f8453 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 17:46:33 +0800 Subject: [PATCH 166/214] fix(core): extend rename-episode-file to support movie folders Movie context-menu renames were failing under v3 because Core only accepted tvshow-folder; allow movie-folder linked videos and route v3 UI through the same API for both TV and movie panels. Co-authored-by: Cursor --- apps/cli/src/route/RenameEpisodeFile.test.ts | 61 +++++++++++ .../src/pipeline/renameEpisodeFile.test.ts | 103 +++++++++++++++++- apps/core/src/pipeline/renameEpisodeFile.ts | 38 +++++-- .../src/hooks/useRenameVideoFileFlow.test.ts | 4 + apps/ui/src/hooks/useRenameVideoFileFlow.ts | 12 +- 5 files changed, 193 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/route/RenameEpisodeFile.test.ts b/apps/cli/src/route/RenameEpisodeFile.test.ts index c28c172e..44556a9c 100644 --- a/apps/cli/src/route/RenameEpisodeFile.test.ts +++ b/apps/cli/src/route/RenameEpisodeFile.test.ts @@ -152,4 +152,65 @@ describe('POST /api/rename-episode-file', () => { const json = (await res.json()) as { error?: string } expect(json.error).toMatch(/not a linked episode/i) }) + + it('renames movie video and associates via Core', async () => { + const movieDir = join(mediaDir, 'Ne Zha (2019)') + mkdirSync(movieDir) + const video = join(movieDir, 'movie.mp4') + const srt = join(movieDir, 'movie.srt') + const enSrt = join(movieDir, 'movie.en.srt') + writeFileSync(video, 'v') + writeFileSync(srt, 's') + writeFileSync(enSrt, 'e') + + const folderPosix = Path.posix(movieDir) + writeFileSync( + join(userDataDir, 'smm.json'), + JSON.stringify({ + folders: [movieDir], + tmdb: {}, + tvdb: {}, + renameRules: [], + dryRun: false, + selectedRenameRule: 'plex', + }), + 'utf-8', + ) + const cacheName = folderPosix.replace(/[/\\:?*|<>"]/g, '_') + mkdirSync(join(userDataDir, 'metadata'), { recursive: true }) + writeFileSync( + join(userDataDir, 'metadata', `${cacheName}.json`), + JSON.stringify({ + mediaFolderPath: folderPosix, + type: 'movie-folder', + files: [Path.posix(video), Path.posix(srt), Path.posix(enSrt)], + mediaFiles: [{ absolutePath: Path.posix(video) }], + movie: { database: 'TMDB', id: '615453', name: 'Ne Zha' }, + }), + 'utf-8', + ) + resetCoreForTests() + + const to = join(movieDir, 'movie_renamed.mp4') + const res = await post({ mediaFolder: movieDir, from: video, to }) + expect(res.status).toBe(200) + const json = (await res.json()) as { + data?: { succeeded: Array<{ from: string; to: string }> } + error?: string + } + expect(json.error).toBeUndefined() + expect(json.data?.succeeded.length).toBeGreaterThanOrEqual(3) + + expect(existsSync(to)).toBe(true) + expect(existsSync(video)).toBe(false) + expect(existsSync(join(movieDir, 'movie_renamed.srt'))).toBe(true) + expect(existsSync(join(movieDir, 'movie_renamed.en.srt'))).toBe(true) + expect(existsSync(srt)).toBe(false) + expect(existsSync(enSrt)).toBe(false) + + const mm = JSON.parse( + readFileSync(join(userDataDir, 'metadata', `${cacheName}.json`), 'utf-8'), + ) as { mediaFiles: Array<{ absolutePath: string }> } + expect(mm.mediaFiles[0]?.absolutePath).toBe(Path.posix(to)) + }) }) diff --git a/apps/core/src/pipeline/renameEpisodeFile.test.ts b/apps/core/src/pipeline/renameEpisodeFile.test.ts index daff7e8e..7236c78c 100644 --- a/apps/core/src/pipeline/renameEpisodeFile.test.ts +++ b/apps/core/src/pipeline/renameEpisodeFile.test.ts @@ -177,12 +177,70 @@ describe("renameEpisodeFilePipeline", () => { ).rejects.toThrow(/not a linked episode/i); }); - it("rejects movie folders", async () => { - const mm = tvMetadata({ type: "movie-folder", mediaFiles: [{ absolutePath: "/m/Show/movie.mkv" }] }); + it("renames movie video + associates and updates metadata", async () => { + const movieFolder = "/m/Movie"; + const mm: MediaMetadata = { + mediaFolderPath: movieFolder, + type: "movie-folder", + files: [ + "/m/Movie/movie.mp4", + "/m/Movie/movie.srt", + "/m/Movie/movie.en.srt", + "/m/Movie/movie.ass", + ], + mediaFiles: [{ absolutePath: "/m/Movie/movie.mp4" }], + movie: { database: "TMDB", id: "615453", name: "Ne Zha" }, + }; + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([movieFolder]), + [metadataCachePath(appDataDir, movieFolder)]: JSON.stringify(mm), + "/m/Movie/movie.mp4": "video", + "/m/Movie/movie.srt": "sub", + "/m/Movie/movie.en.srt": "en", + "/m/Movie/movie.ass": "ass", + }); + const userConfig = new UserConfigHelper(fs, appDataDir); + + const result = await renameEpisodeFilePipeline( + { + mediaFolderPath: movieFolder, + from: "/m/Movie/movie.mp4", + to: "/m/Movie/movie_renamed.mp4", + }, + { + fs, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async (next) => { + await fs.writeTextFile(metadataCachePath(appDataDir, movieFolder), JSON.stringify(next)); + }, + }, + ); + + expect(result.failed).toEqual([]); + expect(result.succeeded).toEqual([ + { from: "/m/Movie/movie.mp4", to: "/m/Movie/movie_renamed.mp4" }, + { from: "/m/Movie/movie.srt", to: "/m/Movie/movie_renamed.srt" }, + { from: "/m/Movie/movie.en.srt", to: "/m/Movie/movie_renamed.en.srt" }, + { from: "/m/Movie/movie.ass", to: "/m/Movie/movie_renamed.ass" }, + ]); + expect(fs.textFiles.has("/m/Movie/movie_renamed.mp4")).toBe(true); + expect(fs.textFiles.has("/m/Movie/movie.mp4")).toBe(false); + + const saved = JSON.parse( + fs.textFiles.get(metadataCachePath(appDataDir, movieFolder))!, + ) as MediaMetadata; + expect(saved.mediaFiles?.[0]?.absolutePath).toBe("/m/Movie/movie_renamed.mp4"); + }); + + it("rejects unsupported folder types", async () => { + const mm = tvMetadata({ type: "music-folder", mediaFiles: [{ absolutePath: "/m/Show/track.mp3" }] }); const fs = createFs({ [userConfigPath(appDataDir)]: configWith([folder]), [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), - "/m/Show/movie.mkv": "x", + "/m/Show/track.mp3": "x", }); const userConfig = new UserConfigHelper(fs, appDataDir); @@ -190,8 +248,41 @@ describe("renameEpisodeFilePipeline", () => { renameEpisodeFilePipeline( { mediaFolderPath: folder, - from: "/m/Show/movie.mkv", - to: "/m/Show/movie2.mkv", + from: "/m/Show/track.mp3", + to: "/m/Show/track2.mp3", + }, + { + fs, + appDataDir, + userConfig, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm, + setMetadata: async () => {}, + }, + ), + ).rejects.toThrow(/not a TV show or movie/i); + }); + + it("rejects unlinked movie video files", async () => { + const movieFolder = "/m/Movie"; + const mm: MediaMetadata = { + mediaFolderPath: movieFolder, + type: "movie-folder", + mediaFiles: [{ absolutePath: "/m/Movie/movie.mp4" }], + }; + const fs = createFs({ + [userConfigPath(appDataDir)]: configWith([movieFolder]), + [metadataCachePath(appDataDir, movieFolder)]: JSON.stringify(mm), + "/m/Movie/orphan.mp4": "x", + }); + const userConfig = new UserConfigHelper(fs, appDataDir); + + await expect( + renameEpisodeFilePipeline( + { + mediaFolderPath: movieFolder, + from: "/m/Movie/orphan.mp4", + to: "/m/Movie/orphan2.mp4", }, { fs, @@ -202,7 +293,7 @@ describe("renameEpisodeFilePipeline", () => { setMetadata: async () => {}, }, ), - ).rejects.toThrow(/not a TV show/i); + ).rejects.toThrow(/not a linked movie video/i); }); it("rejects unmanaged folders", async () => { diff --git a/apps/core/src/pipeline/renameEpisodeFile.ts b/apps/core/src/pipeline/renameEpisodeFile.ts index f7581c07..f26a2c9b 100644 --- a/apps/core/src/pipeline/renameEpisodeFile.ts +++ b/apps/core/src/pipeline/renameEpisodeFile.ts @@ -54,16 +54,26 @@ function isUnderFolder(folderPosix: string, filePosix: string): boolean { return filePosix === folderPosix || filePosix.startsWith(prefix); } -function isLinkedEpisode(mm: MediaMetadata, fromPosix: string): boolean { - return (mm.mediaFiles ?? []).some( - (f) => - mediaFilePathEqual(f.absolutePath, fromPosix) && - f.seasonNumber !== undefined && - f.episodeNumber !== undefined, - ); +function isSupportedMediaFolderType( + type: MediaMetadata["type"], +): type is "tvshow-folder" | "movie-folder" { + return type === "tvshow-folder" || type === "movie-folder"; +} + +/** TV: linked episode with season/episode. Movie: any entry in mediaFiles (video path). */ +function isLinkedMediaFile(mm: MediaMetadata, fromPosix: string): boolean { + return (mm.mediaFiles ?? []).some((f) => { + if (!mediaFilePathEqual(f.absolutePath, fromPosix)) { + return false; + } + if (mm.type === "movie-folder") { + return true; + } + return f.seasonNumber !== undefined && f.episodeNumber !== undefined; + }); } -/** Rename a linked TV episode file (+ same-stem associates). Throws on prerequisite failure. */ +/** Rename a linked TV episode or movie video file (+ same-stem associates). Throws on prerequisite failure. */ export async function renameEpisodeFilePipeline( input: RenameEpisodeFileInput, deps: RenameEpisodeFileDeps, @@ -99,11 +109,15 @@ export async function renameEpisodeFilePipeline( if (!mm) { throw new Error(`Media metadata not found: ${mediaFolderRaw}`); } - if (mm.type !== "tvshow-folder") { - throw new Error(`Folder is not a TV show: ${mediaFolderRaw}`); + if (!isSupportedMediaFolderType(mm.type)) { + throw new Error(`Folder is not a TV show or movie: ${mediaFolderRaw}`); } - if (!isLinkedEpisode(mm, fromPosix)) { - throw new Error(`File is not a linked episode: ${fromRaw}`); + if (!isLinkedMediaFile(mm, fromPosix)) { + const detail = + mm.type === "movie-folder" + ? "File is not a linked movie video" + : "File is not a linked episode"; + throw new Error(`${detail}: ${fromRaw}`); } if (!isUnderFolder(folderPosix, fromPosix)) { throw new Error(`Path is outside media folder: ${fromRaw}`); diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts index 2139a599..3d76d95a 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { renderHook, act } from "@testing-library/react" +vi.mock("@/lib/localStorages", () => ({ + isSmmV3Enabled: vi.fn().mockReturnValue(false), +})) + vi.mock("@/api/renameFiles", () => ({ renameFiles: vi.fn().mockResolvedValue({}), })) diff --git a/apps/ui/src/hooks/useRenameVideoFileFlow.ts b/apps/ui/src/hooks/useRenameVideoFileFlow.ts index 7629cec7..b0fee33e 100644 --- a/apps/ui/src/hooks/useRenameVideoFileFlow.ts +++ b/apps/ui/src/hooks/useRenameVideoFileFlow.ts @@ -24,9 +24,8 @@ export interface UseRenameVideoFileFlowOptions { */ files: string[] /** - * When `"episode"` and `smm.v3.enabled`, confirm calls Core - * `renameEpisodeFile` (TV only). Default `"generic"` keeps legacy - * `/api/renameFiles` (movie panel). + * When `smm.v3.enabled`, confirm calls Core `renameEpisodeFile` (TV + movie). + * Default `"generic"` keeps legacy `/api/renameFiles` when v3 is off. */ mode?: "episode" | "generic" /** @@ -51,13 +50,13 @@ export interface RenameVideoFileFlow { * Encapsulates the "rename the selected video file" right-click flow that * `TvShowPanel` and `MoviePanel` inject into `MediaFileTable`. * - * TV (`mode: "episode"`) + v3: `POST /api/rename-episode-file` → Core. + * TV + movie + v3 ON: `POST /api/rename-episode-file` → Core. * Otherwise: client expands associates and calls `POST /api/renameFiles`. */ export function useRenameVideoFileFlow( options: UseRenameVideoFileFlowOptions, ): RenameVideoFileFlow { - const { mediaFolderPath, files, mode = "generic", onAfterRename } = options + const { mediaFolderPath, files, onAfterRename } = options const { t } = useTranslation(["components", "dialogs"]) const { renameFileDialog } = useDialogs() const [openRename] = renameFileDialog @@ -79,7 +78,7 @@ export function useRenameVideoFileFlow( if (!row.videoFile) return const newAbsolutePath = join(mediaFolderPath, newRelativePath) try { - if (mode === "episode" && isSmmV3Enabled()) { + if (isSmmV3Enabled()) { await renameEpisodeFileViaCore({ mediaFolder: Path.posix(mediaFolderPath), from: row.videoFile, @@ -123,7 +122,6 @@ export function useRenameVideoFileFlow( [ mediaFolderPath, files, - mode, onAfterRename, openRename, fetchMediaMetadata, From 59962dc7a5b95613727ee52d5491e5475b7bae19 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 17:48:47 +0800 Subject: [PATCH 167/214] test: remove deprecated test --- .../common/movie/ImportMovieLibrary.e2e.ts | 121 ------------------ 1 file changed, 121 deletions(-) delete mode 100644 apps/e2e/common/movie/ImportMovieLibrary.e2e.ts diff --git a/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts b/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts deleted file mode 100644 index 7b56837c..00000000 --- a/apps/e2e/common/movie/ImportMovieLibrary.e2e.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect } from '@wdio/globals' -import { - setup, - cleanup, - expectMediaMetadataViaBrowser, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { given, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { MediaMetadata } from '@smm/core/types' -import { Path } from '@smm/core' - -import { testbedOs } from 'test/lib/e2e-platform' - -/** - * Import a movie media library (common: browser / Electron / HarmonyOS). - * Fixtures, config, and metadata assertions use browser-protocol APIs only. - * - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Import Movie Library', () => { - let testFolder = '' - - beforeEach(async () => { - resetStepContext() - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config) => { - config.primaryDatabase = 'TMDB' - config.preferMediaLanguage = 'zh-CN' - }, - os: testbedOs, - }) - - const { default: Page } = await import('test/pageobjects/page') - await Page.refresh() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Import Movie Library', async function () { - this.timeout(6 * 60 * 1000) - - await given('Media library was imported with movie folders', { - base: testFolder, - }) - - const folders = getStepContext()._folders as Array<{ - folderName: string - path: string - type: string - }> - - await then('unknown folder has no movie metadata', async () => { - const f = folders.find((x) => x.folderName === 'UnknownFolder')! - await expectMediaMetadataViaBrowser(f.path, (obj) => { - const mm = obj as MediaMetadata - expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) - expect(mm.type).toBe('movie-folder') - expect(mm.movie).toBeUndefined() - return true - }) - }) - - await then('folder recognized by name has TMDB movie metadata', async () => { - const { folder2 } = await import('test/actions/import-folders') - const f = folders.find((x) => x.folderName === folder2.folderName)! - await expectMediaMetadataViaBrowser(f.path, (obj) => { - const mm = obj as MediaMetadata - expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) - expect(mm.type).toBe('movie-folder') - expect(mm.movie?.database).toBe('TMDB') - return true - }) - }) - - await then('folder recognized by tmdbid has TMDB movie metadata', async () => { - const f = folders.find((x) => x.folderName === '{tmdbid=1539104}')! - await expectMediaMetadataViaBrowser(f.path, (obj) => { - const mm = obj as MediaMetadata - expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) - expect(mm.type).toBe('movie-folder') - expect(mm.movie?.database).toBe('TMDB') - return true - }) - }) - - await then('folder recognized by NFO has TMDB movie metadata', async () => { - const f = folders.find((x) => x.folderName === 'FolderContainsMovieNfo')! - await expectMediaMetadataViaBrowser(f.path, (obj) => { - const mm = obj as MediaMetadata - expect(mm.mediaFolderPath).toBe(Path.posix(f.path)) - expect(mm.type).toBe('movie-folder') - expect(mm.movie?.database).toBe('TMDB') - return true - }) - }) - }) -}) From d1deff23731c4f567f85ee19559132199d2eef7d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 19:14:08 +0800 Subject: [PATCH 168/214] fix(ci): restore CLI executable bit after artifact download GitHub Actions download-artifact strips execute permissions from the compiled CLI, causing all CLI e2e tests to fail during setup. Co-authored-by: Cursor --- ci/run-cli-e2e-test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ci/run-cli-e2e-test.ts b/ci/run-cli-e2e-test.ts index 2fe0281a..7514dbdb 100644 --- a/ci/run-cli-e2e-test.ts +++ b/ci/run-cli-e2e-test.ts @@ -43,6 +43,11 @@ function ensureCliBinary(): void { console.error('Run: cd apps/cli && pnpm run build'); process.exit(1); } + + // GitHub Actions download-artifact does not preserve the executable bit. + if (process.platform !== 'win32') { + fs.chmodSync(CLI_BIN, 0o755); + } } async function main(): Promise { From 21c50e70831920bd69a86585e0672391c7f1d37f Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 20:11:47 +0800 Subject: [PATCH 169/214] fix(test): create user config directory before reset on CI CLI e2e setup calls resetUserConfig on a fresh runner where ~/.config/smm does not exist yet; mkdir parent before writing smm.json. Co-authored-by: Cursor --- packages/test/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index 5d0eb3a5..83af8fca 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -202,7 +202,8 @@ export async function resetUserConfig(userConfigPath?: string, initConfig?: Part if(initConfig) { userConfig = { ...userConfig, ...initConfig } } - + + fs.mkdirSync(path.dirname(userConfigPath), { recursive: true }) fs.writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2), 'utf-8') console.log(`Reset user config at: ${userConfigPath}`) console.log(`[DIAG] resetUserConfig: wrote folders=[] to ${userConfigPath}`) From c8a992aa8fc5dd0d5bdf87b98004d73f50d3db1a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Fri, 28 Aug 2026 20:36:11 +0800 Subject: [PATCH 170/214] fix(e2e): align CLI test metadata paths with Core userDataDir on Linux CLI Core stores metadata and plans under userDataDir, but hello reports a separate XDG appDataDir. Seed/cleanup in the wrong directory caused try-to-recognize failures, and assertions used Windows path separators on Linux CI. Co-authored-by: Cursor --- apps/e2e/cli/base.ts | 4 +++- apps/e2e/cli/helpers.ts | 27 +++++++++++++++++++++------ apps/e2e/cli/import-folder.test.ts | 7 ++++--- apps/e2e/cli/rename-folder.test.ts | 11 ++++------- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/e2e/cli/base.ts b/apps/e2e/cli/base.ts index efce804e..9b6e1486 100644 --- a/apps/e2e/cli/base.ts +++ b/apps/e2e/cli/base.ts @@ -32,7 +32,9 @@ export type CliTestBedSetupOptions = Omit { const body = await runCliHello(binary) - return { userDataDir: body.userDataDir, appDataDir: body.appDataDir } + // CLI Core stores config, metadata, and plans under userDataDir (see getCore.ts). + // hello.appDataDir is the reported XDG data dir and may differ on Linux. + return { userDataDir: body.userDataDir, appDataDir: body.userDataDir } } } diff --git a/apps/e2e/cli/helpers.ts b/apps/e2e/cli/helpers.ts index cb76769a..93c70945 100644 --- a/apps/e2e/cli/helpers.ts +++ b/apps/e2e/cli/helpers.ts @@ -45,10 +45,15 @@ export function parsePlanId(stdout: string): string { return planId } +async function resolveCoreDataDir(binary: string): Promise { + const { userDataDir } = await runCliHello(binary) + return userDataDir +} + export async function planFilePath(binary: string, planId: string): Promise { - const { appDataDir } = await runCliHello(binary) + const dataDir = await resolveCoreDataDir(binary) const filename = `${planId}.plan.json` - return join(appDataDir, 'plans', filename) + return join(dataDir, 'plans', filename) } export function metadataCacheFilePath(appDataDir: string, folderPathInPosix: string): string { @@ -60,8 +65,18 @@ export async function resolveMetadataCachePath( binary: string, folderPathInPosix: string, ): Promise { - const { appDataDir } = await runCliHello(binary) - return metadataCacheFilePath(appDataDir, folderPathInPosix) + const dataDir = await resolveCoreDataDir(binary) + return metadataCacheFilePath(dataDir, folderPathInPosix) +} + +/** CLI `metadata` output line for a linked media file (platform path separators). */ +export function metadataMediaFileLine( + folderPath: string, + fileName: string, + seasonNumber: number, + episodeNumber: number, +): string { + return `absolutePath: ${Path.toPlatformPath(join(folderPath, fileName))} seasonNumber: ${seasonNumber} episodeNumber: ${episodeNumber}` } export function renamedFolderPath(folderPath: string, folderName: string): string { @@ -122,8 +137,8 @@ export async function createAndImportInitializedFolder( } mediaMetadata.mediaFolderPath = Path.posix(folderPath) - const { appDataDir } = await runCliHello(binary) - const cachePath = metadataCacheFilePath(appDataDir, Path.posix(folderPath)) + const dataDir = await resolveCoreDataDir(binary) + const cachePath = metadataCacheFilePath(dataDir, Path.posix(folderPath)) mkdirSync(dirname(cachePath), { recursive: true }) const { files: _files, ...toPersist } = mediaMetadata writeFileSync(cachePath, JSON.stringify(toPersist, null, 2)) diff --git a/apps/e2e/cli/import-folder.test.ts b/apps/e2e/cli/import-folder.test.ts index 9dc53964..c2f2e56b 100644 --- a/apps/e2e/cli/import-folder.test.ts +++ b/apps/e2e/cli/import-folder.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import { folder1, createFolderInTestFolder, folder2 } from '../test/actions/import-folders' import { setup, cleanup, bin } from './base' +import { metadataMediaFileLine } from './helpers' import { $ } from 'bun' const FIVE_MINUTES_MS = 5 * 60 * 1000 @@ -80,9 +81,9 @@ tvShow: E11 In Short, It's Your Fault, Onee-san E12 Angel's Gaze mediaFiles: - - absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1 - - absolutePath: ${folderPath}\\S01E02.mkv seasonNumber: 1 episodeNumber: 2 - - absolutePath: ${folderPath}\\S01E03.mkv seasonNumber: 1 episodeNumber: 3`) + - ${metadataMediaFileLine(folderPath!, 'S01E01.mkv', 1, 1)} + - ${metadataMediaFileLine(folderPath!, 'S01E02.mkv', 1, 2)} + - ${metadataMediaFileLine(folderPath!, 'S01E03.mkv', 1, 3)}`) }, FIVE_MINUTES_MS) it('import TV show folder with --skip-init', async () => { diff --git a/apps/e2e/cli/rename-folder.test.ts b/apps/e2e/cli/rename-folder.test.ts index b1710ff7..26a2f1fc 100644 --- a/apps/e2e/cli/rename-folder.test.ts +++ b/apps/e2e/cli/rename-folder.test.ts @@ -9,6 +9,7 @@ import { createAndImportInitializedFolder, renamedFolderPath, cliOutput, + metadataMediaFileLine, } from './helpers' describe('rename', () => { @@ -54,9 +55,7 @@ describe('rename', () => { expect(cliOutput(meta)).toContain(`mediaFolderPath: ${to}`) expect(meta.text()).toContain('type: tvshow-folder') expect(meta.text()).toContain('id: 84666') - expect(meta.text()).toContain( - `absolutePath: ${to}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, - ) + expect(meta.text()).toContain(metadataMediaFileLine(to, 'S01E01.mkv', 1, 1)) }) it('renames a pre-initialized movie folder via smm rename', async () => { @@ -108,7 +107,7 @@ describe('rename', () => { expect(cliOutput(meta)).toContain('id: 116') expect(cliOutput(meta)).not.toContain('tvShow:') expect(cliOutput(meta)).toContain( - `absolutePath: ${to}\\The Dark Knight [1080P].mkv`, + `absolutePath: ${Path.toPlatformPath(join(to, 'The Dark Knight [1080P].mkv'))}`, ) }) @@ -134,9 +133,7 @@ describe('rename', () => { expect(existsSync(join(media, 'S01E01.jpg'))).toBe(false) const meta = await $`${bin} metadata ${media}`.nothrow() - expect(meta.text()).toContain( - `absolutePath: ${media}\\S01E01_renamed.mkv seasonNumber: 1 episodeNumber: 1`, - ) + expect(meta.text()).toContain(metadataMediaFileLine(media, 'S01E01_renamed.mkv', 1, 1)) }) it('rejects renaming a subdirectory that is not a managed media folder', async () => { From 78ad8f00e69056b80ef84b45bb4fb2c1a101a7ac Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 29 Aug 2026 00:40:58 +0800 Subject: [PATCH 171/214] test: clean up test cases --- .../InitTvShowByCustomTmdbHost.e2e.ts | 162 --------------- .../InitTvShowByCustomTvdbHost.e2e.ts | 176 ---------------- .../InitTvShowByTmdbBehindHttpProxy.e2e.ts | 185 ----------------- .../InitTvShowByTvdbBehindHttpProxy.e2e.ts | 156 --------------- .../ScrapeTvShowByTmdbBehindHttpProxy.e2e.ts | 189 ------------------ .../ScrapeTvShowByTvdbBehindHttpProxy.e2e.ts | 187 ----------------- .../test/specs/ai/AiTool-RenameTool.e2e.ts | 12 +- 7 files changed, 10 insertions(+), 1057 deletions(-) delete mode 100644 apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts delete mode 100644 apps/e2e/common/httpproxy/InitTvShowByCustomTvdbHost.e2e.ts delete mode 100644 apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts delete mode 100644 apps/e2e/common/httpproxy/InitTvShowByTvdbBehindHttpProxy.e2e.ts delete mode 100644 apps/e2e/common/httpproxy/ScrapeTvShowByTmdbBehindHttpProxy.e2e.ts delete mode 100644 apps/e2e/common/httpproxy/ScrapeTvShowByTvdbBehindHttpProxy.e2e.ts diff --git a/apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts b/apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts deleted file mode 100644 index 4fb6f66e..00000000 --- a/apps/e2e/common/httpproxy/InitTvShowByCustomTmdbHost.e2e.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { - setup, - cleanup, - isReverseProxyAccessible, - expectMediaMetadataViaBrowser, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' -import { when, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { MediaMetadata, UserConfig } from '@smm/core/types' -import type { TestFolder } from 'test/actions/import-folders' -import TvShowPanel from 'test/componentobjects/TVShowPanel.co' -import { env } from 'node:process' -import { randomUUID } from 'node:crypto' - -import { testbedOs } from 'test/lib/e2e-platform' - -const CUSTOM_TMDB_HOST = 'https://1255396852-23teay8jtp.ap-hongkong.tencentscf.com' - -/** - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Init TV Show via Custom TMDB Host', () => { - let testFolder = '' - - before(async () => { - const proxyAccessible = await isReverseProxyAccessible() - if (!proxyAccessible) { - throw new Error('Reverse proxy is not accessible — CLI proxy may have failed to start') - } - }) - - beforeEach(async () => { - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config: UserConfig) => { - config.tmdb = { - host: CUSTOM_TMDB_HOST, - } - config.preferMediaLanguage = 'en-US' - return config - }, - os: testbedOs, - }) - resetStepContext() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Scenario: TV show folder initialized via custom TMDB host', async function () { - if (env.slowdown) { - this.timeout(60 * 1000) - } - - // Wait for config to load before importing folder, - // otherwise recognition may read stale default config. - await browser.pause(5000) - - const { folder1 } = await import('test/actions/import-folders') - // Random folder name (not the show title) so a stuck sidebar title - // means recognition failed; a Chinese show title means language wrong. - const opaqueFolderName = `e2e-tmdb-${randomUUID()} {tmdbid=84666}` - await when('Import media folder', { - ...folder1, - folderName: opaqueFolderName, - } satisfies TestFolder) - - if (env.slowdown) { - await delay(5 * 1000) - } - - const expectedTitle = 'WATATEN!: an Angel Flew Down to Me' - const folder = getStepContext()._folder as { path: string } - - await then(`Sidebar shows folder with title "${expectedTitle}"`) - - await then('TV show panel shows the expected title and episode table', async () => { - await TvShowPanel.waitForTitleToBe(expectedTitle) - - expect(await TvShowPanel.toString()).toBe(`Specials -S00E01 - - - - -Season 1 -S01E01 S01E01.mkv V V V -S01E02 S01E02.mkv V V V -S01E03 S01E03.mkv V V V -S01E04 - - - - -S01E05 - - - - -S01E06 - - - - -S01E07 - - - - -S01E08 - - - - -S01E09 - - - - -S01E10 - - - - -S01E11 - - - - -S01E12 - - - -`) - }) - - await then('metadata is persisted with TMDB tvshow id 84666', async () => { - await expectMediaMetadataViaBrowser(folder.path, (m: unknown) => { - expect((m as MediaMetadata).tvShow).toEqual({ - airDate: '2019-01-08', - database: 'TMDB', - id: '84666', - name: 'WATATEN!: an Angel Flew Down to Me', - seasons: [ - { - episodes: [{ episode: 1, name: 'You Never Let Us Down / Always Growing Closer / Let\'s Change You Into This! / I\'m Your Big Sister', season: 0 }], - name: 'Specials', - season: 0, - }, - { - episodes: [ - { episode: 1, name: 'A Funny, Squirmy Feeling', season: 1 }, - { episode: 2, name: 'Incontestably Cute', season: 1 }, - { episode: 3, name: 'Imprinting', season: 1 }, - { episode: 4, name: 'Can We Talk for a Moment?', season: 1 }, - { episode: 5, name: 'Don\'t Worry! Leave It to Me!', season: 1 }, - { episode: 6, name: 'Mya-nee Doesn\'t Have Any Friends', season: 1 }, - { episode: 7, name: 'I Don\'t Understand What Mya-nee Is Saying', season: 1 }, - { episode: 8, name: 'Sometimes Ignorance Is Bliss', season: 1 }, - { episode: 9, name: 'Please Stay Until I Fall Asleep', season: 1 }, - { episode: 10, name: 'I Said Too Much Again', season: 1 }, - { episode: 11, name: 'In Short, It\'s Your Fault, Onee-san', season: 1 }, - { episode: 12, name: 'Angel\'s Gaze', season: 1 }, - ], - name: 'Season 1', - season: 1, - }, - ], - }) - return true - }) - }) - - if (env.slowdown) { - await delay(10 * 1000) - } - }) -}) diff --git a/apps/e2e/common/httpproxy/InitTvShowByCustomTvdbHost.e2e.ts b/apps/e2e/common/httpproxy/InitTvShowByCustomTvdbHost.e2e.ts deleted file mode 100644 index 4f3a633f..00000000 --- a/apps/e2e/common/httpproxy/InitTvShowByCustomTvdbHost.e2e.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { - setup, - cleanup, - isReverseProxyAccessible, - isHttpProxyAccessible, - useEmbeddedHttpProxy, - startEmbeddedHttpProxy, - stopEmbeddedHttpProxy, - getConfiguredHttpProxyAddress, - DEFAULT_EMBEDDED_PROXY_ADDRESS, - expectMediaMetadataViaBrowser, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' -import { when, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { MediaMetadata, UserConfig } from '@smm/core/types' -import type { TestFolder } from 'test/actions/import-folders' -import TvShowPanel from 'test/componentobjects/TVShowPanel.co' -import { env } from 'node:process' -import { randomUUID } from 'node:crypto' - -import { testbedOs } from 'test/lib/e2e-platform' - -/** Official TVDB API (from apps/e2e/.env.local `TVDB_HOST`, not a SCF/mediadb proxy). */ -const OFFICIAL_TVDB_HOST = - (process.env.TVDB_HOST || '').trim() || 'https://api4.thetvdb.com/v4' - -/** - * Init TV show via user-configured official TVDB host + API key. - * Outbound access uses `TVDB_HTTP_PROXY` / embedded proxy when the LAN blocks - * api4.thetvdb.com (see apps/e2e/.env.example). - * - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Init TV Show via Custom TVDB Host', () => { - let testFolder = '' - - before(async () => { - const proxyAccessible = await isReverseProxyAccessible() - if (!proxyAccessible) { - throw new Error('Reverse proxy is not accessible — CLI proxy may have failed to start') - } - - const apiKey = (process.env.TVDB_API_KEY || '').trim() - if (!apiKey) { - throw new Error('TVDB_API_KEY is not set in the e2e environment (apps/e2e/.env.local)') - } - - if (useEmbeddedHttpProxy()) { - await startEmbeddedHttpProxy(DEFAULT_EMBEDDED_PROXY_ADDRESS) - } else { - const tvdbHttpProxy = (process.env.TVDB_HTTP_PROXY || '').trim() - if (!tvdbHttpProxy) { - throw new Error('TVDB_HTTP_PROXY is not set in the e2e environment') - } - const httpProxyUp = await isHttpProxyAccessible(tvdbHttpProxy) - if (!httpProxyUp) { - throw new Error(`TVDB HTTP proxy is not reachable: ${tvdbHttpProxy}`) - } - } - }) - - after(async () => { - await stopEmbeddedHttpProxy() - }) - - beforeEach(async () => { - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config: UserConfig) => { - config.tvdb = { - host: OFFICIAL_TVDB_HOST, - apiKey: (process.env.TVDB_API_KEY || '').trim(), - httpProxy: getConfiguredHttpProxyAddress('tvdb'), - } - config.preferMediaLanguage = 'en-US' - config.applicationLanguage = 'en' - return config - }, - clearLocalStorage: true, - os: testbedOs, - }) - resetStepContext() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Scenario: TV show folder initialized via custom TVDB host', async function () { - if (env.slowdown) { - this.timeout(60 * 1000) - } - - // Wait for config to load before importing folder, - // otherwise recognition may read stale default config. - await browser.pause(5000) - - const { folder1 } = await import('test/actions/import-folders') - // Random folder name (not the show title) so a stuck sidebar title - // means recognition failed; a Chinese show title means language wrong. - const opaqueFolderName = `e2e-tvdb-${randomUUID()} {tvdbid=355969}` - await when('Import media folder', { - ...folder1, - folderName: opaqueFolderName, - } satisfies TestFolder) - - if (env.slowdown) { - await delay(5 * 1000) - } - - const expectedTitle = 'WATATEN!: an Angel Flew Down to Me' - const folder = getStepContext()._folder as { path: string } - - await then(`Sidebar shows folder with title "${expectedTitle}"`) - - await then('TV show panel shows the expected title and episode table', async () => { - await TvShowPanel.waitForTitleToBe(expectedTitle) - - expect(await TvShowPanel.toString()).toBe(`Season 0 -S00E01 - - - - -S00E02 - - - - -Season 1 -S01E01 S01E01.mkv V V V -S01E02 S01E02.mkv V V V -S01E03 S01E03.mkv V V V -S01E04 - - - - -S01E05 - - - - -S01E06 - - - - -S01E07 - - - - -S01E08 - - - - -S01E09 - - - - -S01E10 - - - - -S01E11 - - - - -S01E12 - - - -`) - }) - - await then('metadata is persisted with TVDB tvshow id 355969', async () => { - await expectMediaMetadataViaBrowser(folder.path, (m: unknown) => { - const mm = m as MediaMetadata - expect(mm.tvShow).toBeDefined() - expect(mm.tvShow?.id).toBe('355969') - expect(mm.tvShow?.database).toBe('TVDB') - expect(mm.tvShow?.name).toBe(expectedTitle) - expect(mm.tvShow?.airDate).toBe('2019-01-08') - return true - }) - }) - - if (env.slowdown) { - await delay(10 * 1000) - } - }) -}) diff --git a/apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts b/apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts deleted file mode 100644 index 13c834db..00000000 --- a/apps/e2e/common/httpproxy/InitTvShowByTmdbBehindHttpProxy.e2e.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { - setup, - cleanup, - isReverseProxyAccessible, - isHttpProxyAccessible, - useEmbeddedHttpProxy, - startEmbeddedHttpProxy, - stopEmbeddedHttpProxy, - getCurrentProxyAddress, - DEFAULT_EMBEDDED_PROXY_ADDRESS, - expectMediaMetadataViaBrowser, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' -import { when, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { MediaMetadata, UserConfig } from '@smm/core/types' -import type { TestFolder } from 'test/actions/import-folders' -import TvShowPanel from 'test/componentobjects/TVShowPanel.co' -import { env } from 'node:process' - -import { testbedOs } from 'test/lib/e2e-platform' - -/** - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Init TV Show via TMDB Behind HTTP Proxy', () => { - let testFolder = '' - - before(async () => { - const proxyAccessible = await isReverseProxyAccessible() - if (!proxyAccessible) { - throw new Error('Reverse proxy is not accessible — CLI proxy may have failed to start') - } - - if (useEmbeddedHttpProxy()) { - await startEmbeddedHttpProxy(DEFAULT_EMBEDDED_PROXY_ADDRESS) - } else { - const tmdbHttpProxy = (process.env.TMDB_HTTP_PROXY || '').trim() - if (!tmdbHttpProxy) { - throw new Error('TMDB_HTTP_PROXY is not set in the e2e environment') - } - const httpProxyUp = await isHttpProxyAccessible(tmdbHttpProxy) - if (!httpProxyUp) { - throw new Error(`TMDB HTTP proxy is not reachable: ${tmdbHttpProxy}`) - } - } - }) - - after(async () => { - await stopEmbeddedHttpProxy() - }) - - beforeEach(async () => { - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config: UserConfig) => { - config.tmdb = { - host: process.env.TMDB_HOST || 'https://mediadb.vercel.app/api/tmdb', - apiKey: process.env.TMDB_API_KEY || '', - httpProxy: getCurrentProxyAddress() ?? (process.env.TMDB_HTTP_PROXY || '').trim(), - } - config.preferMediaLanguage = 'en-US' - return config - }, - os: testbedOs, - }) - resetStepContext() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Scenario: TV show folder initialized via TMDB behind HTTP proxy', async function () { - if (env.slowdown) { - this.timeout(60 * 1000) - } - - // Wait for config to load before importing folder, - // otherwise recognition may read stale default config. - await browser.pause(5000) - - const { folder1 } = await import('test/actions/import-folders') - // Neutral folder name (no Chinese title) so a failed recognition cannot be - // mistaken for a preferMediaLanguage / UI language issue. Keep {tmdbid=...} - // so recognition still uses the TMDB-id-in-folder-name path. - const unknownFolderName = `Unknown - ${Date.now()} {tmdbid=84666}` - await when('Import media folder', { - ...folder1, - folderName: unknownFolderName, - } satisfies TestFolder) - - if (env.slowdown) { - await delay(5 * 1000) - } - - const expectedTitle = 'WATATEN!: an Angel Flew Down to Me' - const folder = getStepContext()._folder as { path: string } - - await then(`Sidebar shows folder with title "${expectedTitle}"`) - - await then('TV show panel shows the expected title and episode table', async () => { - await TvShowPanel.waitForTitleToBe(expectedTitle) - - expect(await TvShowPanel.toString()).toBe(`Specials -S00E01 - - - - -Season 1 -S01E01 S01E01.mkv V V V -S01E02 S01E02.mkv V V V -S01E03 S01E03.mkv V V V -S01E04 - - - - -S01E05 - - - - -S01E06 - - - - -S01E07 - - - - -S01E08 - - - - -S01E09 - - - - -S01E10 - - - - -S01E11 - - - - -S01E12 - - - -`) - }) - - await then('metadata is persisted with TMDB tvshow id 84666', async () => { - await expectMediaMetadataViaBrowser(folder.path, (m: unknown) => { - expect((m as MediaMetadata).tvShow).toEqual({ - airDate: '2019-01-08', - database: 'TMDB', - id: '84666', - name: 'WATATEN!: an Angel Flew Down to Me', - seasons: [ - { - episodes: [{ episode: 1, name: 'You Never Let Us Down / Always Growing Closer / Let\'s Change You Into This! / I\'m Your Big Sister', season: 0 }], - name: 'Specials', - season: 0, - }, - { - episodes: [ - { episode: 1, name: 'A Funny, Squirmy Feeling', season: 1 }, - { episode: 2, name: 'Incontestably Cute', season: 1 }, - { episode: 3, name: 'Imprinting', season: 1 }, - { episode: 4, name: 'Can We Talk for a Moment?', season: 1 }, - { episode: 5, name: 'Don\'t Worry! Leave It to Me!', season: 1 }, - { episode: 6, name: 'Mya-nee Doesn\'t Have Any Friends', season: 1 }, - { episode: 7, name: 'I Don\'t Understand What Mya-nee Is Saying', season: 1 }, - { episode: 8, name: 'Sometimes Ignorance Is Bliss', season: 1 }, - { episode: 9, name: 'Please Stay Until I Fall Asleep', season: 1 }, - { episode: 10, name: 'I Said Too Much Again', season: 1 }, - { episode: 11, name: 'In Short, It\'s Your Fault, Onee-san', season: 1 }, - { episode: 12, name: 'Angel\'s Gaze', season: 1 }, - ], - name: 'Season 1', - season: 1, - }, - ], - }) - return true - }) - }) - - if (env.slowdown) { - await delay(10 * 1000) - } - }) -}) diff --git a/apps/e2e/common/httpproxy/InitTvShowByTvdbBehindHttpProxy.e2e.ts b/apps/e2e/common/httpproxy/InitTvShowByTvdbBehindHttpProxy.e2e.ts deleted file mode 100644 index a6e4a4de..00000000 --- a/apps/e2e/common/httpproxy/InitTvShowByTvdbBehindHttpProxy.e2e.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { - setup, - cleanup, - isReverseProxyAccessible, - isHttpProxyAccessible, - useEmbeddedHttpProxy, - startEmbeddedHttpProxy, - stopEmbeddedHttpProxy, - getCurrentProxyAddress, - DEFAULT_EMBEDDED_PROXY_ADDRESS, - expectMediaMetadataViaBrowser, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - resolveSmmTestFolderViaBrowser, -} from 'test/lib/browser-fs' -import { delay } from 'es-toolkit' -import { when, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { MediaMetadata, UserConfig } from '@smm/core/types' -import type { TestFolder } from 'test/actions/import-folders' -import TvShowPanel from 'test/componentobjects/TVShowPanel.co' -import { env } from 'node:process' - -import { testbedOs } from 'test/lib/e2e-platform' - -/** - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Init TV Show via TVDB Behind HTTP Proxy', () => { - let testFolder = '' - - before(async () => { - const proxyAccessible = await isReverseProxyAccessible() - if (!proxyAccessible) { - throw new Error('Reverse proxy is not accessible — CLI proxy may have failed to start') - } - - if (useEmbeddedHttpProxy()) { - await startEmbeddedHttpProxy(DEFAULT_EMBEDDED_PROXY_ADDRESS) - } else { - const tvdbHttpProxy = (process.env.TVDB_HTTP_PROXY || '').trim() - if (!tvdbHttpProxy) { - throw new Error('TVDB_HTTP_PROXY is not set in the e2e environment') - } - const httpProxyUp = await isHttpProxyAccessible(tvdbHttpProxy) - if (!httpProxyUp) { - throw new Error(`TVDB HTTP proxy is not reachable: ${tvdbHttpProxy}`) - } - } - }) - - after(async () => { - await stopEmbeddedHttpProxy() - }) - - beforeEach(async () => { - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config: UserConfig) => { - config.tvdb = { - host: process.env.TVDB_HOST || '', - apiKey: process.env.TVDB_API_KEY || '', - httpProxy: getCurrentProxyAddress() ?? (process.env.TVDB_HTTP_PROXY || '').trim(), - } - return config - }, - os: testbedOs, - }) - resetStepContext() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Scenario: TV show folder initialized via TVDB behind HTTP proxy', async function () { - if (env.slowdown) { - this.timeout(60 * 1000) - } - - // Wait for config to load before importing folder, - // otherwise recognition may read stale default config. - await browser.pause(5000) - - const { folder1 } = await import('test/actions/import-folders') - await when('Import media folder', { - ...folder1, - folderName: `${folder1.mediaName} {tvdbid=355969}`, - } satisfies TestFolder) - - if (env.slowdown) { - await delay(5 * 1000) - } - - const expectedTitle = 'WATATEN!: an Angel Flew Down to Me' - const folder = getStepContext()._folder as { path: string } - - await then(`Sidebar shows folder with title "${expectedTitle}"`) - - await then('TV show panel shows the expected title and episode table', async () => { - await TvShowPanel.waitForTitleToBe(expectedTitle) - - expect(await TvShowPanel.toString()).toBe(`Season 0 -S00E01 - - - - -S00E02 - - - - -Season 1 -S01E01 S01E01.mkv V V V -S01E02 S01E02.mkv V V V -S01E03 S01E03.mkv V V V -S01E04 - - - - -S01E05 - - - - -S01E06 - - - - -S01E07 - - - - -S01E08 - - - - -S01E09 - - - - -S01E10 - - - - -S01E11 - - - - -S01E12 - - - -`) - }) - - await then('metadata is persisted with TVDB tvshow id 355969', async () => { - await expectMediaMetadataViaBrowser(folder.path, (m: unknown) => { - const mm = m as MediaMetadata - expect(mm.tvShow).toBeDefined() - expect(mm.tvShow?.id).toBe('355969') - expect(mm.tvShow?.database).toBe('TVDB') - expect(mm.tvShow?.name).toBe(expectedTitle) - expect(mm.tvShow?.airDate).toBe('2019-01-08') - return true - }) - }) - - if (env.slowdown) { - await delay(10 * 1000) - } - }) -}) diff --git a/apps/e2e/common/httpproxy/ScrapeTvShowByTmdbBehindHttpProxy.e2e.ts b/apps/e2e/common/httpproxy/ScrapeTvShowByTmdbBehindHttpProxy.e2e.ts deleted file mode 100644 index 1bdee6b6..00000000 --- a/apps/e2e/common/httpproxy/ScrapeTvShowByTmdbBehindHttpProxy.e2e.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { - setup, - cleanup, - isReverseProxyAccessible, - isHttpProxyAccessible, - useEmbeddedHttpProxy, - startEmbeddedHttpProxy, - stopEmbeddedHttpProxy, - getConfiguredHttpProxyAddress, - DEFAULT_EMBEDDED_PROXY_ADDRESS, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - fileExistsViaBrowser, - getFileSizeViaBrowser, - joinPlatformPath, - listFilesViaBrowser, - readFileViaBrowser, - resolveSmmTestFolderViaBrowser, - basenamePlatformPath, - updateUserConfigViaBrowser, -} from 'test/lib/browser-fs' -import { given, when, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { UserConfig } from '@smm/core/types' -import { env } from 'node:process' -import { testbedOs } from 'test/lib/e2e-platform' -import page from 'test/pageobjects/page' -import Sidebar from 'test/componentobjects/Sidebar' - -// Intentionally dead proxy: port 1 is closed on every host / container / -// device, so any request routed through it fails fast with ECONNREFUSED. -// If scrape did NOT route through the configured proxy, this dead address -// would be ignored and scrape would succeed on an open network — so a -// failure here deterministically proves the proxy is wired. -const WRONG_HTTP_PROXY = 'http://127.0.0.1:1' - -const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] - -async function getImagePathWithPrefix(folderPath: string, prefix: string): Promise { - const items = await listFilesViaBrowser(folderPath) - const match = items.find((item) => { - if (item.isDirectory) return false - const name = basenamePlatformPath(item.path) - return ( - name.startsWith(`${prefix}.`) && - IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext)) - ) - }) - return match?.path -} - -/** - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Scrape TV Show via TMDB Behind HTTP Proxy', () => { - let testFolder = '' - - before(async () => { - const proxyAccessible = await isReverseProxyAccessible() - if (!proxyAccessible) { - throw new Error('Reverse proxy is not accessible — CLI proxy may have failed to start') - } - - if (useEmbeddedHttpProxy()) { - await startEmbeddedHttpProxy(DEFAULT_EMBEDDED_PROXY_ADDRESS) - } else { - const tmdbHttpProxy = (process.env.TMDB_HTTP_PROXY || '').trim() - if (!tmdbHttpProxy) { - throw new Error('TMDB_HTTP_PROXY is not set in the e2e environment') - } - const httpProxyUp = await isHttpProxyAccessible(tmdbHttpProxy) - if (!httpProxyUp) { - throw new Error(`TMDB HTTP proxy is not reachable: ${tmdbHttpProxy}`) - } - } - }) - - after(async () => { - await stopEmbeddedHttpProxy() - }) - - beforeEach(async () => { - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config: UserConfig) => { - config.tmdb = { - host: 'https://api.themoviedb.org/3', - apiKey: process.env.TMDB_API_KEY || '', - httpProxy: WRONG_HTTP_PROXY, - } - config.preferMediaLanguage = 'zh-CN' - return config - }, - os: testbedOs, - }) - resetStepContext() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Scenario: TV show scraped via TMDB behind HTTP proxy (dead proxy fails, live proxy succeeds)', async function () { - this.timeout(240 * 1000) - - // Phase A — dead proxy: scrape must FAIL (proves the proxy is wired). - await given('TV show folder with TMDB id 84666 and one episode was imported') - await when('folder from context was selected') - await when('I click "Scrape" button in TV show panel') - await then('scrape dialog shows all tasks pending') - await when('I start scrape') - await then('scrape dialog shows all TV show tasks failed') - await when('I close scrape dialog') - - // Phase B — fix the proxy to the working one, scrape again: must succeed. - const liveProxy = getConfiguredHttpProxyAddress('tmdb') - if (!liveProxy) { - throw new Error('No working TMDB HTTP proxy available for Phase B') - } - await updateUserConfigViaBrowser((config: UserConfig) => { - if (config.tmdb) { - config.tmdb.httpProxy = liveProxy - } - return config - }) - await page.refresh() - - const folder = getStepContext()._folder as { - folderName: string - translations?: { title?: Record } - } - await Sidebar.waitForFolderName(folder.folderName) - - await when('folder from context was selected') - await when('I click "Scrape" button in TV show panel') - await then('scrape dialog shows all tasks pending') - await when('I start scrape') - await then('scrape dialog shows all TV show tasks completed') - await when('I close scrape dialog') - - await then('TMDB TV show scrape outputs are written to disk', async () => { - const folderPath = (getStepContext()._folder as { path: string }).path - - const thumbnailPath = joinPlatformPath(folderPath, 'S01E01.jpg') - expect(await fileExistsViaBrowser(thumbnailPath)).toBe(true) - expect(await getFileSizeViaBrowser(thumbnailPath)).toBeGreaterThan(0) - - const posterPath = await getImagePathWithPrefix(folderPath, 'poster') - const fanartPath = await getImagePathWithPrefix(folderPath, 'fanart') - expect(posterPath).toBeDefined() - expect(fanartPath).toBeDefined() - expect(await getFileSizeViaBrowser(posterPath!)).toBeGreaterThan(0) - expect(await getFileSizeViaBrowser(fanartPath!)).toBeGreaterThan(0) - expect(await fileExistsViaBrowser(joinPlatformPath(folderPath, 'S01E02.jpg'))).toBe(false) - - const tvshowNfoPath = joinPlatformPath(folderPath, 'tvshow.nfo') - expect(await fileExistsViaBrowser(tvshowNfoPath)).toBe(true) - expect(await readFileViaBrowser(tvshowNfoPath)).toContain('天使降临到我身边') - - const s01e01EpisodeNfoPath = joinPlatformPath(folderPath, 'S01E01.nfo') - expect(await fileExistsViaBrowser(s01e01EpisodeNfoPath)).toBe(true) - expect(await getFileSizeViaBrowser(s01e01EpisodeNfoPath)).toBeGreaterThan(0) - expect(await readFileViaBrowser(s01e01EpisodeNfoPath)).toContain('心里痒痒的感觉') - }) - - if (env.slowdown) { - await browser.pause(5000) - } - }) -}) diff --git a/apps/e2e/common/httpproxy/ScrapeTvShowByTvdbBehindHttpProxy.e2e.ts b/apps/e2e/common/httpproxy/ScrapeTvShowByTvdbBehindHttpProxy.e2e.ts deleted file mode 100644 index 7c5b89d2..00000000 --- a/apps/e2e/common/httpproxy/ScrapeTvShowByTvdbBehindHttpProxy.e2e.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { expect, browser } from '@wdio/globals' -import { - setup, - cleanup, - isReverseProxyAccessible, - isHttpProxyAccessible, - useEmbeddedHttpProxy, - startEmbeddedHttpProxy, - stopEmbeddedHttpProxy, - getConfiguredHttpProxyAddress, - DEFAULT_EMBEDDED_PROXY_ADDRESS, -} from 'test/lib/testbed' -import { - clearFolderViaBrowser, - fileExistsViaBrowser, - getFileSizeViaBrowser, - joinPlatformPath, - listFilesViaBrowser, - readFileViaBrowser, - resolveSmmTestFolderViaBrowser, - basenamePlatformPath, - updateUserConfigViaBrowser, -} from 'test/lib/browser-fs' -import { given, when, then, resetStepContext, getStepContext } from 'test/lib/gherkin' -import 'test/steps' -import type { UserConfig } from '@smm/core/types' -import { env } from 'node:process' -import { testbedOs } from 'test/lib/e2e-platform' -import page from 'test/pageobjects/page' -import Sidebar from 'test/componentobjects/Sidebar' - -// Intentionally dead proxy: port 1 is closed on every host / container / -// device, so any request routed through it fails fast with ECONNREFUSED. -const WRONG_HTTP_PROXY = 'http://127.0.0.1:1' - -const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] - -async function getImagePathWithPrefix(folderPath: string, prefix: string): Promise { - const items = await listFilesViaBrowser(folderPath) - const match = items.find((item) => { - if (item.isDirectory) return false - const name = basenamePlatformPath(item.path) - return ( - name.startsWith(`${prefix}.`) && - IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext)) - ) - }) - return match?.path -} - -/** - * @supports local, Electron, HarmonyOS, Docker - */ -describe('Scrape TV Show via TVDB Behind HTTP Proxy', () => { - let testFolder = '' - - before(async () => { - const proxyAccessible = await isReverseProxyAccessible() - if (!proxyAccessible) { - throw new Error('Reverse proxy is not accessible — CLI proxy may have failed to start') - } - - if (useEmbeddedHttpProxy()) { - await startEmbeddedHttpProxy(DEFAULT_EMBEDDED_PROXY_ADDRESS) - } else { - const tvdbHttpProxy = (process.env.TVDB_HTTP_PROXY || '').trim() - if (!tvdbHttpProxy) { - throw new Error('TVDB_HTTP_PROXY is not set in the e2e environment') - } - const httpProxyUp = await isHttpProxyAccessible(tvdbHttpProxy) - if (!httpProxyUp) { - throw new Error(`TVDB HTTP proxy is not reachable: ${tvdbHttpProxy}`) - } - } - }) - - after(async () => { - await stopEmbeddedHttpProxy() - }) - - beforeEach(async () => { - await setup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - openBrowserPage: true, - resetUserConfig: (config: UserConfig) => { - config.tvdb = { - host: 'https://api4.thetvdb.com/v4', - apiKey: process.env.TVDB_API_KEY || '', - httpProxy: WRONG_HTTP_PROXY, - } - config.preferMediaLanguage = 'zh-CN' - return config - }, - os: testbedOs, - }) - resetStepContext() - - testFolder = await resolveSmmTestFolderViaBrowser() - await clearFolderViaBrowser(testFolder) - }) - - afterEach(async () => { - await cleanup({ - removeMetadataDir: true, - removePlansDir: true, - removeMediaFolders: true, - removeDirInSidebar: true, - resetUserConfig: true, - os: testbedOs, - }) - if (testFolder) { - await clearFolderViaBrowser(testFolder) - } - }) - - it('Scenario: TV show scraped via TVDB behind HTTP proxy (dead proxy fails, live proxy succeeds)', async function () { - this.timeout(240 * 1000) - - // Phase A — dead proxy: scrape must FAIL (proves the proxy is wired). - await given('TV show folder with TVDB id 355969 and one episode was imported') - await when('folder from context was selected') - await when('I click "Scrape" button in TV show panel') - await then('scrape dialog shows all tasks pending') - await when('I start scrape') - await then('scrape dialog shows all TV show tasks failed') - await when('I close scrape dialog') - - // Phase B — fix the proxy to the working one, scrape again: must succeed. - const liveProxy = getConfiguredHttpProxyAddress('tvdb') - if (!liveProxy) { - throw new Error('No working TVDB HTTP proxy available for Phase B') - } - await updateUserConfigViaBrowser((config: UserConfig) => { - if (config.tvdb) { - config.tvdb.httpProxy = liveProxy - } - return config - }) - await page.refresh() - - const folder = getStepContext()._folder as { - folderName: string - translations?: { title?: Record } - } - await Sidebar.waitForFolderName(folder.folderName) - - await when('folder from context was selected') - await when('I click "Scrape" button in TV show panel') - await then('scrape dialog shows all tasks pending') - await when('I start scrape') - await then('scrape dialog shows all TV show tasks completed') - await when('I close scrape dialog') - - await then('TVDB TV show scrape outputs are written to disk', async () => { - const folderPath = (getStepContext()._folder as { path: string }).path - - const thumbnailPath = joinPlatformPath(folderPath, 'S01E01.jpg') - expect(await fileExistsViaBrowser(thumbnailPath)).toBe(true) - expect(await getFileSizeViaBrowser(thumbnailPath)).toBeGreaterThan(0) - - const posterPath = await getImagePathWithPrefix(folderPath, 'poster') - const fanartPath = await getImagePathWithPrefix(folderPath, 'fanart') - expect(posterPath).toBeDefined() - expect(fanartPath).toBeDefined() - expect(await getFileSizeViaBrowser(posterPath!)).toBeGreaterThan(0) - expect(await getFileSizeViaBrowser(fanartPath!)).toBeGreaterThan(0) - expect(await fileExistsViaBrowser(joinPlatformPath(folderPath, 'S01E02.jpg'))).toBe(false) - - const tvshowNfoPath = joinPlatformPath(folderPath, 'tvshow.nfo') - expect(await fileExistsViaBrowser(tvshowNfoPath)).toBe(true) - expect(await readFileViaBrowser(tvshowNfoPath)).toContain('天使降临到了我身边') - - const s01e01EpisodeNfoPath = joinPlatformPath(folderPath, 'S01E01.nfo') - expect(await fileExistsViaBrowser(s01e01EpisodeNfoPath)).toBe(true) - expect(await getFileSizeViaBrowser(s01e01EpisodeNfoPath)).toBeGreaterThan(0) - expect(await readFileViaBrowser(s01e01EpisodeNfoPath)).toContain('心裏癢癢的感覺') - expect(await fileExistsViaBrowser(joinPlatformPath(folderPath, 'S01E02.nfo'))).toBe(false) - }) - - if (env.slowdown) { - await browser.pause(5000) - } - }) -}) diff --git a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts index d3274af0..205c1755 100644 --- a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts +++ b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts @@ -55,7 +55,11 @@ describe('AI Assistant - Rename Tool', async () => { await browser.waitUntil(async () => { return (await TVShowPanel.toString()).includes(`S01E01 S01E01.mp4 - - -`) - }, {timeout: 5000}) + }, { + timeout: 60000, + interval: 500, + timeoutMsg: 'Expected S01E01.mp4 to be linked to S01E01 after import', + }) await expectMediaMetadataToBe(folder.path!, (obj) => { const mm = obj as MediaMetadata; @@ -88,7 +92,11 @@ describe('AI Assistant - Rename Tool', async () => { await browser.waitUntil(async () => { return (await TVShowPanel.toString()).includes(`S01E01 [1].mp4 - - -`) - }, {timeout: 5000}) + }, { + timeout: 60000, + interval: 500, + timeoutMsg: 'Expected TV show panel to show renamed file [1].mp4', + }) await expectMediaMetadataToBe(folder.path!, (obj) => { const mm = obj as MediaMetadata; From f77b67ebc02ca289675ead227960c66eba49e9d0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 29 Aug 2026 23:41:47 +0800 Subject: [PATCH 172/214] docs: design create-rename-episode-plan Core API and single AI/MCP tool Co-authored-by: Cursor --- ...08-29-create-rename-episode-plan-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-29-create-rename-episode-plan-design.md diff --git a/docs/superpowers/specs/2026-08-29-create-rename-episode-plan-design.md b/docs/superpowers/specs/2026-08-29-create-rename-episode-plan-design.md new file mode 100644 index 00000000..b9d8245b --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-create-rename-episode-plan-design.md @@ -0,0 +1,156 @@ +# create-rename-episode-plan + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +Related product docs: [docs/dev/rename-episodes.md](../../dev/rename-episodes.md), [docs/dev/manage-plan.md](../../dev/manage-plan.md). + +## 1. Background + +AI/MCP rename previously used a three-step task (`begin` → `add` → `end`) implemented largely in `apps/cli` (`renameFilesToolV2`), which read/wrote `{appDataDir}/plans` directly. That duplicated Core plan persistence and, on Linux, diverged from `getCore().applyPlan()` (which used `userDataDir`), causing CI failures (`Plan not found`). + +Product redesign collapses AI/MCP into one tool that asks Core to persist a rename plan with AI-chosen `from → to` pairs. Rule-based rename (`tryToRenameFolder` / Plex|Emby) calculates pairs then reuses the same Core write API. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +AI Agent / MCP client + │ + ▼ +MCP Tool / UI AI Tool (thin: schema + Core call + broadcast + success message) + │ + ▼ +apps/core Core.createRenameEpisodePlan() + │ + ▼ +FsPort → {appDataDir}/plans/{id}.plan.json + +Rule path: +CLI/UI try-to-rename → Core.tryToRenameFolder() + → build Plex|Emby pairs + → createRenameEpisodePlan(creator: "app") +``` + +- **apps/core**: validation + plan write (single source of truth for plan dir). +- **packages/core-routes / apps/cli / apps/ui**: thin tool and HTTP wrappers only. +- **Confirm/apply**: unchanged — user reviews in SMM (`AiBasedRenameFilePrompt` / rule prompt) → `POST /api/apply-plan` → `Core.applyPlan`. + +## 2.2 App Level Architecture + +### Core + +```ts +Core.createRenameEpisodePlan( + mediaFolderPath: string, + files: Array<{ from: string; to: string }>, + options?: { creator?: "app" | "ai"; id?: string }, +): Promise +``` + +Behavior: + +1. Normalize paths with `Path.posix`. +2. Validate (throw on failure — see Key Design). +3. Write `RenameFilesPlan` with `task: "rename-files"`, `status: "pending"`, `creator` default `"app"`. +4. Return the plan. + +`tryToRenameFolderPipeline`: + +1. Keep existing managed-folder / metadata / TV-show checks and `buildTvShowRenamePlanFileEntries`. +2. Call `createRenameEpisodePlan(path, files, { creator: "app" })` instead of local `writePlan`. + +### Tool layer + +Single tool name: `create-rename-episode-plan`. + +- Input: `mediaFolderPath`, `files: [{ from, to }]`. +- Calls `Core.createRenameEpisodePlan(..., { creator: "ai" })`. +- Broadcasts `RenameFilesPlanReady` (same as former end tool). +- Returns `END_PLAN_TASK_SUCCESS_MESSAGE` (user must return to SMM to review/approve). + +### Deletion + +Remove begin/add/end rename task surface: + +- `@smm/core/types/ai-tools/renameFilesTask` (replace with new tool types) +- UI `Begin` / `Add` / `End` rename AI tools +- core-routes `renameFilesTask` builders + MCP handlers `beginRenameTask` / `addRenameFile` / `endRenameTask` +- cli `renameFilesTool` / `renameFilesToolV2` / `renameFilesTaskV2` and Debug routes `startRenameFilesTask` / `addFileToRenameTask` / `endRenameFilesTask` +- System prompt / how-to MCP text that references begin/add/end + +## 2.3 Key Design + +**Validation in Core** (preserve former tool checks; fail before write): + +| Former tool | Rule | +|-------------|------| +| begin | Media metadata exists for folder (opened in SMM) | +| end | `files.length > 0` | +| add | Each `from` is an episode video in `mediaFiles` | +| add | Batch `validateRenameOperations` (path/conflict rules via injected FsPort deps as needed) | + +**Plan identity for apply**: AI and rule plans share the same on-disk layout under Core `appDataDir`, so `apply-plan` / `getPlan` always see the file the tool wrote (fixes Linux XDG split when all writers use Core). + +**Not in scope**: changing apply/reject HTTP; rule-based UI rename button flow beyond sharing `createRenameEpisodePlan`; recognize begin/add/end tools. + +## 3. User Stories + +### 3.1 AI creates arbitrary rename plan + +* **Given** a TV show folder is opened in SMM with at least one recognized episode file +* **When** the agent calls `create-rename-episode-plan` with custom `from`/`to` pairs +* **Then** Core writes a `pending` plan with `creator: "ai"`, tool returns the success message, UI shows AI rename prompt, and after confirm `apply-plan` renames files and updates metadata + +```mermaid +sequenceDiagram + participant U as User + participant A as AI Agent + participant T as MCP/AI Tool + participant C as Core + participant W as UI + + U->>A: ask for renaming episodes + A->>T: create-rename-episode-plan(folder, files) + T->>C: createRenameEpisodePlan(..., creator ai) + C->>C: validate + writePlan + C-->>T: RenameFilesPlan + T->>W: RenameFilesPlanReady + T-->>A: END_PLAN_TASK_SUCCESS_MESSAGE + A-->>U: please review in SMM + U->>W: confirm + W->>C: applyPlan +``` + +### 3.2 Rule-based rename still works via shared write API + +* **Given** a managed TV show folder with metadata +* **When** user/CLI runs try-to-rename (Plex or Emby) +* **Then** `tryToRenameFolder` computes pairs and `createRenameEpisodePlan` persists `creator: "app"` pending plan + +```mermaid +sequenceDiagram + participant U as User/CLI + participant C as Core + + U->>C: tryToRenameFolder(folder, rule) + C->>C: build Plex/Emby pairs + C->>C: createRenameEpisodePlan(..., creator app) + C-->>U: RenameFilesPlan +``` + +## 4. Testing + +| Layer | Change | +|-------|--------| +| Core unit | `createRenameEpisodePlan` validation + write; `tryToRenameFolder` uses it | +| MCP / AI unit | Single-tool success/failure; remove three-step cases | +| E2E `AiTool-RenameTool` | One tool call with custom target (e.g. `[1].mp4`) → confirm → assert panel/metadata | +| CLI rename-episodes | Still covers try-to-rename; plans under Core appDataDir | + +## 5. Out of scope + +- Recognize AI tools (begin/add/end for recognize) +- Changing `END_PLAN_TASK_SUCCESS_MESSAGE` wording (reuse as-is) +- Docker wait-ready / unrelated e2e infrastructure failures From 46c0254ab41b1b425554e9197b7567bbde8c02d5 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 29 Aug 2026 23:48:17 +0800 Subject: [PATCH 173/214] docs: add create-rename-episode-plan implementation plan Co-authored-by: Cursor --- .../2026-08-29-create-rename-episode-plan.md | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-29-create-rename-episode-plan.md diff --git a/docs/superpowers/plans/2026-08-29-create-rename-episode-plan.md b/docs/superpowers/plans/2026-08-29-create-rename-episode-plan.md new file mode 100644 index 00000000..268602e7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-create-rename-episode-plan.md @@ -0,0 +1,488 @@ +# create-rename-episode-plan Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Core.createRenameEpisodePlan`, make `tryToRenameFolder` use it, replace begin/add/end rename AI/MCP tools with one `create-rename-episode-plan` tool, and update unit/e2e tests. + +**Architecture:** Validation + plan write live in `apps/core` (`createRenameEpisodePlanPipeline`). Rule path computes Plex/Emby pairs then calls that API. AI/MCP/Debug/UI tools are thin wrappers that pass AI-chosen `files` with `creator: "ai"`, broadcast `RenameFilesPlanReady`, and return `END_PLAN_TASK_SUCCESS_MESSAGE`. Confirm/apply stays `apply-plan`. + +**Tech Stack:** TypeScript, Vitest, Bun, Hono, `@smm/core` plan + validation helpers, WebdriverIO e2e. + +**Spec:** `docs/superpowers/specs/2026-08-29-create-rename-episode-plan-design.md` + +## Global Constraints + +- Plan files only via Core `appDataDir` / `writePlan` (no cli `Bun.write` to plans). +- Preserve former begin/add/end **validation semantics** inside Core (metadata exists, non-empty files for AI, episode video `from`, `validateRenameOperations`). +- `tryToRenameFolder` must still allow **empty** `files` when paths already match (existing Core test); use `options.allowEmptyFiles: true` only for that path. +- AI tool success message: reuse `END_PLAN_TASK_SUCCESS_MESSAGE` verbatim. +- Delete begin/add/end rename task surface completely (no compatibility shim). +- Align HTTP `getPlans` / plan routes `appDataDir` with `getCore()` so Linux XDG split cannot hide plans from UI/apply. +- Do not change recognize begin/add/end tools. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `apps/core/src/pipeline/createRenameEpisodePlan.ts` | Validate + write rename-files plan | +| `apps/core/src/pipeline/createRenameEpisodePlan.test.ts` | Unit tests for validation/write | +| `apps/core/src/pipeline/tryToRenameFolder.ts` | Call createRenameEpisodePlan after building pairs | +| `apps/core/src/Core.ts` | `createRenameEpisodePlan` method | +| `apps/core/src/Core.test.ts` | Integration coverage | +| `apps/core/src/index.ts` | Export pipeline types if needed | +| `packages/core/types/ai-tools/createRenameEpisodePlan.ts` | Tool name/schema (replace renameFilesTask.ts) | +| `packages/core/ai-tool/registry.ts` / `systemPrompt.ts` | Register + prompt text | +| `packages/core-routes/src/tools/createRenameEpisodePlan.ts` | Thin MCP/agent tool builder | +| `packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts` | MCP register | +| `packages/core-routes/src/mcp/createServer.ts` | Wire new tool; remove old three | +| `apps/cli/src/route/RenameEpisodesPlan.ts` or new route | `POST /api/create-rename-episode-plan` → Core | +| `apps/cli/src/route/Plans.ts` | Same appDataDir as Core (`getUserDataDir`) | +| `apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts` | Debug helper for e2e | +| `apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx` | Single UI AI tool | +| `apps/ui/src/ai/Assistant.tsx` / `tools/index.ts` | Register; remove begin/add/end | +| `apps/e2e/test/lib/debugRenameTool.ts` | One-shot create API | +| `apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts` | Use one-shot tool | +| Delete | `renameFilesToolV2.ts`, `renameFilesTaskV2.ts`, old MCP handlers, old UI tools, `packages/core/types/ai-tools/renameFilesTask.ts`, core-routes `renameFilesTask.ts` (rename-specific) | + +--- + +### Task 1: Core `createRenameEpisodePlanPipeline` + +**Files:** +- Create: `apps/core/src/pipeline/createRenameEpisodePlan.ts` +- Create: `apps/core/src/pipeline/createRenameEpisodePlan.test.ts` +- Modify: `apps/core/src/index.ts` (export) + +**Interfaces:** +- Produces: +```typescript +export interface CreateRenameEpisodePlanOptions { + creator?: "app" | "ai"; + id?: string; + /** When true, allow files: [] (rule path when names already match). Default false. */ + allowEmptyFiles?: boolean; +} + +export interface CreateRenameEpisodePlanDeps { + fs: FsPort; + appDataDir: string; + normalizePosix: (path: string) => string; + getMediaMetadata: (folder: string) => Promise; + createId?: () => string; +} + +export async function createRenameEpisodePlanPipeline( + mediaFolderPath: string, + files: Array<{ from: string; to: string }>, + options: CreateRenameEpisodePlanOptions | undefined, + deps: CreateRenameEpisodePlanDeps, +): Promise; +``` + +- [ ] **Step 1: Write failing tests** + +```typescript +import { describe, expect, it } from "vitest"; +import { createRenameEpisodePlanPipeline } from "./createRenameEpisodePlan"; +import { inMemoryFs } from "../test/inMemoryFs"; // use same helper pattern as Core.test.ts +import { metadataCachePath, planFilePath } from "./paths"; + +describe("createRenameEpisodePlanPipeline", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + + it("writes pending plan with posix paths", async () => { + const mm = { + mediaFolderPath: folder, + type: "tvshow-folder" as const, + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/S01E01.mkv": "", + }); + const plan = await createRenameEpisodePlanPipeline( + folder, + [{ from: "/m/Show/S01E01.mkv", to: "/m/Show/[1].mkv" }], + { creator: "ai", id: "fixed-id" }, + { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm as never, + createId: () => "fixed-id", + }, + ); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("ai"); + expect(plan.files[0]?.to).toBe("/m/Show/[1].mkv"); + expect(await fs.exists(planFilePath(appDataDir, "fixed-id"))).toBe(true); + }); + + it("rejects empty files when allowEmptyFiles is false", async () => { + await expect( + createRenameEpisodePlanPipeline(folder, [], { creator: "ai" }, { + fs: inMemoryFs({}), + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => ({ mediaFolderPath: folder, type: "tvshow-folder", mediaFiles: [] }) as never, + }), + ).rejects.toThrow(/No rename entries/); + }); + + it("rejects when metadata missing", async () => { + await expect( + createRenameEpisodePlanPipeline( + folder, + [{ from: "/m/Show/a.mkv", to: "/m/Show/b.mkv" }], + { creator: "ai" }, + { + fs: inMemoryFs({}), + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => null, + }, + ), + ).rejects.toThrow(/not opened in SMM|Media metadata not found/); + }); +}); +``` + +Adapt imports to match existing `Core.test.ts` / pipeline test helpers (`inMemoryFs` location). + +- [ ] **Step 2: Run tests — expect FAIL** + +Run: `pnpm --filter core-app exec vitest run src/pipeline/createRenameEpisodePlan.test.ts` +(or the package script used for `apps/core` in this repo) + +Expected: FAIL (module/function missing) + +- [ ] **Step 3: Implement pipeline** + +Logic outline: + +1. `posixFolder = deps.normalizePosix(mediaFolderPath)` +2. `mm = await deps.getMediaMetadata(posixFolder)`; if null → throw using `assertMediaFolderHasMetadata(false, …)` message style (`folderPath "…" is not opened in SMM`) +3. Normalize `files` to posix; if `files.length === 0` && !`allowEmptyFiles` → throw `No rename entries in task` +4. For each `from`, `assertEpisodeVideoFile(mm, from)` — throw on error string +5. `validateRenameOperations(files, posixFolder, probeFromFs(deps.fs))` — if `!isValid` throw joined errors (skip existence checks when `files.length === 0`) +6. `writePlan` with `{ id, task: "rename-files", status: "pending", creator: options?.creator ?? "app", mediaFolderPath: posixFolder, files }` +7. Return plan + +Reuse probe helper pattern from `renameEpisodeFile.ts` (`renameFileExistenceProbe`). + +- [ ] **Step 4: Run tests — expect PASS** + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/createRenameEpisodePlan.ts apps/core/src/pipeline/createRenameEpisodePlan.test.ts apps/core/src/index.ts +git commit -m "feat(core): add createRenameEpisodePlanPipeline" +``` + +--- + +### Task 2: Wire `Core.createRenameEpisodePlan` + refactor `tryToRenameFolder` + +**Files:** +- Modify: `apps/core/src/Core.ts` +- Modify: `apps/core/src/pipeline/tryToRenameFolder.ts` +- Modify: `apps/core/src/Core.test.ts` (add direct createRenameEpisodePlan case; keep existing tryToRenameFolder empty-files test) + +**Interfaces:** +- Consumes: `createRenameEpisodePlanPipeline` +- Produces: `Core.createRenameEpisodePlan(mediaFolderPath, files, options?)` + +- [ ] **Step 1: Failing Core.test** + +```typescript +it("createRenameEpisodePlan persists ai plan", async () => { + const fs = seedWithVideoFile(); // same seed style as tryToRenameFolder tests + real file blob + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + const plan = await core.createRenameEpisodePlan( + "/m/Show", + [{ from: "/m/Show/S01E01.mkv", to: "/m/Show/[1].mkv" }], + { creator: "ai" }, + ); + expect(plan.creator).toBe("ai"); + expect(await fs.exists(planFilePath("/data", plan.id))).toBe(true); +}); +``` + +- [ ] **Step 2: Run — FAIL** (`createRenameEpisodePlan is not a function`) + +- [ ] **Step 3: Implement Core method + refactor tryToRenameFolder** + +```typescript +// Core.ts +async createRenameEpisodePlan( + mediaFolderPath: string, + files: Array<{ from: string; to: string }>, + options?: CreateRenameEpisodePlanOptions, +): Promise { + return createRenameEpisodePlanPipeline(mediaFolderPath, files, options, { + fs: this.fs, + appDataDir: this.appDataDir, + normalizePosix: (p) => this.normalizePosix(p), + getMediaMetadata: (folder) => this.getMediaMetadata(folder), + }); +} +``` + +In `tryToRenameFolderPipeline`, replace local `writePlan` block with: + +```typescript +return createRenameEpisodePlanPipeline(posixPath, files, { + creator: "app", + allowEmptyFiles: true, + id: createId(), +}, { + fs: deps.fs, + appDataDir: deps.appDataDir, + normalizePosix: deps.normalizePosix, + getMediaMetadata: async (folder) => { /* read cache same as today or via injected get */ }, + createId, +}); +``` + +Prefer injecting metadata already loaded to avoid double-read: either pass `getMediaMetadata` that returns cached `mediaMetadata`, or add optional `mediaMetadata` to deps. Keep behavior of existing tryToRenameFolder tests green (including empty files). + +- [ ] **Step 4: Run Core tests** + +Run: `pnpm --filter core-app test` (or repo’s `pnpm test:core`) + +Expected: PASS for tryToRenameFolder + new createRenameEpisodePlan + +- [ ] **Step 5: Commit** + +```bash +git commit -m "feat(core): expose createRenameEpisodePlan; tryToRenameFolder reuses it" +``` + +--- + +### Task 3: Align plan HTTP `appDataDir` with Core + +**Files:** +- Modify: `apps/cli/src/route/Plans.ts` — `buildConfig().appDataDir` use `getUserDataDir()` (same as `getCore().appDataDir`) +- Audit: `apps/cli/src/mcp/mcp.ts`, `coreRoutesConfig.ts`, `server.ts` plan-related `getAppDataDir()` usages that list/write plans; switch plan persistence hosts to match Core + +**Why:** On Linux, Core writes `~/.config/smm/plans`; `getAppDataDir()` is `~/.local/share/smm`. UI `getPlans` must see AI plans. + +- [ ] **Step 1: Add/adjust a small unit or document in existing test** that Plans config uses userDataDir (if no easy test, verify by reading both helpers in a cli test with mocked env `USER_DATA_DIR` / `APP_DATA_DIR` different). + +- [ ] **Step 2: Apply code change** + +```typescript +import { getUserDataDir } from '@/utils/config' +// ... +appDataDir: getUserDataDir(), +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "fix(cli): use Core userDataDir for plan HTTP routes on Linux" +``` + +--- + +### Task 4: Tool types + registry + system prompt + +**Files:** +- Create: `packages/core/types/ai-tools/createRenameEpisodePlan.ts` +- Delete (after migration): `packages/core/types/ai-tools/renameFilesTask.ts` +- Modify: `packages/core/ai-tool/registry.ts`, `systemPrompt.ts`, `systemPrompt.test.ts` +- Update any imports of old constants + +**Interfaces:** +```typescript +export const CREATE_RENAME_EPISODE_PLAN = "create-rename-episode-plan" as const; +export const CREATE_RENAME_EPISODE_PLAN_DESCRIPTION = + "Create a rename-files plan for TV episode video files with explicit from/to paths. " + + "After success, tell the user to open SMM, review, and approve the plan."; + +export const createRenameEpisodePlanInputSchema = z.object({ + mediaFolderPath: z.string().describe("Absolute media folder path (POSIX or Windows)"), + files: z.array(z.object({ + from: z.string().describe("Current absolute video path"), + to: z.string().describe("New absolute video path"), + })).min(1), +}); +``` + +- [ ] **Step 1: Add types file; update registry to single rename tool (`backend: true, frontend: true`)** +- [ ] **Step 2: Fix systemPrompt checklist to call `create-rename-episode-plan` once with files** +- [ ] **Step 3: Run `packages/core` ai-tool tests** +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(core): replace rename begin/add/end tool types with create-rename-episode-plan" +``` + +--- + +### Task 5: HTTP + Debug API thin wrappers + +**Files:** +- Modify: `apps/cli/src/route/RenameEpisodesPlan.ts` (or add `createRenameEpisodePlan.ts` route registered from server) +- Create: `apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts` +- Remove: debug begin/add/end rename routes from `debugRenameFilesTask.ts` (+ registration) +- Delete: `apps/cli/src/tools/renameFilesToolV2.ts`, `renameFilesTaskV2.ts` (and fix imports) + +**HTTP** + +`POST /api/create-rename-episode-plan` + +Body: `{ mediaFolderPath, files, creator?: "ai" | "app" }` +Response: `{ data?: { plan }, error?: string }` (200 + error field) + +```typescript +const plan = await getCore().createRenameEpisodePlan( + mediaFolderPath, + files, + { creator: creator === "app" ? "app" : "ai" }, +); +// if AI: broadcast RenameFilesPlanReady like end tool +``` + +**Debug** + +`POST /debug/createRenameEpisodePlan` — same body; return `{ success, data: { planId, plan }, error }` for e2e. + +- [ ] **Step 1: Implement routes; remove old debug rename task routes** +- [ ] **Step 2: Smoke with curl or existing cli test harness if any** +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(cli): HTTP/debug create-rename-episode-plan via Core; remove V2 rename tools" +``` + +--- + +### Task 6: core-routes MCP tool + +**Files:** +- Create: `packages/core-routes/src/tools/createRenameEpisodePlan.ts` +- Create: `packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts` +- Modify: `packages/core-routes/src/mcp/createServer.ts`, `packages/core-routes/src/tools/index.ts` +- Delete: `renameFilesTask.ts` builders usage, `beginRenameTask.ts`, `addRenameFile.ts`, `endRenameTask.ts` +- Update: `packages/core-routes/src/mcp/toolHandlers/staticText.ts`, `test/mcp/how-to-rename-*.ts` + +Tool execute: call host-provided `createRenameEpisodePlan` callback **or** import `createRenameEpisodePlanPipeline` from `core-app` with `config.appDataDir` + fs adapter — prefer pipeline import so MCP does not depend on cli `getCore` singleton. + +Must pass `appDataDir` identical to Core’s plan dir (host config). Broadcast `RenameFilesPlanReady`; return `toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE, planId })`. + +- [ ] **Step 1: Unit test tool success/validation error with mock fs** +- [ ] **Step 2: Implement + remove old handlers** +- [ ] **Step 3: Commit** + +```bash +git commit -m "feat(core-routes): single create-rename-episode-plan MCP tool" +``` + +--- + +### Task 7: UI AI tool + +**Files:** +- Create: `apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx` +- Create: `apps/ui/src/api/createRenameEpisodePlan.ts` (apiFetch POST) +- Modify: `apps/ui/src/ai/tools/index.ts`, `Assistant.tsx`, `Assistant.registry.test.ts` +- Delete: `BeginRenameFilesTask.tsx`, `AddRenameFileToTask.tsx`, `EndRenameFilesTask.tsx` (move `cleanupRenamePlan` to a small helper if still referenced by `useAiBasedRenameFilesFlow`) +- Update: `useAiBasedRenameFilesFlow` imports of `cleanupRenamePlan` + +UI tool: + +```typescript +const resp = await createRenameEpisodePlanApi({ + mediaFolderPath, + files, + creator: "ai", +}); +if (resp.error) return { error: resp.error }; +await queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }); +return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE, taskId: resp.data.plan.id }); +``` + +- [ ] **Step 1: Registry test expects `CreateRenameEpisodePlanTool` only** +- [ ] **Step 2: Implement; remove old tools** +- [ ] **Step 3: `pnpm --filter ui test` relevant files** +- [ ] **Step 4: Commit** + +```bash +git commit -m "feat(ui): single CreateRenameEpisodePlan AI tool" +``` + +--- + +### Task 8: E2E `AiTool-RenameTool` + debug helper + +**Files:** +- Modify: `apps/e2e/test/lib/debugRenameTool.ts` → `createRenameEpisodePlan({ mediaFolderPath, files })` +- Modify: `apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts` + +Replace create/add/end with: + +```typescript +await createRenameEpisodePlan({ + mediaFolderPath: folder.path!, + files: [{ + from: path.join(folder.path!, "S01E01.mp4"), + to: path.join(folder.path!, "[1].mp4"), + }], +}); +await Prompts.aiBasedRenamePrompt.waitForDisplayed(); +await Prompts.confirmButton.click(); +// same waitUntil / expectMediaMetadataToBe +``` + +- [ ] **Step 1: Update helper + spec** +- [ ] **Step 2: Run desktop e2e** + +Run: `bun ci/run-e2e-test.ts --spec ./test/specs/ai/AiTool-RenameTool.e2e.ts` +Expected: PASS (on Windows local) + +- [ ] **Step 3: Commit** + +```bash +git commit -m "test(e2e): AiTool-RenameTool uses create-rename-episode-plan" +``` + +--- + +### Task 9: Docs polish + +**Files:** +- Update: `docs/dev/rename-episodes.md` Testing table row for AI if needed +- Update: `docs/DebugAPI.md` rename sections +- Remove stale begin/add/end from `docs/AI-driven-recognition.md` / `docs/superpowers/reference/AI_RenameFile_Process.md` (short note pointing to new tool) + +- [ ] **Step 1: Edit docs** +- [ ] **Step 2: Commit** + +```bash +git commit -m "docs: document create-rename-episode-plan; remove begin/add/end rename docs" +``` + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| `Core.createRenameEpisodePlan` + validations | 1–2 | +| `tryToRenameFolder` → create API | 2 | +| Single AI/MCP tool + success message + broadcast | 5–7 | +| Delete begin/add/end | 4–7 | +| Linux plan dir / apply visibility | 3 | +| E2E refactor | 8 | +| Docs | 9 | + +## Placeholder / consistency notes + +- Tool constant name: `create-rename-episode-plan` / `CREATE_RENAME_EPISODE_PLAN` +- Empty files: only via `allowEmptyFiles: true` from `tryToRenameFolder` +- `cleanupRenamePlan` must survive End tool deletion if cancel flow still needs it From 56717f81d3f670f0db7e9a25552de6401de143c3 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 29 Aug 2026 23:51:05 +0800 Subject: [PATCH 174/214] feat(core): add createRenameEpisodePlanPipeline Co-authored-by: Cursor --- apps/core/src/index.ts | 5 + .../pipeline/createRenameEpisodePlan.test.ts | 85 +++++++++++++++++ .../src/pipeline/createRenameEpisodePlan.ts | 92 +++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 apps/core/src/pipeline/createRenameEpisodePlan.test.ts create mode 100644 apps/core/src/pipeline/createRenameEpisodePlan.ts diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 85bc2ae7..8cd5dd4f 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -82,6 +82,11 @@ export { type RecognizeFolderDeps, } from "./pipeline/recognizeFolder"; export { tryToRenameFolderPipeline, type TryToRenameFolderDeps } from "./pipeline/tryToRenameFolder"; +export { + createRenameEpisodePlanPipeline, + type CreateRenameEpisodePlanDeps, + type CreateRenameEpisodePlanOptions, +} from "./pipeline/createRenameEpisodePlan"; export type { RenameRuleName } from "./pipeline/renameRules"; export type { Plan } from "./pipeline/plans"; export { diff --git a/apps/core/src/pipeline/createRenameEpisodePlan.test.ts b/apps/core/src/pipeline/createRenameEpisodePlan.test.ts new file mode 100644 index 00000000..c5b54c2b --- /dev/null +++ b/apps/core/src/pipeline/createRenameEpisodePlan.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FsPort } from "../ports/FsPort"; +import { createRenameEpisodePlanPipeline } from "./createRenameEpisodePlan"; +import { metadataCachePath, planFilePath } from "./paths"; + +function inMemoryFs(seed: Record = {}): FsPort { + const files = new Map(Object.entries(seed)); + return { + readTextFile: vi.fn(async (path: string) => { + const v = files.get(path); + if (v === undefined) throw new Error("ENOENT: " + path); + return v; + }), + writeTextFile: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path: string) => files.has(path)), + listFiles: vi.fn(async () => []), + deleteFile: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + listSubdirectories: vi.fn(async () => []), + }; +} + +describe("createRenameEpisodePlanPipeline", () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + + it("writes pending plan with posix paths", async () => { + const mm = { + mediaFolderPath: folder, + type: "tvshow-folder" as const, + mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }], + }; + const fs = inMemoryFs({ + [metadataCachePath(appDataDir, folder)]: JSON.stringify(mm), + "/m/Show/S01E01.mkv": "", + }); + const plan = await createRenameEpisodePlanPipeline( + folder, + [{ from: "/m/Show/S01E01.mkv", to: "/m/Show/[1].mkv" }], + { creator: "ai", id: "fixed-id" }, + { + fs, + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => mm as never, + createId: () => "fixed-id", + }, + ); + expect(plan.status).toBe("pending"); + expect(plan.creator).toBe("ai"); + expect(plan.files[0]?.to).toBe("/m/Show/[1].mkv"); + expect(await fs.exists(planFilePath(appDataDir, "fixed-id"))).toBe(true); + }); + + it("rejects empty files when allowEmptyFiles is false", async () => { + await expect( + createRenameEpisodePlanPipeline(folder, [], { creator: "ai" }, { + fs: inMemoryFs({}), + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => ({ mediaFolderPath: folder, type: "tvshow-folder", mediaFiles: [] }) as never, + }), + ).rejects.toThrow(/No rename entries/); + }); + + it("rejects when metadata missing", async () => { + await expect( + createRenameEpisodePlanPipeline( + folder, + [{ from: "/m/Show/a.mkv", to: "/m/Show/b.mkv" }], + { creator: "ai" }, + { + fs: inMemoryFs({}), + appDataDir, + normalizePosix: (p) => p, + getMediaMetadata: async () => null, + }, + ), + ).rejects.toThrow(/not opened in SMM|Media metadata not found/); + }); +}); diff --git a/apps/core/src/pipeline/createRenameEpisodePlan.ts b/apps/core/src/pipeline/createRenameEpisodePlan.ts new file mode 100644 index 00000000..4603bcbc --- /dev/null +++ b/apps/core/src/pipeline/createRenameEpisodePlan.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import { assertEpisodeVideoFile, assertMediaFolderHasMetadata } from "@core/plan/renamePlan"; +import { validateRenameOperations } from "@core/validations/rename/validateRenameOperations"; +import type { RenameFileExistenceProbe } from "@core/validations/rename/validateRenameFileExistence"; +import type { MediaMetadata } from "@smm/core"; +import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; +import type { FsPort } from "../ports/FsPort"; +import { writePlan } from "./plans"; + +function renameFileExistenceProbe(fs: FsPort): RenameFileExistenceProbe { + return { + isFile: async (path: string): Promise => { + if (fs.isFile) { + return fs.isFile(path); + } + return fs.exists(path); + }, + }; +} + +export interface CreateRenameEpisodePlanOptions { + creator?: "app" | "ai"; + id?: string; + /** When true, allow files: [] (rule path when names already match). Default false. */ + allowEmptyFiles?: boolean; +} + +export interface CreateRenameEpisodePlanDeps { + fs: FsPort; + appDataDir: string; + normalizePosix: (path: string) => string; + getMediaMetadata: (folder: string) => Promise; + createId?: () => string; +} + +export async function createRenameEpisodePlanPipeline( + mediaFolderPath: string, + files: Array<{ from: string; to: string }>, + options: CreateRenameEpisodePlanOptions | undefined, + deps: CreateRenameEpisodePlanDeps, +): Promise { + const posixFolder = deps.normalizePosix(mediaFolderPath); + + const mm = await deps.getMediaMetadata(posixFolder); + const metadataError = assertMediaFolderHasMetadata(!!mm, posixFolder); + if (metadataError) { + throw new Error(metadataError); + } + + const normalizedFiles = files.map((entry) => ({ + from: deps.normalizePosix(entry.from), + to: deps.normalizePosix(entry.to), + })); + + const allowEmptyFiles = options?.allowEmptyFiles ?? false; + if (normalizedFiles.length === 0 && !allowEmptyFiles) { + throw new Error("No rename entries in task"); + } + + for (const entry of normalizedFiles) { + const episodeError = assertEpisodeVideoFile(mm!, entry.from); + if (episodeError) { + throw new Error(episodeError); + } + } + + if (normalizedFiles.length > 0) { + const validation = await validateRenameOperations( + normalizedFiles, + posixFolder, + renameFileExistenceProbe(deps.fs), + ); + if (!validation.isValid) { + throw new Error(validation.errors.join("; ")); + } + } + + const createId = deps.createId ?? randomUUID; + const id = options?.id ?? createId(); + + const plan: RenameFilesPlan = { + id, + task: "rename-files", + status: "pending", + creator: options?.creator ?? "app", + mediaFolderPath: posixFolder, + files: normalizedFiles, + }; + + await writePlan(deps.fs, deps.appDataDir, plan); + return plan; +} From 030103ac7dd3b7e1ed8772ab8f8e9c9cdbdce749 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 29 Aug 2026 23:54:22 +0800 Subject: [PATCH 175/214] feat(core): expose createRenameEpisodePlan; tryToRenameFolder reuses it Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 33 +++++++++++++++++++++ apps/core/src/Core.ts | 17 +++++++++++ apps/core/src/pipeline/tryToRenameFolder.ts | 27 ++++++++++------- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 418ace22..644d046a 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -842,6 +842,7 @@ describe("tryToRenameFolder", () => { return inMemoryFs({ [userConfigPath(appDataDir)]: JSON.stringify({ folders: [folder] }), [metadataCachePath(appDataDir, folder)]: JSON.stringify(tvMetadata), + "/m/Show/S01E01.mkv": "", }); } @@ -884,6 +885,38 @@ describe("tryToRenameFolder", () => { }); }); +describe("createRenameEpisodePlan", () => { + it("persists ai plan", async () => { + const appDataDir = "/data"; + const folder = "/m/Show"; + const fs = inMemoryFs({ + [metadataCachePath(appDataDir, folder)]: JSON.stringify({ + mediaFolderPath: folder, + type: "tvshow-folder", + tvShow: { + id: "1", + name: "Show", + seasons: [{ season: 1, episodes: [{ season: 1, episode: 1, name: "Ep1" }] }], + }, + mediaFiles: [ + { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, + ], + }), + "/m/Show/S01E01.mkv": "", + }); + const core = new Core({ fs, network: emptyNetwork(), appDataDir }); + + const plan = await core.createRenameEpisodePlan( + folder, + [{ from: "/m/Show/S01E01.mkv", to: "/m/Show/[1].mkv" }], + { creator: "ai" }, + ); + + expect(plan.creator).toBe("ai"); + expect(await fs.exists(planFilePath(appDataDir, plan.id))).toBe(true); + }); +}); + describe("applyPlan", () => { const appDataDir = "/data"; const folder = "/m/Show"; diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index dabc8208..2bf4bfbf 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -73,6 +73,10 @@ import { type RecognizeFolderCandidate, type RecognizeFolderDb, } from "./pipeline/recognizeFolder"; +import { + createRenameEpisodePlanPipeline, + type CreateRenameEpisodePlanOptions, +} from "./pipeline/createRenameEpisodePlan"; import { tryToRenameFolderPipeline } from "./pipeline/tryToRenameFolder"; import { prepareScrapeFolder, @@ -500,6 +504,19 @@ export class Core { }); } + async createRenameEpisodePlan( + mediaFolderPath: string, + files: Array<{ from: string; to: string }>, + options?: CreateRenameEpisodePlanOptions, + ): Promise { + return createRenameEpisodePlanPipeline(mediaFolderPath, files, options, { + fs: this.fs, + appDataDir: this.appDataDir, + normalizePosix: (path) => this.normalizePosix(path), + getMediaMetadata: (folder) => this.getMediaMetadata(folder), + }); + } + async getPlan(id: string): Promise { const plan = await readPlan(this.fs, this.appDataDir, id); if (!plan) throw new Error(`Plan not found: ${id}`); diff --git a/apps/core/src/pipeline/tryToRenameFolder.ts b/apps/core/src/pipeline/tryToRenameFolder.ts index 9b98bf1a..c4351092 100644 --- a/apps/core/src/pipeline/tryToRenameFolder.ts +++ b/apps/core/src/pipeline/tryToRenameFolder.ts @@ -4,8 +4,8 @@ import type { MediaMetadata } from "@smm/core"; import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "../ports/FsPort"; import { buildTvShowRenamePlanFileEntries } from "./buildTvShowRenamePlanFileEntries"; +import { createRenameEpisodePlanPipeline } from "./createRenameEpisodePlan"; import { metadataCachePath } from "./paths"; -import { writePlan } from "./plans"; import type { RenameRuleName } from "./renameRules"; import type { UserConfigHelper } from "./userConfigHelper"; @@ -74,15 +74,20 @@ export async function tryToRenameFolderPipeline( const files = buildTvShowRenamePlanFileEntries(mediaMetadata, effectiveRule); - const plan: RenameFilesPlan = { - id: createId(), - task: "rename-files", - status: "pending", - creator: "app", - mediaFolderPath: posixPath, + return createRenameEpisodePlanPipeline( + posixPath, files, - }; - - await writePlan(deps.fs, deps.appDataDir, plan); - return plan; + { + creator: "app", + allowEmptyFiles: true, + id: createId(), + }, + { + fs: deps.fs, + appDataDir: deps.appDataDir, + normalizePosix: deps.normalizePosix, + getMediaMetadata: async () => mediaMetadata, + createId, + }, + ); } From fa063cc416c7fa969d03971d2b3c6b35cbb6b063 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sat, 29 Aug 2026 23:59:08 +0800 Subject: [PATCH 176/214] fix(cli): use Core userDataDir for plan HTTP routes on Linux Co-authored-by: Cursor --- apps/cli/server.ts | 4 +-- apps/cli/src/mcp/mcp.ts | 4 +-- apps/cli/src/route/Plans.test.ts | 57 ++++++++++++++++++++++++++++++++ apps/cli/src/route/Plans.ts | 4 +-- 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/route/Plans.test.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index d51937f6..29a288d7 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -9,7 +9,7 @@ import { setSocketIOManager, acknowledge, broadcast } from './src/utils/socketIO import { handleChatRequest as handleChatRequestCoreRoutes } from './src/route/chatRoute'; import { handleReadFile } from './src/route/ReadFile'; import { createAIProvider } from './lib/ai-provider.ts'; -import { getAppDataDir, getUserConfig } from './src/utils/config.ts'; +import { getUserConfig, getUserDataDir } from './src/utils/config.ts'; import { handleIsFolderAvailable } from './src/route/IsFolderAvailable'; import { handleWriteFile } from './src/route/WriteFile'; import { handleRenameFiles } from './src/route/RenameFiles'; @@ -252,7 +252,7 @@ export class Server { // the Hono shell here is a thin adapter that wires the cli-specific // AI provider factory, user-config reader, and Socket.IO helpers. handleChatRequestCoreRoutes(this.app, { - appDataDir: getAppDataDir(), + appDataDir: getUserDataDir(), logger: createSocketIOLogger(), createAIProvider: (userConfig) => createAIProvider(userConfig), getUserConfig: () => getUserConfig(), diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 87a02ade..1fdd63ea 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -29,7 +29,7 @@ import { END_RECOGNIZE_TASK, } from "@smm/core/types/ai-tools/recognizeMediaFileTask"; import { GET_EPISODES } from "@smm/core/types/ai-tools/getEpisodes"; -import { getAppDataDir, getUserDataDir } from "@/utils/config"; +import { getUserDataDir } from "@/utils/config"; import { acknowledge, broadcast } from "@/utils/socketIO"; import { logger } from "../../lib/logger"; import { getLocalizedToolDescription } from "@/i18n/helpers"; @@ -146,7 +146,7 @@ async function buildMcpConfig(): Promise { const { getCore } = await import("@/core/getCore"); return { getUserConfig, - appDataDir: getAppDataDir(), + appDataDir: getUserDataDir(), userDataDir: getUserDataDir(), acknowledge: (message, timeoutMs) => acknowledge(message as Parameters[0], timeoutMs), diff --git a/apps/cli/src/route/Plans.test.ts b/apps/cli/src/route/Plans.test.ts new file mode 100644 index 00000000..44b4d36f --- /dev/null +++ b/apps/cli/src/route/Plans.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import { handlePlans } from './Plans' + +vi.mock('@/utils/buildAllowlist', () => ({ + buildAllowlist: async () => [], +})) + +describe('POST /api/createPlan', () => { + let userDataDir: string + let appDataDir: string + let previousUserDataDir: string | undefined + let previousAppDataDir: string | undefined + let app: Hono + + beforeEach(() => { + previousUserDataDir = process.env.USER_DATA_DIR + previousAppDataDir = process.env.APP_DATA_DIR + userDataDir = mkdtempSync(join(tmpdir(), 'smm-plans-user-data-')) + appDataDir = mkdtempSync(join(tmpdir(), 'smm-plans-app-data-')) + process.env.USER_DATA_DIR = userDataDir + process.env.APP_DATA_DIR = appDataDir + app = new Hono() + handlePlans(app) + }) + + afterEach(() => { + if (previousUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = previousUserDataDir + if (previousAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = previousAppDataDir + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(appDataDir, { recursive: true, force: true }) + }) + + it('stores plans in the Core userDataDir when appDataDir differs', async () => { + const id = '00000000-0000-4000-8000-000000000003' + const response = await app.request('/api/createPlan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id, + task: 'rename-files', + mediaFolderPath: '/media/show', + creator: 'app', + }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ data: { plan: { id } } }) + expect(existsSync(join(userDataDir, 'plans', `${id}.plan.json`))).toBe(true) + expect(existsSync(join(appDataDir, 'plans', `${id}.plan.json`))).toBe(false) + }) +}) diff --git a/apps/cli/src/route/Plans.ts b/apps/cli/src/route/Plans.ts index ec2cadb7..8173083b 100644 --- a/apps/cli/src/route/Plans.ts +++ b/apps/cli/src/route/Plans.ts @@ -8,7 +8,7 @@ import { } from '@smm/core-routes'; import { logger } from '../../lib/logger'; import { buildAllowlist } from '@/utils/buildAllowlist'; -import { getAppDataDir } from '@/utils/config'; +import { getUserDataDir } from '@/utils/config'; const coreRoutesLogger = { debug: (obj: Record, msg?: string) => logger.debug(obj, msg), @@ -21,7 +21,7 @@ async function buildConfig(): Promise { const allowlist = await buildAllowlist(); return { allowlist, - appDataDir: getAppDataDir(), + appDataDir: getUserDataDir(), logger: coreRoutesLogger, }; } From c3e98a276ad9afe09fd24dd93fbfb8c3857f795a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:04:04 +0800 Subject: [PATCH 177/214] fix(cli): align remaining plan hosts with userDataDir Co-authored-by: Cursor --- apps/cli/index.ts | 4 +- apps/cli/src/coreRoutesServer.test.ts | 74 +++++++++++++++++++ apps/cli/src/coreRoutesServer.ts | 4 +- apps/cli/src/tools/recognizeMediaFilesTool.ts | 4 +- apps/cli/src/tools/renameFilesToolV2.ts | 4 +- 5 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/coreRoutesServer.test.ts diff --git a/apps/cli/index.ts b/apps/cli/index.ts index e1f0317b..516dff6b 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -106,7 +106,7 @@ const cookiesStartupResult = await cookiesCleaner.cleanAll(); logger.info(cookiesStartupResult, 'yt-dlp cookies temp cleanup on startup'); // Clean up stale preparing plan files left over from a previous session -const preparingPlansRemoved = await cleanupStalePlans(appDataDir, undefined, logger); +const preparingPlansRemoved = await cleanupStalePlans(userDataDir, undefined, logger); if (preparingPlansRemoved > 0) { logger.info( { count: preparingPlansRemoved }, @@ -125,7 +125,7 @@ const server = new Server({ const result = await cookiesCleaner.cleanAll(); logger.info(result, 'yt-dlp cookies temp cleanup on shutdown'); - const preparingPlansRemoved = await cleanupStalePlans(appDataDir, undefined, logger); + const preparingPlansRemoved = await cleanupStalePlans(userDataDir, undefined, logger); if (preparingPlansRemoved > 0) { logger.info( { count: preparingPlansRemoved }, diff --git a/apps/cli/src/coreRoutesServer.test.ts b/apps/cli/src/coreRoutesServer.test.ts new file mode 100644 index 00000000..ff452dae --- /dev/null +++ b/apps/cli/src/coreRoutesServer.test.ts @@ -0,0 +1,74 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createCoreRoutesRequestHandler } = vi.hoisted(() => ({ + createCoreRoutesRequestHandler: vi.fn( + (_config: unknown) => + (_request: IncomingMessage, response: ServerResponse) => + response.end(), + ), +})); + +vi.mock("@smm/core-routes", () => ({ + createCoreRoutesRequestHandler, +})); + +vi.mock("@/utils/buildAllowlist", () => ({ + buildAllowlist: vi.fn(async () => []), +})); + +vi.mock("@/cli/helloHttp", () => ({ + buildHelloHttpResponse: vi.fn(() => ({ data: null, error: null })), +})); + +vi.mock("@/utils/socketIO", () => ({ + broadcast: vi.fn(), +})); + +vi.mock("@/coreRoutesPort", () => ({ + resolveCoreRoutesPort: vi.fn(() => 0), +})); + +vi.mock("../lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { startCoreRoutesServer, stopCoreRoutesServer } from "./coreRoutesServer"; + +describe("startCoreRoutesServer", () => { + let previousUserDataDir: string | undefined; + let previousAppDataDir: string | undefined; + + beforeEach(() => { + previousUserDataDir = process.env.USER_DATA_DIR; + previousAppDataDir = process.env.APP_DATA_DIR; + process.env.USER_DATA_DIR = "/core/user-data"; + process.env.APP_DATA_DIR = "/metadata/app-data"; + }); + + afterEach(() => { + if (previousUserDataDir === undefined) delete process.env.USER_DATA_DIR; + else process.env.USER_DATA_DIR = previousUserDataDir; + if (previousAppDataDir === undefined) delete process.env.APP_DATA_DIR; + else process.env.APP_DATA_DIR = previousAppDataDir; + vi.clearAllMocks(); + }); + + it("passes Core's userDataDir to the shared plan routes", async () => { + const server = await startCoreRoutesServer(); + + try { + const config = createCoreRoutesRequestHandler.mock.calls[0]?.[0] as { + appDataDir: string; + }; + expect(config.appDataDir).toBe("/core/user-data"); + } finally { + await stopCoreRoutesServer(server); + } + }); +}); diff --git a/apps/cli/src/coreRoutesServer.ts b/apps/cli/src/coreRoutesServer.ts index 66628093..a599dbb1 100644 --- a/apps/cli/src/coreRoutesServer.ts +++ b/apps/cli/src/coreRoutesServer.ts @@ -5,7 +5,7 @@ import { type CoreRoutesLogger, } from "@smm/core-routes"; import { buildAllowlist } from "@/utils/buildAllowlist"; -import { getAppDataDir } from "@/utils/config"; +import { getUserDataDir } from "@/utils/config"; import { buildHelloHttpResponse } from "@/cli/helloHttp"; import { logger } from "../lib/logger"; import { broadcast } from "@/utils/socketIO"; @@ -25,7 +25,7 @@ export async function startCoreRoutesServer( ): Promise { const port = resolveCoreRoutesPort(); const allowlist = await buildAllowlist(); - const appDataDir = getAppDataDir(); + const appDataDir = getUserDataDir(); const handler = createCoreRoutesRequestHandler( { allowlist, diff --git a/apps/cli/src/tools/recognizeMediaFilesTool.ts b/apps/cli/src/tools/recognizeMediaFilesTool.ts index 7df57511..101cf6c8 100644 --- a/apps/cli/src/tools/recognizeMediaFilesTool.ts +++ b/apps/cli/src/tools/recognizeMediaFilesTool.ts @@ -1,7 +1,7 @@ import { broadcast } from "../utils/socketIO"; import { RecognizeMediaFilePlanReady, type RecognizeMediaFilePlanReadyRequestData } from "@core/event-types"; import type { RecognizeMediaFilePlan, RecognizedFile } from "@core/types/RecognizeMediaFilePlan"; -import { getAppDataDir } from "@/utils/config"; +import { getUserDataDir } from "@/utils/config"; import path from "path"; import { mkdir, readdir, stat } from "fs/promises"; import { Path } from "@core/path"; @@ -13,7 +13,7 @@ const logger = pino(); * Get the path to the plans directory */ function getPlansDir(): string { - return path.join(getAppDataDir(), 'plans'); + return path.join(getUserDataDir(), 'plans'); } /** diff --git a/apps/cli/src/tools/renameFilesToolV2.ts b/apps/cli/src/tools/renameFilesToolV2.ts index 989ab03a..80d99f2c 100644 --- a/apps/cli/src/tools/renameFilesToolV2.ts +++ b/apps/cli/src/tools/renameFilesToolV2.ts @@ -8,7 +8,7 @@ import { createEmptyRenamePlan, prepareAppendRenameEntry, } from '@core/plan/renamePlan' -import { getAppDataDir } from '@/utils/config' +import { getUserDataDir } from '@/utils/config' import path from 'path' import { mkdir, readdir, stat } from 'fs/promises' import { Path } from '@core/path' @@ -19,7 +19,7 @@ import { validateRenameOperations } from './renameFilesInBatch' const logger = pino() function getPlansDir(): string { - return path.join(getAppDataDir(), 'plans') + return path.join(getUserDataDir(), 'plans') } export function getPlanFilePath(planId: string): string { From 371eb957e44a313284bcc9570980f0dab28d48cf Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:06:59 +0800 Subject: [PATCH 178/214] feat(core): replace rename begin/add/end tool types with create-rename-episode-plan Co-authored-by: Cursor --- packages/core/ai-tool/registry.ts | 14 ++--- packages/core/ai-tool/systemPrompt.test.ts | 16 ++--- packages/core/ai-tool/systemPrompt.ts | 10 +--- .../types/ai-tools/createRenameEpisodePlan.ts | 25 ++++++++ .../core/types/ai-tools/renameEpisodeFile.ts | 2 +- .../core/types/ai-tools/renameFilesTask.ts | 58 ------------------- 6 files changed, 40 insertions(+), 85 deletions(-) create mode 100644 packages/core/types/ai-tools/createRenameEpisodePlan.ts delete mode 100644 packages/core/types/ai-tools/renameFilesTask.ts diff --git a/packages/core/ai-tool/registry.ts b/packages/core/ai-tool/registry.ts index 1fc07fc8..4ce554e0 100644 --- a/packages/core/ai-tool/registry.ts +++ b/packages/core/ai-tool/registry.ts @@ -55,11 +55,7 @@ import { TVDB_SEARCH } from '../types/ai-tools/tvdbSearch' import { TVDB_GET_MOVIE } from '../types/ai-tools/tvdbGetMovie' import { TVDB_GET_TV_SHOW } from '../types/ai-tools/tvdbGetTvShow' import { TVDB_GET_LANGUAGES } from '../types/ai-tools/tvdbGetLanguages' -import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, -} from '../types/ai-tools/renameFilesTask' +import { CREATE_RENAME_EPISODE_PLAN } from '../types/ai-tools/createRenameEpisodePlan' import { BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, @@ -80,7 +76,7 @@ export interface AiToolDescriptor { * Whether the tool is available on the in-browser * `ReverseProxyChatTransport` path (HarmonyOS / feature flag). * - * Task tools (`begin-rename-files-task`, etc.) are listed on both + * Plan tools (`create-rename-episode-plan`, etc.) are listed on both * paths but **execute on only one**: backend fs/plan APIs on * `AssistantChatTransport`, browser HTTP plan APIs on * `ReverseProxyChatTransport`. See `Assistant.tsx` conditional mount. @@ -124,10 +120,8 @@ export const AI_TOOL_REGISTRY: readonly AiToolDescriptor[] = [ { name: TVDB_GET_TV_SHOW, backend: true, frontend: false }, { name: TVDB_GET_LANGUAGES, backend: true, frontend: false }, - // Rename files task - { name: BEGIN_RENAME_FILES_TASK, backend: true, frontend: true }, - { name: ADD_RENAME_FILE_TO_TASK, backend: true, frontend: true }, - { name: END_RENAME_FILES_TASK, backend: true, frontend: true }, + // Rename episode plan + { name: CREATE_RENAME_EPISODE_PLAN, backend: true, frontend: true }, // Recognize media file task { name: BEGIN_RECOGNIZE_TASK, backend: true, frontend: true }, diff --git a/packages/core/ai-tool/systemPrompt.test.ts b/packages/core/ai-tool/systemPrompt.test.ts index db7143ff..bf4bec96 100644 --- a/packages/core/ai-tool/systemPrompt.test.ts +++ b/packages/core/ai-tool/systemPrompt.test.ts @@ -4,11 +4,7 @@ import { GET_APPLICATION_CONTEXT } from '../types/ai-tools/getApplicationContext import { GET_MEDIA_METADATA } from '../types/ai-tools/getMediaMetadata' import { GET_EPISODES } from '../types/ai-tools/getEpisodes' import { LIST_FILES_IN_MEDIA_FOLDER } from '../types/ai-tools/listFilesInMediaFolder' -import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, -} from '../types/ai-tools/renameFilesTask' +import { CREATE_RENAME_EPISODE_PLAN } from '../types/ai-tools/createRenameEpisodePlan' import { BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, @@ -39,9 +35,7 @@ describe('systemPrompt', () => { GET_MEDIA_METADATA, GET_EPISODES, LIST_FILES_IN_MEDIA_FOLDER, - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, + CREATE_RENAME_EPISODE_PLAN, BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, END_RECOGNIZE_TASK, @@ -59,6 +53,12 @@ describe('systemPrompt', () => { } }) + it('does not reference deprecated begin/add/end rename task tools', () => { + expect(SYSTEM_PROMPT).not.toContain('begin-rename-files-task') + expect(SYSTEM_PROMPT).not.toContain('add-rename-file-to-task') + expect(SYSTEM_PROMPT).not.toContain('end-rename-files-task') + }) + it('does not reference any kebab-case token that is not a known tool', () => { // Spot-check that we have not introduced stale tool names // (e.g. an old "get-selected-media-metadata" that the LLM diff --git a/packages/core/ai-tool/systemPrompt.ts b/packages/core/ai-tool/systemPrompt.ts index 74f94f19..13148b0e 100644 --- a/packages/core/ai-tool/systemPrompt.ts +++ b/packages/core/ai-tool/systemPrompt.ts @@ -1,8 +1,4 @@ -import { - ADD_RENAME_FILE_TO_TASK, - BEGIN_RENAME_FILES_TASK, - END_RENAME_FILES_TASK, -} from '../types/ai-tools/renameFilesTask' +import { CREATE_RENAME_EPISODE_PLAN } from '../types/ai-tools/createRenameEpisodePlan' import { ADD_RECOGNIZED_MEDIA_FILE, BEGIN_RECOGNIZE_TASK, @@ -73,9 +69,7 @@ You ONLY need to rename the video file. For image files, subtitle files, nfo fil Steps [ ] Call "${GET_MEDIA_METADATA}" to get the video files needs to rename -[ ] Call "${BEGIN_RENAME_FILES_TASK}" to notify AI Agent to start a rename files task -[ ] Call "${ADD_RENAME_FILE_TO_TASK}" to add a file to rename task, call multiple times to add multiple files -[ ] Call "${END_RENAME_FILES_TASK}" to notify AI Agent to end the rename files task +[ ] Call "${CREATE_RENAME_EPISODE_PLAN}" once with mediaFolderPath and a files array of from/to pairs for every video to rename ### Scrape Media Artwork and NFO diff --git a/packages/core/types/ai-tools/createRenameEpisodePlan.ts b/packages/core/types/ai-tools/createRenameEpisodePlan.ts new file mode 100644 index 00000000..8dbfd07c --- /dev/null +++ b/packages/core/types/ai-tools/createRenameEpisodePlan.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +export const CREATE_RENAME_EPISODE_PLAN = 'create-rename-episode-plan' as const + +export const CREATE_RENAME_EPISODE_PLAN_DESCRIPTION = + 'Create a rename-files plan for TV episode video files with explicit from/to paths. ' + + 'After success, tell the user to open SMM, review, and approve the plan.' + +export const createRenameEpisodePlanInputSchema = z.object({ + mediaFolderPath: z + .string() + .describe('Absolute media folder path (POSIX or Windows)'), + files: z + .array( + z.object({ + from: z.string().describe('Current absolute video path'), + to: z.string().describe('New absolute video path'), + }), + ) + .min(1), +}) + +export type CreateRenameEpisodePlanInput = z.infer< + typeof createRenameEpisodePlanInputSchema +> diff --git a/packages/core/types/ai-tools/renameEpisodeFile.ts b/packages/core/types/ai-tools/renameEpisodeFile.ts index c56d5b69..6eef3406 100644 --- a/packages/core/types/ai-tools/renameEpisodeFile.ts +++ b/packages/core/types/ai-tools/renameEpisodeFile.ts @@ -6,7 +6,7 @@ export const RENAME_EPISODE_FILE_DESCRIPTION = 'Rename a linked TV episode video file (and same-stem associates such as subtitles) in a managed TV show folder. ' + 'Use ONLY for a single episode file that already has seasonNumber and episodeNumber in media metadata. ' + 'Do NOT use for folders (use rename-folder), movies, orphan files, or bulk renames ' + - '(use begin/add/end-rename-files-task for multi-file plans).\n\n' + + '(use create-rename-episode-plan for multi-file plans).\n\n' + 'Example: Rename episode file in folder "/path/to/show" from ".../S01E01.mp4" to ".../S01E01_renamed.mp4".' export const renameEpisodeFileInputSchema = z.object({ diff --git a/packages/core/types/ai-tools/renameFilesTask.ts b/packages/core/types/ai-tools/renameFilesTask.ts deleted file mode 100644 index 9293fff5..00000000 --- a/packages/core/types/ai-tools/renameFilesTask.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from 'zod' - -export const BEGIN_RENAME_FILES_TASK = 'begin-rename-files-task' as const -export const ADD_RENAME_FILE_TO_TASK = 'add-rename-file-to-task' as const -export const END_RENAME_FILES_TASK = 'end-rename-files-task' as const - -export const BEGIN_RENAME_FILES_TASK_DESCRIPTION = - 'Begin a rename task V2 for batch renaming media files. ' + - 'This tool creates a task that can be used to add multiple files for renaming. ' + - `Use ${ADD_RENAME_FILE_TO_TASK} to add files, then ${END_RENAME_FILES_TASK} to execute.` - -export const ADD_RENAME_FILE_TO_TASK_DESCRIPTION = - 'Add a file to a rename task. ' + - `This tool adds a single file to an existing task created by ${BEGIN_RENAME_FILES_TASK}. ` + - 'Provide the task ID, current file path, and new file path.' - -export const END_RENAME_FILES_TASK_DESCRIPTION = - 'End a rename task and execute the batch rename operation. ' + - `This tool finalizes the task created by ${BEGIN_RENAME_FILES_TASK} and ` + - 'executes all pending file renames.' - -export const beginRenameFilesTaskInputSchema = z.object({ - mediaFolderPath: z - .string() - .describe( - 'The absolute path of the media folder, it can be POSIX format or Windows format', - ), -}) - -export const addRenameFileToTaskInputSchema = z.object({ - taskId: z - .string() - .describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`), - from: z - .string() - .describe( - 'Current absolute path of the video file to rename (POSIX or Windows format)', - ), - to: z - .string() - .describe('New absolute path for the file (POSIX or Windows format)'), -}) - -export const endRenameFilesTaskInputSchema = z.object({ - taskId: z - .string() - .describe(`The task ID from ${BEGIN_RENAME_FILES_TASK}`), -}) - -export type BeginRenameFilesTaskInput = z.infer< - typeof beginRenameFilesTaskInputSchema -> -export type AddRenameFileToTaskInput = z.infer< - typeof addRenameFileToTaskInputSchema -> -export type EndRenameFilesTaskInput = z.infer< - typeof endRenameFilesTaskInputSchema -> From 035d4386f7be21ba6edbbbb7dea486a220dd442d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:12:02 +0800 Subject: [PATCH 179/214] feat(cli): HTTP/debug create-rename-episode-plan via Core; remove V2 rename tools Co-authored-by: Cursor --- apps/cli/server.ts | 4 +- apps/cli/src/mcp/mcp.ts | 8 - apps/cli/src/route/Debug.ts | 67 ----- apps/cli/src/route/RenameEpisodesPlan.test.ts | 102 +++++++ apps/cli/src/route/RenameEpisodesPlan.ts | 82 ++++++ .../debugCreateRenameEpisodePlan.test.ts | 81 ++++++ .../debug/debugCreateRenameEpisodePlan.ts | 51 ++++ .../src/route/debug/debugRenameFilesTask.ts | 218 -------------- apps/cli/src/tools/index.ts | 14 - apps/cli/src/tools/renameFilesTaskV2.ts | 183 ------------ apps/cli/src/tools/renameFilesToolV2.ts | 266 ------------------ 11 files changed, 318 insertions(+), 758 deletions(-) create mode 100644 apps/cli/src/route/RenameEpisodesPlan.test.ts create mode 100644 apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts create mode 100644 apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts delete mode 100644 apps/cli/src/route/debug/debugRenameFilesTask.ts delete mode 100644 apps/cli/src/tools/renameFilesTaskV2.ts delete mode 100644 apps/cli/src/tools/renameFilesToolV2.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 29a288d7..385e8830 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -35,7 +35,7 @@ import { handleDeleteFile } from './src/route/DeleteFile'; import { handleDeleteFolder } from './src/route/DeleteFolder'; import { handleDebugRequest } from './src/route/Debug'; import { handleDebugRecognizeTaskRoutes } from './src/route/debug/debugRecognizeTask'; -import { handleDebugRenameFilesTaskRoutes } from './src/route/debug/debugRenameFilesTask'; +import { handleDebugCreateRenameEpisodePlan } from './src/route/debug/debugCreateRenameEpisodePlan'; import { handleDebugGetApplicationContextRoute } from './src/route/debug/debugGetApplicationContext'; import { handleDebugGetMediaMetadataRoute } from './src/route/debug/debugGetMediaMetadata'; import { handleDebugRenameFolderToolRoute } from './src/route/debug/debugRenameFolderTool'; @@ -286,7 +286,7 @@ export class Server { handleDeleteFolder(this.app); handleDebugRequest(this.app); handleDebugRecognizeTaskRoutes(this.app); - handleDebugRenameFilesTaskRoutes(this.app); + handleDebugCreateRenameEpisodePlan(this.app); handleDebugGetApplicationContextRoute(this.app); handleDebugGetMediaMetadataRoute(this.app); handleDebugRenameFolderToolRoute(this.app); diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 1fdd63ea..d9ee4f33 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -18,11 +18,6 @@ import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; -import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, -} from "@smm/core/types/ai-tools/renameFilesTask"; import { BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, @@ -57,9 +52,6 @@ const TOOL_NAME_KEYS = [ RENAME_EPISODE_FILE, SCRAPE, GET_JOB, - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, END_RECOGNIZE_TASK, diff --git a/apps/cli/src/route/Debug.ts b/apps/cli/src/route/Debug.ts index 89b22749..b1434a3f 100644 --- a/apps/cli/src/route/Debug.ts +++ b/apps/cli/src/route/Debug.ts @@ -6,7 +6,6 @@ import { getUserConfigPath } from '../utils/config'; import { mediaMetadataDir } from '../route/mediaMetadata/utils'; import { unlink, rm } from 'fs/promises'; import { existsSync } from 'fs'; -import { endRenameFilesTaskV2, readRenamePlanFile, getPlanFilePath } from '../tools/renameFilesToolV2'; interface DebugApiResponseBody { success: boolean; @@ -45,35 +44,6 @@ const renameFilesInBatchSchema = debugRequestBaseSchema.extend({ clientId: z.string().optional(), }); -// Schema for beginRenameFilesTask function -const beginRenameFilesTaskSchema = debugRequestBaseSchema.extend({ - name: z.literal('beginRenameFilesTask'), - mediaFolderPath: z.string().min(1, 'Media folder path is required'), - clientId: z.string().optional(), -}); - -// Schema for addRenameFileToTask function -const addRenameFileToTaskSchema = debugRequestBaseSchema.extend({ - name: z.literal('addRenameFileToTask'), - taskId: z.string().min(1, 'Task ID is required'), - from: z.string().min(1, 'Source path is required'), - to: z.string().min(1, 'Destination path is required'), - clientId: z.string().optional(), -}); - -// Schema for endRenameFilesTask function -const endRenameFilesTaskSchema = debugRequestBaseSchema.extend({ - name: z.literal('endRenameFilesTask'), - taskId: z.string().min(1, 'Task ID is required'), - clientId: z.string().optional(), -}); - -// Schema for renameFilesPlanReady function -const renameFilesPlanReadySchema = debugRequestBaseSchema.extend({ - name: z.literal('renameFilesPlanReady'), - taskId: z.string().min(1, 'Task ID is required'), -}); - // Schema for cleanUp function const cleanUpSchema = debugRequestBaseSchema.extend({ name: z.literal('cleanUp'), @@ -84,10 +54,6 @@ const debugRequestSchema = z.discriminatedUnion('name', [ broadcastMessageSchema, retrieveSchema, renameFilesInBatchSchema, - beginRenameFilesTaskSchema, - addRenameFileToTaskSchema, - endRenameFilesTaskSchema, - renameFilesPlanReadySchema, cleanUpSchema, // Add more schemas here as new debug functions are added ]); @@ -206,39 +172,6 @@ export async function processDebugRequest(body: any): Promise ({ + createRenameEpisodePlan: vi.fn(), + broadcast: vi.fn(), +})) + +vi.mock('../core/getCore', () => ({ + getCore: () => mocks, +})) + +vi.mock('@/utils/socketIO', () => ({ + broadcast: mocks.broadcast, +})) + +vi.mock('@/utils/config', async (importOriginal) => ({ + ...(await importOriginal()), + getUserDataDir: () => 'C:/smm-data', +})) + +import { handleRenameEpisodesPlan } from './RenameEpisodesPlan' + +const plan = { + id: 'plan-1', + task: 'rename-files' as const, + status: 'pending' as const, + creator: 'ai' as const, + mediaFolderPath: '/media/Show', + files: [{ from: '/media/Show/old.mkv', to: '/media/Show/S01E01.mkv' }], +} + +describe('POST /api/create-rename-episode-plan', () => { + let app: Hono + + beforeEach(() => { + mocks.createRenameEpisodePlan.mockReset() + mocks.broadcast.mockReset() + app = new Hono() + handleRenameEpisodesPlan(app) + }) + + async function post(body: unknown) { + return app.request('/api/create-rename-episode-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('creates an AI plan through Core and broadcasts it', async () => { + mocks.createRenameEpisodePlan.mockResolvedValue(plan) + + const response = await post({ + mediaFolderPath: '/media/Show', + files: plan.files, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ data: { plan } }) + expect(mocks.createRenameEpisodePlan).toHaveBeenCalledWith( + '/media/Show', + plan.files, + { creator: 'ai' }, + ) + expect(mocks.broadcast).toHaveBeenCalledWith({ + event: 'renameFilesPlanReady', + data: { + taskId: 'plan-1', + planFilePath: '/C:/smm-data/plans/plan-1.plan.json', + }, + }) + }) + + it('creates an app plan without broadcasting it', async () => { + mocks.createRenameEpisodePlan.mockResolvedValue({ ...plan, creator: 'app' }) + + const response = await post({ + mediaFolderPath: '/media/Show', + files: plan.files, + creator: 'app', + }) + + expect(response.status).toBe(200) + expect(mocks.createRenameEpisodePlan).toHaveBeenCalledWith( + '/media/Show', + plan.files, + { creator: 'app' }, + ) + expect(mocks.broadcast).not.toHaveBeenCalled() + }) + + it('returns an Error Reason for invalid input', async () => { + const response = await post({ mediaFolderPath: '/media/Show', files: 'invalid' }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + error: 'Error Reason: files must be an array', + }) + expect(mocks.createRenameEpisodePlan).not.toHaveBeenCalled() + }) +}) diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index fe8e1b4a..78765b38 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -2,8 +2,13 @@ import type { Hono } from 'hono' import { Path } from '@core/path' import type { Plan } from 'core-app' import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' +import { + RenameFilesPlanReady, + type RenameFilesPlanReadyRequestData, +} from '@core/event-types' import { getCore } from '../core/getCore' import { broadcast } from '@/utils/socketIO' +import { getUserDataDir } from '@/utils/config' import { logger } from '../../lib/logger' export interface TryToRenameEpisodesRequestBody { @@ -16,6 +21,17 @@ export interface TryToRenameEpisodesResponseBody { error?: string } +export interface CreateRenameEpisodePlanRequestBody { + mediaFolderPath: string + files: Array<{ from: string; to: string }> + creator?: 'ai' | 'app' +} + +export interface CreateRenameEpisodePlanResponseBody { + data?: { plan: RenameFilesPlan } + error?: string +} + export interface ApplyPlanRequestBody { id: string } @@ -40,6 +56,54 @@ function readStringField(body: unknown, key: string): string | undefined { return typeof value === 'string' ? value : undefined } +function readRenameFiles( + body: unknown, +): Array<{ from: string; to: string }> | undefined { + if (typeof body !== 'object' || body === null || !('files' in body)) return undefined + const files = (body as Record).files + if (!Array.isArray(files)) return undefined + if ( + !files.every( + (file) => + typeof file === 'object' && + file !== null && + typeof (file as Record).from === 'string' && + typeof (file as Record).to === 'string', + ) + ) { + return undefined + } + return files as Array<{ from: string; to: string }> +} + +export async function createRenameEpisodePlanFromBody( + body: unknown, +): Promise { + const mediaFolderPath = readStringField(body, 'mediaFolderPath') + if (!mediaFolderPath?.trim()) { + return { error: 'Error Reason: mediaFolderPath is required' } + } + + const files = readRenameFiles(body) + if (!files) { + return { error: 'Error Reason: files must be an array' } + } + + const creator = readStringField(body, 'creator') === 'app' ? 'app' : 'ai' + const plan = await getCore().createRenameEpisodePlan(mediaFolderPath, files, { creator }) + + if (creator === 'ai') { + const planFilePath = Path.posix(`${getUserDataDir()}/plans/${plan.id}.plan.json`) + const data: RenameFilesPlanReadyRequestData = { + taskId: plan.id, + planFilePath, + } + broadcast({ event: RenameFilesPlanReady.event, data }) + } + + return { data: { plan } } +} + /** * Rename-episodes plan HTTP surface matching docs/dev/rename-episodes.md: * - POST /api/try-to-rename-episodes → Core.tryToRenameFolder @@ -47,6 +111,24 @@ function readStringField(body: unknown, key: string): string | undefined { * - POST /api/reject-plan → Core.rejectPlan */ export function handleRenameEpisodesPlan(app: Hono): void { + app.post('/api/create-rename-episode-plan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + return c.json(await createRenameEpisodePlanFromBody(body), 200) + } catch (error) { + logger.error({ error }, '[POST /api/create-rename-episode-plan] route error') + const err: CreateRenameEpisodePlanResponseBody = { + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(err, 200) + } + }) + app.post('/api/try-to-rename-episodes', async (c) => { try { let body: unknown = {} diff --git a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts new file mode 100644 index 00000000..bdd73a90 --- /dev/null +++ b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => ({ + createRenameEpisodePlan: vi.fn(), + broadcast: vi.fn(), +})) + +vi.mock('../../core/getCore', () => ({ + getCore: () => mocks, +})) + +vi.mock('@/utils/socketIO', () => ({ + broadcast: mocks.broadcast, +})) + +vi.mock('../../../lib/logger', () => ({ + logger: { error: vi.fn() }, +})) + +vi.mock('@/utils/config', async (importOriginal) => ({ + ...(await importOriginal()), + getUserDataDir: () => '/smm-data', +})) + +import { handleDebugCreateRenameEpisodePlan } from './debugCreateRenameEpisodePlan' + +describe('POST /debug/createRenameEpisodePlan', () => { + let app: Hono + + beforeEach(() => { + mocks.createRenameEpisodePlan.mockReset() + mocks.broadcast.mockReset() + app = new Hono() + handleDebugCreateRenameEpisodePlan(app) + }) + + it('returns the created plan and plan id', async () => { + const plan = { + id: 'plan-debug', + task: 'rename-files', + status: 'pending', + creator: 'app', + mediaFolderPath: '/media/Show', + files: [{ from: '/media/Show/a.mkv', to: '/media/Show/S01E01.mkv' }], + } + mocks.createRenameEpisodePlan.mockResolvedValue(plan) + + const response = await app.request('/debug/createRenameEpisodePlan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mediaFolderPath: '/media/Show', + files: plan.files, + creator: 'app', + }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + data: { planId: 'plan-debug', plan }, + }) + }) + + it('returns a debug error response when Core rejects the plan', async () => { + mocks.createRenameEpisodePlan.mockRejectedValue(new Error('No rename entries in task')) + + const response = await app.request('/debug/createRenameEpisodePlan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mediaFolderPath: '/media/Show', files: [] }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Error Reason: No rename entries in task', + }) + }) +}) diff --git a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts new file mode 100644 index 00000000..57594b82 --- /dev/null +++ b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts @@ -0,0 +1,51 @@ +import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' +import type { Hono } from 'hono' +import { logger } from '../../../lib/logger' +import { createRenameEpisodePlanFromBody } from '../RenameEpisodesPlan' + +interface DebugCreateRenameEpisodePlanResponseBody { + success: boolean + data?: { + planId: string + plan: RenameFilesPlan + } + error?: string +} + +export function handleDebugCreateRenameEpisodePlan(app: Hono): void { + app.post('/debug/createRenameEpisodePlan', async (c) => { + try { + let body: unknown = {} + try { + body = await c.req.json() + } catch { + /* empty */ + } + + const result = await createRenameEpisodePlanFromBody(body) + if (!result.data) { + const response: DebugCreateRenameEpisodePlanResponseBody = { + success: false, + error: result.error, + } + return c.json(response, 200) + } + + const response: DebugCreateRenameEpisodePlanResponseBody = { + success: true, + data: { + planId: result.data.plan.id, + plan: result.data.plan, + }, + } + return c.json(response, 200) + } catch (error) { + logger.error({ error }, '[POST /debug/createRenameEpisodePlan] route error') + const response: DebugCreateRenameEpisodePlanResponseBody = { + success: false, + error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + return c.json(response, 200) + } + }) +} diff --git a/apps/cli/src/route/debug/debugRenameFilesTask.ts b/apps/cli/src/route/debug/debugRenameFilesTask.ts deleted file mode 100644 index e8257d8d..00000000 --- a/apps/cli/src/route/debug/debugRenameFilesTask.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { z } from 'zod/v3'; -import { logger } from '../../../lib/logger'; -import { - createBeginRenameFilesTaskV2Tool, - createAddRenameFileToTaskV2Tool, - createEndRenameFilesTaskV2Tool, -} from '../../tools/renameFilesTaskV2'; -import type { Hono } from 'hono'; - -interface ToolResultBase { - error?: string; -} - -interface BeginRenameFilesTaskToolResult extends ToolResultBase { - taskId?: string; -} - -interface AddRenameFileToTaskToolResult extends ToolResultBase {} - -interface EndRenameFilesTaskToolResult extends ToolResultBase {} - -interface DebugRenameFilesTaskResponseBody { - success: boolean; - data?: T; - error?: string; -} - -const startRenameFilesTaskSchema = z.object({ - mediaFolderPath: z.string().min(1, 'Media folder path is required'), - clientId: z.string().optional(), -}); - -const addFileToRenameTaskSchema = z.object({ - taskId: z.string().min(1, 'Task ID is required'), - from: z.string().min(1, 'Current file path is required'), - to: z.string().min(1, 'New file path is required'), - clientId: z.string().optional(), -}); - -const endRenameFilesTaskSchema = z.object({ - taskId: z.string().min(1, 'Task ID is required'), - clientId: z.string().optional(), -}); - -export async function processStartRenameFilesTask(body: any): Promise> { - try { - console.log(`[DebugAPI] Received startRenameFilesTask request:`, body); - - const validationResult = startRenameFilesTaskSchema.safeParse(body); - - if (!validationResult.success) { - return { - success: false, - error: `Validation failed: ${validationResult.error.issues.map(i => i.message).join(', ')}`, - }; - } - - const validatedBody = validationResult.data; - const clientId = validatedBody.clientId || ''; - - const tool = createBeginRenameFilesTaskV2Tool(clientId); - - const result: BeginRenameFilesTaskToolResult = await tool.execute({ - mediaFolderPath: validatedBody.mediaFolderPath, - }); - - if (result.error) { - console.log(`[DebugAPI] startRenameFilesTask completed with error:`, result.error); - } else { - console.log(`[DebugAPI] startRenameFilesTask completed successfully`); - } - - return { - success: !result.error, - data: result, - error: result.error, - }; - } catch (error) { - console.error(`[DebugAPI] Error executing startRenameFilesTask:`, error); - return { - success: false, - error: `Failed to execute startRenameFilesTask: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } -} - -export async function processAddFileToRenameTask(body: any): Promise> { - try { - console.log(`[DebugAPI] Received addFileToRenameTask request:`, body); - - const validationResult = addFileToRenameTaskSchema.safeParse(body); - - if (!validationResult.success) { - return { - success: false, - error: `Validation failed: ${validationResult.error.issues.map(i => i.message).join(', ')}`, - }; - } - - const validatedBody = validationResult.data; - const clientId = validatedBody.clientId || ''; - - const tool = createAddRenameFileToTaskV2Tool(clientId); - - const result: AddRenameFileToTaskToolResult = await tool.execute({ - taskId: validatedBody.taskId, - from: validatedBody.from, - to: validatedBody.to, - }); - - if (result.error) { - console.log(`[DebugAPI] addFileToRenameTask completed with error:`, result.error); - } else { - console.log(`[DebugAPI] addFileToRenameTask completed successfully`); - } - - return { - success: !result.error, - data: result, - error: result.error, - }; - } catch (error) { - console.error(`[DebugAPI] Error executing addFileToRenameTask:`, error); - return { - success: false, - error: `Failed to execute addFileToRenameTask: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } -} - -export async function processEndRenameFilesTask(body: any): Promise> { - try { - console.log(`[DebugAPI] Received endRenameFilesTask request:`, body); - - const validationResult = endRenameFilesTaskSchema.safeParse(body); - - if (!validationResult.success) { - return { - success: false, - error: `Validation failed: ${validationResult.error.issues.map(i => i.message).join(', ')}`, - }; - } - - const validatedBody = validationResult.data; - const clientId = validatedBody.clientId || ''; - - const tool = createEndRenameFilesTaskV2Tool(clientId); - - const result: EndRenameFilesTaskToolResult = await tool.execute({ - taskId: validatedBody.taskId, - }); - - if (result.error) { - console.log(`[DebugAPI] endRenameFilesTask completed with error:`, result.error); - } else { - console.log(`[DebugAPI] endRenameFilesTask completed successfully`); - } - - return { - success: !result.error, - data: result, - error: result.error, - }; - } catch (error) { - console.error(`[DebugAPI] Error executing endRenameFilesTask:`, error); - return { - success: false, - error: `Failed to execute endRenameFilesTask: ${error instanceof Error ? error.message : 'Unknown error'}`, - }; - } -} - -export function handleDebugRenameFilesTaskRoutes(app: Hono) { - app.post('/debug/startRenameFilesTask', async (c) => { - try { - const rawBody = await c.req.json(); - const result = await processStartRenameFilesTask(rawBody); - - return c.json(result, 200); - } catch (error) { - logger.error({ error }, 'Debug API startRenameFilesTask route error:'); - return c.json({ - success: false, - error: `Failed to process startRenameFilesTask request: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, 500); - } - }); - - app.post('/debug/addFileToRenameTask', async (c) => { - try { - const rawBody = await c.req.json(); - const result = await processAddFileToRenameTask(rawBody); - - return c.json(result, 200); - } catch (error) { - logger.error({ error }, 'Debug API addFileToRenameTask route error:'); - return c.json({ - success: false, - error: `Failed to process addFileToRenameTask request: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, 500); - } - }); - - app.post('/debug/endRenameFilesTask', async (c) => { - try { - const rawBody = await c.req.json(); - const result = await processEndRenameFilesTask(rawBody); - - return c.json(result, 200); - } catch (error) { - logger.error({ error }, 'Debug API endRenameFilesTask route error:'); - return c.json({ - success: false, - error: `Failed to process endRenameFilesTask request: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, 500); - } - }); -} diff --git a/apps/cli/src/tools/index.ts b/apps/cli/src/tools/index.ts index b72e1d67..9f9a7f7c 100644 --- a/apps/cli/src/tools/index.ts +++ b/apps/cli/src/tools/index.ts @@ -10,14 +10,6 @@ import { createMatchEpisodesInBatchTool } from './matchEpisodesInBatch'; import { createRenameFilesInBatchTool } from './renameFilesInBatch'; import { createAskForConfirmationTool } from './askForConfirmation'; import { getApplicationContextAgentTool, getApplicationContextMcpTool } from './getApplicationContext'; -import { - createBeginRenameFilesTaskTool, - createAddRenameFileToTaskTool, - createEndRenameFilesTaskTool, - createBeginRenameFilesTaskV2Tool, - createAddRenameFileToTaskV2Tool, - createEndRenameFilesTaskV2Tool, -} from './renameFilesTaskV2'; import { createBeginRecognizeTaskTool, createAddRecognizedMediaFileTool, @@ -39,12 +31,6 @@ export { createRenameFilesInBatchTool, createRenameFolderTool, createAskForConfirmationTool, - createBeginRenameFilesTaskTool, - createAddRenameFileToTaskTool, - createEndRenameFilesTaskTool, - createBeginRenameFilesTaskV2Tool, - createAddRenameFileToTaskV2Tool, - createEndRenameFilesTaskV2Tool, createBeginRecognizeTaskTool, createAddRecognizedMediaFileTool, createEndRecognizeTaskTool, diff --git a/apps/cli/src/tools/renameFilesTaskV2.ts b/apps/cli/src/tools/renameFilesTaskV2.ts deleted file mode 100644 index 3b6a6559..00000000 --- a/apps/cli/src/tools/renameFilesTaskV2.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Path } from '@core/path' -import { logger } from '../../lib/logger' -import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, - BEGIN_RENAME_FILES_TASK_DESCRIPTION, - ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - END_RENAME_FILES_TASK_DESCRIPTION, - beginRenameFilesTaskInputSchema, - addRenameFileToTaskInputSchema, - endRenameFilesTaskInputSchema, -} from '@core/types/ai-tools/renameFilesTask' -import { assertMediaFolderHasMetadata } from '@core/plan/renamePlan' -import { toolError, toolOk, formatToolError } from '@core/ai-tool/toolResult' -import { - beginRenameFilesTaskV2, - addRenameFileToTaskV2, - endRenameFilesTaskV2, - getRenameTask, -} from './renameFilesToolV2' -import { metadataCacheFilePath } from '../route/mediaMetadata/utils' - -export const createBeginRenameFilesTaskTool = ( - clientId: string, - abortSignal?: AbortSignal, -) => ({ - description: BEGIN_RENAME_FILES_TASK_DESCRIPTION, - toolName: BEGIN_RENAME_FILES_TASK, - inputSchema: beginRenameFilesTaskInputSchema, - execute: async ({ mediaFolderPath }: { mediaFolderPath: string }) => { - if (abortSignal?.aborted) { - throw new Error('Request was aborted') - } - logger.info( - { mediaFolderPath, clientId }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Starting new rename task`, - ) - - const folderPathInPosix = Path.posix(mediaFolderPath) - const metadataFilePath = metadataCacheFilePath(folderPathInPosix) - const metadataExists = await Bun.file(metadataFilePath).exists() - - const metadataError = assertMediaFolderHasMetadata(metadataExists, folderPathInPosix) - if (metadataError) { - logger.warn( - { folderPath: folderPathInPosix }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Media metadata not found`, - ) - return toolError(metadataError.replace(/^Error Reason: /, '')) - } - - try { - const taskId = await beginRenameFilesTaskV2(folderPathInPosix) - logger.info( - { taskId, mediaFolderPath: folderPathInPosix, clientId }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Task created successfully`, - ) - return toolOk({ taskId }) - } catch (error) { - logger.error( - { - mediaFolderPath: folderPathInPosix, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Failed to create task`, - ) - return formatToolError(error) - } - }, -}) - -export const createAddRenameFileToTaskTool = ( - clientId: string, - abortSignal?: AbortSignal, -) => ({ - description: ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - toolName: ADD_RENAME_FILE_TO_TASK, - inputSchema: addRenameFileToTaskInputSchema, - execute: async ({ - taskId, - from, - to, - }: { - taskId: string - from: string - to: string - }) => { - if (abortSignal?.aborted) { - throw new Error('Request was aborted') - } - logger.info( - { taskId, from, to, clientId }, - `[tool][${ADD_RENAME_FILE_TO_TASK}] Adding file to task`, - ) - - try { - await addRenameFileToTaskV2(taskId, from, to) - logger.info( - { taskId, from, to, clientId }, - `[tool][${ADD_RENAME_FILE_TO_TASK}] File added successfully`, - ) - return toolOk({}) - } catch (error) { - logger.error( - { - taskId, - from, - to, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${ADD_RENAME_FILE_TO_TASK}] Failed to add file`, - ) - return formatToolError(error) - } - }, -}) - -export const createEndRenameFilesTaskTool = ( - clientId: string, - abortSignal?: AbortSignal, -) => ({ - description: END_RENAME_FILES_TASK_DESCRIPTION, - toolName: END_RENAME_FILES_TASK, - inputSchema: endRenameFilesTaskInputSchema, - execute: async ({ taskId }: { taskId: string }) => { - if (abortSignal?.aborted) { - throw new Error('Request was aborted') - } - logger.info( - { taskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Ending rename task`, - ) - - try { - const task = await getRenameTask(taskId) - - if (!task) { - logger.error( - { taskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Task not found`, - ) - return toolError(`Task with id "${taskId}" not found`) - } - - if (task.files.length === 0) { - logger.warn( - { taskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] No files in task`, - ) - return toolError('No rename entries in task') - } - - await endRenameFilesTaskV2(taskId) - - logger.info( - { taskId, fileCount: task.files.length, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Plan ready, UI notified`, - ) - - return toolOk({}) - } catch (error) { - logger.error( - { - taskId, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${END_RENAME_FILES_TASK}] End task error`, - ) - return formatToolError(error) - } - }, -}) - -/** @deprecated Use createBeginRenameFilesTaskTool */ -export const createBeginRenameFilesTaskV2Tool = createBeginRenameFilesTaskTool -/** @deprecated Use createAddRenameFileToTaskTool */ -export const createAddRenameFileToTaskV2Tool = createAddRenameFileToTaskTool -/** @deprecated Use createEndRenameFilesTaskTool */ -export const createEndRenameFilesTaskV2Tool = createEndRenameFilesTaskTool diff --git a/apps/cli/src/tools/renameFilesToolV2.ts b/apps/cli/src/tools/renameFilesToolV2.ts deleted file mode 100644 index 80d99f2c..00000000 --- a/apps/cli/src/tools/renameFilesToolV2.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { broadcast } from '../utils/socketIO' -import { - RenameFilesPlanReady, - type RenameFilesPlanReadyRequestData, -} from '@core/event-types' -import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' -import { - createEmptyRenamePlan, - prepareAppendRenameEntry, -} from '@core/plan/renamePlan' -import { getUserDataDir } from '@/utils/config' -import path from 'path' -import { mkdir, readdir, stat } from 'fs/promises' -import { Path } from '@core/path' -import pino from 'pino' -import { findMediaMetadata } from '@/utils/mediaMetadata' -import { validateRenameOperations } from './renameFilesInBatch' - -const logger = pino() - -function getPlansDir(): string { - return path.join(getUserDataDir(), 'plans') -} - -export function getPlanFilePath(planId: string): string { - const plansDir = getPlansDir() - return path.join(plansDir, `${planId}.plan.json`) -} - -async function ensurePlansDirExists(): Promise { - const plansDir = getPlansDir() - await mkdir(plansDir, { recursive: true }) -} - -export async function readRenamePlanFile(planId: string): Promise { - const planFilePath = getPlanFilePath(planId) - - const file = Bun.file(planFilePath) - if (!(await file.exists())) { - return null - } - - try { - const content = await file.json() - return content as RenameFilesPlan - } catch (error) { - throw new Error( - `Failed to read plan file: ${error instanceof Error ? error.message : String(error)}`, - ) - } -} - -async function writeRenamePlanFile(plan: RenameFilesPlan): Promise { - await ensurePlansDirExists() - const planFilePath = getPlanFilePath(plan.id) - await Bun.write(planFilePath, JSON.stringify(plan, null, 2)) -} - -/** - * Begin a rename-files task. Creates a plan file and returns plan.id (taskId). - */ -export async function beginRenameFilesTaskV2(mediaFolderPath: string): Promise { - const plan = createEmptyRenamePlan(mediaFolderPath, undefined, { creator: 'ai' }) - await writeRenamePlanFile(plan) - return plan.id -} - -/** - * Add a rename entry to an existing rename task. - */ -export async function addRenameFileToTaskV2( - taskId: string, - from: string, - to: string, -): Promise { - const plan = await readRenamePlanFile(taskId) - - if (!plan) { - throw new Error(`Task with id ${taskId} not found`) - } - - const result = await prepareAppendRenameEntry(plan, { from, to }, { - validateOperations: validateRenameOperations, - getMediaMetadata: async (folderPathInPosix) => { - return (await findMediaMetadata(folderPathInPosix)) ?? null - }, - }) - - if ('error' in result) { - throw new Error(result.error.replace(/^Error Reason: /, '')) - } - - await writeRenamePlanFile(result) -} - -export async function getRenameTask(taskId: string): Promise { - const plan = await readRenamePlanFile(taskId) - return plan ?? undefined -} - -/** - * End a rename task: persist plan and broadcast RenameFilesPlanReady. - */ -export async function endRenameFilesTaskV2(taskId: string): Promise { - const plan = await readRenamePlanFile(taskId) - - if (!plan) { - throw new Error(`Task with id ${taskId} not found`) - } - - const planFilePath = getPlanFilePath(plan.id) - const planFilePathInPosix = Path.posix(planFilePath) - - const data: RenameFilesPlanReadyRequestData = { - taskId: plan.id, - planFilePath: planFilePathInPosix, - } - - broadcast({ - event: RenameFilesPlanReady.event, - data, - }) -} - -export type UpdateRenamePlanStatus = 'rejected' | 'completed' - -export async function updateRenamePlanStatus( - planId: string, - status: UpdateRenamePlanStatus, -): Promise { - const plansDir = getPlansDir() - - try { - const stats = await stat(plansDir) - if (!stats.isDirectory()) { - throw new Error('Plans path exists but is not a directory') - } - } catch { - throw new Error('Plans directory does not exist') - } - - const files = await readdir(plansDir) - - for (const file of files) { - if (!file.endsWith('.plan.json')) { - continue - } - - const planFilePath = path.join(plansDir, file) - - try { - const fileContent = Bun.file(planFilePath) - - if (!(await fileContent.exists())) { - continue - } - - const plan = (await fileContent.json()) as RenameFilesPlan - - if (plan.task === 'rename-files' && plan.id === planId) { - if (plan.status !== 'pending') { - throw new Error(`Plan cannot be updated: plan has status "${plan.status}"`) - } - - plan.status = status - await Bun.write(planFilePath, JSON.stringify(plan, null, 2)) - - logger.info({ planId, planFilePath, status }, 'Rename plan status updated successfully') - return - } - } catch (error) { - logger.warn( - { planFilePath, error: error instanceof Error ? error.message : String(error) }, - 'Failed to parse plan file, skipping', - ) - } - } - - throw new Error(`Plan with id "${planId}" not found`) -} - -export async function getRenamePlanByPlanId(planId: string): Promise { - const plansDir = getPlansDir() - try { - const stats = await stat(plansDir) - if (!stats.isDirectory()) { - return null - } - } catch { - return null - } - - const files = await readdir(plansDir) - for (const file of files) { - if (!file.endsWith('.plan.json')) continue - const planFilePath = path.join(plansDir, file) - try { - const content = (await Bun.file(planFilePath).json()) as RenameFilesPlan - if (content.task === 'rename-files' && content.id === planId) { - return content - } - } catch { - continue - } - } - return null -} - -export async function getAllPendingRenamePlans(): Promise { - const plansDir = getPlansDir() - const pending: RenameFilesPlan[] = [] - - try { - try { - const stats = await stat(plansDir) - if (!stats.isDirectory()) { - logger.warn({ plansDir }, 'Plans path exists but is not a directory') - return [] - } - } catch { - return [] - } - - const files = await readdir(plansDir) - - for (const file of files) { - if (!file.endsWith('.plan.json')) { - continue - } - - const planFilePath = path.join(plansDir, file) - - try { - const fileContent = Bun.file(planFilePath) - - if (!(await fileContent.exists())) { - continue - } - - const content = (await fileContent.json()) as Record - - if ( - typeof content === 'object' && - content !== null && - content.task === 'rename-files' && - content.status === 'pending' - ) { - pending.push(content as unknown as RenameFilesPlan) - } - } catch (error) { - logger.warn( - { planFilePath, error: error instanceof Error ? error.message : String(error) }, - 'Failed to parse plan file, skipping', - ) - } - } - - return pending - } catch (error) { - logger.error( - { plansDir, error: error instanceof Error ? error.message : String(error) }, - 'Failed to read plans directory', - ) - return [] - } -} From 17f4577d5f354ef98bdc791dc1f311a039a07318 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:15:06 +0800 Subject: [PATCH 180/214] fix(cli): avoid double Error Reason prefix on create-rename-episode-plan Co-authored-by: Cursor --- apps/cli/src/route/RenameEpisodesPlan.test.ts | 16 +++++++++++++++ apps/cli/src/route/RenameEpisodesPlan.ts | 5 ++--- .../debugCreateRenameEpisodePlan.test.ts | 20 +++++++++++++++++++ .../debug/debugCreateRenameEpisodePlan.ts | 3 ++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/route/RenameEpisodesPlan.test.ts b/apps/cli/src/route/RenameEpisodesPlan.test.ts index eb6668e0..c82bf956 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.test.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.test.ts @@ -99,4 +99,20 @@ describe('POST /api/create-rename-episode-plan', () => { }) expect(mocks.createRenameEpisodePlan).not.toHaveBeenCalled() }) + + it('does not double-prefix when Core throws a pre-prefixed error', async () => { + mocks.createRenameEpisodePlan.mockRejectedValue( + new Error('Error Reason: No rename entries in task'), + ) + + const response = await post({ + mediaFolderPath: '/media/Show', + files: [], + }) + + expect(response.status).toBe(200) + const json = await response.json() + expect(json.error).toBe('Error Reason: No rename entries in task') + expect(json.error.match(/Error Reason:/g)).toHaveLength(1) + }) }) diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index 78765b38..1aaa7a3e 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -6,6 +6,7 @@ import { RenameFilesPlanReady, type RenameFilesPlanReadyRequestData, } from '@core/event-types' +import { formatToolError } from '@core/ai-tool/toolResult' import { getCore } from '../core/getCore' import { broadcast } from '@/utils/socketIO' import { getUserDataDir } from '@/utils/config' @@ -122,9 +123,7 @@ export function handleRenameEpisodesPlan(app: Hono): void { return c.json(await createRenameEpisodePlanFromBody(body), 200) } catch (error) { logger.error({ error }, '[POST /api/create-rename-episode-plan] route error') - const err: CreateRenameEpisodePlanResponseBody = { - error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, - } + const err: CreateRenameEpisodePlanResponseBody = formatToolError(error) return c.json(err, 200) } }) diff --git a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts index bdd73a90..5b95e844 100644 --- a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts +++ b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts @@ -78,4 +78,24 @@ describe('POST /debug/createRenameEpisodePlan', () => { error: 'Error Reason: No rename entries in task', }) }) + + it('does not double-prefix when Core throws a pre-prefixed error', async () => { + mocks.createRenameEpisodePlan.mockRejectedValue( + new Error('Error Reason: No rename entries in task'), + ) + + const response = await app.request('/debug/createRenameEpisodePlan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mediaFolderPath: '/media/Show', files: [] }), + }) + + expect(response.status).toBe(200) + const json = await response.json() + expect(json).toEqual({ + success: false, + error: 'Error Reason: No rename entries in task', + }) + expect(json.error.match(/Error Reason:/g)).toHaveLength(1) + }) }) diff --git a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts index 57594b82..9a8bd51d 100644 --- a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts +++ b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.ts @@ -1,5 +1,6 @@ import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' import type { Hono } from 'hono' +import { formatToolError } from '@core/ai-tool/toolResult' import { logger } from '../../../lib/logger' import { createRenameEpisodePlanFromBody } from '../RenameEpisodesPlan' @@ -43,7 +44,7 @@ export function handleDebugCreateRenameEpisodePlan(app: Hono): void { logger.error({ error }, '[POST /debug/createRenameEpisodePlan] route error') const response: DebugCreateRenameEpisodePlanResponseBody = { success: false, - error: `Error Reason: ${error instanceof Error ? error.message : 'Unknown error'}`, + ...formatToolError(error), } return c.json(response, 200) } From 27e5ff4349cda7ab121d8919103a9b2daa8a880d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:25:10 +0800 Subject: [PATCH 181/214] feat(core-routes): single create-rename-episode-plan MCP tool Co-authored-by: Cursor --- .superpowers/sdd/task-6-report.md | 92 ++++++ apps/cli/src/test/ai-tool-registry.test.ts | 4 +- apps/core/package.json | 5 +- .../src/pipeline/createRenameEpisodePlan.ts | 6 +- apps/core/src/pipeline/paths.ts | 2 +- apps/core/src/pipeline/plans.ts | 4 +- packages/core-routes/package.json | 1 + packages/core-routes/src/chat.ts | 10 +- packages/core-routes/src/chatFs.ts | 12 +- .../src/mcp/createRenameEpisodePlan.test.ts | 173 +++++++++++ packages/core-routes/src/mcp/createServer.ts | 15 +- .../src/mcp/toolHandlers/addRenameFile.ts | 144 --------- .../src/mcp/toolHandlers/beginRenameTask.ts | 105 ------- .../toolHandlers/createRenameEpisodePlan.ts | 44 +++ .../src/mcp/toolHandlers/endRenameTask.ts | 85 ----- .../src/mcp/toolHandlers/staticText.ts | 4 +- .../src/tools/createRenameEpisodePlan.ts | 127 ++++++++ packages/core-routes/src/tools/index.ts | 49 +-- .../core-routes/src/tools/renameFilesTask.ts | 290 ------------------ .../src/tools/renameFilesTaskDefaults.ts | 28 -- pnpm-lock.yaml | 6 + 21 files changed, 476 insertions(+), 730 deletions(-) create mode 100644 .superpowers/sdd/task-6-report.md create mode 100644 packages/core-routes/src/mcp/createRenameEpisodePlan.test.ts delete mode 100644 packages/core-routes/src/mcp/toolHandlers/addRenameFile.ts delete mode 100644 packages/core-routes/src/mcp/toolHandlers/beginRenameTask.ts create mode 100644 packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts delete mode 100644 packages/core-routes/src/mcp/toolHandlers/endRenameTask.ts create mode 100644 packages/core-routes/src/tools/createRenameEpisodePlan.ts delete mode 100644 packages/core-routes/src/tools/renameFilesTask.ts delete mode 100644 packages/core-routes/src/tools/renameFilesTaskDefaults.ts diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md new file mode 100644 index 00000000..dfa388c1 --- /dev/null +++ b/.superpowers/sdd/task-6-report.md @@ -0,0 +1,92 @@ +# Task 6 Report + +## Status + +Implemented the single `create-rename-episode-plan` MCP/chat tool in +`core-routes`. It calls `createRenameEpisodePlanPipeline` with the host +`appDataDir` and filesystem adapter, writes a pending AI-created plan, +broadcasts `RenameFilesPlanReady`, and returns +`END_PLAN_TASK_SUCCESS_MESSAGE` with `planId`. + +The legacy begin/add/end rename tool builders and MCP handlers were removed. +The rename how-to text and CLI AI-tool registry alignment test now reference +the single tool. + +## TDD + +- Added MCP tests for successful plan creation with an in-memory filesystem. +- Added validation coverage for an empty `files` array. +- Added guidance coverage ensuring the how-to text names only the single tool. +- The initial red run failed because the old rename tool modules still imported + the removed Task 4 contract; after replacement, the focused tests passed. + +## Verification + +- `packages/core-routes: pnpm test` — 34 files passed, 323 tests passed, + 1 skipped. +- `packages/core-routes: pnpm typecheck` — passed. +- `packages/core-routes: pnpm build` — passed. +- `apps/core: pnpm typecheck` — passed. +- `apps/cli: pnpm exec vitest run src/test/ai-tool-registry.test.ts` — + 3 tests passed. +- `git diff --check` — passed. + +## Commit + +`feat(core-routes): single create-rename-episode-plan MCP tool` + +## Concerns + +None. The pre-existing dirty E2E and documentation files were not included in +this task. +# Task 6 Report: AI tool type definitions + execution for TVDB + +## What I implemented + +Created 7 new files mirroring the existing TMDB analog: + +1. **`packages/core/types/ai-tools/tvdbCommon.ts`** — `tvdbLanguageSchema`, `tvdbBaseUrlSchema`, `TvdbToolHostOptions`, `toTvdbCoreOptions`, `formatTvdbToolError`. +2. **`packages/core/types/ai-tools/tvdbSearch.ts`** — `TVDB_SEARCH`, `TVDB_SEARCH_DESCRIPTION`, `tvdbSearchInputSchema`/`tvdbSearchOutputSchema`, `TvdbSearchInput`/`TvdbSearchOutput`. +3. **`packages/core/types/ai-tools/tvdbGetMovie.ts`** — `TVDB_GET_MOVIE`, description, input/output schemas + types. +4. **`packages/core/types/ai-tools/tvdbGetTvShow.ts`** — `TVDB_GET_TV_SHOW`, description, input/output schemas + types. +5. **`packages/core/types/ai-tools/tvdbGetLanguages.ts`** — `TVDB_GET_LANGUAGES`, description, input/output schemas + types. +6. **`packages/core-routes/src/tools/tvdb.ts`** — runner types (`SearchInTvdbRunner`, `GetTvShowInTvdbRunner`, `GetMovieInTvdbRunner`, `GetTvdbLanguagesRunner`), `TvdbToolRunners`, `executeTvdbSearch`, `executeTvdbGetMovie`, `executeTvdbGetTvShow`, `executeTvdbGetLanguages`, `buildTvdbSearchTool`, `buildTvdbGetMovieTool`, `buildTvdbGetTvShowTool`, `buildTvdbGetLanguagesTool`. +7. **`packages/core-routes/src/tools/tvdb.test.ts`** — the test suite from the brief (8 tests). + +All code was written verbatim from the task brief. The implementation uses loose `Record` runner types and does NOT import from `@smm/tvdb4`, satisfying the critical constraint. + +## What I tested and results + +### RED (Step 2) +Before implementation, ran `pnpm vitest run src/tools/tvdb.test.ts` (from `packages/core-routes`): +- Result: **FAIL** — `Error: Cannot find module './tvdb.ts'` (module not found). + +### GREEN (Step 5) +After creating the 5 type files and `tvdb.ts`: +- `pnpm vitest run src/tools/tvdb.test.ts` → **1 file passed, 8 tests passed**. +- `pnpm vitest run src/tools/tmdb.test.ts` → **1 file passed, 7 tests passed** (TMDB analog unaffected). + +### Typecheck +- `pnpm run typecheck` in `packages/core-routes`: only **pre-existing** errors remain in `src/tools/tmdb.ts` (TS2352 casts, present before this task). No errors reference any of my new files. +- `pnpm run typecheck` in `packages/core`: no errors referencing any `tvdb*` file. + +## Files changed + +- `packages/core/types/ai-tools/tvdbCommon.ts` (new) +- `packages/core/types/ai-tools/tvdbSearch.ts` (new) +- `packages/core/types/ai-tools/tvdbGetMovie.ts` (new) +- `packages/core/types/ai-tools/tvdbGetTvShow.ts` (new) +- `packages/core/types/ai-tools/tvdbGetLanguages.ts` (new) +- `packages/core-routes/src/tools/tvdb.ts` (new) +- `packages/core-routes/src/tools/tvdb.test.ts` (new) + +## Self-review findings + +- Verified all files match the brief's code exactly (imports, schemas, runner types, builders). +- Verified the runner-call argument shapes against the test expectations (e.g. `toTvdbCoreOptions` produces `{ language, host }`, `host` undefined when `baseURL` absent, `type` passed through for search). +- Confirmed no `@smm/tvdb4` import anywhere in the new code. +- Scope kept to the 7 files listed in the brief — `packages/core-routes/src/tools/index.ts` integration is not part of this task (presumably a later task wires TVDB runners/tools into `ChatTools`/`ChatToolsExtraDeps`). + +## Issues / concerns + +- None blocking. Note: `pnpm vitest` is not resolvable from the repo root (vitest not hoisted there); the task's documented command from `packages/core-routes` (`pnpm vitest run src/tools/tvdb.test.ts`) works, and `pnpm --filter @smm/core-routes exec vitest run ...` works from the root as an alternative. diff --git a/apps/cli/src/test/ai-tool-registry.test.ts b/apps/cli/src/test/ai-tool-registry.test.ts index 12f49795..908ac786 100644 --- a/apps/cli/src/test/ai-tool-registry.test.ts +++ b/apps/cli/src/test/ai-tool-registry.test.ts @@ -67,9 +67,7 @@ const CONSTANT_NAME_TO_TOOL_NAME: Record = { TVDB_GET_MOVIE: 'tvdb-get-movie', TVDB_GET_TV_SHOW: 'tvdb-get-tv-show', TVDB_GET_LANGUAGES: 'tvdb-get-languages', - BEGIN_RENAME_FILES_TASK: 'begin-rename-files-task', - ADD_RENAME_FILE_TO_TASK: 'add-rename-file-to-task', - END_RENAME_FILES_TASK: 'end-rename-files-task', + CREATE_RENAME_EPISODE_PLAN: 'create-rename-episode-plan', BEGIN_RECOGNIZE_TASK: 'begin-recognize-task', ADD_RECOGNIZED_MEDIA_FILE: 'add-recognized-media-file', END_RECOGNIZE_TASK: 'end-recognize-task', diff --git a/apps/core/package.json b/apps/core/package.json index d33eabf4..00f333b3 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -4,7 +4,9 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./createRenameEpisodePlan": "./src/pipeline/createRenameEpisodePlan.ts", + "./FsPort": "./src/ports/FsPort.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -12,6 +14,7 @@ "test:watch": "vitest --watch" }, "dependencies": { + "@smm/core": "workspace:*", "@smm/tvdb4": "workspace:*", "es-toolkit": "^1.42.0" }, diff --git a/apps/core/src/pipeline/createRenameEpisodePlan.ts b/apps/core/src/pipeline/createRenameEpisodePlan.ts index 4603bcbc..5fa5b23e 100644 --- a/apps/core/src/pipeline/createRenameEpisodePlan.ts +++ b/apps/core/src/pipeline/createRenameEpisodePlan.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; -import { assertEpisodeVideoFile, assertMediaFolderHasMetadata } from "@core/plan/renamePlan"; -import { validateRenameOperations } from "@core/validations/rename/validateRenameOperations"; -import type { RenameFileExistenceProbe } from "@core/validations/rename/validateRenameFileExistence"; +import { assertEpisodeVideoFile, assertMediaFolderHasMetadata } from "@smm/core/plan/renamePlan"; +import { validateRenameOperations } from "@smm/core/validations/rename/validateRenameOperations"; +import type { RenameFileExistenceProbe } from "@smm/core/validations/rename/validateRenameFileExistence"; import type { MediaMetadata } from "@smm/core"; import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "../ports/FsPort"; diff --git a/apps/core/src/pipeline/paths.ts b/apps/core/src/pipeline/paths.ts index 61d2c31b..8df9e95d 100644 --- a/apps/core/src/pipeline/paths.ts +++ b/apps/core/src/pipeline/paths.ts @@ -1,4 +1,4 @@ -import { Path } from "@core/path"; +import { Path } from "@smm/core/path"; export function joinPosix(...parts: string[]): string { return parts.join("/"); diff --git a/apps/core/src/pipeline/plans.ts b/apps/core/src/pipeline/plans.ts index 0a80e836..25c38551 100644 --- a/apps/core/src/pipeline/plans.ts +++ b/apps/core/src/pipeline/plans.ts @@ -1,5 +1,5 @@ -import { Path } from "@core/path"; -import { isActivePlanStatus } from "@core/types/planCommon"; +import { Path } from "@smm/core/path"; +import { isActivePlanStatus } from "@smm/core/types/planCommon"; import type { RecognizeMediaFilePlan } from "@smm/core/types/RecognizeMediaFilePlan"; import type { RenameFilesPlan } from "@smm/core/types/RenameFilesPlan"; import type { FsPort } from "../ports/FsPort"; diff --git a/packages/core-routes/package.json b/packages/core-routes/package.json index d5fa34bb..9d1e7115 100644 --- a/packages/core-routes/package.json +++ b/packages/core-routes/package.json @@ -22,6 +22,7 @@ "@modelcontextprotocol/sdk": "^1.25.3", "@smm/core": "workspace:*", "ai": "^6.0.36", + "core-app": "workspace:*", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "socket.io": "^4.8.3", diff --git a/packages/core-routes/src/chat.ts b/packages/core-routes/src/chat.ts index 652adc2f..ac3ea3dc 100644 --- a/packages/core-routes/src/chat.ts +++ b/packages/core-routes/src/chat.ts @@ -19,11 +19,7 @@ import { TVDB_SEARCH } from "@smm/core/types/ai-tools/tvdbSearch"; import { TVDB_GET_MOVIE } from "@smm/core/types/ai-tools/tvdbGetMovie"; import { TVDB_GET_TV_SHOW } from "@smm/core/types/ai-tools/tvdbGetTvShow"; import { TVDB_GET_LANGUAGES } from "@smm/core/types/ai-tools/tvdbGetLanguages"; -import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, -} from "@smm/core/types/ai-tools/renameFilesTask"; +import { CREATE_RENAME_EPISODE_PLAN } from "@smm/core/types/ai-tools/createRenameEpisodePlan"; import { BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, @@ -153,9 +149,7 @@ export async function doChat( [TVDB_GET_MOVIE]: tools[TVDB_GET_MOVIE], [TVDB_GET_TV_SHOW]: tools[TVDB_GET_TV_SHOW], [TVDB_GET_LANGUAGES]: tools[TVDB_GET_LANGUAGES], - [BEGIN_RENAME_FILES_TASK]: tools[BEGIN_RENAME_FILES_TASK], - [ADD_RENAME_FILE_TO_TASK]: tools[ADD_RENAME_FILE_TO_TASK], - [END_RENAME_FILES_TASK]: tools[END_RENAME_FILES_TASK], + [CREATE_RENAME_EPISODE_PLAN]: tools[CREATE_RENAME_EPISODE_PLAN], [BEGIN_RECOGNIZE_TASK]: tools[BEGIN_RECOGNIZE_TASK], [ADD_RECOGNIZED_MEDIA_FILE]: tools[ADD_RECOGNIZED_MEDIA_FILE], [END_RECOGNIZE_TASK]: tools[END_RECOGNIZE_TASK], diff --git a/packages/core-routes/src/chatFs.ts b/packages/core-routes/src/chatFs.ts index 24130444..fd2d284b 100644 --- a/packages/core-routes/src/chatFs.ts +++ b/packages/core-routes/src/chatFs.ts @@ -1,4 +1,6 @@ -import { readFile, writeFile, stat } from "node:fs/promises"; +import { mkdir, readFile, writeFile, stat } from "node:fs/promises"; +import { dirname } from "node:path"; +import { Path } from "@smm/core/path"; import type { ChatFs } from "./chatTypes.ts"; /** @@ -14,7 +16,7 @@ export function defaultChatFs(): ChatFs { return { async readJson(filePath: string): Promise { try { - const contents = await readFile(filePath, "utf-8"); + const contents = await readFile(Path.toPlatformPath(filePath), "utf-8"); return JSON.parse(contents) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -26,12 +28,14 @@ export function defaultChatFs(): ChatFs { async writeJson(filePath: string, value: unknown): Promise { const serialized = JSON.stringify(value, null, 2); - await writeFile(filePath, serialized, "utf-8"); + const platformPath = Path.toPlatformPath(filePath); + await mkdir(dirname(platformPath), { recursive: true }); + await writeFile(platformPath, serialized, "utf-8"); }, async exists(filePath: string): Promise { try { - await stat(filePath); + await stat(Path.toPlatformPath(filePath)); return true; } catch { return false; diff --git a/packages/core-routes/src/mcp/createRenameEpisodePlan.test.ts b/packages/core-routes/src/mcp/createRenameEpisodePlan.test.ts new file mode 100644 index 00000000..19ecabbd --- /dev/null +++ b/packages/core-routes/src/mcp/createRenameEpisodePlan.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from "vitest"; +import type { UserConfig } from "@smm/core/types"; +import { CREATE_RENAME_EPISODE_PLAN } from "@smm/core/types/ai-tools/createRenameEpisodePlan"; +import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/core/types/ai-tools/planTaskMessages"; +import { RenameFilesPlanReady } from "@smm/core/event-types"; +import type { ChatFs } from "../chatTypes.ts"; +import { createMcpStreamableHttpHandler } from "./createServer.ts"; + +async function callMcp( + handler: Awaited>, + id: number, + method: string, + params: Record, +): Promise> { + const response = await handler( + new Request("http://localhost/mcp/", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }), + ); + const text = await response.text(); + const data = response.headers.get("content-type")?.includes("application/json") + ? text + : (text + .split("\n") + .filter((line) => line.startsWith("data:")) + .at(-1) + ?.slice("data:".length) + .trim() ?? ""); + return JSON.parse(data) as Record; +} + +async function initialize( + handler: Awaited>, +): Promise { + await callMcp(handler, 1, "initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.1" }, + }); +} + +function createMockFs(folder: string): ChatFs & { + values: Map; +} { + const metadata = { + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [ + { + absolutePath: `${folder}/S01E01.mkv`, + seasonNumber: 1, + episodeNumber: 1, + }, + ], + }; + const values = new Map(); + return { + values, + async readJson(path: string): Promise { + return ( + path.includes("/metadata/") ? metadata : (values.get(path) ?? null) + ) as T | null; + }, + writeJson: vi.fn(async (path: string, value: unknown) => { + values.set(path, value); + }), + exists: vi.fn(async (path: string) => path === `${folder}/S01E01.mkv`), + }; +} + +const userConfig = { + folders: [], +} as unknown as UserConfig; + +describe(CREATE_RENAME_EPISODE_PLAN, () => { + it("creates a pending plan, broadcasts readiness, and returns its plan id", async () => { + const folder = "/media/show"; + const fs = createMockFs(folder); + const broadcast = vi.fn(); + const handler = await createMcpStreamableHttpHandler({ + appDataDir: "/app-data", + userDataDir: "/user-data", + getUserConfig: async () => userConfig, + fs, + broadcast, + }); + await initialize(handler); + + const response = await callMcp(handler, 2, "tools/call", { + name: CREATE_RENAME_EPISODE_PLAN, + arguments: { + mediaFolderPath: folder, + files: [ + { + from: `${folder}/S01E01.mkv`, + to: `${folder}/Show - S01E01.mkv`, + }, + ], + }, + }); + const result = response.result as { + structuredContent?: { message?: string; planId?: string }; + }; + + expect(result.structuredContent?.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(result.structuredContent?.planId).toEqual(expect.any(String)); + expect([...fs.values.values()]).toContainEqual( + expect.objectContaining({ + id: result.structuredContent?.planId, + status: "pending", + creator: "ai", + }), + ); + expect(broadcast).toHaveBeenCalledWith({ + event: RenameFilesPlanReady.event, + data: { + taskId: result.structuredContent?.planId, + planFilePath: `/app-data/plans/${result.structuredContent?.planId}.plan.json`, + }, + }); + }); + + it("returns an MCP validation error for an empty files array", async () => { + const folder = "/media/show"; + const handler = await createMcpStreamableHttpHandler({ + appDataDir: "/app-data", + userDataDir: "/user-data", + getUserConfig: async () => userConfig, + fs: createMockFs(folder), + }); + await initialize(handler); + + const response = await callMcp(handler, 2, "tools/call", { + name: CREATE_RENAME_EPISODE_PLAN, + arguments: { mediaFolderPath: folder, files: [] }, + }); + const result = response.result as { isError?: boolean; content?: unknown[] }; + + expect(result.isError).toBe(true); + expect(result.content).toBeDefined(); + }); +}); + +describe("how-to-rename-episode-video-files", () => { + it("instructs clients to submit one create-rename-episode-plan call", async () => { + const handler = await createMcpStreamableHttpHandler({ + appDataDir: "/app-data", + userDataDir: "/user-data", + getUserConfig: async () => userConfig, + fs: createMockFs("/media/show"), + }); + await initialize(handler); + + const response = await callMcp(handler, 2, "tools/call", { + name: "how-to-rename-episode-video-files", + arguments: {}, + }); + const result = response.result as { + structuredContent?: { text?: string }; + }; + const text = result.structuredContent?.text ?? ""; + + expect(text).toContain(CREATE_RENAME_EPISODE_PLAN); + expect(text).not.toContain("begin-rename-episode-video-file-task"); + expect(text).not.toContain("add-rename-episode-video-file-to-task"); + expect(text).not.toContain("end-rename-episode-video-file-task"); + }); +}); diff --git a/packages/core-routes/src/mcp/createServer.ts b/packages/core-routes/src/mcp/createServer.ts index fa5d8d73..1074aad0 100644 --- a/packages/core-routes/src/mcp/createServer.ts +++ b/packages/core-routes/src/mcp/createServer.ts @@ -1,15 +1,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { defaultChatFs } from "../chatFs.ts"; -import { defaultRenameFilesTaskDeps } from "../tools/renameFilesTaskDefaults.ts"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; import { registerAddRecognizedFileTool } from "./toolHandlers/addRecognizedFile.ts"; -import { registerAddRenameFileTool } from "./toolHandlers/addRenameFile.ts"; import { registerBeginRecognizeTaskTool } from "./toolHandlers/beginRecognizeTask.ts"; -import { registerBeginRenameTaskTool } from "./toolHandlers/beginRenameTask.ts"; +import { registerCreateRenameEpisodePlanTool } from "./toolHandlers/createRenameEpisodePlan.ts"; import { registerEndRecognizeTaskTool } from "./toolHandlers/endRecognizeTask.ts"; -import { registerEndRenameTaskTool } from "./toolHandlers/endRenameTask.ts"; import { registerGetApplicationContextTool } from "./toolHandlers/getApplicationContext.ts"; import { registerGetEpisodeTool } from "./toolHandlers/getEpisode.ts"; import { registerGetEpisodesTool } from "./toolHandlers/getEpisodes.ts"; @@ -71,9 +67,6 @@ export type McpRequestHandler = (req: Request) => Promise; export async function createMcpStreamableHttpHandler( config: McpConfig, ): Promise { - const fs = config.fs ?? defaultChatFs(); - const renameFilesTaskDeps = defaultRenameFilesTaskDeps(config.appDataDir); - const server = new McpServer( { name: "Simple Media Manager (SMM)", @@ -119,10 +112,8 @@ export async function createMcpStreamableHttpHandler( registerGetJobTool(server, config); } - // Episode-level rename task (begin / add / end). - registerBeginRenameTaskTool(server, config, renameFilesTaskDeps); - registerAddRenameFileTool(server, config, renameFilesTaskDeps); - registerEndRenameTaskTool(server, config, renameFilesTaskDeps); + // Episode-level rename plan. + registerCreateRenameEpisodePlanTool(server, config); // Episode recognition task (begin / add / end). registerBeginRecognizeTaskTool(server, config); diff --git a/packages/core-routes/src/mcp/toolHandlers/addRenameFile.ts b/packages/core-routes/src/mcp/toolHandlers/addRenameFile.ts deleted file mode 100644 index b671dbcb..00000000 --- a/packages/core-routes/src/mcp/toolHandlers/addRenameFile.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Path } from "@smm/core/path"; -import { extname } from "node:path"; -import { videoFileExtensions } from "@smm/core/utils"; -import { defaultChatFs } from "../../chatFs.ts"; -import { - createErrorResponse, - createSuccessResponse, - type McpToolResponse, -} from "../index.ts"; -import type { McpConfig } from "../types.ts"; -import { - buildAddRenameFileToTaskTool, - type RenameFilesTaskDeps, -} from "../../tools/renameFilesTask.ts"; -import { ADD_RENAME_FILE_TO_TASK } from "@smm/core/types/ai-tools/renameFilesTask"; - -/** - * Register the `add-rename-file` MCP tool. Adds a single (from, to) - * rename entry to an existing rename-files task. - * - * Validates that both `from` and `to` look like video files so - * non-video files (subtitle, nfo, etc.) are rejected with a clear - * message — matching the original `apps/cli` MCP behaviour. - */ -export function registerAddRenameFileTool( - server: McpServer, - config: McpConfig, - deps: RenameFilesTaskDeps, -): void { - const fs = config.fs ?? defaultChatFs(); - - const agentTool = buildAddRenameFileToTaskTool( - "mcp", - config.appDataDir, - fs, - deps, - config.logger, - undefined, - ); - - const inputSchema = z.object({ - taskId: z - .string() - .describe("The task ID returned from begin-rename-task"), - from: z - .string() - .describe("The current absolute path of the file to rename"), - to: z - .string() - .describe("The new absolute path for the file"), - }); - - server.registerTool( - ADD_RENAME_FILE_TO_TASK, - { - description: agentTool.description, - inputSchema, - }, - async (args: unknown): Promise => { - const { taskId, from, to } = (args ?? {}) as { - taskId?: string; - from?: string; - to?: string; - }; - - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse( - "Invalid taskId: 'taskId' must be a non-empty string", - ); - } - if (typeof from !== "string" || from.trim() === "") { - return createErrorResponse( - "Invalid path: 'from' must be a non-empty string", - ); - } - if (typeof to !== "string" || to.trim() === "") { - return createErrorResponse( - "Invalid path: 'to' must be a non-empty string", - ); - } - if (!isVideoFile(from)) { - return createErrorResponse( - "Invalid path: 'from' must be a video file", - ); - } - if (!isVideoFile(to)) { - return createErrorResponse( - "Invalid path: 'to' must be a video file", - ); - } - - try { - const result = await agentTool.execute({ - taskId, - from: Path.posix(from), - to: Path.posix(to), - }); - - // Agent tools return `{ error: "..." }` instead of throwing - // when validation fails (e.g. source file does not exist on - // disk, file is outside the media folder, or path is not a - // video file linked to any episode). Surface those failures - // to the MCP client so the AI model does not silently add a - // non-existent file to the plan. - if ( - typeof result === "object" && - result !== null && - "error" in result && - typeof result.error === "string" - ) { - const message = result.error; - if (message.includes("Not Episode Video File")) { - return createSuccessResponse({ - success: false, - error: - `"${from}" is not video file to any episode, you're not allowed to rename it. ` + - `Call "get-episodes" tool to get the list of episode video files that needs to rename.`, - }); - } - return createSuccessResponse({ success: false, error: message }); - } - - return createSuccessResponse({ success: true, taskId }); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - if (message.includes("Not Episode Video File")) { - return createErrorResponse( - `"${from}" is not video file to any episode, you're not allowed to rename it. ` + - `Call "get-episodes" tool to get the list of episode video files that needs to rename.`, - ); - } - return createErrorResponse(message); - } - }, - ); -} - -function isVideoFile(filePath: string): boolean { - const extension = extname(filePath).toLowerCase(); - return videoFileExtensions.includes(extension); -} diff --git a/packages/core-routes/src/mcp/toolHandlers/beginRenameTask.ts b/packages/core-routes/src/mcp/toolHandlers/beginRenameTask.ts deleted file mode 100644 index e1e770ac..00000000 --- a/packages/core-routes/src/mcp/toolHandlers/beginRenameTask.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Path } from "@smm/core/path"; -import { metadataCacheFilePath } from "../../mediaMetadataCache.ts"; -import { assertMediaFolderHasMetadata } from "@smm/core/plan/renamePlan"; -import { defaultChatFs } from "../../chatFs.ts"; -import { - createErrorResponse, - createSuccessResponse, - type McpToolResponse, -} from "../index.ts"; -import type { McpConfig } from "../types.ts"; -import { - buildBeginRenameFilesTaskTool, - type RenameFilesTaskDeps, -} from "../../tools/renameFilesTask.ts"; -import { BEGIN_RENAME_FILES_TASK } from "@smm/core/types/ai-tools/renameFilesTask"; - -/** - * Register the `begin-rename-task` MCP tool. Creates a new - * rename-files task for a media folder and returns the task ID. - * - * Uses the existing core-routes agent toolset under the hood - * (`buildBeginRenameFilesTaskTool` with `clientId="mcp"`); the - * `ChatFs` abstraction keeps file I/O runtime-neutral. - */ -export function registerBeginRenameTaskTool( - server: McpServer, - config: McpConfig, - deps: RenameFilesTaskDeps, -): void { - const fs = config.fs ?? defaultChatFs(); - - const agentTool = buildBeginRenameFilesTaskTool( - "mcp", - config.appDataDir, - fs, - deps, - config.broadcast, - config.logger, - undefined, - ); - - const inputSchema = z.object({ - mediaFolderPath: z - .string() - .describe("The absolute path of the media folder, in POSIX or Windows format"), - }); - - server.registerTool( - BEGIN_RENAME_FILES_TASK, - { - description: agentTool.description, - inputSchema, - }, - async (args: unknown): Promise => { - const { mediaFolderPath } = (args ?? {}) as { mediaFolderPath?: string }; - if ( - typeof mediaFolderPath !== "string" || - mediaFolderPath.trim() === "" - ) { - return createErrorResponse( - "Invalid path: 'mediaFolderPath' must be a non-empty string", - ); - } - - try { - const result = await agentTool.execute({ mediaFolderPath }); - - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result as { error?: string }; - if (errorResult.error) { - return createErrorResponse(errorResult.error); - } - } - - if (typeof result === "object" && result !== null && "taskId" in result) { - return createSuccessResponse({ - success: true, - taskId: (result as { taskId: string }).taskId, - mediaFolderPath: Path.posix(mediaFolderPath), - }); - } - - config.logger?.error?.( - { - resultType: typeof result, - resultKeys: - typeof result === "object" && result !== null - ? Object.keys(result) - : [], - }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Unexpected agent tool result shape`, - ); - return createSuccessResponse(result as { [x: string]: unknown }); - } catch (error) { - return createErrorResponse( - `Error starting rename task: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }, - ); -} diff --git a/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts b/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts new file mode 100644 index 00000000..a86145ca --- /dev/null +++ b/packages/core-routes/src/mcp/toolHandlers/createRenameEpisodePlan.ts @@ -0,0 +1,44 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CREATE_RENAME_EPISODE_PLAN, + CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + createRenameEpisodePlanInputSchema, +} from "@smm/core/types/ai-tools/createRenameEpisodePlan"; +import { defaultChatFs } from "../../chatFs.ts"; +import { buildCreateRenameEpisodePlanTool } from "../../tools/createRenameEpisodePlan.ts"; +import { + createErrorResponse, + createSuccessResponse, + type McpToolResponse, +} from "../index.ts"; +import type { McpConfig } from "../types.ts"; + +export function registerCreateRenameEpisodePlanTool( + server: McpServer, + config: McpConfig, +): void { + const tool = buildCreateRenameEpisodePlanTool( + config.appDataDir, + config.fs ?? defaultChatFs(), + config.broadcast, + config.logger, + ); + const description = + config.toolDescriptions?.[CREATE_RENAME_EPISODE_PLAN] ?? + CREATE_RENAME_EPISODE_PLAN_DESCRIPTION; + + server.registerTool( + CREATE_RENAME_EPISODE_PLAN, + { + description, + inputSchema: createRenameEpisodePlanInputSchema, + }, + async (args: unknown): Promise => { + const result = await tool.execute(args); + if (result.error) { + return createErrorResponse(result.error); + } + return createSuccessResponse(result); + }, + ); +} diff --git a/packages/core-routes/src/mcp/toolHandlers/endRenameTask.ts b/packages/core-routes/src/mcp/toolHandlers/endRenameTask.ts deleted file mode 100644 index 72294a55..00000000 --- a/packages/core-routes/src/mcp/toolHandlers/endRenameTask.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { defaultChatFs } from "../../chatFs.ts"; -import { - createErrorResponse, - createSuccessResponse, - type McpToolResponse, -} from "../index.ts"; -import type { McpConfig } from "../types.ts"; -import { - buildEndRenameFilesTaskTool, - type RenameFilesTaskDeps, -} from "../../tools/renameFilesTask.ts"; -import { END_RENAME_FILES_TASK } from "@smm/core/types/ai-tools/renameFilesTask"; -import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/core/types/ai-tools/planTaskMessages"; - -/** - * Register the `end-rename-task` MCP tool. Finalises a rename task - * and broadcasts a Socket.IO `RenameFilesPlanReady` event so the UI - * can pick up the plan. - */ -export function registerEndRenameTaskTool( - server: McpServer, - config: McpConfig, - deps: RenameFilesTaskDeps, -): void { - const fs = config.fs ?? defaultChatFs(); - - const agentTool = buildEndRenameFilesTaskTool( - "mcp", - config.appDataDir, - fs, - config.broadcast, - config.logger, - undefined, - ); - - const inputSchema = z.object({ - taskId: z - .string() - .describe("The task ID returned from begin-rename-task"), - }); - - server.registerTool( - END_RENAME_FILES_TASK, - { - description: agentTool.description, - inputSchema, - }, - async (args: unknown): Promise => { - const { taskId } = (args ?? {}) as { taskId?: string }; - if (typeof taskId !== "string" || taskId.trim() === "") { - return createErrorResponse( - "Invalid taskId: 'taskId' must be a non-empty string", - ); - } - - try { - const result = await agentTool.execute({ taskId }); - - if (typeof result === "object" && result !== null && "error" in result) { - const errorResult = result as { error?: string }; - if (errorResult.error) { - return createSuccessResponse({ - success: false, - error: errorResult.error, - }); - } - } - - return createSuccessResponse({ - success: true, - taskId, - message: END_PLAN_TASK_SUCCESS_MESSAGE, - }); - } catch (error) { - return createErrorResponse( - `Error ending rename task: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }, - ); -} diff --git a/packages/core-routes/src/mcp/toolHandlers/staticText.ts b/packages/core-routes/src/mcp/toolHandlers/staticText.ts index eb170509..f2adbfe5 100644 --- a/packages/core-routes/src/mcp/toolHandlers/staticText.ts +++ b/packages/core-routes/src/mcp/toolHandlers/staticText.ts @@ -66,9 +66,7 @@ AI助手应该参考一下步骤: 2. 使用 "get-media-metadata" 工具获取媒体目录的媒体元数据, 主要关注电视剧的季集信息 3. 使用 "get-episodes" 工具获取需要季集视频文件 4. 思考重命名命名方案 -5. 使用 "begin-rename-episode-video-file-task" 工具开始重命名任务 -6. 使用 "add-rename-episode-video-file-to-task" 工具添加需要重命名的文件 -7. 使用 "end-rename-episode-video-file-task" 工具结束重命名任务 +5. 使用 "create-rename-episode-plan" 工具一次提交全部需要重命名的文件 ## 文件命名规则 diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.ts new file mode 100644 index 00000000..d7d254bd --- /dev/null +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.ts @@ -0,0 +1,127 @@ +import { createRenameEpisodePlanPipeline } from "core-app/createRenameEpisodePlan"; +import type { FsPort } from "core-app/FsPort"; +import { Path } from "@smm/core/path"; +import { + CREATE_RENAME_EPISODE_PLAN, + CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + createRenameEpisodePlanInputSchema, +} from "@smm/core/types/ai-tools/createRenameEpisodePlan"; +import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/core/types/ai-tools/planTaskMessages"; +import { + RenameFilesPlanReady, + type RenameFilesPlanReadyRequestData, +} from "@smm/core/event-types"; +import { formatToolError, toolOk } from "@smm/core/ai-tool/toolResult"; +import type { ChatFs } from "../chatTypes.ts"; +import type { CoreRoutesLogger } from "../types.ts"; +import type { WebSocketMessage } from "../socketIO/types.ts"; + +function unsupportedFsOperation(name: string): never { + throw new Error(`${name} is not supported by the rename-plan filesystem adapter`); +} + +function createFsPort(fs: ChatFs): FsPort { + return { + async readTextFile(path: string): Promise { + const value = await fs.readJson(path); + if (value === null) { + throw new Error(`File not found: ${path}`); + } + return JSON.stringify(value); + }, + async writeTextFile(path: string, content: string): Promise { + await fs.writeJson(path, JSON.parse(content) as unknown); + }, + async writeBinaryFile(): Promise { + unsupportedFsOperation("writeBinaryFile"); + }, + exists: (path: string) => fs.exists(path), + isFile: (path: string) => fs.exists(path), + async listFiles(): Promise { + return unsupportedFsOperation("listFiles"); + }, + async listSubdirectories(): Promise { + return unsupportedFsOperation("listSubdirectories"); + }, + async deleteFile(): Promise { + unsupportedFsOperation("deleteFile"); + }, + async rename(): Promise { + unsupportedFsOperation("rename"); + }, + async mkdir(): Promise { + unsupportedFsOperation("mkdir"); + }, + }; +} + +function metadataPath(appDataDir: string, mediaFolderPath: string): string { + const filename = Path.posix(mediaFolderPath).replace(/[/\\:?*|<>"]/g, "_"); + return new Path(appDataDir, `metadata/${filename}.json`).abs("posix"); +} + +function planPath(appDataDir: string, planId: string): string { + return new Path(appDataDir, `plans/${planId}.plan.json`).abs("posix"); +} + +export function buildCreateRenameEpisodePlanTool( + appDataDir: string, + fs: ChatFs, + broadcast?: (message: WebSocketMessage) => void, + logger?: CoreRoutesLogger, + abortSignal?: AbortSignal, +) { + return { + description: CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + inputSchema: createRenameEpisodePlanInputSchema, + execute: async (args: unknown) => { + if (abortSignal?.aborted) { + throw new Error("Request was aborted"); + } + + const parsed = createRenameEpisodePlanInputSchema.safeParse(args); + if (!parsed.success) { + return formatToolError(parsed.error); + } + + try { + const plan = await createRenameEpisodePlanPipeline( + parsed.data.mediaFolderPath, + parsed.data.files, + { creator: "ai" }, + { + fs: createFsPort(fs), + appDataDir, + normalizePosix: Path.posix, + getMediaMetadata: (folder) => + fs.readJson(metadataPath(appDataDir, folder)), + }, + ); + + const data: RenameFilesPlanReadyRequestData = { + taskId: plan.id, + planFilePath: planPath(appDataDir, plan.id), + }; + broadcast?.({ event: RenameFilesPlanReady.event, data }); + logger?.info( + { + planId: plan.id, + folderPath: plan.mediaFolderPath, + fileCount: plan.files.length, + }, + `[tool][${CREATE_RENAME_EPISODE_PLAN}] Plan created`, + ); + + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + planId: plan.id, + }); + } catch (error) { + return formatToolError(error); + } + }, + }; +} + +export const CREATE_RENAME_EPISODE_PLAN_TOOL_NAME = + CREATE_RENAME_EPISODE_PLAN; diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index f82d107e..a15d013f 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -31,10 +31,8 @@ import { type TvdbToolRunners, } from "./tvdb.ts"; import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, -} from "@smm/core/types/ai-tools/renameFilesTask"; + CREATE_RENAME_EPISODE_PLAN, +} from "@smm/core/types/ai-tools/createRenameEpisodePlan"; import { BEGIN_RECOGNIZE_TASK, ADD_RECOGNIZED_MEDIA_FILE, @@ -64,13 +62,7 @@ import { buildGetJobTool, type GetJobRunner, } from "./getJob.ts"; -import { - buildAddRenameFileToTaskTool, - buildBeginRenameFilesTaskTool, - buildEndRenameFilesTaskTool, - type RenameFilesTaskDeps, -} from "./renameFilesTask.ts"; -import { defaultRenameFilesTaskDeps } from "./renameFilesTaskDefaults.ts"; +import { buildCreateRenameEpisodePlanTool } from "./createRenameEpisodePlan.ts"; import { buildAddRecognizedMediaFileTool, buildBeginRecognizeTaskTool, @@ -101,9 +93,9 @@ export interface ChatTools { [TVDB_GET_MOVIE]: ReturnType; [TVDB_GET_TV_SHOW]: ReturnType; [TVDB_GET_LANGUAGES]: ReturnType; - [BEGIN_RENAME_FILES_TASK]: ReturnType; - [ADD_RENAME_FILE_TO_TASK]: ReturnType; - [END_RENAME_FILES_TASK]: ReturnType; + [CREATE_RENAME_EPISODE_PLAN]: ReturnType< + typeof buildCreateRenameEpisodePlanTool + >; [BEGIN_RECOGNIZE_TASK]: ReturnType; [ADD_RECOGNIZED_MEDIA_FILE]: ReturnType; [END_RECOGNIZE_TASK]: ReturnType; @@ -112,11 +104,9 @@ export interface ChatTools { /** * Extra dependencies the host (cli / ohos) injects so the chat * tools can run inside core-routes. Optional override for rename - * validation/metadata lookup; defaults to - * {@link defaultRenameFilesTaskDeps} when omitted. + * host-specific Core runners. */ export interface ChatToolsExtraDeps { - renameFilesTask?: RenameFilesTaskDeps; /** Host Core runner for single-episode rename (Bun cli / Electron). */ renameEpisodeFile?: RenameEpisodeFileRunner; /** Host Core runner for scrape job start. */ @@ -171,11 +161,6 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { logger, }; - // Same deps as the MCP rename tools: metadata cache read + - // bundled rename validation. Hosts can override via `extra`. - const renameFilesTaskDeps = - extra?.renameFilesTask ?? defaultRenameFilesTaskDeps(config.appDataDir); - const tmdbRunners: TmdbToolRunners | undefined = extra?.tmdb; const tvdbRunners: TvdbToolRunners | undefined = extra?.tvdb; @@ -223,25 +208,7 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { [TVDB_GET_MOVIE]: buildTvdbGetMovieTool(tvdbRunners, abortSignal), [TVDB_GET_TV_SHOW]: buildTvdbGetTvShowTool(tvdbRunners, abortSignal), [TVDB_GET_LANGUAGES]: buildTvdbGetLanguagesTool(tvdbRunners, abortSignal), - [BEGIN_RENAME_FILES_TASK]: buildBeginRenameFilesTaskTool( - clientId, - config.appDataDir, - fs, - renameFilesTaskDeps, - broadcast, - logger, - abortSignal, - ), - [ADD_RENAME_FILE_TO_TASK]: buildAddRenameFileToTaskTool( - clientId, - config.appDataDir, - fs, - renameFilesTaskDeps, - logger, - abortSignal, - ), - [END_RENAME_FILES_TASK]: buildEndRenameFilesTaskTool( - clientId, + [CREATE_RENAME_EPISODE_PLAN]: buildCreateRenameEpisodePlanTool( config.appDataDir, fs, broadcast, diff --git a/packages/core-routes/src/tools/renameFilesTask.ts b/packages/core-routes/src/tools/renameFilesTask.ts deleted file mode 100644 index e01f24f6..00000000 --- a/packages/core-routes/src/tools/renameFilesTask.ts +++ /dev/null @@ -1,290 +0,0 @@ -import path from "node:path"; -import { Path } from "@smm/core/path"; -import { - BEGIN_RENAME_FILES_TASK, - ADD_RENAME_FILE_TO_TASK, - END_RENAME_FILES_TASK, - BEGIN_RENAME_FILES_TASK_DESCRIPTION, - ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - END_RENAME_FILES_TASK_DESCRIPTION, - beginRenameFilesTaskInputSchema, - addRenameFileToTaskInputSchema, - endRenameFilesTaskInputSchema, -} from "@smm/core/types/ai-tools/renameFilesTask"; -import { assertMediaFolderHasMetadata } from "@smm/core/plan/renamePlan"; -import { END_PLAN_TASK_SUCCESS_MESSAGE, PLAN_CANCELLED_BY_USER_MESSAGE } from "@smm/core/types/ai-tools/planTaskMessages"; -import { formatToolError, toolError, toolOk } from "@smm/core/ai-tool/toolResult"; -import { - RenameFilesPlanReady, - type RenameFilesPlanReadyRequestData, -} from "@smm/core/event-types"; -import type { CoreRoutesLogger } from "../types.ts"; -import { metadataCacheFilePath } from "../mediaMetadataCache.ts"; -import { defaultBroadcast } from "./broadcast.ts"; -import type { WebSocketMessage } from "../socketIO/types.ts"; -import { - appendRenamePlanEntry, - beginRenamePlan, - planFilePath, - readRenamePlan, - updatePlanContent, - type RenamePlanAppendDeps, -} from "./plans.ts"; -import type { ChatFs } from "../chatTypes.ts"; - -/** - * Dependencies the `rename-files-task` tools need in addition to the - * chat config. Injected by the host (cli / ohos) because the - * underlying validation + filesystem checks are not in - * `core-routes`. The cli wires its existing - * `validateRenameOperations` to `validateOperations` and its - * `findMediaMetadata` to `getMediaMetadata`. - */ -export interface RenameFilesTaskDeps { - validateOperations: RenamePlanAppendDeps["validateOperations"]; - getMediaMetadata: RenamePlanAppendDeps["getMediaMetadata"]; -} - -function makeLogger(logger: CoreRoutesLogger | undefined) { - return { - info: (obj: Record, msg?: string) => - logger?.info(obj, msg), - warn: (obj: Record, msg?: string) => - logger?.warn(obj, msg), - error: (obj: Record, msg?: string) => - logger?.error(obj, msg), - }; -} - -export function buildBeginRenameFilesTaskTool( - clientId: string, - appDataDir: string, - fs: ChatFs, - _deps: RenameFilesTaskDeps, - broadcast: ((message: WebSocketMessage) => void) | undefined, - logger: CoreRoutesLogger | undefined, - abortSignal?: AbortSignal, -) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: BEGIN_RENAME_FILES_TASK_DESCRIPTION, - toolName: BEGIN_RENAME_FILES_TASK, - inputSchema: beginRenameFilesTaskInputSchema, - execute: async (args: unknown) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { mediaFolderPath } = (args ?? {}) as { mediaFolderPath?: string }; - log.info( - { mediaFolderPath, clientId }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Starting new rename task`, - ); - - const folderPathInPosix = Path.posix(mediaFolderPath ?? ""); - const metadataFilePath = metadataCacheFilePath(appDataDir, folderPathInPosix); - const metadataExists = await fs.exists(metadataFilePath); - - const metadataError = assertMediaFolderHasMetadata( - metadataExists, - folderPathInPosix, - ); - if (metadataError) { - log.warn( - { folderPath: folderPathInPosix }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Media metadata not found`, - ); - return toolError(metadataError.replace(/^Error Reason: /, "")); - } - - try { - const taskId = await beginRenamePlan(appDataDir, folderPathInPosix, fs); - log.info( - { taskId, mediaFolderPath: folderPathInPosix, clientId }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Task created successfully`, - ); - // Notify the UI so it can re-fetch active plans and show the - // "generating" prompt while the plan is still preparing. - const fullPlanPath = planFilePath(appDataDir, taskId); - const planFilePathInPosix = Path.posix(fullPlanPath); - const data: RenameFilesPlanReadyRequestData = { - taskId, - planFilePath: planFilePathInPosix, - }; - emit({ - event: RenameFilesPlanReady.event, - data, - }); - log.info( - { taskId, mediaFolderPath: folderPathInPosix, clientId, broadcast: true }, - `[tool][${BEGIN_RENAME_FILES_TASK}] RenameFilesPlanReady broadcast sent`, - ); - return toolOk({ taskId }); - } catch (error) { - log.error( - { - mediaFolderPath: folderPathInPosix, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${BEGIN_RENAME_FILES_TASK}] Failed to create task`, - ); - return formatToolError(error); - } - }, - }; -} - -export function buildAddRenameFileToTaskTool( - clientId: string, - appDataDir: string, - fs: ChatFs, - deps: RenameFilesTaskDeps, - logger: CoreRoutesLogger | undefined, - abortSignal?: AbortSignal, -) { - const log = makeLogger(logger); - return { - description: ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - toolName: ADD_RENAME_FILE_TO_TASK, - inputSchema: addRenameFileToTaskInputSchema, - execute: async (args: unknown) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId, from, to } = (args ?? {}) as { - taskId?: string; - from?: string; - to?: string; - }; - const normalizedTaskId = (taskId ?? "").trim(); - log.info( - { taskId: normalizedTaskId, from, to, clientId }, - `[tool][${ADD_RENAME_FILE_TO_TASK}] Adding file to task`, - ); - - try { - await appendRenamePlanEntry( - appDataDir, - normalizedTaskId, - from ?? "", - to ?? "", - fs, - deps, - ); - log.info( - { taskId: normalizedTaskId, from, to, clientId }, - `[tool][${ADD_RENAME_FILE_TO_TASK}] File added successfully`, - ); - return toolOk({}); - } catch (error) { - log.error( - { - taskId: normalizedTaskId, - from, - to, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${ADD_RENAME_FILE_TO_TASK}] Failed to add file`, - ); - return formatToolError(error); - } - }, - }; -} - -export function buildEndRenameFilesTaskTool( - clientId: string, - appDataDir: string, - fs: ChatFs, - broadcast: ((message: WebSocketMessage) => void) | undefined, - logger: CoreRoutesLogger | undefined, - abortSignal?: AbortSignal, -) { - const log = makeLogger(logger); - const emit = broadcast ?? defaultBroadcast; - return { - description: END_RENAME_FILES_TASK_DESCRIPTION, - toolName: END_RENAME_FILES_TASK, - inputSchema: endRenameFilesTaskInputSchema, - execute: async (args: unknown) => { - if (abortSignal?.aborted) { - throw new Error("Request was aborted"); - } - const { taskId } = (args ?? {}) as { taskId?: string }; - const normalizedTaskId = (taskId ?? "").trim(); - log.info( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Ending rename task`, - ); - - try { - const task = await readRenamePlan(appDataDir, normalizedTaskId, fs); - - if (!task) { - log.error( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Task not found`, - ); - return toolError(`Task with id "${normalizedTaskId}" not found`); - } - - if (task.status === "rejected") { - log.warn( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Task cancelled by user`, - ); - return toolError(PLAN_CANCELLED_BY_USER_MESSAGE); - } - - if (task.files.length === 0) { - log.warn( - { taskId: normalizedTaskId, clientId }, - `[tool][${END_RENAME_FILES_TASK}] No files in task`, - ); - return toolError("No rename entries in task"); - } - - // Flip preparing → pending so the plan becomes visible to the UI. - await updatePlanContent(appDataDir, task.id, { status: "pending" }, fs); - - const fullPlanPath = planFilePath(appDataDir, task.id); - const planFilePathInPosix = Path.posix(fullPlanPath); - - const data: RenameFilesPlanReadyRequestData = { - taskId: task.id, - planFilePath: planFilePathInPosix, - }; - - // Fire-and-forget notify — UI listens via broadcast, not ack. - emit({ - event: RenameFilesPlanReady.event, - data, - }); - - log.info( - { taskId: normalizedTaskId, fileCount: task.files.length, clientId }, - `[tool][${END_RENAME_FILES_TASK}] Plan ready, UI notified`, - ); - - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }); - } catch (error) { - log.error( - { - taskId: normalizedTaskId, - error: error instanceof Error ? error.message : String(error), - clientId, - }, - `[tool][${END_RENAME_FILES_TASK}] End task error`, - ); - return formatToolError(error); - } - }, - }; -} - -/** Re-exported tool name constants for the tools registry. */ -export const BEGIN_RENAME_FILES_TASK_TOOL_NAME = BEGIN_RENAME_FILES_TASK; -export const ADD_RENAME_FILE_TO_TASK_TOOL_NAME = ADD_RENAME_FILE_TO_TASK; -export const END_RENAME_FILES_TASK_TOOL_NAME = END_RENAME_FILES_TASK; diff --git a/packages/core-routes/src/tools/renameFilesTaskDefaults.ts b/packages/core-routes/src/tools/renameFilesTaskDefaults.ts deleted file mode 100644 index 67622171..00000000 --- a/packages/core-routes/src/tools/renameFilesTaskDefaults.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readMediaMetadataCache } from "../mediaMetadataCache.ts"; -import { validateRenameOperations } from "../renameFilesValidation.ts"; -import type { RenameFilesTaskDeps } from "./renameFilesTask.ts"; - -/** - * Default {@link RenameFilesTaskDeps} implementation backed by - * core-routes' runtime-neutral metadata cache and the bundled - * {@link validateRenameOperations} validator. Hosts (Bun cli or - * Node OHOS) can use this directly or supply their own. - * - * - `validateOperations` — runs the same checks as the original - * `apps/cli/src/tools/renameFilesInBatch.ts#validateRenameOperations`. - * - `getMediaMetadata` — reads `{appDataDir}/metadata/*.json` via - * `node:fs/promises`, so it works on both Bun and Node. - */ -export function defaultRenameFilesTaskDeps( - appDataDir: string, -): RenameFilesTaskDeps { - return { - validateOperations: async (files, folderPathInPosix) => { - return validateRenameOperations(files, folderPathInPosix); - }, - getMediaMetadata: async (folderPathInPosix: string) => { - return (await readMediaMetadataCache(appDataDir, folderPathInPosix)) ?? - null; - }, - }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91a284d2..5cd09231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,9 @@ importers: apps/core: dependencies: + '@smm/core': + specifier: workspace:* + version: link:../../packages/core '@smm/tvdb4': specifier: workspace:* version: link:../../packages/tvdb4 @@ -697,6 +700,9 @@ importers: ai: specifier: ^6.0.36 version: 6.0.98(zod@4.3.6) + core-app: + specifier: workspace:* + version: link:../../apps/core http-proxy-agent: specifier: ^7.0.2 version: 7.0.2 From 743ad141122463e713e464b262cc7a381f4b9757 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:28:03 +0800 Subject: [PATCH 182/214] fix(core-routes): always broadcast RenameFilesPlanReady from create-rename-episode-plan Restore defaultBroadcast fallback so plan-ready events are never skipped when the host omits config.broadcast; add regression test for the default path. Co-authored-by: Cursor --- .superpowers/sdd/task-6-report.md | 58 +++------- .../src/tools/createRenameEpisodePlan.test.ts | 100 ++++++++++++++++++ .../src/tools/createRenameEpisodePlan.ts | 4 +- 3 files changed, 116 insertions(+), 46 deletions(-) create mode 100644 packages/core-routes/src/tools/createRenameEpisodePlan.test.ts diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md index dfa388c1..69f8fedb 100644 --- a/.superpowers/sdd/task-6-report.md +++ b/.superpowers/sdd/task-6-report.md @@ -39,54 +39,22 @@ the single tool. None. The pre-existing dirty E2E and documentation files were not included in this task. -# Task 6 Report: AI tool type definitions + execution for TVDB -## What I implemented +## Review fix: defaultBroadcast fallback -Created 7 new files mirroring the existing TMDB analog: +**Issue:** `buildCreateRenameEpisodePlanTool` used `broadcast?.(...)`, so when the +host omitted optional `config.broadcast`, the required `RenameFilesPlanReady` +event was silently skipped. -1. **`packages/core/types/ai-tools/tvdbCommon.ts`** — `tvdbLanguageSchema`, `tvdbBaseUrlSchema`, `TvdbToolHostOptions`, `toTvdbCoreOptions`, `formatTvdbToolError`. -2. **`packages/core/types/ai-tools/tvdbSearch.ts`** — `TVDB_SEARCH`, `TVDB_SEARCH_DESCRIPTION`, `tvdbSearchInputSchema`/`tvdbSearchOutputSchema`, `TvdbSearchInput`/`TvdbSearchOutput`. -3. **`packages/core/types/ai-tools/tvdbGetMovie.ts`** — `TVDB_GET_MOVIE`, description, input/output schemas + types. -4. **`packages/core/types/ai-tools/tvdbGetTvShow.ts`** — `TVDB_GET_TV_SHOW`, description, input/output schemas + types. -5. **`packages/core/types/ai-tools/tvdbGetLanguages.ts`** — `TVDB_GET_LANGUAGES`, description, input/output schemas + types. -6. **`packages/core-routes/src/tools/tvdb.ts`** — runner types (`SearchInTvdbRunner`, `GetTvShowInTvdbRunner`, `GetMovieInTvdbRunner`, `GetTvdbLanguagesRunner`), `TvdbToolRunners`, `executeTvdbSearch`, `executeTvdbGetMovie`, `executeTvdbGetTvShow`, `executeTvdbGetLanguages`, `buildTvdbSearchTool`, `buildTvdbGetMovieTool`, `buildTvdbGetTvShowTool`, `buildTvdbGetLanguagesTool`. -7. **`packages/core-routes/src/tools/tvdb.test.ts`** — the test suite from the brief (8 tests). +**Fix:** Restored the sibling-tool pattern (`const emit = broadcast ?? +defaultBroadcast`) from `endRenameTask` / `endRecognizeTask`. Plan-ready events +now always fire via injected callback or the no-op default. -All code was written verbatim from the task brief. The implementation uses loose `Record` runner types and does NOT import from `@smm/tvdb4`, satisfying the critical constraint. +**Test:** Added `src/tools/createRenameEpisodePlan.test.ts` covering the +no-injected-callback path (spies on `defaultBroadcast`) and injected-broadcast +preference. -## What I tested and results +**Verification:** `pnpm exec vitest run src/tools/createRenameEpisodePlan.test.ts +src/mcp/createRenameEpisodePlan.test.ts` — 2 files, 5 tests passed. -### RED (Step 2) -Before implementation, ran `pnpm vitest run src/tools/tvdb.test.ts` (from `packages/core-routes`): -- Result: **FAIL** — `Error: Cannot find module './tvdb.ts'` (module not found). - -### GREEN (Step 5) -After creating the 5 type files and `tvdb.ts`: -- `pnpm vitest run src/tools/tvdb.test.ts` → **1 file passed, 8 tests passed**. -- `pnpm vitest run src/tools/tmdb.test.ts` → **1 file passed, 7 tests passed** (TMDB analog unaffected). - -### Typecheck -- `pnpm run typecheck` in `packages/core-routes`: only **pre-existing** errors remain in `src/tools/tmdb.ts` (TS2352 casts, present before this task). No errors reference any of my new files. -- `pnpm run typecheck` in `packages/core`: no errors referencing any `tvdb*` file. - -## Files changed - -- `packages/core/types/ai-tools/tvdbCommon.ts` (new) -- `packages/core/types/ai-tools/tvdbSearch.ts` (new) -- `packages/core/types/ai-tools/tvdbGetMovie.ts` (new) -- `packages/core/types/ai-tools/tvdbGetTvShow.ts` (new) -- `packages/core/types/ai-tools/tvdbGetLanguages.ts` (new) -- `packages/core-routes/src/tools/tvdb.ts` (new) -- `packages/core-routes/src/tools/tvdb.test.ts` (new) - -## Self-review findings - -- Verified all files match the brief's code exactly (imports, schemas, runner types, builders). -- Verified the runner-call argument shapes against the test expectations (e.g. `toTvdbCoreOptions` produces `{ language, host }`, `host` undefined when `baseURL` absent, `type` passed through for search). -- Confirmed no `@smm/tvdb4` import anywhere in the new code. -- Scope kept to the 7 files listed in the brief — `packages/core-routes/src/tools/index.ts` integration is not part of this task (presumably a later task wires TVDB runners/tools into `ChatTools`/`ChatToolsExtraDeps`). - -## Issues / concerns - -- None blocking. Note: `pnpm vitest` is not resolvable from the repo root (vitest not hoisted there); the task's documented command from `packages/core-routes` (`pnpm vitest run src/tools/tvdb.test.ts`) works, and `pnpm --filter @smm/core-routes exec vitest run ...` works from the root as an alternative. +**Commit:** `fix(core-routes): always broadcast RenameFilesPlanReady from create-rename-episode-plan` diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts new file mode 100644 index 00000000..b7d2d5ab --- /dev/null +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { CREATE_RENAME_EPISODE_PLAN } from "@smm/core/types/ai-tools/createRenameEpisodePlan"; +import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@smm/core/types/ai-tools/planTaskMessages"; +import { RenameFilesPlanReady } from "@smm/core/event-types"; +import type { ChatFs } from "../chatTypes.ts"; +import * as broadcastModule from "./broadcast.ts"; +import { buildCreateRenameEpisodePlanTool } from "./createRenameEpisodePlan.ts"; + +function createMockFs(folder: string): ChatFs { + const metadata = { + mediaFolderPath: folder, + type: "tvshow-folder", + mediaFiles: [ + { + absolutePath: `${folder}/S01E01.mkv`, + seasonNumber: 1, + episodeNumber: 1, + }, + ], + }; + const values = new Map(); + return { + async readJson(path: string): Promise { + return ( + path.includes("/metadata/") ? metadata : (values.get(path) ?? null) + ) as T | null; + }, + writeJson: vi.fn(async (path: string, value: unknown) => { + values.set(path, value); + }), + exists: vi.fn(async (path: string) => path === `${folder}/S01E01.mkv`), + }; +} + +describe(`buildCreateRenameEpisodePlanTool (${CREATE_RENAME_EPISODE_PLAN})`, () => { + it("uses defaultBroadcast when no callback is injected", async () => { + const folder = "/media/show"; + const emitSpy = vi.spyOn(broadcastModule, "defaultBroadcast"); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + ); + + const result = await tool.execute({ + mediaFolderPath: folder, + files: [ + { + from: `${folder}/S01E01.mkv`, + to: `${folder}/Show - S01E01.mkv`, + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); + expect(result.planId).toEqual(expect.any(String)); + expect(emitSpy).toHaveBeenCalledWith({ + event: RenameFilesPlanReady.event, + data: { + taskId: result.planId, + planFilePath: `/app-data/plans/${result.planId}.plan.json`, + }, + }); + + emitSpy.mockRestore(); + }); + + it("prefers injected broadcast over defaultBroadcast", async () => { + const folder = "/media/show"; + const broadcast = vi.fn(); + const emitSpy = vi.spyOn(broadcastModule, "defaultBroadcast"); + const tool = buildCreateRenameEpisodePlanTool( + "/app-data", + createMockFs(folder), + broadcast, + ); + + const result = await tool.execute({ + mediaFolderPath: folder, + files: [ + { + from: `${folder}/S01E01.mkv`, + to: `${folder}/Show - S01E01.mkv`, + }, + ], + }); + + expect(result.planId).toEqual(expect.any(String)); + expect(broadcast).toHaveBeenCalledWith({ + event: RenameFilesPlanReady.event, + data: { + taskId: result.planId, + planFilePath: `/app-data/plans/${result.planId}.plan.json`, + }, + }); + expect(emitSpy).not.toHaveBeenCalled(); + + emitSpy.mockRestore(); + }); +}); diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.ts index d7d254bd..a4a5096d 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.ts @@ -15,6 +15,7 @@ import { formatToolError, toolOk } from "@smm/core/ai-tool/toolResult"; import type { ChatFs } from "../chatTypes.ts"; import type { CoreRoutesLogger } from "../types.ts"; import type { WebSocketMessage } from "../socketIO/types.ts"; +import { defaultBroadcast } from "./broadcast.ts"; function unsupportedFsOperation(name: string): never { throw new Error(`${name} is not supported by the rename-plan filesystem adapter`); @@ -71,6 +72,7 @@ export function buildCreateRenameEpisodePlanTool( logger?: CoreRoutesLogger, abortSignal?: AbortSignal, ) { + const emit = broadcast ?? defaultBroadcast; return { description: CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, inputSchema: createRenameEpisodePlanInputSchema, @@ -102,7 +104,7 @@ export function buildCreateRenameEpisodePlanTool( taskId: plan.id, planFilePath: planPath(appDataDir, plan.id), }; - broadcast?.({ event: RenameFilesPlanReady.event, data }); + emit({ event: RenameFilesPlanReady.event, data }); logger?.info( { planId: plan.id, From f20a24dfe74d2d55645608c5da2a6af3a62b74d6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:34:59 +0800 Subject: [PATCH 183/214] feat(ui): single CreateRenameEpisodePlan AI tool Co-authored-by: Cursor --- apps/ui/src/ai/Assistant.registry.test.ts | 4 +- apps/ui/src/ai/Assistant.tsx | 8 +-- apps/ui/src/ai/plan/cleanupRenamePlan.ts | 5 ++ apps/ui/src/ai/tools/AddRenameFileToTask.tsx | 45 -------------- apps/ui/src/ai/tools/BeginRenameFilesTask.tsx | 49 --------------- .../src/ai/tools/CreateRenameEpisodePlan.tsx | 41 +++++++++++++ apps/ui/src/ai/tools/EndRenameFilesTask.tsx | 61 ------------------- apps/ui/src/ai/tools/index.ts | 4 +- apps/ui/src/api/createRenameEpisodePlan.ts | 32 ++++++++++ .../src/hooks/tv/useAiBasedRenameFilesFlow.ts | 2 +- 10 files changed, 83 insertions(+), 168 deletions(-) create mode 100644 apps/ui/src/ai/plan/cleanupRenamePlan.ts delete mode 100644 apps/ui/src/ai/tools/AddRenameFileToTask.tsx delete mode 100644 apps/ui/src/ai/tools/BeginRenameFilesTask.tsx create mode 100644 apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx delete mode 100644 apps/ui/src/ai/tools/EndRenameFilesTask.tsx create mode 100644 apps/ui/src/api/createRenameEpisodePlan.ts diff --git a/apps/ui/src/ai/Assistant.registry.test.ts b/apps/ui/src/ai/Assistant.registry.test.ts index 137ea0d7..ece77d2b 100644 --- a/apps/ui/src/ai/Assistant.registry.test.ts +++ b/apps/ui/src/ai/Assistant.registry.test.ts @@ -29,9 +29,7 @@ import { AI_TOOL_REGISTRY } from '@core/ai-tool/registry' const ASSISTANT_PATH = join(__dirname, 'Assistant.tsx') const FRONTEND_TRANSPORT_ONLY_TASK_COMPONENTS = new Set([ - 'BeginRenameFilesTaskTool', - 'AddRenameFileToTaskTool', - 'EndRenameFilesTaskTool', + 'CreateRenameEpisodePlanTool', 'BeginRecognizeTaskTool', 'AddRecognizedMediaFileTool', 'EndRecognizeTaskTool', diff --git a/apps/ui/src/ai/Assistant.tsx b/apps/ui/src/ai/Assistant.tsx index 4c41c3aa..ef51617f 100644 --- a/apps/ui/src/ai/Assistant.tsx +++ b/apps/ui/src/ai/Assistant.tsx @@ -28,9 +28,7 @@ import { BeginRecognizeTaskTool, AddRecognizedMediaFileTool, EndRecognizeTaskTool, - BeginRenameFilesTaskTool, - AddRenameFileToTaskTool, - EndRenameFilesTaskTool, + CreateRenameEpisodePlanTool, } from "./tools"; import { AIBasedConfirmationBridge } from "./AIBasedConfirmationBridge"; import { useUIMediaFolderStore } from "@/stores/uiMediaFolderStore"; @@ -335,9 +333,7 @@ function AssistantImpl() { - - - + )} diff --git a/apps/ui/src/ai/plan/cleanupRenamePlan.ts b/apps/ui/src/ai/plan/cleanupRenamePlan.ts new file mode 100644 index 00000000..5206a069 --- /dev/null +++ b/apps/ui/src/ai/plan/cleanupRenamePlan.ts @@ -0,0 +1,5 @@ +import { deletePlanDraft } from './aiPlanDrafts' + +export async function cleanupRenamePlan(planId: string): Promise { + deletePlanDraft(planId) +} diff --git a/apps/ui/src/ai/tools/AddRenameFileToTask.tsx b/apps/ui/src/ai/tools/AddRenameFileToTask.tsx deleted file mode 100644 index 88aad1b7..00000000 --- a/apps/ui/src/ai/tools/AddRenameFileToTask.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { makeAssistantTool, tool } from "@assistant-ui/react" -import { - ADD_RENAME_FILE_TO_TASK, - ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - addRenameFileToTaskInputSchema, -} from "@core/types/ai-tools/renameFilesTask" -import { formatToolError, requireNonEmptyString, toolOk } from "@core/ai-tool/toolResult" -import { appendRenameEntryWithValidation } from "../plan/renamePlanService" - -const addRenameFileToTask = tool({ - description: ADD_RENAME_FILE_TO_TASK_DESCRIPTION, - parameters: addRenameFileToTaskInputSchema, - execute: async ({ taskId, from, to }) => { - const taskIdCheck = requireNonEmptyString(taskId, "taskId") - if (typeof taskIdCheck !== "string") { - return taskIdCheck - } - const fromCheck = requireNonEmptyString(from, "from") - if (typeof fromCheck !== "string") { - return fromCheck - } - const toCheck = requireNonEmptyString(to, "to") - if (typeof toCheck !== "string") { - return toCheck - } - - try { - const result = await appendRenameEntryWithValidation(taskIdCheck, { - from: fromCheck, - to: toCheck, - }) - if ("error" in result) { - return { error: result.error } - } - return toolOk({}) - } catch (error) { - return formatToolError(error) - } - }, -}) - -export const AddRenameFileToTaskTool = makeAssistantTool({ - ...addRenameFileToTask, - toolName: ADD_RENAME_FILE_TO_TASK, -}) diff --git a/apps/ui/src/ai/tools/BeginRenameFilesTask.tsx b/apps/ui/src/ai/tools/BeginRenameFilesTask.tsx deleted file mode 100644 index 9e14174d..00000000 --- a/apps/ui/src/ai/tools/BeginRenameFilesTask.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { makeAssistantTool, tool } from "@assistant-ui/react" -import { - BEGIN_RENAME_FILES_TASK, - BEGIN_RENAME_FILES_TASK_DESCRIPTION, - beginRenameFilesTaskInputSchema, -} from "@core/types/ai-tools/renameFilesTask" -import { formatToolError, requireNonEmptyString, toolOk } from "@core/ai-tool/toolResult" -import { createPlan } from "@/api/createPlan" -import { PLANS_QUERY_ROOT } from "@/hooks/plans" -import { queryClient } from "@/lib/queryClient" -import { assertRenameMediaFolderOpened } from "../plan/renamePlanService" -import { setPlanDraft } from "../plan/aiPlanDrafts" - -const beginRenameFilesTask = tool({ - description: BEGIN_RENAME_FILES_TASK_DESCRIPTION, - parameters: beginRenameFilesTaskInputSchema, - execute: async ({ mediaFolderPath }) => { - const pathCheck = requireNonEmptyString(mediaFolderPath, "mediaFolderPath") - if (typeof pathCheck !== "string") { - return { taskId: undefined, ...pathCheck } - } - - const metadataError = await assertRenameMediaFolderOpened(pathCheck) - if (metadataError) { - return { taskId: undefined, error: metadataError } - } - - try { - const resp = await createPlan({ - task: "rename-files", - mediaFolderPath: pathCheck, - creator: "ai", - }) - if (resp.error || !resp.data) { - return { taskId: undefined, error: resp.error ?? "createPlan failed" } - } - setPlanDraft(resp.data.plan) - void queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) - return toolOk({ taskId: resp.data.plan.id }) - } catch (error) { - return { taskId: undefined, ...formatToolError(error) } - } - }, -}) - -export const BeginRenameFilesTaskTool = makeAssistantTool({ - ...beginRenameFilesTask, - toolName: BEGIN_RENAME_FILES_TASK, -}) diff --git a/apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx b/apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx new file mode 100644 index 00000000..47492c90 --- /dev/null +++ b/apps/ui/src/ai/tools/CreateRenameEpisodePlan.tsx @@ -0,0 +1,41 @@ +import { makeAssistantTool, tool } from '@assistant-ui/react' +import { + CREATE_RENAME_EPISODE_PLAN, + CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + createRenameEpisodePlanInputSchema, +} from '@core/types/ai-tools/createRenameEpisodePlan' +import { END_PLAN_TASK_SUCCESS_MESSAGE } from '@core/types/ai-tools/planTaskMessages' +import { formatToolError, toolOk } from '@core/ai-tool/toolResult' +import { createRenameEpisodePlanApi } from '@/api/createRenameEpisodePlan' +import { PLANS_QUERY_ROOT } from '@/hooks/plans' +import { queryClient } from '@/lib/queryClient' + +const createRenameEpisodePlan = tool({ + description: CREATE_RENAME_EPISODE_PLAN_DESCRIPTION, + parameters: createRenameEpisodePlanInputSchema, + execute: async ({ mediaFolderPath, files }) => { + try { + const resp = await createRenameEpisodePlanApi({ + mediaFolderPath, + files, + creator: 'ai', + }) + if (resp.error || !resp.data) { + return { error: resp.error ?? 'Error Reason: Plan creation returned no data' } + } + + await queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) + return toolOk({ + message: END_PLAN_TASK_SUCCESS_MESSAGE, + taskId: resp.data.plan.id, + }) + } catch (error) { + return formatToolError(error) + } + }, +}) + +export const CreateRenameEpisodePlanTool = makeAssistantTool({ + ...createRenameEpisodePlan, + toolName: CREATE_RENAME_EPISODE_PLAN, +}) diff --git a/apps/ui/src/ai/tools/EndRenameFilesTask.tsx b/apps/ui/src/ai/tools/EndRenameFilesTask.tsx deleted file mode 100644 index 74a30bd8..00000000 --- a/apps/ui/src/ai/tools/EndRenameFilesTask.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { makeAssistantTool, tool } from "@assistant-ui/react" -import { - END_RENAME_FILES_TASK, - END_RENAME_FILES_TASK_DESCRIPTION, - endRenameFilesTaskInputSchema, -} from "@core/types/ai-tools/renameFilesTask" -import { END_PLAN_TASK_SUCCESS_MESSAGE } from "@core/types/ai-tools/planTaskMessages" -import { formatToolError, requireNonEmptyString, toolOk } from "@core/ai-tool/toolResult" -import { updatePlan } from "@/api/updatePlan" -import { queryClient } from "@/lib/queryClient" -import { PLANS_QUERY_ROOT } from "@/hooks/plans" -import { deletePlanDraft } from "../plan/aiPlanDrafts" -import { resolveRenamePlanDraft } from "../plan/renamePlanService" - -const endRenameFilesTask = tool({ - description: END_RENAME_FILES_TASK_DESCRIPTION, - parameters: endRenameFilesTaskInputSchema, - execute: async ({ taskId }) => { - const taskIdCheck = requireNonEmptyString(taskId, "taskId") - if (typeof taskIdCheck !== "string") { - return taskIdCheck - } - - try { - const plan = await resolveRenamePlanDraft(taskIdCheck) - if (!plan) { - return { error: `Error Reason: Task with id "${taskIdCheck}" not found` } - } - if (plan.files.length === 0) { - return { error: "Error Reason: No rename entries in task" } - } - - const resp = await updatePlan(taskIdCheck.trim(), { status: "pending" }) - if (resp.error) { - return { error: resp.error } - } - - deletePlanDraft(taskIdCheck) - await queryClient.invalidateQueries({ queryKey: [PLANS_QUERY_ROOT] }) - - return toolOk({ message: END_PLAN_TASK_SUCCESS_MESSAGE }) - } catch (error) { - return formatToolError(error) - } - }, -}) - -export const EndRenameFilesTaskTool = makeAssistantTool({ - ...endRenameFilesTask, - toolName: END_RENAME_FILES_TASK, -}) - -/** - * Drop the in-memory draft for a finalized rename plan. The backend - * file is removed when the plan reaches a terminal status via - * `/api/updatePlan`. - */ -// eslint-disable-next-line react-refresh/only-export-components -export async function cleanupRenamePlan(planId: string): Promise { - deletePlanDraft(planId) -} diff --git a/apps/ui/src/ai/tools/index.ts b/apps/ui/src/ai/tools/index.ts index 93877539..56fae39d 100644 --- a/apps/ui/src/ai/tools/index.ts +++ b/apps/ui/src/ai/tools/index.ts @@ -14,6 +14,4 @@ export { TmdbGetTvShowTool } from './TmdbGetTvShow'; export { BeginRecognizeTaskTool } from './BeginRecognizeTask'; export { AddRecognizedMediaFileTool } from './AddRecognizedMediaFile'; export { EndRecognizeTaskTool, cleanupRecognizePlan } from './EndRecognizeTask'; -export { BeginRenameFilesTaskTool } from './BeginRenameFilesTask'; -export { AddRenameFileToTaskTool } from './AddRenameFileToTask'; -export { EndRenameFilesTaskTool, cleanupRenamePlan } from './EndRenameFilesTask'; +export { CreateRenameEpisodePlanTool } from './CreateRenameEpisodePlan'; diff --git a/apps/ui/src/api/createRenameEpisodePlan.ts b/apps/ui/src/api/createRenameEpisodePlan.ts new file mode 100644 index 00000000..e1ecbe3e --- /dev/null +++ b/apps/ui/src/api/createRenameEpisodePlan.ts @@ -0,0 +1,32 @@ +import type { PlanCreator } from '@core/types/planCommon' +import type { RenameFilesPlan } from '@core/types/RenameFilesPlan' +import { apiFetch } from '@/lib/apiFetch' + +export interface CreateRenameEpisodePlanRequest { + mediaFolderPath: string + files: Array<{ from: string; to: string }> + creator: PlanCreator +} + +export interface CreateRenameEpisodePlanResponseBody { + data?: { plan: RenameFilesPlan } + error?: string +} + +export async function createRenameEpisodePlanApi( + request: CreateRenameEpisodePlanRequest, + signal?: AbortSignal, +): Promise { + const resp = await apiFetch('/api/create-rename-episode-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }) + + if (!resp.ok) { + throw new Error(`HTTP Layer Error: ${resp.status} ${resp.statusText}`) + } + + return (await resp.json()) as CreateRenameEpisodePlanResponseBody +} diff --git a/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts b/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts index 12066333..035335d8 100644 --- a/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts +++ b/apps/ui/src/hooks/tv/useAiBasedRenameFilesFlow.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from "react" import { toast } from "sonner" -import { cleanupRenamePlan } from "@/ai/tools/EndRenameFilesTask" +import { cleanupRenamePlan } from "@/ai/plan/cleanupRenamePlan" import { selectActiveAiPlan } from "@/components/tv/plans/selectActiveAppPlan" import { useTvShowWebSocketEvents } from "./useTvShowWebSocketEvents" import { toUpdatePlanPatch, useUpdatePlanMutation } from "@/hooks/plans" From 74d1be9a859f4841dcb018ed780211d98e7a9023 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:41:16 +0800 Subject: [PATCH 184/214] test(e2e): AiTool-RenameTool uses create-rename-episode-plan Co-authored-by: Cursor --- apps/e2e/test/lib/debugRenameTool.ts | 91 ++++--------------- .../test/specs/ai/AiTool-RenameTool.e2e.ts | 18 ++-- 2 files changed, 23 insertions(+), 86 deletions(-) diff --git a/apps/e2e/test/lib/debugRenameTool.ts b/apps/e2e/test/lib/debugRenameTool.ts index bcfe27ea..67b5ef82 100644 --- a/apps/e2e/test/lib/debugRenameTool.ts +++ b/apps/e2e/test/lib/debugRenameTool.ts @@ -1,98 +1,41 @@ const API_BASE_URL = 'http://localhost:30000' -export interface CreateTaskOptions { +export interface CreateRenameEpisodePlanOptions { mediaFolderPath: string - clientId?: string -} - -export interface AddFileOptions { - taskId: string - from: string - to: string - clientId?: string -} - -export interface EndTaskOptions { - taskId: string - clientId?: string + files: Array<{ + from: string + to: string + }> } export interface DebugApiResponse { success: boolean - data?: { taskId?: string; [key: string]: unknown } - error?: string -} - -export function requireTaskId(response: DebugApiResponse, label = 'createTask'): string { - if (!response.success) { - throw new Error(`${label} failed: ${response.error ?? 'unknown error'}`) - } - const taskId = response.data?.taskId - if (!taskId) { - throw new Error(`${label} failed: missing taskId in response: ${JSON.stringify(response)}`) - } - return taskId -} - -export async function createTask(options: CreateTaskOptions): Promise { - const { mediaFolderPath, clientId } = options - - try { - const response = await fetch(`${API_BASE_URL}/debug/startRenameFilesTask`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ mediaFolderPath, clientId }), - }) - - return (await response.json()) as DebugApiResponse - } catch (error: any) { - return { - success: false, - error: error.message || 'Unknown error', - } - } -} - -export async function addFile(options: AddFileOptions): Promise { - const { taskId, from, to, clientId } = options - - try { - const response = await fetch(`${API_BASE_URL}/debug/addFileToRenameTask`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ taskId, from, to, clientId }), - }) - - return (await response.json()) as DebugApiResponse - } catch (error: any) { - return { - success: false, - error: error.message || 'Unknown error', - } + data?: { + planId: string + plan: unknown } + error?: string } -export async function endTask(options: EndTaskOptions): Promise { - const { taskId, clientId } = options +export async function createRenameEpisodePlan( + options: CreateRenameEpisodePlanOptions, +): Promise { + const { mediaFolderPath, files } = options try { - const response = await fetch(`${API_BASE_URL}/debug/endRenameFilesTask`, { + const response = await fetch(`${API_BASE_URL}/debug/createRenameEpisodePlan`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ taskId, clientId }), + body: JSON.stringify({ mediaFolderPath, files }), }) return (await response.json()) as DebugApiResponse - } catch (error: any) { + } catch (error: unknown) { return { success: false, - error: error.message || 'Unknown error', + error: error instanceof Error ? error.message : 'Unknown error', } } } diff --git a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts index 205c1755..ec76ebbf 100644 --- a/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts +++ b/apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts @@ -9,7 +9,7 @@ import env from 'test/lib/env' import { type MediaMetadata } from '@smm/core/types' import { createFolderInTestFolder, folder1 } from 'test/actions/import-folders' import Sidebar from 'test/componentobjects/Sidebar' -import { addFile, createTask, endTask, requireTaskId } from 'test/lib/debugRenameTool' +import { createRenameEpisodePlan } from 'test/lib/debugRenameTool' import { Path } from '@smm/core' import Prompts from 'test/componentobjects/Prompts' @@ -73,19 +73,13 @@ describe('AI Assistant - Rename Tool', async () => { }) - // 1. Start rename plan - const taskId = requireTaskId(await createTask({ + await createRenameEpisodePlan({ mediaFolderPath: folder.path!, - })) - await addFile({ - taskId: taskId, - from: path.join(folder.path!, 'S01E01.mp4'), - to: path.join(folder.path!, '[1].mp4'), + files: [{ + from: path.join(folder.path!, 'S01E01.mp4'), + to: path.join(folder.path!, '[1].mp4'), + }], }) - await endTask({ - taskId: taskId, - }) - await Prompts.aiBasedRenamePrompt.waitForDisplayed() await Prompts.confirmButton.click() From 14b9806bf2e28f263bf50f6b3c7844a72efad7e1 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:44:02 +0800 Subject: [PATCH 185/214] docs: document create-rename-episode-plan; remove begin/add/end rename docs Co-authored-by: Cursor --- docs/AI-driven-recognition.md | 42 ++--- docs/DebugAPI.md | 153 ++++------------- docs/dev/rename-episodes.md | 39 ++++- .../reference/AI_RenameFile_Process.md | 157 ++---------------- 4 files changed, 93 insertions(+), 298 deletions(-) diff --git a/docs/AI-driven-recognition.md b/docs/AI-driven-recognition.md index 5bf698ee..71aa8429 100644 --- a/docs/AI-driven-recognition.md +++ b/docs/AI-driven-recognition.md @@ -200,37 +200,21 @@ The backend emits **RecognizeMediaFilePlanReady** when a plan is ready. The UI d --- -## 10. AI-Driven Rename File V2 +## 10. AI-Driven Rename Episodes -Rename-file V2 follows the same plan-on-disk and confirm/reject pattern as recognition. The legacy rename tools (`beginRenameFilesTask`, `addRenameFileToTask`, `endRenameFilesTask`) remain unchanged and use in-memory state and blocking confirmation. +AI/MCP rename now uses a **single tool**, **`create-rename-episode-plan`**, backed by `Core.createRenameEpisodePlan`. The agent submits all `{ from, to }` pairs in one call; Core validates, writes `{appDataDir}/plans/{id}.plan.json`, and the tool broadcasts **`RenameFilesPlanReady`**. The user reviews in SMM and applies via the same plan flow as rule-based rename. -### Tools and flow +The former begin/add/end rename tools (`beginRenameFilesTaskV2`, `addRenameFileToTaskV2`, `endRenameFilesTaskV2`) were removed. -- **`beginRenameFilesTaskV2(mediaFolderPath)`** – Creates a plan file in `${userDataDir}/plans/` with `task: "rename-files"`, `status: "pending"`, and empty `files`. Returns `taskId`. -- **`addRenameFileToTaskV2(taskId, from, to)`** – Appends `{ from, to }` (POSIX paths) to the plan’s `files` array. -- **`endRenameFilesTaskV2(taskId)`** – Persists the plan and broadcasts **`RenameFilesPlanReady`** so the UI can show the review prompt. +Product and API details: [docs/dev/rename-episodes.md](./dev/rename-episodes.md). Plan lifecycle: [docs/dev/manage-plan.md](./dev/manage-plan.md). -Plans use the same `plans/` directory as recognition; they are distinguished by `plan.task === "rename-files"`. +### Key files -### Data and APIs - -- **`RenameFilesPlan`** – `core/types/RenameFilesPlan.ts`: `id`, `task: "rename-files"`, `status`, `mediaFolderPath`, `files: { from, to }[]`. -- **`RenameFilesPlanReady`** – `core/event-types.ts`: event and `{ taskId, planFilePath }`. -- **Get pending plans** – `POST /api/getPendingPlans` response includes a `renamePlans` array of pending `RenameFilesPlan`. -- **Update plan** – `POST /api/updatePlan` with `{ planId, status }` supports rename plans (backend tries recognition first, then rename by `planId`). - -### UI - -- **State** – `pendingRenamePlans` in the global states provider, populated from `getPendingPlans().renamePlans`. -- **Review** – When a pending rename plan matches the current media folder, the TV Show panel builds `seasonsForPreview` via `buildSeasonsByRenameFilesPlan`, opens **openAiBasedRenameFilePrompt** with `status: "wait-for-ack"`, and shows the preview. -- **Confirm** – Frontend builds season models from the plan, calls `startToRenameFiles(seasonsFromPlan)` (same as rule-based rename; uses `renameFile` API per file), then on success calls `updatePlan(plan.id, 'completed')` and `fetchPendingPlans()`. -- **Cancel** – Frontend calls `updatePlan(planId, 'rejected')`. - -### Key files (V2 rename) - -| Layer / role | Path | -|---------------------|------| -| Plan types | `core/types/RenameFilesPlan.ts` | -| Plan-ready event | `core/event-types.ts` (`RenameFilesPlanReady`) | -| Plan I/O + broadcast| `cli/src/tools/renameFilesToolV2.ts` | -| AI tool wrappers | `cli/src/tools/renameFilesTaskV2.ts` | +| Layer / role | Path | +|--------------|------| +| Core API | `apps/core/src/pipeline/createRenameEpisodePlan.ts`, `Core.createRenameEpisodePlan` | +| Tool types | `packages/core/types/ai-tools/createRenameEpisodePlan.ts` | +| MCP / AI tool | `packages/core-routes/src/tools/createRenameEpisodePlan.ts` | +| HTTP + broadcast | `apps/cli/src/route/RenameEpisodesPlan.ts` | +| Debug e2e helper | `POST /debug/createRenameEpisodePlan` | +| Plan types / event | `packages/core/types/RenameFilesPlan.ts`, `RenameFilesPlanReady` in `packages/core/event-types.ts` | diff --git a/docs/DebugAPI.md b/docs/DebugAPI.md index aa3bd201..6d930ddf 100644 --- a/docs/DebugAPI.md +++ b/docs/DebugAPI.md @@ -162,27 +162,33 @@ curl -X POST http://localhost:30000/debug \ - If `clientId` is not provided or no UI is connected, the confirmation will timeout and the operation will fail - The function returns validation errors if any rename operations are invalid -### beginRenameFilesTask +### createRenameEpisodePlan -The beginRenameFilesTask function creates a new rename task for a media folder. This allows you to collect multiple file rename operations before executing them all at once. You should call this function first, then use `addRenameFileToTask` to add files, and finally call `endRenameFilesTask` to execute all renames. +Creates a pending rename-files plan via `Core.createRenameEpisodePlan` (same as the AI/MCP tool **`create-rename-episode-plan`**). Use this in e2e tests to skip the AI chat flow and drive the rename review UI directly. + +When `creator` is omitted, the debug route defaults to `"ai"` and broadcasts **`RenameFilesPlanReady`**. The user still confirms in SMM; apply with `POST /api/apply-plan` or CLI `smm apply `. ```typescript -interface BeginRenameFilesTaskDebugApiRequestBody { - name: "beginRenameFilesTask", - mediaFolderPath: string, - clientId?: string +interface CreateRenameEpisodePlanDebugApiRequestBody { + mediaFolderPath: string + files: Array<{ from: string; to: string }> + creator?: "ai" | "app" } ``` -#### Example: Begin a rename task +#### Example: Create an AI rename plan ```bash -curl -X POST http://localhost:30000/debug \ +curl -X POST http://localhost:30000/debug/createRenameEpisodePlan \ -H "Content-Type: application/json" \ -d '{ - "name": "beginRenameFilesTask", "mediaFolderPath": "/path/to/media/folder", - "clientId": "optional-client-id" + "files": [ + { + "from": "/path/to/media/folder/S01E01.mkv", + "to": "/path/to/media/folder/Show Name - S01E01 - Episode Title.mkv" + } + ] }' ``` @@ -191,89 +197,20 @@ curl -X POST http://localhost:30000/debug \ { "success": true, "data": { - "taskId": "550e8400-e29b-41d4-a716-446655440000", - "error": undefined + "planId": "550e8400-e29b-41d4-a716-446655440000", + "plan": { "id": "...", "task": "rename-files", "status": "pending", "files": [] } } } ``` **Note:** -- The `mediaFolderPath` must be a folder that is currently opened in SMM (has metadata cache) -- Returns a `taskId` that must be used with `addRenameFileToTask` and `endRenameFilesTask` -- The task is stored in memory and will be lost if the server restarts - -### addRenameFileToTask - -The addRenameFileToTask function adds a single file rename operation to an existing task. You can call this function multiple times to add multiple files to the same task. - -```typescript -interface AddRenameFileToTaskDebugApiRequestBody { - name: "addRenameFileToTask", - taskId: string, - from: string, - to: string, - clientId?: string -} -``` - -#### Example: Add a file rename to a task - -```bash -curl -X POST http://localhost:30000/debug \ - -H "Content-Type: application/json" \ - -d '{ - "name": "addRenameFileToTask", - "taskId": "550e8400-e29b-41d4-a716-446655440000", - "from": "/path/to/media/folder/file1.txt", - "to": "/path/to/media/folder/file1_renamed.txt", - "clientId": "optional-client-id" - }' -``` - -**Note:** -- The `taskId` must be a valid task ID returned from `beginRenameFilesTask` -- You can call this function multiple times with the same `taskId` to add multiple files -- Paths can be in POSIX format or Windows format (they will be normalized) - -### endRenameFilesTask - -The endRenameFilesTask function ends a rename task and executes all collected rename operations. It will: -1. Validate all rename operations -2. Ask for user confirmation (if clientId is provided and UI is connected) -3. Perform the file renames on the filesystem -4. Update media metadata -5. Clean up the task - -```typescript -interface EndRenameFilesTaskDebugApiRequestBody { - name: "endRenameFilesTask", - taskId: string, - clientId?: string -} -``` - -#### Example: End a rename task and execute all renames - -```bash -curl -X POST http://localhost:30000/debug \ - -H "Content-Type: application/json" \ - -d '{ - "name": "endRenameFilesTask", - "taskId": "550e8400-e29b-41d4-a716-446655440000", - "clientId": "optional-client-id" - }' -``` - -**Note:** -- The `taskId` must be a valid task ID returned from `beginRenameFilesTask` -- If `clientId` is provided and a UI client is connected, it will show a confirmation dialog -- If `clientId` is not provided or no UI is connected, the confirmation will timeout and the operation will fail -- The function returns validation errors if any rename operations are invalid -- After execution, the task is removed from memory +- `mediaFolderPath` must be a managed TV show folder with metadata cache +- `files` must be non-empty; validation errors return `{ success: false, error: "..." }` +- See [Rename Episodes](./dev/rename-episodes.md) for product flow and [Manage Plan](./dev/manage-plan.md) for apply/reject ### Simulate Rename Plan Ready -Simulate MCP client call `"end-rename-episode-video-file-task"` MCP tool. +Simulate the **`RenameFilesPlanReady`** Socket.IO event after a plan file already exists (e.g. when testing UI without calling `createRenameEpisodePlan`). ```bash curl -X POST http://localhost:30000/debug \ @@ -324,45 +261,21 @@ curl -X POST http://localhost:30000/debug \ - Errors are collected and returned in the response if any occur - The function returns success if at least one operation completes, even if the other fails -#### Complete Example: Using all three functions together +#### Complete Example: Create plan, then apply from CLI ```bash -# Step 1: Begin a rename task -TASK_ID=$(curl -s -X POST http://localhost:30000/debug \ +PLAN_ID=$(curl -s -X POST http://localhost:30000/debug/createRenameEpisodePlan \ -H "Content-Type: application/json" \ -d '{ - "name": "beginRenameFilesTask", "mediaFolderPath": "/path/to/media/folder", - "clientId": "optional-client-id" - }' | jq -r '.data.taskId') - -# Step 2: Add multiple files to the task -curl -X POST http://localhost:30000/debug \ - -H "Content-Type: application/json" \ - -d "{ - \"name\": \"addRenameFileToTask\", - \"taskId\": \"$TASK_ID\", - \"from\": \"/path/to/media/folder/file1.txt\", - \"to\": \"/path/to/media/folder/file1_renamed.txt\", - \"clientId\": \"optional-client-id\" - }" + "files": [ + { + "from": "/path/to/media/folder/S01E01.mkv", + "to": "/path/to/media/folder/Show Name - S01E01 - Episode Title.mkv" + } + ] + }' | jq -r '.data.planId') -curl -X POST http://localhost:30000/debug \ - -H "Content-Type: application/json" \ - -d "{ - \"name\": \"addRenameFileToTask\", - \"taskId\": \"$TASK_ID\", - \"from\": \"/path/to/media/folder/file2.txt\", - \"to\": \"/path/to/media/folder/file2_renamed.txt\", - \"clientId\": \"optional-client-id\" - }" - -# Step 3: End the task and execute all renames -curl -X POST http://localhost:30000/debug \ - -H "Content-Type: application/json" \ - -d "{ - \"name\": \"endRenameFilesTask\", - \"taskId\": \"$TASK_ID\", - \"clientId\": \"optional-client-id\" - }" +# Review in SMM UI, or apply directly: +smm apply "$PLAN_ID" ``` \ No newline at end of file diff --git a/docs/dev/rename-episodes.md b/docs/dev/rename-episodes.md index 85c06cef..87242092 100644 --- a/docs/dev/rename-episodes.md +++ b/docs/dev/rename-episodes.md @@ -1,6 +1,6 @@ # Rename Episodes -**Supported Platform** Web UI, CLI, Electron, ohos +**Supported Platform** Web UI, CLI, Electron, ohos, MCP tool, AI tool **Status** wip SMM provides 3 renaming functions: @@ -55,7 +55,6 @@ sequenceDiagram ## Web UI, Electron and ohos - ### UC1: Rename episodes When user click the rename button @@ -114,7 +113,40 @@ sequenceDiagram W->>W: invalidate useMediaMetadataQuery ``` -# Testing +## MCP Tool and AI Tool + +AI and MCP clients use a single tool, **`create-rename-episode-plan`**, instead of the former begin/add/end rename task flow. + +Input: + +- `mediaFolderPath` — absolute media folder path (POSIX or Windows) +- `files` — non-empty array of `{ from, to }` video paths chosen by the agent + +The tool calls `Core.createRenameEpisodePlan(..., { creator: "ai" })`, writes a pending rename plan under `{appDataDir}/plans/`, broadcasts **`RenameFilesPlanReady`**, and returns a success message telling the user to open SMM and approve the plan. Confirm/reject/apply follow [Manage Plan](./manage-plan.md) (`POST /api/apply-plan`, `POST /api/reject-plan`). + +HTTP surface (same Core call): `POST /api/create-rename-episode-plan`. E2e/debug helper: `POST /debug/createRenameEpisodePlan`. + +```mermaid +sequenceDiagram + participant U as User + participant A as AI Agent + participant T as MCP Tool/AI Tool + participant C as Core + participant W as UI + + U->>A: ask for renaming episodes + A->>T: create-rename-episode-plan(folder, files) + T->>C: createRenameEpisodePlan(..., creator ai) + C->>T: RenameFilesPlan (pending) + T->>W: RenameFilesPlanReady + T->>A: success message (review in SMM) + A->>U: message to user + U->>W: review + confirm + W->>C: applyPlan() +``` + + +## Testing | Use Case | Platform | Test | |--|--|--| @@ -122,6 +154,7 @@ sequenceDiagram | UC1 | CLI | apps/e2e/cli/rename-episodes.test.ts | | UC2 | Web UI, Electron and Ohos | apps/e2e/common/tv/TVShow-RenameEpisodes.e2e.ts | | UC2 | CLI | apps/e2e/cli/rename-episodes.test.ts | +| AI rename plan | MCP tool, AI tool | apps/e2e/test/specs/ai/AiTool-RenameTool.e2e.ts | ## References diff --git a/docs/superpowers/reference/AI_RenameFile_Process.md b/docs/superpowers/reference/AI_RenameFile_Process.md index 18dde5cf..8896be47 100644 --- a/docs/superpowers/reference/AI_RenameFile_Process.md +++ b/docs/superpowers/reference/AI_RenameFile_Process.md @@ -1,152 +1,17 @@ -# AI 重命名文件流程文档 +# AI Rename Episodes (superseded) -本文档描述了 AI 代理重命名文件的完整流程,包括 AI Chatbox、后端服务、Socket 通道和前端界面之间的交互。 +> **This document is outdated.** AI/MCP episode rename no longer uses the three-step `beginRenameFilesTask` → `addRenameFileToTask` → `endRenameFilesTask` flow (in-memory task cache + incremental Socket preview). -## 概述 +Use the single-tool design instead: -AI 重命名文件流程采用任务模式,允许 AI 逐步推断文件名,并在所有文件推断完成后统一请求用户确认。这种设计提供了更好的用户体验,用户可以实时预览重命名结果,并在最后统一确认或取消。 +- **Tool:** `create-rename-episode-plan` (`CREATE_RENAME_EPISODE_PLAN`) +- **Core:** `Core.createRenameEpisodePlan(mediaFolderPath, files, { creator: "ai" })` +- **Outcome:** pending rename plan on disk + `RenameFilesPlanReady` → user reviews in SMM → `apply-plan` -## 流程说明 +**Current docs** -### 阶段 1: 初始化任务 +- [Rename Episodes](../../dev/rename-episodes.md) — product flow, CLI/UI/AI sequences, tests +- [Manage Plan](../../dev/manage-plan.md) — apply, reject, list plans +- [Design spec](../specs/2026-08-29-create-rename-episode-plan-design.md) — architecture and validation rules -1. **AI Chatbox** 调用 `beginRenameFilesTask` 工具,传入媒体文件夹路径 -2. **Backend** 创建重命名任务,生成唯一任务 ID,并将任务存储在缓存中 -3. **Backend** 通过 Socket 广播 `askForRenameFilesConfirmation_begin` 事件,包含媒体文件夹路径 -4. **Frontend** 接收到 `begin` 事件后,进入预览模式,准备显示重命名列表 - -### 阶段 2: 逐步添加文件 - -对于每个需要重命名的文件: - -1. **AI Chatbox** 推断新文件名,调用 `addRenameFileToTask` 工具,传入任务 ID、原文件路径和新文件路径 -2. **Backend** 将重命名操作添加到任务缓存中 -3. **Backend** 通过 Socket 广播 `askForRenameFilesConfirmation_addFile` 事件,包含文件的原路径和新路径 -4. **Frontend** 接收到 `addFile` 事件后,实时渲染新文件名到预览列表中 - -此过程会重复执行,直到所有文件都被处理。 - -### 阶段 3: 完成任务并请求确认 - -1. **AI Chatbox** 调用 `endRenameFilesTask` 工具,传入任务 ID -2. **Backend** 执行以下操作: - - 从缓存中获取任务数据 - - 验证所有重命名操作(检查文件是否存在、路径是否有效、是否有冲突等) - - 通过 Socket 广播 `askForRenameFilesConfirmation_end` 事件 - - 调用 `askForRenameFilesConfirmation` 函数,等待用户确认 -3. **Frontend** 接收到 `end` 事件后,显示确认对话框,列出所有重命名操作 -4. **Frontend** 用户点击确认或取消按钮 -5. **Frontend** 通过 Socket 发送确认响应(`confirmed: true/false`) - -### 阶段 4: 执行重命名 - -如果用户确认: - -1. **Backend** 接收到确认响应后: - - 执行批量重命名操作(调用 `executeBatchRenameOperations`) - - 更新媒体元数据(调用 `updateMediaMetadataAndBroadcast`) - - 清理任务缓存 - - 通过 Socket 广播元数据更新事件 -2. **Frontend** 接收到元数据更新后,刷新界面显示新的文件名 - -如果用户取消: - -1. **Backend** 接收到取消响应后,清理任务缓存,不执行任何重命名操作 -2. **Frontend** 退出预览模式 - -## 流程图 - -```mermaid -sequenceDiagram - participant AI as AI Chatbox - participant BE as Backend - participant WS as WebSocket - participant FE as Frontend - participant FS as FileSystem - - Note over AI,FS: 阶段 1: 初始化任务 - AI->>BE: beginRenameFilesTask(mediaFolderPath) - BE->>BE: 创建任务,生成 taskId - BE->>WS: broadcast "begin" event - WS->>FE: "askForRenameFilesConfirmation_begin" - FE->>FE: 进入预览模式 - - Note over AI,FS: 阶段 2: 逐步添加文件 - loop 对每个文件 - AI->>BE: addRenameFileToTask(taskId, from, to) - BE->>BE: 添加到任务缓存 - BE->>WS: broadcast "addFile" event - WS->>FE: "askForRenameFilesConfirmation_addFile" - FE->>FE: 渲染新文件名到预览列表 - end - - Note over AI,FS: 阶段 3: 完成任务并请求确认 - AI->>BE: endRenameFilesTask(taskId) - BE->>BE: 获取任务数据 - BE->>BE: 验证所有重命名操作 - BE->>WS: broadcast "end" event - WS->>FE: "askForRenameFilesConfirmation_end" - FE->>FE: 显示确认对话框 - BE->>FE: askForRenameFilesConfirmation(files) - FE->>BE: 用户确认/取消响应 - - Note over AI,FS: 阶段 4: 执行重命名 - alt 用户确认 - BE->>FS: executeBatchRenameOperations() - FS-->>BE: 重命名成功 - BE->>BE: updateMediaMetadataAndBroadcast() - BE->>WS: broadcast 元数据更新 - WS->>FE: 元数据更新事件 - FE->>FE: 刷新界面 - BE->>BE: 清理任务缓存 - else 用户取消 - BE->>BE: 清理任务缓存 - FE->>FE: 退出预览模式 - end -``` - -## 关键组件 - -### Backend 工具 - -- **`beginRenameFilesTask`**: 创建新的重命名任务 -- **`addRenameFileToTask`**: 向任务中添加单个文件重命名操作 -- **`endRenameFilesTask`**: 完成任务,验证并执行所有重命名操作 - -### Socket 事件 - -- **`askForRenameFilesConfirmation_begin`**: 任务开始,包含媒体文件夹路径 -- **`askForRenameFilesConfirmation_addFile`**: 添加文件,包含原路径和新路径 -- **`askForRenameFilesConfirmation_end`**: 任务结束,准备请求确认 -- **`askForRenameFilesConfirmation`**: 请求用户确认,包含所有文件列表 - -### 验证规则 - -在 `endRenameFilesTask` 阶段,系统会执行以下验证: - -1. 检查源文件是否存在 -2. 检查目标文件是否已存在 -3. 验证路径是否在媒体文件夹内 -4. 检查是否有重复的源文件或目标文件 -5. 检查是否有链式冲突(A->B, B->C) -6. 检查路径格式是否正常 - -## 错误处理 - -- 如果验证失败,返回错误信息,不执行重命名 -- 如果用户取消,清理任务缓存,不执行任何操作 -- 如果重命名执行失败,记录错误日志,更新部分成功的文件元数据 - -## 相关文件 - -- `cli/src/tools/renameFilesTool.ts`: 任务管理核心逻辑 -- `cli/src/tools/renameFilesTask.ts`: AI 工具定义 -- `cli/src/tools/renameFilesInBatch.ts`: 批量重命名工具(替代方案) -- `cli/src/events/askForRenameFilesConfirmation.ts`: 确认事件处理 -- `core/event-types.ts`: 事件类型定义 - - - -TODO: user cancel the task in the middle -TODO; user cancel the task after end -TODO: lost connection should reject tool call to AI \ No newline at end of file +**E2e helper:** `POST /debug/createRenameEpisodePlan` (see [Debug API](../../DebugAPI.md)) From 2ede545ff51aa58f495686d623e30e624663077d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:45:24 +0800 Subject: [PATCH 186/214] docs: show populated files in createRenameEpisodePlan debug example Co-authored-by: Cursor --- docs/DebugAPI.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/DebugAPI.md b/docs/DebugAPI.md index 6d930ddf..0a7c4af7 100644 --- a/docs/DebugAPI.md +++ b/docs/DebugAPI.md @@ -198,7 +198,17 @@ curl -X POST http://localhost:30000/debug/createRenameEpisodePlan \ "success": true, "data": { "planId": "550e8400-e29b-41d4-a716-446655440000", - "plan": { "id": "...", "task": "rename-files", "status": "pending", "files": [] } + "plan": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "task": "rename-files", + "status": "pending", + "files": [ + { + "from": "/path/to/media/folder/S01E01.mkv", + "to": "/path/to/media/folder/Show Name - S01E01 - Episode Title.mkv" + } + ] + } } } ``` From 6a2cff500ca2b7acab56f8331664d1cdb8abcac4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 00:59:40 +0800 Subject: [PATCH 187/214] fix(e2e): migrate MCP rename specs to create-rename-episode-plan Replace the removed multi-step rename flow across e2e helpers, docs, and localization so branch coverage exercises the supported one-shot plan API. Co-authored-by: Cursor --- .superpowers/sdd/task-final-fix-report.md | 17 ++++ apps/cli/docs/MCP.md | 35 +++---- apps/cli/public/locales/en/tools.json | 21 +--- apps/cli/public/locales/zh-CN/tools.json | 21 +--- apps/cli/src/mcp/mcp.ts | 2 + apps/cli/src/tools/renameFilesTool.ts | 98 ------------------- .../common/mcp/McpOther-RenameTaskFlow.e2e.ts | 20 ++-- .../mcp/McpPrompt-CancelPreparingPlan.e2e.ts | 13 ++- apps/e2e/test/lib/McpClient.ts | 42 ++------ apps/e2e/test/lib/mcpToolTypes.ts | 39 ++------ docs/DebugAPI.md | 14 --- 11 files changed, 70 insertions(+), 252 deletions(-) create mode 100644 .superpowers/sdd/task-final-fix-report.md delete mode 100644 apps/cli/src/tools/renameFilesTool.ts diff --git a/.superpowers/sdd/task-final-fix-report.md b/.superpowers/sdd/task-final-fix-report.md new file mode 100644 index 00000000..57c37756 --- /dev/null +++ b/.superpowers/sdd/task-final-fix-report.md @@ -0,0 +1,17 @@ +# Final whole-branch review fixes + +## Fixed + +- Migrated `McpOther-RenameTaskFlow` and `McpPrompt-CancelPreparingPlan` from the removed begin/add/end MCP rename flow to one-shot `create-rename-episode-plan` calls with non-empty `files`. +- Replaced the e2e `McpClient` begin/add/end rename helpers and request/response types with `createRenameEpisodePlan`. +- Updated MCP documentation, CLI tool-description localization registration, and English/Chinese locale descriptions for `create-rename-episode-plan`; removed obsolete rename tool descriptions. +- Removed the dead `renameFilesPlanReady` Debug API documentation and deleted the unreferenced legacy `renameFilesTool.ts`. +- Left the app-data-directory architecture unchanged for follow-up work. + +## Test evidence + +- `pnpm --filter e2e typecheck` — passed. +- `bun ci/run-e2e-test.ts --spec ./common/mcp/McpOther-RenameTaskFlow.e2e.ts` — passed (artifact `artifacts/cicd/1788022664`). +- `bun ci/run-e2e-test.ts --spec ./common/mcp/McpPrompt-CancelPreparingPlan.e2e.ts` — passed (artifact `artifacts/cicd/1788022711`). +- `git diff --check` — passed. +- `pnpm --filter cli typecheck` — blocked by existing `Response.json(): unknown` assertions in `debugCreateRenameEpisodePlan.test.ts` and `RenameEpisodesPlan.test.ts`; no reported error was in a file changed by this fix. diff --git a/apps/cli/docs/MCP.md b/apps/cli/docs/MCP.md index 55b39fc2..549c4315 100644 --- a/apps/cli/docs/MCP.md +++ b/apps/cli/docs/MCP.md @@ -133,28 +133,15 @@ This runs only the MCP server over stdio (stdin/stdout). Do not write to stdout - **Success**: Returns JSON with `renamed: true` and the actual `from` and `to` paths used - **Error**: Returns JSON with `renamed: false` and error message, or tool error for system failures -#### Tool: begin-rename-task -- **Description**: Begin a batch rename task for a media folder. Returns a task ID that must be used for subsequent operations. +#### Tool: create-rename-episode-plan +- **Description**: Create a pending rename plan for TV episode video files. Submit every rename operation in one call; the user reviews and approves the plan in SMM. - **Parameters**: - - `mediaFolderPath` (string, required): Path to the media folder for batch rename -- **Success**: Returns JSON with `success: true`, `taskId`, and `mediaFolderPath` -- **Error**: Returns error if path is invalid or task creation fails - -#### Tool: add-rename-file -- **Description**: Add a file rename operation to an existing task. -- **Parameters**: - - `taskId` (string, required): ID of the existing rename task - - `from` (string, required): Current file path - - `to` (string, required): New file path -- **Success**: Returns JSON with `success: true` and `taskId` -- **Error**: Returns error if task doesn't exist, parameters are invalid, or add operation fails - -#### Tool: end-rename-task -- **Description**: End a batch rename task and finalize the plan. -- **Parameters**: - - `taskId` (string, required): ID of the rename task to finalize -- **Success**: Returns JSON with `success: true`, `taskId`, and `fileCount` -- **Error**: Returns JSON with `success: false` and error message if task not found or empty + - `mediaFolderPath` (string, required): Absolute path to the managed TV show folder + - `files` (array, required): Non-empty array of rename operations + - `from` (string, required): Current absolute video file path + - `to` (string, required): New absolute video file path +- **Success**: Returns JSON with `message` and `planId` +- **Error**: Returns an error if the folder, files, or rename operations are invalid ### Recognize Operations @@ -182,15 +169,15 @@ This runs only the MCP server over stdio (stdin/stdout). Do not write to stdout - **Success**: Returns JSON with `success: true`, `taskId`, and `fileCount` - **Error**: Returns error if task doesn't exist or finalization fails -## Batch Operation Workflow +## Recognize Batch Operation Workflow -Both rename and recognize tools follow a three-step batch operation pattern: +The recognize tools follow a three-step batch operation pattern: 1. **Begin Task**: Create a new batch operation task and get a task ID 2. **Add Items**: Add individual files/operations to the task using the task ID 3. **End Task**: Finalize the task to execute the batch operation -This pattern allows for building complex operations with multiple files before execution, with validation at each step. +Rename plans instead use the one-shot `create-rename-episode-plan` tool documented above. ## Response Format diff --git a/apps/cli/public/locales/en/tools.json b/apps/cli/public/locales/en/tools.json index 04963f5b..c4060bbb 100644 --- a/apps/cli/public/locales/en/tools.json +++ b/apps/cli/public/locales/en/tools.json @@ -20,30 +20,15 @@ "rename-episode-file": { "description": "Rename a linked TV episode video file and same-stem associates (e.g. subtitles). TV episode files only — not folders, movies, or bulk rename plans." }, + "create-rename-episode-plan": { + "description": "Create a pending rename plan for TV episode video files from a non-empty list of absolute from/to paths. The user reviews and approves the plan in SMM." + }, "scrape": { "description": "Start a scrape job for a managed TV show or movie folder (poster, fanart, thumbnails, nfo). Returns a job id immediately; use get-job to check status." }, "get-job": { "description": "Get the status of a background job by id (scrape or import). Poll until status is succeeded, failed, or aborted." }, - "begin-rename-task": { - "description": "Begin a rename files task for a media folder. Call this first, then addRenameFileToTask for each file, then endRenameFilesTask. The user will be asked to confirm before files are renamed. Returns a task ID to use with addRenameFileToTask and endRenameFilesTask." - }, - "add-rename-file-to-task": { - "description": "Add video file to rename. This tool is only for video file that linked to an episode." - }, - "end-rename-task": { - "description": "End the construction of the task. SMM will process the task in its own process." - }, - "begin-rename-task-v2": { - "description": "Begin a rename task for episode video file in a media folder. This tool is only for video files. This tool returns a task id, which is required for subsequent add-rename-episode-video-file and end-rename-episode-video-file-task calls." - }, - "add-rename-file-to-task-v2": { - "description": "Add a file rename (from/to) to an existing rename task V2. Call this after beginRenameFilesTaskV2 for each file to rename. You DO NOT need to add thumbnail, subtitle, or nfo files; they are renamed when the main video is renamed." - }, - "end-rename-task-v2": { - "description": "End a rename task V2 and notify the UI that the plan is ready for review. The plan is persisted and the user will see a confirm/cancel prompt in the UI. The chat is not blocked. Call this after adding all files with addRenameFileToTaskV2. The task must have at least one file." - }, "begin-recognize-task": { "description": "Begin a media file recognition task for a media folder. This tool creates a new task that allows you to collect multiple recognized media files before presenting the complete plan to the user for review. You should call this tool first, then use addRecognizedMediaFile to add recognized files, and finally call endRecognizeTask to notify the UI that the plan is ready. Example: Begin a recognition task for folder \"/path/to/media/folder\". This tool returns a task ID that you must use with addRecognizedMediaFile and endRecognizeTask." }, diff --git a/apps/cli/public/locales/zh-CN/tools.json b/apps/cli/public/locales/zh-CN/tools.json index 2cd60e7f..b47705d9 100644 --- a/apps/cli/public/locales/zh-CN/tools.json +++ b/apps/cli/public/locales/zh-CN/tools.json @@ -20,30 +20,15 @@ "rename-episode-file": { "description": "重命名已关联的电视剧集视频文件及其同名关联文件(如字幕)。仅用于剧集文件,不可用于文件夹、电影或批量重命名计划。" }, + "create-rename-episode-plan": { + "description": "根据非空的绝对来源/目标路径列表,为电视剧集视频文件创建待处理的重命名计划。用户将在 SMM 中审查并批准该计划。" + }, "scrape": { "description": "为已管理的电视剧或电影文件夹启动刮削任务(海报、背景、缩略图、NFO)。立即返回任务 id;请用 get-job 查询状态。" }, "get-job": { "description": "按 id 查询后台任务状态(刮削或导入)。轮询直到 status 为 succeeded、failed 或 aborted。" }, - "begin-rename-task": { - "description": "为媒体文件夹开始重命名文件任务。首先调用此工具,然后为每个文件调用 addRenameFileToTask,最后调用 endRenameFilesTask。用户将被要求确认后才会重命名文件。返回一个任务 ID,用于 addRenameFileToTask 和 endRenameFilesTask。" - }, - "add-rename-file-to-task": { - "description": "将文件重命名(从/到)添加到现有的重命名任务。在 beginRenameFilesTask 之后为每个要重命名的文件调用此工具。您不需要添加缩略图、字幕或 nfo 文件;它们会在主视频重命名时一起重命名。" - }, - "end-rename-task": { - "description": "结束重命名任务并执行所有排队的重命名操作。在使用 addRenameFileToTask 添加所有文件后调用此工具。任务必须至少包含一个文件。用户将被要求确认后才会重命名文件。" - }, - "begin-rename-task-v2": { - "description": "为媒体文件夹中的剧集视频文件开始重命名任务。此工具仅适用于视频文件。此工具返回任务 ID,该 ID 是后续 add-rename-episode-video-file 和 end-rename-episode-video-file-task 调用所必需的。" - }, - "add-rename-file-to-task-v2": { - "description": "将文件重命名(从/到)添加到现有的重命名任务 V2。在 beginRenameFilesTaskV2 之后为每个要重命名的文件调用此工具。您不需要添加缩略图、字幕或 nfo 文件;它们会在主视频重命名时一起重命名。" - }, - "end-rename-task-v2": { - "description": "结束重命名任务 V2 并通知 UI 计划已准备好审查。计划已持久化,用户将在 UI 中看到确认/取消提示。聊天不会被阻塞。在使用 addRenameFileToTaskV2 添加所有文件后调用此工具。任务必须至少包含一个文件。" - }, "begin-recognize-task": { "description": "为媒体文件夹开始媒体文件识别任务。此工具创建一个新任务,允许您在向用户展示完整计划之前收集多个已识别的媒体文件。您应该首先调用此工具,然后使用 addRecognizedMediaFile 添加已识别的文件,最后调用 endRecognizeTask 通知 UI 计划已准备好。示例:为文件夹 \"/path/to/media/folder\" 开始识别任务。此工具返回一个任务 ID,您必须将其用于 addRecognizedMediaFile 和 endRecognizeTask。" }, diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index d9ee4f33..9fe87c3f 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -9,6 +9,7 @@ import { GET_MEDIA_FOLDERS } from "@smm/core/types/ai-tools/getMediaFolders"; import { GET_MEDIA_METADATA } from "@smm/core/types/ai-tools/getMediaMetadata"; import { RENAME_FOLDER } from "@smm/core/types/ai-tools/renameFolder"; import { RENAME_EPISODE_FILE } from "@smm/core/types/ai-tools/renameEpisodeFile"; +import { CREATE_RENAME_EPISODE_PLAN } from "@smm/core/types/ai-tools/createRenameEpisodePlan"; import { SCRAPE } from "@smm/core/types/ai-tools/scrape"; import { GET_JOB } from "@smm/core/types/ai-tools/getJob"; import { TMDB_SEARCH } from "@smm/core/types/ai-tools/tmdbSearch"; @@ -50,6 +51,7 @@ const TOOL_NAME_KEYS = [ LIST_FILES_KEY, RENAME_FOLDER, RENAME_EPISODE_FILE, + CREATE_RENAME_EPISODE_PLAN, SCRAPE, GET_JOB, BEGIN_RECOGNIZE_TASK, diff --git a/apps/cli/src/tools/renameFilesTool.ts b/apps/cli/src/tools/renameFilesTool.ts deleted file mode 100644 index 9c92b2b4..00000000 --- a/apps/cli/src/tools/renameFilesTool.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { broadcast } from "../utils/socketIO"; -import { AskForRenameFilesConfirmation, type AskForRenameFilesConfirmationAddFileResponseData, type AskForRenameFilesConfirmationBeginRequestData, type AskForRenameFilesConfirmationEndRequestData } from "@core/event-types"; - -export interface Task { - mediaFolderPath: string; - files: { - from: string; - to: string; - }[]; -} -const cache: Record = {}; - - -/** - * - * @param mediaFolderPath The absolute path of the media folder, it can be POSIX format or Windows format - * @return the id of task - */ -export function beginRenameFilesTask(mediaFolderPath: string): string { - const id = crypto.randomUUID(); - cache[id] = { - mediaFolderPath, - files: [], - }; - - - const data: AskForRenameFilesConfirmationBeginRequestData = { - mediaFolderPath, - } - - broadcast({ - event: AskForRenameFilesConfirmation.beginEvent, - data, - } - ) - - return id; - -} - -/** - * Add a rename file to task - * @param id The id of task - * @param from The source file path - * @param to The destination file path - */ -export function addRenameFileToTask(id: string, from: string, to: string) { - if (!cache[id]) { - throw new Error(`Task with id ${id} not found`); - } - - cache[id].files.push({ from, to }); - - const data: AskForRenameFilesConfirmationAddFileResponseData = { - from, - to, - } - - broadcast({ - event: AskForRenameFilesConfirmation.addFileEvent, - data, - } - ) -} - -/** - * Get a task by ID - * @param id The id of task - * @return The task or undefined if not found - */ -export function getTask(id: string): Task | undefined { - return cache[id]; -} - -/** - * End a rename files task and remove it from cache - * @param id The id of task - */ -export function endRenameFilesTask(id: string) { - if (!cache[id]) { - throw new Error(`Task with id ${id} not found`); - } - - const mediaFolderPath = cache[id].mediaFolderPath; - - // Remove task from cache - delete cache[id]; - - const data: AskForRenameFilesConfirmationEndRequestData = { - mediaFolderPath, - } - - broadcast({ - event: AskForRenameFilesConfirmation.endEvent, - data, - } - ) -} \ No newline at end of file diff --git a/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts b/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts index bbc5cbaa..0a2a2521 100644 --- a/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts +++ b/apps/e2e/common/mcp/McpOther-RenameTaskFlow.e2e.ts @@ -67,23 +67,19 @@ describe('MCP Other - RenameTaskFlow', () => { } }) - it('MCP rename task tools should rename episode video file via begin/add/end flow', async () => { + it('MCP create rename episode plan tool should rename an episode video file', async () => { const folderPath = await seedRecognizedTvShowFolder({ ...folder1 }, testFolder) expect(await TVShowPanel.toString()).toContain('S01E01 S01E01.mkv V V V') - const r = await mcpClient.beginRenameFilesTask(ctx.clientCwd, ctx.mcpAddress, { + await mcpClient.createRenameEpisodePlan(ctx.clientCwd, ctx.mcpAddress, { mediaFolderPath: folderPath, - }) - - await mcpClient.addRenameFileToTask(ctx.clientCwd, ctx.mcpAddress, { - taskId: r.taskId, - from: joinPlatformPath(folderPath, 'S01E01.mkv'), - to: joinPlatformPath(folderPath, '[1].mp4'), - }) - - await mcpClient.endRenameFilesTask(ctx.clientCwd, ctx.mcpAddress, { - taskId: r.taskId, + files: [ + { + from: joinPlatformPath(folderPath, 'S01E01.mkv'), + to: joinPlatformPath(folderPath, '[1].mp4'), + }, + ], }) await Prompts.aiBasedRenamePrompt.waitForDisplayed() diff --git a/apps/e2e/common/mcp/McpPrompt-CancelPreparingPlan.e2e.ts b/apps/e2e/common/mcp/McpPrompt-CancelPreparingPlan.e2e.ts index 2fee95b0..a578aebb 100644 --- a/apps/e2e/common/mcp/McpPrompt-CancelPreparingPlan.e2e.ts +++ b/apps/e2e/common/mcp/McpPrompt-CancelPreparingPlan.e2e.ts @@ -6,6 +6,7 @@ import { cleanup, setup } from 'test/lib/testbed' import { testbedOs } from 'test/lib/e2e-platform' import { clearFolderViaBrowser, + joinPlatformPath, resolveSmmTestFolderViaBrowser, } from 'test/lib/browser-fs' import { @@ -20,7 +21,7 @@ import { * @supports local, Electron, Docker * @unsupported HarmonyOS */ -describe('MCP Prompt - Cancel Preparing Plan', () => { +describe('MCP Prompt - Cancel Pending Plan', () => { const ctx = createMcpSpecContext() let testFolder = '' @@ -59,11 +60,17 @@ describe('MCP Prompt - Cancel Preparing Plan', () => { } }) - it('should show AI rename prompt after begin and dismiss it on cancel', async () => { + it('should show the pending AI rename prompt and dismiss it on cancel', async () => { const folderPath = await seedRecognizedTvShowFolder({ ...folder1 }, testFolder) - await mcpClient.beginRenameFilesTask(ctx.clientCwd, ctx.mcpAddress, { + await mcpClient.createRenameEpisodePlan(ctx.clientCwd, ctx.mcpAddress, { mediaFolderPath: folderPath, + files: [ + { + from: joinPlatformPath(folderPath, 'S01E01.mkv'), + to: joinPlatformPath(folderPath, '[1].mp4'), + }, + ], }) await Prompts.aiBasedRenamePrompt.waitForDisplayed({ timeout: 15000 }) diff --git a/apps/e2e/test/lib/McpClient.ts b/apps/e2e/test/lib/McpClient.ts index 2aabd259..2c1325eb 100644 --- a/apps/e2e/test/lib/McpClient.ts +++ b/apps/e2e/test/lib/McpClient.ts @@ -6,12 +6,8 @@ import { McpToolName, type AddRecognizedFileRequest, type AddRecognizedFileResponse, - type AddRenameFileToTaskRequest, - type AddRenameFileToTaskResponse, - type BeginRenameFilesTaskRequest, - type BeginRenameFilesTaskResponse, - type EndRenameFilesTaskRequest, - type EndRenameFilesTaskResponse, + type CreateRenameEpisodePlanRequest, + type CreateRenameEpisodePlanResponse, type BeginRecognizeTaskRequest, type BeginRecognizeTaskResponse, type EndRecognizeTaskRequest, @@ -241,41 +237,15 @@ class McpClient { return this.execTyped(clientCwd, mcpAddress, McpToolName.getJob, toolArgs(req)) } - async beginRenameFilesTask( + async createRenameEpisodePlan( clientCwd: string, mcpAddress: string, - req: BeginRenameFilesTaskRequest, - ): Promise { + req: CreateRenameEpisodePlanRequest, + ): Promise { return this.execTyped( clientCwd, mcpAddress, - McpToolName.beginRenameFilesTask, - toolArgs(req), - ) - } - - async addRenameFileToTask( - clientCwd: string, - mcpAddress: string, - req: AddRenameFileToTaskRequest, - ): Promise { - return this.execTyped( - clientCwd, - mcpAddress, - McpToolName.addRenameFileToTask, - toolArgs(req), - ) - } - - async endRenameFilesTask( - clientCwd: string, - mcpAddress: string, - req: EndRenameFilesTaskRequest, - ): Promise { - return this.execTyped( - clientCwd, - mcpAddress, - McpToolName.endRenameFilesTask, + McpToolName.createRenameEpisodePlan, toolArgs(req), ) } diff --git a/apps/e2e/test/lib/mcpToolTypes.ts b/apps/e2e/test/lib/mcpToolTypes.ts index d4c2a1fa..25a717ed 100644 --- a/apps/e2e/test/lib/mcpToolTypes.ts +++ b/apps/e2e/test/lib/mcpToolTypes.ts @@ -18,9 +18,7 @@ export const McpToolName = { renameFolder: 'rename-folder', scrape: 'scrape', getJob: 'get-job', - beginRenameFilesTask: 'begin-rename-files-task', - addRenameFileToTask: 'add-rename-file-to-task', - endRenameFilesTask: 'end-rename-files-task', + createRenameEpisodePlan: 'create-rename-episode-plan', beginRecognizeTask: 'begin-recognize-task', addRecognizedFile: 'add-recognized-media-file', endRecognizeTask: 'end-recognize-task', @@ -195,35 +193,18 @@ export interface GetJobResponse { error?: string } -// --- rename task (begin/append/end) --- -export interface BeginRenameFilesTaskRequest { +// --- create-rename-episode-plan --- +export interface CreateRenameEpisodePlanRequest { mediaFolderPath: string + files: Array<{ + from: string + to: string + }> } -export interface BeginRenameFilesTaskResponse { - success: boolean - taskId: string - mediaFolderPath: string -} - -export interface AddRenameFileToTaskRequest { - taskId: string - from: string - to: string -} - -export interface AddRenameFileToTaskResponse { - success?: boolean -} - -export interface EndRenameFilesTaskRequest { - taskId: string -} - -export interface EndRenameFilesTaskResponse { - success: boolean - taskId: string - message?: string +export interface CreateRenameEpisodePlanResponse { + message: string + planId: string } // --- recognize task --- diff --git a/docs/DebugAPI.md b/docs/DebugAPI.md index 0a7c4af7..e28ee227 100644 --- a/docs/DebugAPI.md +++ b/docs/DebugAPI.md @@ -218,20 +218,6 @@ curl -X POST http://localhost:30000/debug/createRenameEpisodePlan \ - `files` must be non-empty; validation errors return `{ success: false, error: "..." }` - See [Rename Episodes](./dev/rename-episodes.md) for product flow and [Manage Plan](./dev/manage-plan.md) for apply/reject -### Simulate Rename Plan Ready - -Simulate the **`RenameFilesPlanReady`** Socket.IO event after a plan file already exists (e.g. when testing UI without calling `createRenameEpisodePlan`). - -```bash -curl -X POST http://localhost:30000/debug \ - -H "Content-Type: application/json" \ - -d '{ - "name": "renameFilesPlanReady", - "taskId": "8d4b0237-e2d3-4eea-a175-881426bf5e95" - }' -``` - - ### cleanUp The cleanUp function allows developers to clean up user configuration and media metadata cache. This is useful for testing, debugging, and resolving configuration issues. It deletes: From 133afd68b438c191c38d91f138453dc12b676c58 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 01:16:13 +0800 Subject: [PATCH 188/214] test: fix test failure --- apps/e2e/cli/recognize.test.ts | 15 +++++++++------ apps/e2e/cli/rename-episodes.test.ts | 7 +++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/e2e/cli/recognize.test.ts b/apps/e2e/cli/recognize.test.ts index b2c202d6..866718fd 100644 --- a/apps/e2e/cli/recognize.test.ts +++ b/apps/e2e/cli/recognize.test.ts @@ -11,6 +11,9 @@ import { cliOutput, } from './helpers' +const isWindows = process.platform === 'win32' +const SLASH = isWindows ? '\\' : '/' + describe('recognize', () => { beforeEach(async () => { await setup({ @@ -59,13 +62,13 @@ describe('recognize', () => { const meta = await $`${bin} metadata ${folderPath}`.nothrow() expect(meta.exitCode).toBe(0) expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + `absolutePath: ${folderPath}${SLASH}S01E01.mkv seasonNumber: 1 episodeNumber: 1`, ) expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\S01E02.mkv seasonNumber: 1 episodeNumber: 2`, + `absolutePath: ${folderPath}${SLASH}S01E02.mkv seasonNumber: 1 episodeNumber: 2`, ) expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\S01E03.mkv seasonNumber: 1 episodeNumber: 3`, + `absolutePath: ${folderPath}${SLASH}S01E03.mkv seasonNumber: 1 episodeNumber: 3`, ) }) @@ -98,10 +101,10 @@ describe('recognize', () => { const meta = await $`${bin} metadata ${folderPath}`.nothrow() expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + `absolutePath: ${folderPath}${SLASH}S01E01.mkv seasonNumber: 1 episodeNumber: 1`, ) expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\S01E02.mkv seasonNumber: 1 episodeNumber: 2`, + `absolutePath: ${folderPath}${SLASH}S01E02.mkv seasonNumber: 1 episodeNumber: 2`, ) expect(meta.text()).not.toContain('extra-video.mkv') }) @@ -160,7 +163,7 @@ describe('recognize', () => { const meta = await $`${bin} metadata ${folderPath}`.nothrow() expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\S01E01.mkv seasonNumber: 1 episodeNumber: 1`, + `absolutePath: ${folderPath}${SLASH}S01E01.mkv seasonNumber: 1 episodeNumber: 1`, ) }) diff --git a/apps/e2e/cli/rename-episodes.test.ts b/apps/e2e/cli/rename-episodes.test.ts index 77be0b69..4720e26f 100644 --- a/apps/e2e/cli/rename-episodes.test.ts +++ b/apps/e2e/cli/rename-episodes.test.ts @@ -13,6 +13,9 @@ import { cliOutput, } from './helpers' +const isWindows = process.platform === 'win32' +const SLASH = isWindows ? '\\' : '/' + const SHOW_NAME = 'WATATEN an Angel Flew Down to Me' const EP1_NAME = 'A Funny, Squirmy Feeling' const PLEX_S01E01_BASENAME = `${SHOW_NAME} - S01E01 - ${EP1_NAME}.mkv` @@ -74,7 +77,7 @@ describe('rename files', () => { const meta = await $`${bin} metadata ${folderPath}`.nothrow() expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\Season 01\\${PLEX_S01E01_BASENAME} seasonNumber: 1 episodeNumber: 1`, + `absolutePath: ${folderPath}${SLASH}Season 01${SLASH}${PLEX_S01E01_BASENAME} seasonNumber: 1 episodeNumber: 1`, ) }) @@ -106,7 +109,7 @@ describe('rename files', () => { const meta = await $`${bin} metadata ${folderPath}`.nothrow() expect(meta.text()).toContain( - `absolutePath: ${folderPath}\\Season 1\\${EMBY_S01E01_BASENAME} seasonNumber: 1 episodeNumber: 1`, + `absolutePath: ${folderPath}${SLASH}Season 1${SLASH}${EMBY_S01E01_BASENAME} seasonNumber: 1 episodeNumber: 1`, ) }) From 27d6dc16c0ae19589c1e84dcbd8ef350ec770917 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 01:25:24 +0800 Subject: [PATCH 189/214] fix(cli): resolve core-app subpath imports in vitest Co-authored-by: Cursor --- apps/cli/tsconfig.json | 4 ++++ apps/cli/vitest.config.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index a73a5c19..284cb7a9 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -37,6 +37,10 @@ "@smm/test": ["../../packages/test/src/index.ts"], "@smm/test/*": ["../../packages/test/*"], "core-app": ["../core/src/index.ts"], + "core-app/createRenameEpisodePlan": [ + "../core/src/pipeline/createRenameEpisodePlan.ts" + ], + "core-app/FsPort": ["../core/src/ports/FsPort.ts"], "core-app/*": ["../core/src/*"] } } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 325865d7..b232a937 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -26,6 +26,19 @@ export default defineConfig({ find: '@smm/test', replacement: resolve(__dirname, '../../packages/test/src/index.ts'), }, + // core-app subpaths must win over the bare `core-app` → index alias + // (otherwise vitest cannot resolve package.json exports for nested deps). + { + find: 'core-app/createRenameEpisodePlan', + replacement: resolve( + __dirname, + '../core/src/pipeline/createRenameEpisodePlan.ts', + ), + }, + { + find: 'core-app/FsPort', + replacement: resolve(__dirname, '../core/src/ports/FsPort.ts'), + }, { find: 'core-app', replacement: resolve(__dirname, '../core/src/index.ts'), From 752511696fd28ece17b0ebee9c79852e976a40a4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 01:29:07 +0800 Subject: [PATCH 190/214] fix(core-routes): narrow createRenameEpisodePlan test result for tsc Co-authored-by: Cursor --- .../core-routes/src/tools/createRenameEpisodePlan.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts index b7d2d5ab..5882bbb0 100644 --- a/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts +++ b/packages/core-routes/src/tools/createRenameEpisodePlan.test.ts @@ -51,7 +51,9 @@ describe(`buildCreateRenameEpisodePlanTool (${CREATE_RENAME_EPISODE_PLAN})`, () ], }); - expect(result.error).toBeUndefined(); + if (!("planId" in result)) { + throw new Error(result.error); + } expect(result.message).toBe(END_PLAN_TASK_SUCCESS_MESSAGE); expect(result.planId).toEqual(expect.any(String)); expect(emitSpy).toHaveBeenCalledWith({ @@ -85,6 +87,9 @@ describe(`buildCreateRenameEpisodePlanTool (${CREATE_RENAME_EPISODE_PLAN})`, () ], }); + if (!("planId" in result)) { + throw new Error(result.error); + } expect(result.planId).toEqual(expect.any(String)); expect(broadcast).toHaveBeenCalledWith({ event: RenameFilesPlanReady.event, From e321aa17930410cc275ec3b5ca5b61f95c7ecd05 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 01:31:04 +0800 Subject: [PATCH 191/214] fix(cli): type response.json() in create-rename-episode-plan tests Co-authored-by: Cursor --- apps/cli/src/route/RenameEpisodesPlan.test.ts | 2 +- apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/route/RenameEpisodesPlan.test.ts b/apps/cli/src/route/RenameEpisodesPlan.test.ts index c82bf956..286896d4 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.test.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.test.ts @@ -111,7 +111,7 @@ describe('POST /api/create-rename-episode-plan', () => { }) expect(response.status).toBe(200) - const json = await response.json() + const json = (await response.json()) as { error: string } expect(json.error).toBe('Error Reason: No rename entries in task') expect(json.error.match(/Error Reason:/g)).toHaveLength(1) }) diff --git a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts index 5b95e844..afe205fb 100644 --- a/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts +++ b/apps/cli/src/route/debug/debugCreateRenameEpisodePlan.test.ts @@ -91,7 +91,7 @@ describe('POST /debug/createRenameEpisodePlan', () => { }) expect(response.status).toBe(200) - const json = await response.json() + const json = (await response.json()) as { success: boolean; error: string } expect(json).toEqual({ success: false, error: 'Error Reason: No rename entries in task', From 3486fdbb9d03c332e5b454c63972b5c1c08a5598 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:32:19 +0800 Subject: [PATCH 192/214] docs: add Core media metadata CRUD design Capture architecture for Core-owned metadata CRUD, CLI RPC bridges with ProblemDetails, and UI/e2e clients that no longer know cache file paths. Co-authored-by: Cursor --- ...6-08-30-core-media-metadata-crud-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-core-media-metadata-crud-design.md diff --git a/docs/superpowers/specs/2026-08-30-core-media-metadata-crud-design.md b/docs/superpowers/specs/2026-08-30-core-media-metadata-crud-design.md new file mode 100644 index 00000000..67ce8f14 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-core-media-metadata-crud-design.md @@ -0,0 +1,182 @@ +# Core Media Metadata CRUD + +This design document describe the high level design of a feature. +The design document is golden source and reference by one or more features. + +## 1. Background + +Media metadata cache files historically were read/written by multiple layers: + +- UI computed `{appDataDir}/metadata/*.json` via `hello().appDataDir` and used `readFile` / `writeFile`. +- Core (`MediaMetadataHelper`) used Core’s own `appDataDir`, which on Linux was wired to `userDataDir` (`~/.config/smm`) while `hello()` reported `getAppDataDir()` (`~/.local/share/smm`). + +That split caused production bugs (e.g. `Movie-RenameVideoFile` e2e: UI persisted metadata under `~/.local/share/smm`, Core `renameEpisodeFile` looked under `~/.config/smm`, returned `Media metadata not found`, disk rename never ran). + +Additionally, `MediaMetadata.files` is deprecated: live file lists belong to `listFiles`, not the metadata cache. + +**Goals** + +1. All metadata file CRUD lives in `apps/core`. +2. CLI exposes thin RPC-style HTTP bridges. +3. UI and e2e never know metadata file directories—only `mediaFolderPath` + HTTP (or hooks wrapping HTTP). +4. Align Core storage root with `hello().appDataDir`. + +## 2. Architecture + +## 2.1 Project Level Architecture + +``` +apps/ui ──HTTP──► apps/cli (bridge RPC) + │ + ▼ + apps/core (sole owner of metadata file paths) + │ + ▼ + {appDataDir}/metadata/*.json +``` + +- **Single source of truth**: `apps/core` `MediaMetadataHelper` owns cache path layout. +- **Directory ownership**: Core `appDataDir` for metadata equals `getAppDataDir()` / `hello().appDataDir` (Linux: `~/.local/share/smm`). User config remains at `getUserDataDir()` (Linux: `~/.config/smm`). +- **UI / e2e**: Only pass `mediaFolderPath`; CRUD via HTTP. No `metadataCacheFilePath` in UI or e2e production paths. +- **Scope of this design**: CLI thin bridge. Extracting routes into `packages/core-routes` is a later optional step. + +## 2.2 App Level Architecture + +### `apps/core` + +Normalize public methods on `Core` (reuse `MediaMetadataHelper`): + +| Method | Behavior | +|--------|----------| +| `getMetadata(folderPath)` | Read cache. Missing → throw (HTTP maps to 404 ProblemDetails). | +| `createMetadata(mm)` | If cache exists → conflict. Else write new record with allowed persisted fields (at least `mediaFolderPath`). | +| `setMetadata(folderPath, patch)` | Cache **must** exist; else throw. Patch may only include `type` \| `mediaFiles` \| `tvShow` \| `movie`. Any other key in the patch → validation error (HTTP 400). Merge allowed keys and write. | +| `deleteMetadata(folderPath)` | Delete cache. Missing → idempotent success. | + +Public Core surface uses the four names above. Legacy `getMediaMetadata` is removed or aliased to `getMetadata` during migration so internal pipelines compile. Internal pipelines (`renameEpisodeFile`, recognize, scrape, etc.) continue using the same `MediaMetadataHelper`. + +### `packages/core` types + +- Remove deprecated `files` from `MediaMetadata`. +- Persistable fields: `mediaFolderPath`, `type`, `mediaFiles`, `tvShow`, `movie`. +- Add request/response types for the four RPCs; errors use existing `ProblemDetails` (RFC 9457). + +### `apps/cli` + +Register four POST routes that call `getCore()`: + +| RPC | Core | +|-----|------| +| `POST /api/get-metadata` | `getMetadata` | +| `POST /api/create-metadata` | `createMetadata` | +| `POST /api/set-metadata` | `setMetadata` | +| `POST /api/delete-metadata` | `deleteMetadata` | + +Success: HTTP **200**, body `{ data: ... }`. + +Errors for **these four APIs only**: `Content-Type: application/problem+json` + `ProblemDetails`: + +| Scenario | status | +|---------|--------| +| Metadata not found (get / set) | 404 | +| Create when already exists | 409 | +| Validation (missing path, illegal set fields) | 400 | +| Unexpected | 500 | + +Stable `type` URNs, e.g. `urn:smm:problem:metadata-not-found`. + +Other existing APIs keep current conventions until a separate migration. + +Legacy `/api/readMediaMetadata` and `/api/writeMediaMetadata` are deprecated and removed once callers are migrated. + +### `apps/ui` + +- `useMediaMetadataQuery(path)` → `POST /api/get-metadata`. 404 ProblemDetails → no cache (`null` / empty query state); callers may `createMetadata`. +- `useMediaMetadataMutation()` → create / set / delete via the three write RPCs; update TanStack Query cache (`mediaMetadataQueryKey`). +- Remove UI path logic in `readMediaMetadataV2` / `writeMediaMetadata` (`writeFile` to metadata paths) / repository dependence on local cache paths. +- File listing for panels continues via `listFiles`, decoupled from metadata. + +### `apps/e2e` + +- Read/write metadata through browser `execute` (or `/api/execute`) calling the same HTTP RPCs. +- Prefer `delete-metadata` over knowing metadata directories; `removeMetadataDir` may remain as optional cleanup fallback only. + +## 2.3 Key Design + +1. **Path opacity**: Only Core knows `{appDataDir}/metadata/{sanitizedFolder}.json`. +2. **Partial update**: `setMetadata` is a whitelist patch, not full document replace of arbitrary keys. +3. **Explicit create**: Missing cache is not silently upserted by `setMetadata`. +4. **ProblemDetails for this surface**: Real HTTP status + RFC 9457 body for the four metadata RPCs only. +5. **Fix Linux split**: Core metadata `appDataDir` === reported `hello().appDataDir`. + +## 3. User Stories + +### 3.1 UI loads metadata without knowing cache path + +* **Given** - A managed media folder path and Core has persisted metadata under `appDataDir` +* **When** - UI calls `useMediaMetadataQuery(folderPath)` +* **Then** - CLI `get-metadata` returns 200 `{ data: MediaMetadata }` with no `files` field; UI never opens a metadata JSON path + +```mermaid +sequenceDiagram + participant UI + participant CLI + participant Core + participant FS as Metadata FS + UI->>CLI: POST /api/get-metadata { path } + CLI->>Core: getMetadata(path) + Core->>FS: read cache + FS-->>Core: JSON + Core-->>CLI: MediaMetadata + CLI-->>UI: 200 { data } +``` + +### 3.2 Metadata missing returns ProblemDetails 404 + +* **Given** - No metadata cache for the folder +* **When** - Client calls `POST /api/get-metadata` +* **Then** - Response is 404 `application/problem+json` with `type` indicating metadata-not-found + +```mermaid +sequenceDiagram + participant Client + participant CLI + participant Core + Client->>CLI: POST /api/get-metadata + CLI->>Core: getMetadata + Core-->>CLI: throw not found + CLI-->>Client: 404 ProblemDetails +``` + +### 3.3 setMetadata rejects missing cache and illegal fields + +* **Given** - No cache, or patch includes non-whitelist keys +* **When** - Client calls `POST /api/set-metadata` +* **Then** - Missing cache → 404 ProblemDetails; illegal fields → 400 ProblemDetails; disk files unchanged for invalid requests + +### 3.4 E2E renames movie video after recognize + +* **Given** - E2E imported a movie folder; UI/Core share one `appDataDir` for metadata +* **When** - User confirms rename via UI (Core `renameEpisodeFile`) +* **Then** - Core finds metadata; video and associated files are renamed on disk + +### 3.5 E2E CRUD via execute + +* **Given** - Browser session with auth to CLI +* **When** - Test runs create → get → set → get → delete via HTTP inside `execute` +* **Then** - Each step matches Core behavior without asserting filesystem metadata paths + +## 4. Out of scope + +- Moving HTTP handlers into `packages/core-routes` +- Migrating all project APIs to ProblemDetails +- Rewriting TMDB/recognize business logic beyond changing metadata I/O entry points + +## 5. Test plan (summary) + +| Layer | Coverage | +|-------|----------| +| Core unit | get missing; set illegal fields; set missing; create conflict; delete idempotent | +| CLI route | 200 + data; 404/409/400 ProblemDetails | +| UI | Query/mutation against API (mock fetch as needed) | +| E2E | Metadata CRUD via execute; Movie-RenameVideoFile regression | From 5bca562f5dc4363303b191ee89c915412e9c8343 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:34:58 +0800 Subject: [PATCH 193/214] docs: add Core media metadata CRUD implementation plan Task-by-task plan for Core CRUD, CLI ProblemDetails RPCs, UI hooks, and e2e HTTP helpers. Co-authored-by: Cursor --- .../2026-08-30-core-media-metadata-crud.md | 550 ++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-core-media-metadata-crud.md diff --git a/docs/superpowers/plans/2026-08-30-core-media-metadata-crud.md b/docs/superpowers/plans/2026-08-30-core-media-metadata-crud.md new file mode 100644 index 00000000..247a473d --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-core-media-metadata-crud.md @@ -0,0 +1,550 @@ +# Core Media Metadata CRUD Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `apps/core` the sole owner of media-metadata cache CRUD; expose four CLI RPC bridges; migrate UI hooks and e2e to HTTP so neither knows metadata file directories. + +**Architecture:** `MediaMetadataHelper` stores caches under the **data** dir (`getAppDataDir()` / `hello().appDataDir`). `UserConfigHelper` stays on **config** dir (`getUserDataDir()`). CLI routes map 1:1 to Core methods. UI `useMediaMetadataQuery` / `useMediaMetadataMutation` call those routes. Errors for these four APIs use RFC 9457 ProblemDetails with real HTTP status. + +**Tech Stack:** TypeScript, Vitest, Bun, Hono, TanStack Query, WebdriverIO e2e. + +**Spec:** `docs/superpowers/specs/2026-08-30-core-media-metadata-crud-design.md` + +## Global Constraints + +- Persistable `MediaMetadata` fields only: `mediaFolderPath`, `type`, `mediaFiles`, `tvShow`, `movie` — **delete** `files` from the interface. +- `setMetadata(folderPath, patch)` whitelist: `type` | `mediaFiles` | `tvShow` | `movie` only; any other patch key → validation error (HTTP 400). +- `setMetadata` / `getMetadata`: missing cache → not-found error (HTTP 404 ProblemDetails). +- `createMetadata`: existing cache → conflict (HTTP 409 ProblemDetails). +- `deleteMetadata`: missing cache → idempotent success. +- Metadata RPCs only: ProblemDetails + real status; do not migrate unrelated APIs. +- UI/e2e must not call `metadataCacheFilePath` or write metadata via `writeFile`. +- Linux: metadata root === `~/.local/share/smm` (or `APP_DATA_DIR`); config root === `~/.config/smm` (or `USER_DATA_DIR`). + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `packages/core/types.ts` | Remove `files`; add get/create/set/delete request bodies + ProblemDetails usage notes | +| `apps/core/src/errors/MetadataNotFoundError.ts` (or `pipeline/metadataErrors.ts`) | Typed Core errors for not-found / conflict / validation | +| `apps/core/src/pipeline/setMetadataPatch.ts` | Whitelist merge helper for `setMetadata` | +| `apps/core/src/Core.ts` | `getMetadata` / `createMetadata` / `setMetadata(path, patch)` / `deleteMetadata`; wire Helper roots | +| `apps/core/src/Core.test.ts` (+ focused new test file if preferred) | Unit coverage for CRUD + dir split | +| `apps/cli/src/core/getCore.ts` | Pass `appDataDir: getAppDataDir()`, `userDataDir: getUserDataDir()` | +| `apps/cli/src/route/metadata/problemDetails.ts` | Map Core errors → ProblemDetails JSON responses | +| `apps/cli/src/route/metadata/GetMetadata.ts` | `POST /api/get-metadata` | +| `apps/cli/src/route/metadata/CreateMetadata.ts` | `POST /api/create-metadata` | +| `apps/cli/src/route/metadata/SetMetadata.ts` | `POST /api/set-metadata` | +| `apps/cli/src/route/metadata/DeleteMetadata.ts` | `POST /api/delete-metadata` | +| `apps/cli/server.ts` | Register four routes | +| `apps/ui/src/api/metadata.ts` | Thin `apiFetch` clients for the four RPCs | +| `apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts` | Query via get-metadata; 404 → null | +| `apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts` | create/set/delete + cache update | +| `apps/ui/src/lib/mediaMetadataQueryKeys.ts` | `queryFn` uses new API client | +| `apps/e2e/test/lib/metadata-http.ts` | Browser helpers calling the four RPCs | +| Delete / stop using after migration | UI `writeMediaMetadata` path-write, `readMediaMetadataV2` cache-path logic; CLI `handleReadMediaMetadata` / `handleWriteMediaMetadata` once unused | + +--- + +### Task 1: Remove `files` from `MediaMetadata` + add RPC types + +**Files:** +- Modify: `packages/core/types.ts` (`MediaMetadata`, request bodies near line ~590 and ~841) +- Fix compile errors in same PR slice where `files` is assigned (prefer minimal: stop writing `files`, delete property access that only existed for cache) + +**Interfaces:** +- Produces: +```typescript +export interface MediaMetadata { + mediaFolderPath?: string + mediaFiles?: MediaFileMetadata[] + type?: "music-folder" | "tvshow-folder" | "movie-folder" + tvShow?: TvShowMediaMetadata + movie?: MovieMediaMetadata +} + +export interface GetMetadataRequestBody { path: string } +export interface CreateMetadataRequestBody { data: MediaMetadata } +export interface SetMetadataRequestBody { + path: string + patch: Pick +} +export interface DeleteMetadataRequestBody { path: string } +export interface MetadataSuccessResponseBody { + data?: MediaMetadata | true +} +``` + +- [ ] **Step 1: Delete `files` from the interface** + +Remove the deprecated `files` block from `MediaMetadata` in `packages/core/types.ts`. + +- [ ] **Step 2: Add the four request body types + success response type** + +Place them near existing `ReadMediaMetadataRequestBody` (can leave old types until Task 8 deletes legacy routes). + +- [ ] **Step 3: Fix TypeScript breaks from removing `files`** + +Run from repo root: +```bash +pnpm exec tsc -p packages/core --noEmit +pnpm exec tsc -p apps/core --noEmit +``` +Expected: errors only at `files` assignments/reads. Fix by removing those assignments (UI/Core already treat `files` as non-persisted). Update `PersistedMediaMetadata` if it was `Omit` — simplify to `MediaMetadata` or keep alias. + +- [ ] **Step 4: Commit** + +```bash +git add packages/core/types.ts apps/core/src/pipeline/mediaMetadataValidation.ts +git commit -m "$(cat <<'EOF' +refactor(core): drop MediaMetadata.files and add metadata RPC types + +EOF +)" +``` + +--- + +### Task 2: Core errors + `setMetadata` patch helper (TDD) + +**Files:** +- Create: `apps/core/src/pipeline/metadataErrors.ts` +- Create: `apps/core/src/pipeline/setMetadataPatch.ts` +- Create: `apps/core/src/pipeline/setMetadataPatch.test.ts` + +**Interfaces:** +- Produces: +```typescript +export class MetadataNotFoundError extends Error { + readonly folderPath: string + constructor(folderPath: string) +} +export class MetadataAlreadyExistsError extends Error { + readonly folderPath: string + constructor(folderPath: string) +} +export class MetadataValidationError extends Error { + constructor(message: string) +} + +export type MetadataPatch = Pick + +/** Throws MetadataValidationError if patch has disallowed own-keys. */ +export function applyMetadataPatch( + current: MediaMetadata, + patch: Record, +): MediaMetadata +``` + +- [ ] **Step 1: Write failing tests** + +```typescript +import { describe, expect, it } from "vitest" +import { applyMetadataPatch } from "./setMetadataPatch" +import { MetadataValidationError } from "./metadataErrors" + +describe("applyMetadataPatch", () => { + const base = { mediaFolderPath: "/m/Show", type: "movie-folder" as const } + + it("merges allowed keys", () => { + const next = applyMetadataPatch(base, { + mediaFiles: [{ absolutePath: "/m/Show/a.mp4" }], + movie: { id: 1, title: "A" } as never, + }) + expect(next.mediaFiles?.[0]?.absolutePath).toBe("/m/Show/a.mp4") + expect(next.type).toBe("movie-folder") + }) + + it("rejects unknown keys", () => { + expect(() => applyMetadataPatch(base, { mediaFolderPath: "/x" })).toThrow(MetadataValidationError) + }) +}) +``` + +- [ ] **Step 2: Run test — expect FAIL** + +```bash +cd apps/core && pnpm exec vitest run src/pipeline/setMetadataPatch.test.ts +``` + +- [ ] **Step 3: Implement `metadataErrors.ts` + `applyMetadataPatch`** + +Allowed keys set: `type`, `mediaFiles`, `tvShow`, `movie`. Any other own enumerable key → `MetadataValidationError`. + +- [ ] **Step 4: Run test — expect PASS** + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/pipeline/metadataErrors.ts apps/core/src/pipeline/setMetadataPatch.ts apps/core/src/pipeline/setMetadataPatch.test.ts +git commit -m "$(cat <<'EOF' +feat(core): add metadata patch whitelist helper and typed errors + +EOF +)" +``` + +--- + +### Task 3: Core CRUD methods + separate config/data roots + +**Files:** +- Modify: `apps/core/src/Core.ts` (constructor + metadata methods ~404–412) +- Modify: `apps/core/src/Core.test.ts` (or create `apps/core/src/Core.metadataCrud.test.ts`) +- Modify: `apps/cli/src/core/getCore.ts` + +**Critical wiring (do not skip):** + +```typescript +// Core constructor +this.userConfig = new UserConfigHelper(this.fs, this.userDataDir); +const metadataRoot = this.reportedAppDataDir ?? this.appDataDir; +this.mediaMetadata = new MediaMetadataHelper(this.fs, metadataRoot); + +// getCore() +instance = new Core({ + fs: new NodejsFsAdapter(), + // ... + appDataDir: getAppDataDir(), // data: metadata + plans when using appDataDir + userDataDir: getUserDataDir(), // config: smm.json + reportedAppDataDir: getAppDataDir(), + // ... +}); +``` + +If `UserConfigHelper` previously read `smm.json` from `appDataDir` while `getCore` passed `userDataDir` as `appDataDir`, switching `appDataDir` to `getAppDataDir()` **requires** UserConfigHelper to use `userDataDir` or config will move. Verify existing Core tests that write `smm.json` under `appDataDir` — update fixtures to pass distinct `userDataDir` / `appDataDir` when needed. + +**Interfaces:** +- Produces on `Core`: +```typescript +async getMetadata(folderPath: string): Promise // throws MetadataNotFoundError +async createMetadata(mm: MediaMetadata): Promise // throws MetadataAlreadyExistsError +async setMetadata(folderPath: string, patch: MetadataPatch): Promise +async deleteMetadata(folderPath: string): Promise +``` + +- Internal full writes that used old `setMetadata(mm)` must call `this.mediaMetadata.write(...)` or `createMetadata` as appropriate. +- Call sites that need “null if missing” keep `this.mediaMetadata.read(folder)` (do **not** make `getMetadata` return null). + +- [ ] **Step 1: Write failing Core tests** + +Cover: get throws when missing; create then get; create twice throws; set merges; set missing throws; set illegal key throws; delete idempotent; metadata file lands under `reportedAppDataDir` while `smm.json` under `userDataDir`. + +- [ ] **Step 2: Run tests — expect FAIL** + +```bash +cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts +``` + +- [ ] **Step 3: Implement Core methods + constructor split + getCore env wiring** + +Rename/replace public `getMediaMetadata` usages carefully: grep `getMediaMetadata` and update. Prefer keeping a deprecated private adapter only if compile cost is high — otherwise delete. + +Old full-document `setMetadata(mm: MediaMetadata)` conflicts with new signature — rename internal full write to `writeMetadata` private method **or** only use `mediaMetadata.write` inside Core. + +- [ ] **Step 4: Run Core unit tests — expect PASS** + +```bash +cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts src/Core.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/core/src/Core.ts apps/core/src/Core.metadataCrud.test.ts apps/cli/src/core/getCore.ts +git commit -m "$(cat <<'EOF' +feat(core): metadata CRUD API and separate config/data dirs + +EOF +)" +``` + +--- + +### Task 4: CLI ProblemDetails helper + four RPC routes (TDD) + +**Files:** +- Create: `apps/cli/src/route/metadata/problemDetails.ts` +- Create: `apps/cli/src/route/metadata/GetMetadata.ts` (+ `.test.ts`) +- Create: `apps/cli/src/route/metadata/CreateMetadata.ts` (+ `.test.ts`) +- Create: `apps/cli/src/route/metadata/SetMetadata.ts` (+ `.test.ts`) +- Create: `apps/cli/src/route/metadata/DeleteMetadata.ts` (+ `.test.ts`) +- Modify: `apps/cli/server.ts` (register handlers) + +**Interfaces:** +```typescript +export function problemJson( + c: Context, + status: 400 | 404 | 409 | 500, + type: string, + title: string, + detail: string, +): Response +// Content-Type: application/problem+json +// body: ProblemDetails { type, title, status, detail, instance: c.req.path } +``` + +Map: +- `MetadataNotFoundError` → 404 `urn:smm:problem:metadata-not-found` +- `MetadataAlreadyExistsError` → 409 `urn:smm:problem:metadata-already-exists` +- `MetadataValidationError` → 400 `urn:smm:problem:metadata-validation` +- other → 500 `urn:smm:problem:internal` + +Success examples: +- get/create/set: `c.json({ data: mm }, 200)` +- delete: `c.json({ data: true }, 200)` + +- [ ] **Step 1: Write failing route test for get-metadata 404** + +Follow `RenameEpisodeFile.test.ts` pattern: temp `USER_DATA_DIR` + `APP_DATA_DIR`, `resetCoreForTests()`, mount route, `app.request`. + +```typescript +it("returns 404 ProblemDetails when missing", async () => { + const res = await app.request("/api/get-metadata", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: "/no/such/folder" }), + }) + expect(res.status).toBe(404) + expect(res.headers.get("content-type")).toContain("application/problem+json") + const body = await res.json() + expect(body.type).toBe("urn:smm:problem:metadata-not-found") +}) +``` + +- [ ] **Step 2: Run — expect FAIL** + +```bash +cd apps/cli && pnpm exec vitest run src/route/metadata/GetMetadata.test.ts +``` + +- [ ] **Step 3: Implement get/create/set/delete handlers + register in `server.ts`** + +- [ ] **Step 4: Expand tests** for create 409, set 400 illegal field, delete 200 twice, happy-path roundtrip create→get→set→get→delete + +- [ ] **Step 5: Run all metadata route tests — PASS** + +- [ ] **Step 6: Commit** + +```bash +git add apps/cli/src/route/metadata apps/cli/server.ts +git commit -m "$(cat <<'EOF' +feat(cli): add get/create/set/delete-metadata RPC bridges + +EOF +)" +``` + +--- + +### Task 5: UI API client + hooks + +**Files:** +- Create: `apps/ui/src/api/metadata.ts` +- Modify: `apps/ui/src/lib/mediaMetadataQueryKeys.ts` +- Modify: `apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts` +- Create or replace: `apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts` +- Modify: `apps/ui/src/hooks/mediaMetadata/index.ts` exports +- Adapt callers of `useUpdateMediaMetadataMutation` / `useFetchMediaMetadataMutation` to the new mutation (re-export aliases temporarily if needed) + +**Interfaces:** +```typescript +export class MetadataHttpError extends Error { + constructor(public problem: ProblemDetails, public status: number) +} + +export async function getMetadata(path: string, signal?: AbortSignal): Promise +// 404 → throw MetadataHttpError with status 404 + +export async function createMetadata(data: MediaMetadata): Promise +export async function setMetadata(path: string, patch: MetadataPatch): Promise +export async function deleteMetadata(path: string): Promise +``` + +`useMediaMetadataQuery`: on 404, return `null` (use `queryFn` that catches `MetadataHttpError` with status 404). Do **not** synthesize blank metadata with `files`. + +`useMediaMetadataMutation`: expose `create`, `set`, `remove` (or `deleteMetadata`) that update `mediaMetadataQueryKey`. + +- [ ] **Step 1: Implement `apps/ui/src/api/metadata.ts` using `apiFetch`** + +Parse ProblemDetails when `!resp.ok`. + +- [ ] **Step 2: Point `mediaMetadataReadQueryOptions` at `getMetadata`** + +- [ ] **Step 3: Implement `useMediaMetadataMutation` and switch call sites** + +Grep: +```bash +rg "useUpdateMediaMetadataMutation|useFetchMediaMetadataMutation|mediaMetadataRepository\.(write|read)" apps/ui/src -n +``` + +- [ ] **Step 4: Typecheck UI** + +```bash +pnpm exec tsc -p apps/ui --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/ui/src/api/metadata.ts apps/ui/src/hooks/mediaMetadata apps/ui/src/lib/mediaMetadataQueryKeys.ts +git commit -m "$(cat <<'EOF' +feat(ui): read/write media metadata via Core HTTP RPCs + +EOF +)" +``` + +--- + +### Task 6: Remove UI path-based metadata persistence + +**Files:** +- Modify/delete usage of: `apps/ui/src/api/writeMediaMetadata.ts`, `apps/ui/src/api/readMediaMetadataV2.ts`, `apps/ui/src/api/mediaMetadataRepository.ts`, `apps/ui/src/api/deleteMediaMetadata.ts` +- Ensure no remaining `metadataCacheFilePath` in `apps/ui/src` production code (tests may mock) + +- [ ] **Step 1: Grep and eliminate path writers** + +```bash +rg "metadataCacheFilePath|writeMediaMetadata\(|readMediaMetadataV2" apps/ui/src -n +``` + +Replace remaining repository methods with HTTP client wrappers or delete the repository class if unused. + +- [ ] **Step 2: Typecheck + unit tests for UI if present** + +```bash +pnpm exec tsc -p apps/ui --noEmit +pnpm --filter ui test -- --run 2>/dev/null || true +``` + +- [ ] **Step 3: Commit** + +```bash +git commit -m "$(cat <<'EOF' +refactor(ui): stop persisting metadata via writeFile cache paths + +EOF +)" +``` + +--- + +### Task 7: E2E metadata HTTP helpers + +**Files:** +- Create: `apps/e2e/test/lib/metadata-http.ts` +- Modify: `apps/e2e/test/lib/testbed.ts` (optional: use `deleteMetadataViaBrowser` in cleanup) +- Modify any helper like `expectMediaMetadataViaBrowser` to use get-metadata + +**Pattern (browser execute + fetch to CLI origin):** + +```typescript +export async function getMetadataViaBrowser(folderPath: string): Promise { + return browser.execute(async (path) => { + const token = new URLSearchParams(location.search).get("token") + ?? localStorage.getItem("smm-auth-token") + const res = await fetch("/api/get-metadata", { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ path }), + }) + if (res.status === 404) return null + if (!res.ok) throw new Error(await res.text()) + const body = await res.json() + return body.data + }, folderPath) +} +``` + +Adjust auth header to match how other e2e browser helpers call APIs (reuse existing helper if one exists — prefer DRY with `browser-fs` / execute patterns in `apps/e2e/test/lib`). + +- [ ] **Step 1: Add create/get/set/delete helpers** + +- [ ] **Step 2: Add a small e2e or reuse debug path to smoke CRUD** (optional dedicated spec under `apps/e2e/common/other/` if lightweight) + +- [ ] **Step 3: Commit** + +```bash +git add apps/e2e/test/lib/metadata-http.ts +git commit -m "$(cat <<'EOF' +test(e2e): add browser helpers for metadata HTTP CRUD + +EOF +)" +``` + +--- + +### Task 8: Retire legacy CLI metadata write/read routes (when unused) + +**Files:** +- Modify: `apps/cli/server.ts` — unregister `handleReadMediaMetadata` / `handleWriteMediaMetadata` if no callers +- Delete or gut: `apps/cli/src/route/mediaMetadata/read.ts`, `write.ts` (keep utils only if still needed by debug) + +- [ ] **Step 1: Confirm no UI/e2e callers of `/api/readMediaMetadata` or `/api/writeMediaMetadata`** + +```bash +rg "readMediaMetadata|writeMediaMetadata" apps/ui apps/e2e packages -n +``` + +- [ ] **Step 2: Remove registration + dead code** + +- [ ] **Step 3: Commit** + +```bash +git commit -m "$(cat <<'EOF' +chore(cli): remove legacy read/write MediaMetadata file routes + +EOF +)" +``` + +--- + +### Task 9: Regression — Movie rename e2e + Core rename path + +**Files:** +- Verify: `apps/e2e/common/movie/Movie-RenameVideoFile.e2e.ts` +- Ensure recognize/import writes metadata via Core (`createMetadata` / Core import blank write) into the **same** data dir Core rename reads + +- [ ] **Step 1: Confirm import/recognize path uses Core metadata helper (not UI writeFile)** + +If UI still writes metadata during initialize, Task 5–6 must already have routed that through `createMetadata`/`setMetadata` HTTP. + +- [ ] **Step 2: Run rename e2e** + +```bash +bun ci/run-e2e-test.ts --spec ./common/movie/Movie-RenameVideoFile.e2e.ts +``` + +Expected: PASS; no browser console `Media metadata not found` on confirm. + +- [ ] **Step 3: Commit any testbed fixes if required** + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| Remove `files` | Task 1 | +| Core get/create/set/delete | Task 3 | +| set whitelist + missing errors | Task 2–3 | +| appDataDir alignment | Task 3 (`getCore` + constructor) | +| HTTP four RPCs + ProblemDetails | Task 4 | +| UI hooks via HTTP | Task 5–6 | +| E2E via execute/HTTP | Task 7 | +| Legacy route removal | Task 8 | +| Rename regression | Task 9 | + +## Self-review notes + +- No TBD placeholders in task steps. +- `setMetadata` signature change vs old full-document write is explicit in Task 3. +- Config vs data directory split is mandatory to avoid moving `smm.json` onto Linux data dir. From 09552361df4ba68013028a2291a518bba0b13691 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:40:12 +0800 Subject: [PATCH 194/214] refactor(core): drop MediaMetadata.files and add metadata RPC types Co-authored-by: Cursor --- apps/core/src/Core.test.ts | 5 +-- apps/core/src/Core.ts | 5 ++- .../src/pipeline/importFolderPipeline.test.ts | 1 - .../core/src/pipeline/importFolderPipeline.ts | 12 +++---- apps/core/src/pipeline/importLibrary.ts | 3 +- .../src/pipeline/mediaMetadataValidation.ts | 11 +++--- .../src/pipeline/recognizeEpisodes.test.ts | 7 ++-- apps/core/src/pipeline/recognizeEpisodes.ts | 8 ++--- .../src/pipeline/recognizeMediaFolder.test.ts | 2 -- .../core/src/pipeline/recognizeMediaFolder.ts | 17 +++++++-- .../src/pipeline/renameEpisodeFile.test.ts | 6 ---- .../src/pipeline/tryToRecognizeEpisodes.ts | 12 ++++--- packages/core/mediaMetadata.test.ts | 17 +++------ packages/core/mediaMetadata.ts | 23 ++---------- packages/core/types.ts | 35 +++++++++++-------- 15 files changed, 69 insertions(+), 95 deletions(-) diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index 644d046a..f04a00a9 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -494,13 +494,12 @@ describe("getFolders", () => { describe("setMetadata", () => { const cache = metadataCachePath("/data/smm", "/m/Show"); - it("persists metadata and strips files so getMediaMetadata round-trips without files", async () => { + it("persists metadata so getMediaMetadata round-trips", async () => { const fs = inMemoryFs(); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); const mm = { mediaFolderPath: "/m/Show", type: "tvshow-folder" as const, - files: ["/m/Show/S01E01.mkv"], mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv" }], }; @@ -683,7 +682,6 @@ describe("renameFolder", () => { const mm = { mediaFolderPath: from, type: "tvshow-folder" as const, - files: [`${from}/S01E01.mkv`], mediaFiles: [{ absolutePath: `${from}/S01E01.mkv` }], }; const fs = inMemoryFs({ @@ -706,7 +704,6 @@ describe("renameFolder", () => { expect(await fs.exists(newCache)).toBe(true); const written = JSON.parse(await fs.readTextFile(newCache)) as typeof mm; expect(written.mediaFolderPath).toBe(to); - expect(written.files).toBeUndefined(); expect(written.mediaFiles?.[0]?.absolutePath).toBe(`${to}/S01E01.mkv`); const folders = await core.getFolders(); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 2bf4bfbf..311f6f25 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -405,10 +405,9 @@ export class Core { return this.mediaMetadata.read(this.normalizePosix(folder)); } - /** Writes the metadata cache for `mm.mediaFolderPath`. Strips deprecated `files`. Full replace. */ + /** Writes the metadata cache for `mm.mediaFolderPath`. Full replace. */ async setMetadata(mm: MediaMetadata): Promise { - const { files: _files, ...rest } = mm; - await this.mediaMetadata.write(rest); + await this.mediaMetadata.write(mm); } /** Removes a folder from the user config and deletes its metadata cache. Idempotent. */ diff --git a/apps/core/src/pipeline/importFolderPipeline.test.ts b/apps/core/src/pipeline/importFolderPipeline.test.ts index 8c6ee1e3..6a5619dd 100644 --- a/apps/core/src/pipeline/importFolderPipeline.test.ts +++ b/apps/core/src/pipeline/importFolderPipeline.test.ts @@ -71,7 +71,6 @@ describe("ImportFolderPipeline", () => { expect(mm.type).toBe("music-folder"); expect(mm.mediaFolderPath).toBe(mediaDir); - expect(mm.files?.sort()).toEqual(["/m/My.Show/S01E01.mkv", "/m/My.Show/poster.jpg"]); const savedConfig = JSON.parse((await fs.readTextFile(userConfigPath(appDataDir))) as string); expect(savedConfig.folders).toContain(mediaDir); diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index 94e8e515..dc07dda8 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -42,7 +42,6 @@ export function createBlankMediaMetadata(folderPath: string, type: FolderType): return { mediaFolderPath: posixPath, type: mediaMetadataType(type), - files: [], mediaFiles: [], }; } @@ -83,7 +82,7 @@ export class ImportFolderPipeline { logger.info({ folderPath: posixPath, type }, "importFolder: stage=listFiles"); const listed = await fs.listFiles(posixPath); - mm.files = listed.map((f) => Path.posix(f)); + const listedPosix = listed.map((f) => Path.posix(f)); stages.push("listFiles"); cb.onStage?.("listFiles", 40); @@ -107,7 +106,7 @@ export class ImportFolderPipeline { tvdb, language, primaryDatabase: userConfig.primaryDatabase, - }); + }, listedPosix); if (result.tvShow !== undefined) mm.tvShow = result.tvShow; if (result.movie !== undefined) mm.movie = result.movie; stages.push("recognize"); @@ -120,13 +119,13 @@ export class ImportFolderPipeline { logger.info({ folderPath: posixPath }, "importFolder: stage=episodes"); if (type === "tvshow" && mm.tvShow !== undefined) { - mm.mediaFiles = recognizeEpisodes(mm).map((i) => ({ + mm.mediaFiles = recognizeEpisodes(mm, listedPosix).map((i) => ({ absolutePath: i.file, seasonNumber: i.season, episodeNumber: i.episode, })); } else if (type === "movie" && mm.movie !== undefined) { - const firstVideo = (mm.files ?? []).find(isVideoFile); + const firstVideo = listedPosix.find(isVideoFile); mm.mediaFiles = firstVideo === undefined ? [] : [{ absolutePath: firstVideo }]; } stages.push("episodes"); @@ -134,9 +133,8 @@ export class ImportFolderPipeline { } logger.info({ folderPath: posixPath }, "importFolder: stage=persist"); - const { files: _files, ...mmToPersist } = mm; const mediaMetadataStore = new MediaMetadataHelper(fs, appDataDir); - await mediaMetadataStore.write(mmToPersist); + await mediaMetadataStore.write(mm); stages.push("persist"); cb.onStage?.("persist", 95); diff --git a/apps/core/src/pipeline/importLibrary.ts b/apps/core/src/pipeline/importLibrary.ts index 763ed46e..8a6b762d 100644 --- a/apps/core/src/pipeline/importLibrary.ts +++ b/apps/core/src/pipeline/importLibrary.ts @@ -49,8 +49,7 @@ export async function prepareLibraryFoldersForImport( ): Promise { for (const folder of folders) { const blank = createBlankMediaMetadata(folder, type); - const { files: _files, ...toPersist } = blank; - await deps.writeBlankMetadata(toPersist); + await deps.writeBlankMetadata(blank); } if (folders.length > 0) { await deps.upsertFolders(folders); diff --git a/apps/core/src/pipeline/mediaMetadataValidation.ts b/apps/core/src/pipeline/mediaMetadataValidation.ts index 2f1bf157..3c2cc8c1 100644 --- a/apps/core/src/pipeline/mediaMetadataValidation.ts +++ b/apps/core/src/pipeline/mediaMetadataValidation.ts @@ -2,8 +2,8 @@ import type { MediaFileMetadata, MediaMetadata } from "@smm/core"; const MEDIA_FOLDER_TYPES = ["music-folder", "tvshow-folder", "movie-folder"] as const; -/** Media metadata persisted on disk; {@link MediaMetadata.files} is never stored. */ -export type PersistedMediaMetadata = Omit; +/** Media metadata persisted on disk. */ +export type PersistedMediaMetadata = MediaMetadata; function assertObject(value: unknown, field: string): Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { @@ -33,7 +33,7 @@ function validateMediaFileMetadata(value: unknown, index: number): MediaFileMeta return entry; } -/** Validates metadata before persist; strips deprecated `files` when present. */ +/** Validates metadata before persist. */ export function validatePersistedMediaMetadata(value: unknown): PersistedMediaMetadata { const obj = assertObject(value, "MediaMetadata"); @@ -81,8 +81,7 @@ export function validatePersistedMediaMetadata(value: unknown): PersistedMediaMe return result; } -/** Removes deprecated `files` from loaded metadata. */ +/** Returns metadata suitable for persistence (identity after `files` removal from type). */ export function stripDeprecatedFiles(mm: MediaMetadata): PersistedMediaMetadata { - const { files: _files, ...rest } = mm; - return rest; + return mm; } diff --git a/apps/core/src/pipeline/recognizeEpisodes.test.ts b/apps/core/src/pipeline/recognizeEpisodes.test.ts index 490d33f9..99fdf926 100644 --- a/apps/core/src/pipeline/recognizeEpisodes.test.ts +++ b/apps/core/src/pipeline/recognizeEpisodes.test.ts @@ -70,7 +70,6 @@ describe("recognizeEpisodes", () => { it("matches video files to tvShow seasons via SXXEYY", () => { const mm: MediaMetadata = { mediaFolderPath: "/m/My.Show", - files: ["/m/My.Show/S01E01.mkv", "/m/My.Show/S01E02.mkv", "/m/My.Show/poster.jpg"], tvShow: { database: "TMDB", id: "1", @@ -87,15 +86,15 @@ describe("recognizeEpisodes", () => { ], }, }; - expect(recognizeEpisodes(mm)).toEqual([ + expect(recognizeEpisodes(mm, ["/m/My.Show/S01E01.mkv", "/m/My.Show/S01E02.mkv", "/m/My.Show/poster.jpg"])).toEqual([ { season: 1, episode: 1, file: "/m/My.Show/S01E01.mkv" }, { season: 1, episode: 2, file: "/m/My.Show/S01E02.mkv" }, ]); }); it("returns [] when there is no tvShow", () => { - const mm: MediaMetadata = { mediaFolderPath: "/m", files: ["/m/S01E01.mkv"] }; - expect(recognizeEpisodes(mm)).toEqual([]); + const mm: MediaMetadata = { mediaFolderPath: "/m" }; + expect(recognizeEpisodes(mm, ["/m/S01E01.mkv"])).toEqual([]); }); }); diff --git a/apps/core/src/pipeline/recognizeEpisodes.ts b/apps/core/src/pipeline/recognizeEpisodes.ts index 7b753da1..dcb12127 100644 --- a/apps/core/src/pipeline/recognizeEpisodes.ts +++ b/apps/core/src/pipeline/recognizeEpisodes.ts @@ -95,18 +95,16 @@ export function preciselyRecognizeEpisodes( return pattern4(episodes, videoFiles); } -export function recognizeEpisodes(mm: MediaMetadata): RecognizedEpisode[] { +export function recognizeEpisodes(mm: MediaMetadata, filePaths: string[]): RecognizedEpisode[] { if ( - mm.files === undefined || - mm.files === null || - mm.files.length === 0 || + filePaths.length === 0 || mm.tvShow === undefined || mm.tvShow.seasons === undefined || mm.tvShow.seasons.length === 0 ) { return []; } - let videoFiles = mm.files.filter(isVideoFile); + let videoFiles = filePaths.filter(isVideoFile); videoFiles = excludeFiles(videoFiles); if (videoFiles.length === 0) return []; const episodes = buildEpisodes(mm); diff --git a/apps/core/src/pipeline/recognizeMediaFolder.test.ts b/apps/core/src/pipeline/recognizeMediaFolder.test.ts index d02d40d3..efb3fcae 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.test.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.test.ts @@ -72,7 +72,6 @@ function writeNfo(folderPath: string, fileName: string, xml: string): void { async function mediaMetadataFrom(created: TestFolder): Promise { const folderPosix = Path.posix(created.path!); - const listed = await fs.listFiles(folderPosix); const type = created.type === "tvshow" ? "tvshow-folder" @@ -82,7 +81,6 @@ async function mediaMetadataFrom(created: TestFolder): Promise { return { mediaFolderPath: folderPosix, type, - files: listed.map((p) => Path.posix(p)), }; } diff --git a/apps/core/src/pipeline/recognizeMediaFolder.ts b/apps/core/src/pipeline/recognizeMediaFolder.ts index 11b407ab..20ff490e 100644 --- a/apps/core/src/pipeline/recognizeMediaFolder.ts +++ b/apps/core/src/pipeline/recognizeMediaFolder.ts @@ -1,3 +1,4 @@ +import { Path } from "@core/path"; import type { MediaMetadata, MovieMediaMetadata, @@ -94,9 +95,10 @@ async function recognizeByNfo( result: RecognitionResult, isTvShow: boolean, tvdbLang: string, + filePaths: string[], ): Promise { const nfoName = isTvShow ? "tvshow.nfo" : "movie.nfo"; - const nfoPath = (mm.files ?? []).find((f) => f.endsWith(`/${nfoName}`)); + const nfoPath = filePaths.find((f) => f.endsWith(`/${nfoName}`)); if (nfoPath === undefined) return; let xml: string; @@ -223,13 +225,22 @@ async function searchInTvdb( * NFO → tmdbid in folder name → tvdbid in folder name → search by folder name * (ordered by primaryDatabase). Only reached for tvshow / movie folders. */ -export async function recognizeMediaFolder(mm: MediaMetadata, deps: RecognitionDeps): Promise { +export async function recognizeMediaFolder( + mm: MediaMetadata, + deps: RecognitionDeps, + filePaths?: string[], +): Promise { const result: RecognitionResult = {}; const folderName = folderNameOf(mm); const isTvShow = mm.type === "tvshow-folder"; const tvdbLang = mapToTvdbLangCode(deps.language as PreferMediaLanguage); + const paths = + filePaths ?? + (mm.mediaFolderPath + ? (await deps.fs.listFiles(mm.mediaFolderPath)).map((f) => Path.posix(f)) + : []); - await recognizeByNfo(mm, deps, result, isTvShow, tvdbLang); + await recognizeByNfo(mm, deps, result, isTvShow, tvdbLang, paths); const tmdbId = getTmdbIdFromFolderName(folderName); if (tmdbId !== null && result.tvShow === undefined && result.movie === undefined) { diff --git a/apps/core/src/pipeline/renameEpisodeFile.test.ts b/apps/core/src/pipeline/renameEpisodeFile.test.ts index 7236c78c..599c38c3 100644 --- a/apps/core/src/pipeline/renameEpisodeFile.test.ts +++ b/apps/core/src/pipeline/renameEpisodeFile.test.ts @@ -182,12 +182,6 @@ describe("renameEpisodeFilePipeline", () => { const mm: MediaMetadata = { mediaFolderPath: movieFolder, type: "movie-folder", - files: [ - "/m/Movie/movie.mp4", - "/m/Movie/movie.srt", - "/m/Movie/movie.en.srt", - "/m/Movie/movie.ass", - ], mediaFiles: [{ absolutePath: "/m/Movie/movie.mp4" }], movie: { database: "TMDB", id: "615453", name: "Ne Zha" }, }; diff --git a/apps/core/src/pipeline/tryToRecognizeEpisodes.ts b/apps/core/src/pipeline/tryToRecognizeEpisodes.ts index 43ceba72..85351523 100644 --- a/apps/core/src/pipeline/tryToRecognizeEpisodes.ts +++ b/apps/core/src/pipeline/tryToRecognizeEpisodes.ts @@ -63,11 +63,13 @@ export async function tryToRecognizeEpisodesPipeline( const listed = await deps.fs.listFiles(posixPath); const listedPosix = listed.map((file) => Path.posix(file)); - const recognized = recognizeEpisodes({ - ...mediaMetadata, - mediaFolderPath: posixPath, - files: listedPosix, - }); + const recognized = recognizeEpisodes( + { + ...mediaMetadata, + mediaFolderPath: posixPath, + }, + listedPosix, + ); const files: RecognizedFile[] = recognized.map((item) => ({ season: item.season, diff --git a/packages/core/mediaMetadata.test.ts b/packages/core/mediaMetadata.test.ts index 753fe2c5..02f7de16 100644 --- a/packages/core/mediaMetadata.test.ts +++ b/packages/core/mediaMetadata.test.ts @@ -125,15 +125,6 @@ describe('createMediaMetadata', () => { expect(result.type).toBe('movie-folder'); }); - it('should merge files property', () => { - const files = ['/media/tvshows/Show/episode1.mkv', '/media/tvshows/Show/episode2.mkv']; - const result = createMediaMetadata('/media/tvshows/Show', 'tvshow-folder', { - files, - }); - - expect(result.files).toEqual(files); - }); - it('should merge mediaFiles property', () => { const mediaFiles = [ { @@ -233,14 +224,14 @@ describe('createMediaMetadata', () => { it('should create independent objects when props differ', () => { const result1 = createMediaMetadata('/media/music', 'music-folder', { - files: ['/a/1.mp3'], + mediaFiles: [{ absolutePath: '/a/1.mp3' }], }); const result2 = createMediaMetadata('/media/music', 'music-folder', { - files: ['/a/2.mp3'], + mediaFiles: [{ absolutePath: '/a/2.mp3' }], }); - expect(result1.files).toEqual(['/a/1.mp3']); - expect(result2.files).toEqual(['/a/2.mp3']); + expect(result1.mediaFiles).toEqual([{ absolutePath: '/a/1.mp3' }]); + expect(result2.mediaFiles).toEqual([{ absolutePath: '/a/2.mp3' }]); }); }); }); diff --git a/packages/core/mediaMetadata.ts b/packages/core/mediaMetadata.ts index 5b69a943..8b2c6356 100644 --- a/packages/core/mediaMetadata.ts +++ b/packages/core/mediaMetadata.ts @@ -4,8 +4,7 @@ import type { MediaMetadata } from "./types"; /** * Rename media folder in media metadata, which means: * 1. Rename mediaMetadata.mediaFolderPath - * 2. Rename folder in mediaMetadata.files - * 3. Rename folder in mediaMetadata.mediaFiles.absolutePath + * 2. Rename folder in mediaMetadata.mediaFiles.absolutePath * * This method assumes the "from" and "to" path are valid. * Devleoper need to do validation before calling this method. @@ -36,17 +35,7 @@ export function renameFolderInMediaMetadata(mediaMetadata: MediaMetadata, from: } } - // 2. Rename folder in mediaMetadata.files - if (result.files) { - result.files = result.files.map(file => { - if (file.startsWith(fromNormalized)) { - return toNormalized + file.slice(fromNormalized.length); - } - return file; - }); - } - - // 3. Rename folder in mediaMetadata.mediaFiles.absolutePath + // 2. Rename folder in mediaMetadata.mediaFiles.absolutePath if (result.mediaFiles) { result.mediaFiles = result.mediaFiles.map(mediaFile => { if (mediaFile.absolutePath.startsWith(fromNormalized)) { @@ -63,7 +52,7 @@ export function renameFolderInMediaMetadata(mediaMetadata: MediaMetadata, from: } /** - * Update media metadata files array and mediaFiles array after renaming files. + * Update media metadata mediaFiles array after renaming files. */ export function updateMediaMetadataAfterRename( mediaMetadata: MediaMetadata, @@ -74,11 +63,6 @@ export function updateMediaMetadataAfterRename( renameMap.set(Path.posix(from), Path.posix(to)); } - const updatedFiles = mediaMetadata.files?.map((file) => { - const normalizedFile = Path.posix(file); - return renameMap.get(normalizedFile) ?? file; - }); - const updatedMediaFiles = mediaMetadata.mediaFiles?.map((mediaFile) => { const normalizedPath = Path.posix(mediaFile.absolutePath); const newPath = renameMap.get(normalizedPath); @@ -114,7 +98,6 @@ export function updateMediaMetadataAfterRename( return { ...mediaMetadata, - files: updatedFiles, mediaFiles: fullyUpdatedMediaFiles, }; } diff --git a/packages/core/types.ts b/packages/core/types.ts index 963e124f..167320c6 100644 --- a/packages/core/types.ts +++ b/packages/core/types.ts @@ -594,20 +594,6 @@ export interface MediaMetadata { */ mediaFolderPath?: string, - /** - * @deprecated - * - * DO NOT USE this field anymore, it will be removed in the future. - * - * The absolute paths of files (all files, media files, subtitle files, poster files, etc.) in media folder - * The path is in POSIX format - * string[] - the files in the folder, empyt array means there is no files in the folder. - * null - the folder is not existed - * undefined - the files was not set, does not reflect to the actual state of the folder - * This value should not persist in file cache, SMM will load local files everytime it loads media metadata. - */ - files?: string[] | null | undefined, - /** * Stores the recognized media files * @@ -838,6 +824,27 @@ export type TmdbTvSeasonDetails = Omit & { } +export interface GetMetadataRequestBody { + path: string; +} + +export interface CreateMetadataRequestBody { + data: MediaMetadata; +} + +export interface SetMetadataRequestBody { + path: string; + patch: Pick; +} + +export interface DeleteMetadataRequestBody { + path: string; +} + +export interface MetadataSuccessResponseBody { + data?: MediaMetadata | true; +} + export interface ReadMediaMetadataRequestBody { /** * Absolute path of media folder in platform-specific format From e92334eb4bb113d3aa519b6dd24c00f7c52f4e7b Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:41:54 +0800 Subject: [PATCH 195/214] feat(core): add metadata patch whitelist helper and typed errors Co-authored-by: Cursor --- apps/core/src/pipeline/metadataErrors.ts | 26 +++++++++++++++++ .../src/pipeline/setMetadataPatch.test.ts | 20 +++++++++++++ apps/core/src/pipeline/setMetadataPatch.ts | 28 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 apps/core/src/pipeline/metadataErrors.ts create mode 100644 apps/core/src/pipeline/setMetadataPatch.test.ts create mode 100644 apps/core/src/pipeline/setMetadataPatch.ts diff --git a/apps/core/src/pipeline/metadataErrors.ts b/apps/core/src/pipeline/metadataErrors.ts new file mode 100644 index 00000000..7a07bca4 --- /dev/null +++ b/apps/core/src/pipeline/metadataErrors.ts @@ -0,0 +1,26 @@ +export class MetadataNotFoundError extends Error { + readonly folderPath: string + + constructor(folderPath: string) { + super(`Metadata not found: ${folderPath}`) + this.name = "MetadataNotFoundError" + this.folderPath = folderPath + } +} + +export class MetadataAlreadyExistsError extends Error { + readonly folderPath: string + + constructor(folderPath: string) { + super(`Metadata already exists: ${folderPath}`) + this.name = "MetadataAlreadyExistsError" + this.folderPath = folderPath + } +} + +export class MetadataValidationError extends Error { + constructor(message: string) { + super(message) + this.name = "MetadataValidationError" + } +} diff --git a/apps/core/src/pipeline/setMetadataPatch.test.ts b/apps/core/src/pipeline/setMetadataPatch.test.ts new file mode 100644 index 00000000..2a909ead --- /dev/null +++ b/apps/core/src/pipeline/setMetadataPatch.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { applyMetadataPatch } from "./setMetadataPatch" +import { MetadataValidationError } from "./metadataErrors" + +describe("applyMetadataPatch", () => { + const base = { mediaFolderPath: "/m/Show", type: "movie-folder" as const } + + it("merges allowed keys", () => { + const next = applyMetadataPatch(base, { + mediaFiles: [{ absolutePath: "/m/Show/a.mp4" }], + movie: { id: 1, title: "A" } as never, + }) + expect(next.mediaFiles?.[0]?.absolutePath).toBe("/m/Show/a.mp4") + expect(next.type).toBe("movie-folder") + }) + + it("rejects unknown keys", () => { + expect(() => applyMetadataPatch(base, { mediaFolderPath: "/x" })).toThrow(MetadataValidationError) + }) +}) diff --git a/apps/core/src/pipeline/setMetadataPatch.ts b/apps/core/src/pipeline/setMetadataPatch.ts new file mode 100644 index 00000000..c0fb1aa4 --- /dev/null +++ b/apps/core/src/pipeline/setMetadataPatch.ts @@ -0,0 +1,28 @@ +import type { MediaMetadata } from "@smm/core/types" +import { MetadataValidationError } from "./metadataErrors" + +export type MetadataPatch = Pick + +const ALLOWED_PATCH_KEYS = new Set([ + "type", + "mediaFiles", + "tvShow", + "movie", +]) + +/** Throws MetadataValidationError if patch has disallowed own-keys. */ +export function applyMetadataPatch( + current: MediaMetadata, + patch: Record, +): MediaMetadata { + for (const key of Object.keys(patch)) { + if (!ALLOWED_PATCH_KEYS.has(key as keyof MetadataPatch)) { + throw new MetadataValidationError(`Disallowed metadata patch key: ${key}`) + } + } + + return { + ...current, + ...patch, + } +} From 048af3ffa9e2b5b5e33b7df73728e0745ce00d8c Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:47:38 +0800 Subject: [PATCH 196/214] feat(core): metadata CRUD API and separate config/data dirs Co-authored-by: Cursor --- .superpowers/sdd/task-3-report.md | 109 ++++++++++++++ apps/cli/src/cli/folderDisplay.ts | 15 +- apps/cli/src/cli/runCli.ts | 14 +- apps/cli/src/core/getCore.ts | 10 +- apps/cli/src/route/FolderMetadata.ts | 15 +- apps/cli/src/route/Scrape.test.ts | 7 +- apps/cli/test/helpers/testFolders.ts | 2 +- apps/core/src/Core.metadataCrud.test.ts | 137 ++++++++++++++++++ apps/core/src/Core.test.ts | 74 +++------- apps/core/src/Core.ts | 68 +++++++-- .../core/src/pipeline/importFolderPipeline.ts | 7 +- 11 files changed, 366 insertions(+), 92 deletions(-) create mode 100644 .superpowers/sdd/task-3-report.md create mode 100644 apps/core/src/Core.metadataCrud.test.ts diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 00000000..943dde70 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,109 @@ +# Task 3 Report: Core metadata CRUD and separate roots + +## Status + +Implemented the requested Core metadata CRUD API and separated user configuration storage from application metadata storage. + +## Changes + +- Added `Core.getMetadata(folderPath)`, which returns persisted metadata or throws `MetadataNotFoundError`. +- Added `Core.createMetadata(mm)`, which writes new metadata or throws `MetadataAlreadyExistsError`. +- Replaced the old full-document `setMetadata(mm)` API with `setMetadata(folderPath, patch)`, using Task 2's `applyMetadataPatch`. +- Added idempotent `Core.deleteMetadata(folderPath)`. +- Kept null-if-missing reads and full-document writes private to Core through `MediaMetadataHelper`. +- Wired `UserConfigHelper` to `userDataDir` and `MediaMetadataHelper` to `reportedAppDataDir ?? appDataDir`. +- Wired CLI `getCore()` with `getAppDataDir()` for application data and `getUserDataDir()` for configuration. +- Passed the split roots through the import pipeline so imports continue to read/write configuration and metadata in their respective locations. +- Migrated Core and CLI consumers away from the removed public `getMediaMetadata` and full-document `setMetadata` methods. + +## TDD evidence + +### RED + +Command: + +```bash +cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts +``` + +Result: failed as expected — 8 tests failed. The new methods were absent (`core.getMetadata is not a function`, `core.createMetadata is not a function`), and the old `setMetadata` signature rejected the new call shape. + +The failing tests covered: + +- get missing metadata +- create then get +- duplicate create +- patch merge +- patch missing metadata +- illegal patch key +- idempotent delete +- separate metadata/config roots + +### GREEN + +Command: + +```bash +cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts +``` + +Result: passed — 8/8 tests. + +The separate-root CLI integration test initially exposed that `ImportFolderPipeline` still used one root for both stores. After passing `userDataDir` separately, the previously failing `FolderMetadata.test.ts` passed. + +## Verification + +- `cd apps/core && pnpm test && pnpm typecheck`: passed, 45 files / 310 tests, followed by a clean Core TypeScript check. +- `cd apps/core && pnpm exec vitest run src/Core.metadataCrud.test.ts src/Core.test.ts`: passed, 80 tests in the required Task 3 suites. +- `cd apps/cli && pnpm exec vitest run src/cli/folderDisplay.test.ts src/route/FolderMetadata.test.ts src/route/Scrape.test.ts`: passed, 3 files / 14 tests. +- IDE lint diagnostics for edited files: no errors. + +## Known concern + +`apps/cli` typecheck remains blocked by existing Task 1 migration debt: several unrelated files still reference the removed `MediaMetadata.files` property. The Task 3 Core typecheck passes; Task 3 did not reimplement the prior migration. + +## Commit + +Planned commit message: `feat(core): metadata CRUD API and separate config/data dirs` +# Task 3 Report: Align plan HTTP appDataDir with Core + +## Status + +Completed and committed as `fa063cc4` (`fix(cli): use Core userDataDir for plan HTTP routes on Linux`). + +## Changes + +- `apps/cli/src/route/Plans.ts`: plan HTTP routes now use `getUserDataDir()`. +- `apps/cli/src/mcp/mcp.ts`: MCP plan-writing tools now use `getUserDataDir()`. +- `apps/cli/server.ts`: chat plan-writing tools now use `getUserDataDir()`. +- `apps/cli/src/route/Plans.test.ts`: added a split-directory regression test proving `/api/createPlan` writes only under `USER_DATA_DIR/plans`. + +## Audit + +- `apps/cli/src/route/coreRoutesConfig.ts` retains `getAppDataDir()` because its callers perform list-files, get-episodes, rename-folder, and rename-files HTTP operations rather than plan persistence. Changing it would relocate metadata-cache reads outside this task. +- Plan persistence hosts in `Plans.ts`, `mcp.ts`, and `server.ts` now match `getCore().appDataDir`. +- No second plan store was introduced. + +## TDD and Verification + +- RED: the focused test failed because no plan existed under `USER_DATA_DIR/plans`. +- GREEN: focused regression passed (1 test). +- Full CLI suite passed: 73 files and 474 tests; 2 files and 13 tests skipped. +- CLI `tsc --noEmit` passed. +- Changed-file lint diagnostics and `git show --check` passed. + +## Self-review + +- Commit contains only the three plan-host wiring changes and regression test. +- Unrelated dirty E2E and documentation files were not staged or committed. +- No correctness issues found. The core-routes `appDataDir` field is overloaded for plan and metadata dependencies in chat/MCP, but Core's directory is the required compatibility behavior for these plan-producing hosts. + +## Important review follow-up + +- `apps/cli/src/coreRoutesServer.ts` now supplies `getUserDataDir()` to the shared core-routes handler. +- Startup and shutdown stale-plan cleanup in `apps/cli/index.ts` now scan `userDataDir`. +- The legacy rename and recognition plan writers now store plans under `getUserDataDir()/plans`. +- Added `apps/cli/src/coreRoutesServer.test.ts`; its RED run received `/metadata/app-data`, and its GREEN run received `/core/user-data`. +- Focused plan-route tests passed: 2 files and 2 tests. +- Full CLI tests passed: 74 files and 475 tests; 2 files and 13 tests skipped. +- CLI `tsc --noEmit` and changed-file lint diagnostics passed. diff --git a/apps/cli/src/cli/folderDisplay.ts b/apps/cli/src/cli/folderDisplay.ts index 2508b6f5..3f32a1d6 100644 --- a/apps/cli/src/cli/folderDisplay.ts +++ b/apps/cli/src/cli/folderDisplay.ts @@ -76,12 +76,17 @@ export async function resolveShowFolder(folder: string): Promise< } } - const mm = await getCore().getMediaMetadata(folder) - if (mm === null) { - return { - ok: true, - result: { path: folder, status: 'error_loading_metadata' }, + let mm: MediaMetadata + try { + mm = await getCore().getMetadata(folder) + } catch (error) { + if (error instanceof Error && error.name === 'MetadataNotFoundError') { + return { + ok: true, + result: { path: folder, status: 'error_loading_metadata' }, + } } + throw error } const title = titleFromMetadata(mm) diff --git a/apps/cli/src/cli/runCli.ts b/apps/cli/src/cli/runCli.ts index b66e892d..c5bb8b6a 100644 --- a/apps/cli/src/cli/runCli.ts +++ b/apps/cli/src/cli/runCli.ts @@ -240,16 +240,16 @@ export async function runCli(argv: string[] = process.argv): Promise { if (opts.set !== undefined) { const raw = await readFile(opts.set, 'utf-8') const mm = JSON.parse(raw) as MediaMetadata - await getCore().setMetadata({ ...mm, mediaFolderPath: folder }) + await getCore().setMetadata(folder, { + type: mm.type, + mediaFiles: mm.mediaFiles, + tvShow: mm.tvShow, + movie: mm.movie, + }) console.log(`updated metadata for ${folder}`) return } - const mm = await getCore().getMediaMetadata(folder) - if (mm === null) { - console.error(`No metadata cache for folder: ${folder}`) - exitCode = 1 - return - } + const mm = await getCore().getMetadata(folder) for (const line of formatMediaMetadata(folder, mm)) { console.log(line) } diff --git a/apps/cli/src/core/getCore.ts b/apps/cli/src/core/getCore.ts index 6ed521d5..dbc7e644 100644 --- a/apps/cli/src/core/getCore.ts +++ b/apps/cli/src/core/getCore.ts @@ -18,18 +18,18 @@ export interface GetCoreOptions { logger?: LoggerPort } -/** Lazy singleton. appDataDir = userDataDir so getFolders reads production smm.json. */ +/** Lazy singleton with separate application-data and user-config roots. */ export function getCore(options?: GetCoreOptions): Core { if (!instance) { - const userDataDir = getUserDataDir() + const appDataDir = getAppDataDir() instance = new Core({ fs: new NodejsFsAdapter(), network: new NodejsNetworkPort(), logger: options?.logger ?? new NoopLoggerAdapter(), - appDataDir: userDataDir, - userDataDir, + appDataDir, + userDataDir: getUserDataDir(), version: APP_VERSION, - reportedAppDataDir: getAppDataDir(), + reportedAppDataDir: appDataDir, tmpDir: getTmpDir(), logDir: getLogDir(), platform: process.platform, diff --git a/apps/cli/src/route/FolderMetadata.ts b/apps/cli/src/route/FolderMetadata.ts index a2d0ea3a..0c32251d 100644 --- a/apps/cli/src/route/FolderMetadata.ts +++ b/apps/cli/src/route/FolderMetadata.ts @@ -32,12 +32,17 @@ export function handleFolderMetadata(app: Hono): void { } return c.json(err, 200) } - const mm = await getCore().getMediaMetadata(path) - if (mm === null) { - const err: FolderMetadataResponseBody = { - error: `Error Reason: No metadata cache for folder: ${path}`, + let mm: MediaMetadata + try { + mm = await getCore().getMetadata(path) + } catch (error) { + if (error instanceof Error && error.name === 'MetadataNotFoundError') { + const err: FolderMetadataResponseBody = { + error: `Error Reason: No metadata cache for folder: ${path}`, + } + return c.json(err, 200) } - return c.json(err, 200) + throw error } const ok: FolderMetadataResponseBody = { data: mm } return c.json(ok, 200) diff --git a/apps/cli/src/route/Scrape.test.ts b/apps/cli/src/route/Scrape.test.ts index b509b8b8..e03e0df8 100644 --- a/apps/cli/src/route/Scrape.test.ts +++ b/apps/cli/src/route/Scrape.test.ts @@ -87,7 +87,6 @@ describe('POST /api/scrape', () => { const metadata: MediaMetadata = { type: 'tvshow-folder', mediaFolderPath: posixPath, - files: [], mediaFiles: [], tvShow: { database: 'TMDB', @@ -96,7 +95,11 @@ describe('POST /api/scrape', () => { seasons: [], }, } - await getCore().setMetadata(metadata) + await getCore().setMetadata(posixPath, { + type: metadata.type, + mediaFiles: metadata.mediaFiles, + tvShow: metadata.tvShow, + }) const res = await postScrape({ path: folderPath }) expect(res.status).toBe(200) diff --git a/apps/cli/test/helpers/testFolders.ts b/apps/cli/test/helpers/testFolders.ts index 12bec03f..8e350112 100644 --- a/apps/cli/test/helpers/testFolders.ts +++ b/apps/cli/test/helpers/testFolders.ts @@ -99,7 +99,7 @@ export async function createAndImportInitializedFolder( } mediaMetadata.mediaFolderPath = Path.posix(folderPath) - await getCore().setMetadata(mediaMetadata) + await getCore().createMetadata(mediaMetadata) return created } diff --git a/apps/core/src/Core.metadataCrud.test.ts b/apps/core/src/Core.metadataCrud.test.ts new file mode 100644 index 00000000..dd90c3ce --- /dev/null +++ b/apps/core/src/Core.metadataCrud.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MediaMetadata } from "@smm/core"; +import { Core } from "./Core"; +import type { FsPort } from "./ports/FsPort"; +import type { NetworkPort } from "./ports/NetworkPort"; +import { + MetadataAlreadyExistsError, + MetadataNotFoundError, + MetadataValidationError, +} from "./pipeline/metadataErrors"; +import type { MetadataPatch } from "./pipeline/setMetadataPatch"; +import { metadataCachePath, userConfigPath } from "./pipeline/paths"; + +function inMemoryFs(): FsPort { + const files = new Map(); + return { + readTextFile: vi.fn(async (path) => { + const value = files.get(path); + if (value === undefined) throw new Error(`ENOENT: ${path}`); + return value; + }), + writeTextFile: vi.fn(async (path, content) => { + files.set(path, content); + }), + writeBinaryFile: vi.fn(async () => {}), + exists: vi.fn(async (path) => files.has(path)), + listFiles: vi.fn(async () => []), + listSubdirectories: vi.fn(async () => []), + deleteFile: vi.fn(async (path) => { + files.delete(path); + }), + rename: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + }; +} + +const network: NetworkPort = { + fetch: vi.fn(async () => { + throw new Error("Unexpected network request"); + }), +}; + +function createCore(fs: FsPort): Core { + return new Core({ + fs, + network, + appDataDir: "/data/internal", + userDataDir: "/data/config", + reportedAppDataDir: "/data/reported", + }); +} + +const metadata: MediaMetadata = { + mediaFolderPath: "/media/Show", + type: "tvshow-folder", + mediaFiles: [], + tvShow: { id: "1", name: "Show", database: "TMDB", seasons: [] }, +}; + +describe("Core metadata CRUD", () => { + it("throws MetadataNotFoundError when metadata is missing", async () => { + const core = createCore(inMemoryFs()); + + await expect(core.getMetadata("/media/Missing")).rejects.toThrow(MetadataNotFoundError); + }); + + it("creates metadata and reads it back", async () => { + const core = createCore(inMemoryFs()); + + await expect(core.createMetadata(metadata)).resolves.toEqual(metadata); + await expect(core.getMetadata(metadata.mediaFolderPath!)).resolves.toEqual(metadata); + }); + + it("throws MetadataAlreadyExistsError when creating metadata twice", async () => { + const core = createCore(inMemoryFs()); + await core.createMetadata(metadata); + + await expect(core.createMetadata(metadata)).rejects.toThrow(MetadataAlreadyExistsError); + }); + + it("merges an allowed patch into existing metadata", async () => { + const core = createCore(inMemoryFs()); + await core.createMetadata(metadata); + + const updated = await core.setMetadata(metadata.mediaFolderPath!, { + type: "movie-folder", + movie: { id: "2", name: "Movie", database: "TMDB" }, + }); + + expect(updated).toMatchObject({ + mediaFolderPath: "/media/Show", + type: "movie-folder", + movie: { id: "2", name: "Movie", database: "TMDB" }, + }); + expect(updated.tvShow).toEqual(metadata.tvShow); + }); + + it("throws MetadataNotFoundError when patching missing metadata", async () => { + const core = createCore(inMemoryFs()); + + await expect(core.setMetadata("/media/Missing", { type: "movie-folder" })).rejects.toThrow( + MetadataNotFoundError, + ); + }); + + it("throws MetadataValidationError for an illegal patch key", async () => { + const core = createCore(inMemoryFs()); + await core.createMetadata(metadata); + const illegalPatch = { mediaFolderPath: "/media/Other" } as unknown as MetadataPatch; + + await expect(core.setMetadata(metadata.mediaFolderPath!, illegalPatch)).rejects.toThrow( + MetadataValidationError, + ); + }); + + it("deletes metadata idempotently", async () => { + const core = createCore(inMemoryFs()); + await core.createMetadata(metadata); + + await expect(core.deleteMetadata(metadata.mediaFolderPath!)).resolves.toBeUndefined(); + await expect(core.deleteMetadata(metadata.mediaFolderPath!)).resolves.toBeUndefined(); + await expect(core.getMetadata(metadata.mediaFolderPath!)).rejects.toThrow(MetadataNotFoundError); + }); + + it("stores metadata under reportedAppDataDir and config under userDataDir", async () => { + const fs = inMemoryFs(); + const core = createCore(fs); + + await core.createMetadata(metadata); + await core.setUserConfigKey("dryRun", true); + + expect(await fs.exists(metadataCachePath("/data/reported", metadata.mediaFolderPath!))).toBe(true); + expect(await fs.exists(userConfigPath("/data/config"))).toBe(true); + expect(await fs.exists(metadataCachePath("/data/internal", metadata.mediaFolderPath!))).toBe(false); + expect(await fs.exists(userConfigPath("/data/internal"))).toBe(false); + }); +}); diff --git a/apps/core/src/Core.test.ts b/apps/core/src/Core.test.ts index f04a00a9..f3a5d38b 100644 --- a/apps/core/src/Core.test.ts +++ b/apps/core/src/Core.test.ts @@ -174,7 +174,7 @@ describe("Core", () => { expect(savedConfig.folders).toContain("/m/Deferred"); expect(fs.listFiles).not.toHaveBeenCalled(); expect(await fs.exists(metadataCachePath("/data/smm", Path.posix("/m/Deferred")))).toBe(true); - expect(await core.getMediaMetadata("/m/Deferred")).toEqual({ + expect(await core.getMetadata("/m/Deferred")).toEqual({ mediaFolderPath: "/m/Deferred", type: "tvshow-folder", mediaFiles: [], @@ -491,10 +491,10 @@ describe("getFolders", () => { }); }); -describe("setMetadata", () => { +describe("createMetadata", () => { const cache = metadataCachePath("/data/smm", "/m/Show"); - it("persists metadata so getMediaMetadata round-trips", async () => { + it("persists metadata so getMetadata round-trips", async () => { const fs = inMemoryFs(); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); const mm = { @@ -503,42 +503,21 @@ describe("setMetadata", () => { mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv" }], }; - await core.setMetadata(mm); + await core.createMetadata(mm); const written = JSON.parse(await fs.readTextFile(cache)) as Record; - expect(written).toEqual({ - mediaFolderPath: "/m/Show", - type: "tvshow-folder", - mediaFiles: [{ absolutePath: "/m/Show/S01E01.mkv" }], - }); + expect(written).toEqual(mm); expect(written).not.toHaveProperty("files"); - expect(await core.getMediaMetadata("/m/Show")).toEqual(written); - }); - - it("fully replaces an existing cache file", async () => { - const fs = inMemoryFs({ - [cache]: JSON.stringify({ mediaFolderPath: "/m/Show", type: "tvshow-folder", tvShow: { name: "Old" } }), - }); - const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - - await core.setMetadata({ - mediaFolderPath: "/m/Show", - type: "movie-folder", - movie: { id: "1", name: "New", database: "TMDB" }, - }); - - expect(await core.getMediaMetadata("/m/Show")).toEqual({ - mediaFolderPath: "/m/Show", - type: "movie-folder", - movie: { id: "1", name: "New", database: "TMDB" }, - }); + expect(await core.getMetadata("/m/Show")).toEqual(written); }); it("rejects missing mediaFolderPath without writing", async () => { const fs = inMemoryFs(); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - await expect(core.setMetadata({ type: "tvshow-folder" })).rejects.toThrow("mediaFolderPath is required"); + await expect(core.createMetadata({ type: "tvshow-folder" })).rejects.toThrow( + "mediaFolderPath is required", + ); expect(fs.writeTextFile).not.toHaveBeenCalled(); }); @@ -546,47 +525,42 @@ describe("setMetadata", () => { const fs = inMemoryFs(); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - await expect(core.setMetadata({ mediaFolderPath: "", type: "tvshow-folder" })).rejects.toThrow( + await expect(core.createMetadata({ mediaFolderPath: "", type: "tvshow-folder" })).rejects.toThrow( "mediaFolderPath is required", ); expect(fs.writeTextFile).not.toHaveBeenCalled(); }); - it("uses the same cache key as getMediaMetadata for a Windows path", async () => { + it("uses the same cache key as getMetadata for a Windows path", async () => { const stored = "C:\\Movies\\Show"; const winCache = metadataCachePath("/data/smm", Path.posix(stored)); const fs = inMemoryFs(); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - await core.setMetadata({ mediaFolderPath: stored, type: "tvshow-folder" }); + await core.createMetadata({ mediaFolderPath: stored, type: "tvshow-folder" }); expect(await fs.exists(winCache)).toBe(true); - expect(await core.getMediaMetadata(stored)).toEqual({ + expect(await core.getMetadata(stored)).toEqual({ mediaFolderPath: stored, type: "tvshow-folder", }); }); }); -describe("getMediaMetadata", () => { +describe("getMetadata", () => { const cache = metadataCachePath("/data/smm", "/m/Show"); it("returns the cached metadata for a folder", async () => { const mm = { mediaFolderPath: "/m/Show", type: "tvshow-folder" }; const fs = inMemoryFs({ [cache]: JSON.stringify(mm) }); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - expect(await core.getMediaMetadata("/m/Show")).toEqual(mm); - }); - - it("returns null when there is no cache", async () => { - const core = new Core({ fs: inMemoryFs(), network: emptyNetwork(), appDataDir: "/data/smm" }); - expect(await core.getMediaMetadata("/m/Show")).toBeNull(); + expect(await core.getMetadata("/m/Show")).toEqual(mm); }); - it("returns null when the cache JSON is corrupt", async () => { + it("throws when the cache JSON is corrupt", async () => { const fs = inMemoryFs({ [cache]: "{ not json" }); const core = new Core({ fs, network: emptyNetwork(), appDataDir: "/data/smm" }); - expect(await core.getMediaMetadata("/m/Show")).toBeNull(); + await expect(core.getMetadata("/m/Show")).rejects.toThrow("Metadata not found: /m/Show"); }); }); @@ -952,7 +926,7 @@ describe("applyPlan", () => { const core = new Core({ fs, network: emptyNetwork(), appDataDir }); const plan = await core.tryToRecognizeEpisodes("/m/Show"); await core.applyPlan(plan); - const mm = await core.getMediaMetadata("/m/Show"); + const mm = await core.getMetadata("/m/Show"); expect(mm?.mediaFiles).toEqual([ { absolutePath: "/m/Show/S01E01.mkv", seasonNumber: 1, episodeNumber: 1 }, { absolutePath: "/m/Show/S01E02.mkv", seasonNumber: 1, episodeNumber: 2 }, @@ -963,11 +937,11 @@ describe("applyPlan", () => { it("applies empty files plan as no-op on mediaFiles but deletes plan", async () => { const fs = seed({ "/m/Show/random.mkv": "" }); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); - const before = await core.getMediaMetadata("/m/Show"); + const before = await core.getMetadata("/m/Show"); const plan = await core.tryToRecognizeEpisodes("/m/Show"); expect(plan.files).toEqual([]); await core.applyPlan(plan); - const after = await core.getMediaMetadata("/m/Show"); + const after = await core.getMetadata("/m/Show"); expect(after?.mediaFiles).toEqual(before?.mediaFiles ?? []); expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); }); @@ -1009,7 +983,7 @@ describe("applyPlan", () => { expect(await fs.exists("/m/Show/S01E01.mkv")).toBe(false); expect(await fs.exists("/m/Show/S01E01.ass")).toBe(false); - const mm = await core.getMediaMetadata("/m/Show"); + const mm = await core.getMetadata("/m/Show"); expect(mm?.mediaFiles).toEqual([ { absolutePath: targetVideo, seasonNumber: 1, episodeNumber: 1 }, ]); @@ -1038,11 +1012,11 @@ describe("applyPlan", () => { [matching]: "", }); const core = new Core({ fs, network: emptyNetwork(), appDataDir }); - const before = await core.getMediaMetadata("/m/Show"); + const before = await core.getMetadata("/m/Show"); const plan = await core.tryToRenameFolder("/m/Show"); expect(plan.files).toEqual([]); await core.applyPlan(plan); - const after = await core.getMediaMetadata("/m/Show"); + const after = await core.getMetadata("/m/Show"); expect(after?.mediaFiles).toEqual(before?.mediaFiles ?? []); expect(await fs.exists(planFilePath("/data", plan.id))).toBe(false); }); @@ -1709,7 +1683,7 @@ describe("tryToRecognizeFolder / recognizeFolder", () => { appDataDir: "/data/smm", }); await core.recognizeFolder(folder, { db: "tmdb", id: "84666" }); - const mm = await core.getMediaMetadata(folder); + const mm = await core.getMetadata(folder); expect(mm?.tvShow?.id).toBe("84666"); expect(mm?.mediaFiles).toEqual([]); }); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index 311f6f25..f71479e4 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -90,6 +90,8 @@ import type { RenameRuleName } from "./pipeline/renameRules"; import { isUserConfigKey, UserConfigHelper } from "./pipeline/userConfigHelper"; import { MediaMetadataHelper } from "./pipeline/mediaMetadataHelper"; import type { PersistedMediaMetadata } from "./pipeline/mediaMetadataValidation"; +import { MetadataAlreadyExistsError, MetadataNotFoundError } from "./pipeline/metadataErrors"; +import { applyMetadataPatch, type MetadataPatch } from "./pipeline/setMetadataPatch"; import { JobStore } from "./jobs/jobStore"; import { initialScrapeTasks, type ImportJob, type ImportLibraryJob, type Job } from "./jobs/types"; @@ -143,7 +145,7 @@ export interface CoreOptions { fs: FsPort; network: NetworkPort; logger?: LoggerPort; - /** Root directory holding smm.json and metadata/. */ + /** Root directory holding application data such as metadata and plans. */ appDataDir: string; /** App version string (e.g. "1.3.8"); getAppConfig() falls back to "". */ version?: string; @@ -227,8 +229,9 @@ export class Core { this.logDir = options.logDir; this.platform = options.platform; this.osLocale = options.osLocale; - this.userConfig = new UserConfigHelper(this.fs, this.appDataDir); - this.mediaMetadata = new MediaMetadataHelper(this.fs, this.appDataDir); + this.userConfig = new UserConfigHelper(this.fs, this.userDataDir); + const metadataRoot = this.reportedAppDataDir ?? this.appDataDir; + this.mediaMetadata = new MediaMetadataHelper(this.fs, metadataRoot); this.discover = options.discover; this.mcpServer = options.mcpServer; if (options.enableHostSpeedTest === true) { @@ -400,14 +403,40 @@ export class Core { return this.userConfig.getFolders(); } - /** Reads the persisted metadata cache for a folder; null when absent or corrupt. */ - async getMediaMetadata(folder: string): Promise { - return this.mediaMetadata.read(this.normalizePosix(folder)); + /** Reads persisted metadata or throws when the cache is absent or corrupt. */ + async getMetadata(folderPath: string): Promise { + const normalizedPath = this.normalizePosix(folderPath); + const metadata = await this.mediaMetadata.read(normalizedPath); + if (!metadata) throw new MetadataNotFoundError(normalizedPath); + return metadata; } - /** Writes the metadata cache for `mm.mediaFolderPath`. Full replace. */ - async setMetadata(mm: MediaMetadata): Promise { + /** Creates persisted metadata and rejects an existing cache. */ + async createMetadata(mm: MediaMetadata): Promise { + const folderPath = this.normalizePosix(mm.mediaFolderPath ?? ""); + if (await this.mediaMetadata.read(folderPath)) { + throw new MetadataAlreadyExistsError(folderPath); + } await this.mediaMetadata.write(mm); + return this.getMetadata(folderPath); + } + + /** Applies an allow-listed partial update to existing metadata. */ + async setMetadata( + folderPath: string, + patch: MetadataPatch, + ): Promise { + const normalizedPath = this.normalizePosix(folderPath); + const current = await this.mediaMetadata.read(normalizedPath); + if (!current) throw new MetadataNotFoundError(normalizedPath); + const next = applyMetadataPatch(current, patch); + await this.mediaMetadata.write(next); + return this.getMetadata(normalizedPath); + } + + /** Deletes persisted metadata. Idempotent. */ + async deleteMetadata(folderPath: string): Promise { + await this.mediaMetadata.delete(this.normalizePosix(folderPath)); } /** Removes a folder from the user config and deletes its metadata cache. Idempotent. */ @@ -441,8 +470,8 @@ export class Core { appDataDir: this.appDataDir, userConfig: this.userConfig, normalizePosix: (path) => this.normalizePosix(path), - getMediaMetadata: (folder) => this.getMediaMetadata(folder), - setMetadata: (mm) => this.setMetadata(mm), + getMediaMetadata: (folder) => this.readMetadata(folder), + setMetadata: (mm) => this.writeMetadata(mm), }); } @@ -512,7 +541,7 @@ export class Core { fs: this.fs, appDataDir: this.appDataDir, normalizePosix: (path) => this.normalizePosix(path), - getMediaMetadata: (folder) => this.getMediaMetadata(folder), + getMediaMetadata: (folder) => this.readMetadata(folder), }); } @@ -535,8 +564,8 @@ export class Core { fs: this.fs, appDataDir: this.appDataDir, normalizePosix: (p) => this.normalizePosix(p), - setMetadata: (mm) => this.setMetadata(mm), - getMediaMetadata: (folder) => this.getMediaMetadata(folder), + setMetadata: (mm) => this.writeMetadata(mm), + getMediaMetadata: (folder) => this.readMetadata(folder), }); } @@ -741,6 +770,14 @@ export class Core { } } + private readMetadata(folderPath: string): Promise { + return this.mediaMetadata.read(this.normalizePosix(folderPath)); + } + + private async writeMetadata(mm: MediaMetadata): Promise { + await this.mediaMetadata.write(mm); + } + private createScrapeDeps(): ScrapeFolderDeps { return { fs: this.fs, @@ -798,7 +835,7 @@ export class Core { try { await this.userConfig.addFolder(folderPath); const blankMetadata = createBlankMediaMetadata(folderPath, job.type); - await this.setMetadata(blankMetadata); + await this.writeMetadata(blankMetadata); this.jobs.update(job.id, { status: "succeeded", stage: "metadata", progress: 100 }); } catch (error) { this.jobs.update(job.id, { @@ -819,7 +856,8 @@ export class Core { fs: this.fs, network: this.network, logger: this.logger, - appDataDir: this.appDataDir, + appDataDir: this.reportedAppDataDir ?? this.appDataDir, + userDataDir: this.userDataDir, discover: this.discover, reverseProxyUrl: this.reverseProxyUrl, }); diff --git a/apps/core/src/pipeline/importFolderPipeline.ts b/apps/core/src/pipeline/importFolderPipeline.ts index dc07dda8..d96df0f7 100644 --- a/apps/core/src/pipeline/importFolderPipeline.ts +++ b/apps/core/src/pipeline/importFolderPipeline.ts @@ -16,6 +16,9 @@ export interface ImportFolderPipelineOptions { fs: FsPort; network: NetworkPort; logger: LoggerPort; + /** Root directory holding smm.json. Defaults to appDataDir for compatibility. */ + userDataDir?: string; + /** Root directory holding metadata caches. */ appDataDir: string; /** Discover config for TMDB/TVDB host failover (UI-aligned). */ discover?: DiscoverPort; @@ -55,12 +58,12 @@ export class ImportFolderPipeline { cb: ImportFolderPipelineCallbacks = {}, runOptions: ImportFolderPipelineRunOptions = {}, ): Promise { - const { fs, logger, appDataDir, network, discover, reverseProxyUrl } = this.options; + const { fs, logger, appDataDir, userDataDir, network, discover, reverseProxyUrl } = this.options; const posixPath = Path.posix(folderPath); const stages: JobStage[] = []; const skipRegistration = runOptions.skipRegistration === true; - const userConfigStore = new UserConfigHelper(fs, appDataDir); + const userConfigStore = new UserConfigHelper(fs, userDataDir ?? appDataDir); let userConfig; if (skipRegistration) { userConfig = await userConfigStore.read(); From d1bb0cc8f173f9517cfecc18524ee48fcd9e77f7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:51:41 +0800 Subject: [PATCH 197/214] fix(core): use metadata root for scrape/recognize pipelines Co-authored-by: Cursor --- .superpowers/sdd/task-3-fix-report.md | 25 ++++++++ apps/core/src/Core.metadataCrud.test.ts | 59 +++++++++++++++++++ apps/core/src/Core.ts | 47 ++++++++------- apps/core/src/pipeline/mediaMetadataHelper.ts | 32 ++++++++++ 4 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 .superpowers/sdd/task-3-fix-report.md diff --git a/.superpowers/sdd/task-3-fix-report.md b/.superpowers/sdd/task-3-fix-report.md new file mode 100644 index 00000000..3c3d6763 --- /dev/null +++ b/.superpowers/sdd/task-3-fix-report.md @@ -0,0 +1,25 @@ +# Task 3 Important Findings Fix Report + +## Status + +Fixed metadata-root propagation and made Core metadata create/update operations atomic within the existing `MediaMetadataHelper` path mutex. + +## Changes + +- Added `Core.getMetadataRoot()` and used it for metadata helpers, scrape/recognition/rename/import pipelines, and plan persistence operations. +- Added `MediaMetadataHelper.createIfAbsent()` so existence check and write share one path lock. +- Added `MediaMetadataHelper.updateIfPresent()` so read-modify-write patches share one path lock. +- Added focused regressions for distinct metadata roots, concurrent creates, and concurrent non-overlapping patches. + +## Verification + +- Red phase: all three new regressions failed for the expected reasons. +- `pnpm exec vitest run src/Core.metadataCrud.test.ts`: 11 tests passed. +- `pnpm test` in `apps/core`: 45 files and 313 tests passed. +- `pnpm typecheck` in `apps/core`: passed. +- IDE lint diagnostics for changed TypeScript files: none. + +## Residual + +- Locking remains process-local, matching the existing helper design; no distributed or cross-process lock was added. +- A corrupt existing cache is not overwritten: create reports already-exists based on the physical file, while update reports not-found because the helper cannot read valid metadata. diff --git a/apps/core/src/Core.metadataCrud.test.ts b/apps/core/src/Core.metadataCrud.test.ts index dd90c3ce..dcf6118f 100644 --- a/apps/core/src/Core.metadataCrud.test.ts +++ b/apps/core/src/Core.metadataCrud.test.ts @@ -78,6 +78,21 @@ describe("Core metadata CRUD", () => { await expect(core.createMetadata(metadata)).rejects.toThrow(MetadataAlreadyExistsError); }); + it("allows only one of two concurrent creates", async () => { + const core = createCore(inMemoryFs()); + + const results = await Promise.allSettled([ + core.createMetadata(metadata), + core.createMetadata(metadata), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(results.find((result) => result.status === "rejected")).toMatchObject({ + reason: expect.any(MetadataAlreadyExistsError), + }); + }); + it("merges an allowed patch into existing metadata", async () => { const core = createCore(inMemoryFs()); await core.createMetadata(metadata); @@ -95,6 +110,25 @@ describe("Core metadata CRUD", () => { expect(updated.tvShow).toEqual(metadata.tvShow); }); + it("preserves both concurrent metadata patches", async () => { + const core = createCore(inMemoryFs()); + await core.createMetadata(metadata); + + await Promise.all([ + core.setMetadata(metadata.mediaFolderPath!, { + movie: { id: "2", name: "Movie", database: "TMDB" }, + }), + core.setMetadata(metadata.mediaFolderPath!, { + mediaFiles: [{ absolutePath: "/media/Show/S01E01.mkv" }], + }), + ]); + + await expect(core.getMetadata(metadata.mediaFolderPath!)).resolves.toMatchObject({ + movie: { id: "2", name: "Movie", database: "TMDB" }, + mediaFiles: [{ absolutePath: "/media/Show/S01E01.mkv" }], + }); + }); + it("throws MetadataNotFoundError when patching missing metadata", async () => { const core = createCore(inMemoryFs()); @@ -134,4 +168,29 @@ describe("Core metadata CRUD", () => { expect(await fs.exists(metadataCachePath("/data/internal", metadata.mediaFolderPath!))).toBe(false); expect(await fs.exists(userConfigPath("/data/internal"))).toBe(false); }); + + it("recognizes episodes from the same metadata root used by CRUD", async () => { + const fs = inMemoryFs(); + const core = createCore(fs); + const metadataWithEpisodes: MediaMetadata = { + ...metadata, + tvShow: { + ...metadata.tvShow!, + seasons: [ + { + season: 1, + name: "Season 1", + episodes: [{ season: 1, episode: 1, name: "Pilot" }], + }, + ], + }, + }; + await core.setUserConfigKey("folders", [metadata.mediaFolderPath!]); + await core.createMetadata(metadataWithEpisodes); + + await expect(core.tryToRecognizeEpisodes(metadata.mediaFolderPath!)).resolves.toMatchObject({ + mediaFolderPath: metadata.mediaFolderPath, + task: "recognize-media-file", + }); + }); }); diff --git a/apps/core/src/Core.ts b/apps/core/src/Core.ts index f71479e4..1970b58f 100644 --- a/apps/core/src/Core.ts +++ b/apps/core/src/Core.ts @@ -230,8 +230,7 @@ export class Core { this.platform = options.platform; this.osLocale = options.osLocale; this.userConfig = new UserConfigHelper(this.fs, this.userDataDir); - const metadataRoot = this.reportedAppDataDir ?? this.appDataDir; - this.mediaMetadata = new MediaMetadataHelper(this.fs, metadataRoot); + this.mediaMetadata = new MediaMetadataHelper(this.fs, this.getMetadataRoot()); this.discover = options.discover; this.mcpServer = options.mcpServer; if (options.enableHostSpeedTest === true) { @@ -414,11 +413,11 @@ export class Core { /** Creates persisted metadata and rejects an existing cache. */ async createMetadata(mm: MediaMetadata): Promise { const folderPath = this.normalizePosix(mm.mediaFolderPath ?? ""); - if (await this.mediaMetadata.read(folderPath)) { + const created = await this.mediaMetadata.createIfAbsent(mm); + if (!created) { throw new MetadataAlreadyExistsError(folderPath); } - await this.mediaMetadata.write(mm); - return this.getMetadata(folderPath); + return created; } /** Applies an allow-listed partial update to existing metadata. */ @@ -427,11 +426,11 @@ export class Core { patch: MetadataPatch, ): Promise { const normalizedPath = this.normalizePosix(folderPath); - const current = await this.mediaMetadata.read(normalizedPath); - if (!current) throw new MetadataNotFoundError(normalizedPath); - const next = applyMetadataPatch(current, patch); - await this.mediaMetadata.write(next); - return this.getMetadata(normalizedPath); + const updated = await this.mediaMetadata.updateIfPresent(normalizedPath, (current) => + applyMetadataPatch(current, patch), + ); + if (!updated) throw new MetadataNotFoundError(normalizedPath); + return updated; } /** Deletes persisted metadata. Idempotent. */ @@ -467,7 +466,7 @@ export class Core { async renameEpisodeFile(input: RenameEpisodeFileInput): Promise { return renameEpisodeFilePipeline(input, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), userConfig: this.userConfig, normalizePosix: (path) => this.normalizePosix(path), getMediaMetadata: (folder) => this.readMetadata(folder), @@ -478,7 +477,7 @@ export class Core { async tryToRecognizeEpisodes(path: string): Promise { return tryToRecognizeEpisodesPipeline(path, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), userConfig: this.userConfig, normalizePosix: (p) => this.normalizePosix(p), }); @@ -491,7 +490,7 @@ export class Core { const { client: tvdb } = await this.createTvdbClient({}, false); return tryToRecognizeFolderPipeline(path, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), userConfig: this.userConfig, mediaMetadata: this.mediaMetadata, normalizePosix: (p) => this.normalizePosix(p), @@ -512,7 +511,7 @@ export class Core { const { client: tvdb } = await this.createTvdbClient({}, false); await recognizeFolderPipeline(path, options, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), userConfig: this.userConfig, mediaMetadata: this.mediaMetadata, normalizePosix: (p) => this.normalizePosix(p), @@ -526,7 +525,7 @@ export class Core { async tryToRenameFolder(path: string, rule?: RenameRuleName): Promise { return tryToRenameFolderPipeline(path, rule, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), userConfig: this.userConfig, normalizePosix: (p) => this.normalizePosix(p), }); @@ -539,30 +538,30 @@ export class Core { ): Promise { return createRenameEpisodePlanPipeline(mediaFolderPath, files, options, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), normalizePosix: (path) => this.normalizePosix(path), getMediaMetadata: (folder) => this.readMetadata(folder), }); } async getPlan(id: string): Promise { - const plan = await readPlan(this.fs, this.appDataDir, id); + const plan = await readPlan(this.fs, this.getMetadataRoot(), id); if (!plan) throw new Error(`Plan not found: ${id}`); return plan; } async listPlans(options?: ListPlansOptions): Promise { - return listPlans(this.fs, this.appDataDir, options); + return listPlans(this.fs, this.getMetadataRoot(), options); } async rejectPlan(id: string): Promise { - return rejectPlan(this.fs, this.appDataDir, id); + return rejectPlan(this.fs, this.getMetadataRoot(), id); } async applyPlan(plan: Plan): Promise { await applyPlanPipeline(plan, { fs: this.fs, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), normalizePosix: (p) => this.normalizePosix(p), setMetadata: (mm) => this.writeMetadata(mm), getMediaMetadata: (folder) => this.readMetadata(folder), @@ -770,6 +769,10 @@ export class Core { } } + private getMetadataRoot(): string { + return this.reportedAppDataDir ?? this.appDataDir; + } + private readMetadata(folderPath: string): Promise { return this.mediaMetadata.read(this.normalizePosix(folderPath)); } @@ -782,7 +785,7 @@ export class Core { return { fs: this.fs, network: this.network, - appDataDir: this.appDataDir, + appDataDir: this.getMetadataRoot(), userConfig: this.userConfig, normalizePosix: (p: string) => this.normalizePosix(p), discover: this.discover, @@ -856,7 +859,7 @@ export class Core { fs: this.fs, network: this.network, logger: this.logger, - appDataDir: this.reportedAppDataDir ?? this.appDataDir, + appDataDir: this.getMetadataRoot(), userDataDir: this.userDataDir, discover: this.discover, reverseProxyUrl: this.reverseProxyUrl, diff --git a/apps/core/src/pipeline/mediaMetadataHelper.ts b/apps/core/src/pipeline/mediaMetadataHelper.ts index fe6ce73f..d448d4f7 100644 --- a/apps/core/src/pipeline/mediaMetadataHelper.ts +++ b/apps/core/src/pipeline/mediaMetadataHelper.ts @@ -68,6 +68,38 @@ export class MediaMetadataHelper { }); } + /** Creates metadata only when no cache file exists. */ + async createIfAbsent( + metadata: PersistedMediaMetadata, + ): Promise { + const validated = validatePersistedMediaMetadata(metadata); + const cachePath = this.cachePath(validated.mediaFolderPath!); + let result: PersistedMediaMetadata | null = null; + await withWriteLocks([cachePath], async () => { + if (await this.fs.exists(cachePath)) return; + await this.fs.writeTextFile(cachePath, JSON.stringify(validated, null, 2)); + result = validated; + }); + return result; + } + + /** Updates an existing valid cache atomically; null when absent or corrupt. */ + async updateIfPresent( + folder: string, + mutator: (current: PersistedMediaMetadata) => PersistedMediaMetadata, + ): Promise { + const cachePath = this.cachePath(folder); + let result: PersistedMediaMetadata | null = null; + await withWriteLocks([cachePath], async () => { + const current = await this.readUnlocked(folder); + if (!current) return; + const validated = validatePersistedMediaMetadata(mutator(current)); + await this.fs.writeTextFile(cachePath, JSON.stringify(validated, null, 2)); + result = validated; + }); + return result; + } + async update( folder: string, mutator: (current: PersistedMediaMetadata) => PersistedMediaMetadata, From a69a2dba209478446a37fef1f9e61c74948f9abc Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 04:56:35 +0800 Subject: [PATCH 198/214] feat(cli): add get/create/set/delete-metadata RPC bridges Co-authored-by: Cursor --- apps/cli/server.ts | 8 ++ .../src/route/metadata/CreateMetadata.test.ts | 63 +++++++++++ apps/cli/src/route/metadata/CreateMetadata.ts | 25 +++++ .../src/route/metadata/DeleteMetadata.test.ts | 60 +++++++++++ apps/cli/src/route/metadata/DeleteMetadata.ts | 21 ++++ .../src/route/metadata/GetMetadata.test.ts | 100 ++++++++++++++++++ apps/cli/src/route/metadata/GetMetadata.ts | 21 ++++ .../src/route/metadata/SetMetadata.test.ts | 61 +++++++++++ apps/cli/src/route/metadata/SetMetadata.ts | 26 +++++ apps/cli/src/route/metadata/problemDetails.ts | 58 ++++++++++ 10 files changed, 443 insertions(+) create mode 100644 apps/cli/src/route/metadata/CreateMetadata.test.ts create mode 100644 apps/cli/src/route/metadata/CreateMetadata.ts create mode 100644 apps/cli/src/route/metadata/DeleteMetadata.test.ts create mode 100644 apps/cli/src/route/metadata/DeleteMetadata.ts create mode 100644 apps/cli/src/route/metadata/GetMetadata.test.ts create mode 100644 apps/cli/src/route/metadata/GetMetadata.ts create mode 100644 apps/cli/src/route/metadata/SetMetadata.test.ts create mode 100644 apps/cli/src/route/metadata/SetMetadata.ts create mode 100644 apps/cli/src/route/metadata/problemDetails.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 385e8830..05992f17 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -59,6 +59,10 @@ import { handleTvdb } from './src/route/Tvdb'; import { handleCoreFetch } from './src/route/CoreFetch'; import { handleShowFolder } from './src/route/ShowFolder'; import { handleFolderMetadata } from './src/route/FolderMetadata'; +import { handleGetMetadata } from './src/route/metadata/GetMetadata'; +import { handleCreateMetadata } from './src/route/metadata/CreateMetadata'; +import { handleSetMetadata } from './src/route/metadata/SetMetadata'; +import { handleDeleteMetadata } from './src/route/metadata/DeleteMetadata'; import { handleTencentAsrTranscribe } from './src/route/tencentAsr/Transcribe'; import { handleExecuteCmd } from './src/route/executeCmd'; import { handleDiscoverExecutables } from './src/route/discoverExecutables'; @@ -310,6 +314,10 @@ export class Server { handleCoreFetch(this.app); handleShowFolder(this.app); handleFolderMetadata(this.app); + handleGetMetadata(this.app); + handleCreateMetadata(this.app); + handleSetMetadata(this.app); + handleDeleteMetadata(this.app); handleTencentAsrTranscribe(this.app); handleExecuteCmd(this.app); handleDiscoverExecutables(this.app); diff --git a/apps/cli/src/route/metadata/CreateMetadata.test.ts b/apps/cli/src/route/metadata/CreateMetadata.test.ts new file mode 100644 index 00000000..8f204ea2 --- /dev/null +++ b/apps/cli/src/route/metadata/CreateMetadata.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import type { MediaMetadata } from '@core/types' +import { resetCoreForTests } from '../../core/getCore' +import { handleCreateMetadata } from './CreateMetadata' + +const metadata: MediaMetadata = { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + mediaFiles: [], +} + +describe('POST /api/create-metadata', () => { + let appDataDir: string + let userDataDir: string + let previousAppDataDir: string | undefined + let previousUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + previousAppDataDir = process.env.APP_DATA_DIR + previousUserDataDir = process.env.USER_DATA_DIR + appDataDir = mkdtempSync(join(tmpdir(), 'smm-create-metadata-app-')) + userDataDir = mkdtempSync(join(tmpdir(), 'smm-create-metadata-user-')) + process.env.APP_DATA_DIR = appDataDir + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleCreateMetadata(app) + }) + + afterEach(() => { + resetCoreForTests() + if (previousAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = previousAppDataDir + if (previousUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = previousUserDataDir + rmSync(appDataDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + }) + + async function create() { + return app.request('/api/create-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ data: metadata }), + }) + } + + it('returns 409 ProblemDetails when metadata already exists', async () => { + expect((await create()).status).toBe(200) + + const res = await create() + + expect(res.status).toBe(409) + expect(res.headers.get('content-type')).toContain('application/problem+json') + const body = (await res.json()) as { type: string } + expect(body.type).toBe('urn:smm:problem:metadata-already-exists') + }) +}) diff --git a/apps/cli/src/route/metadata/CreateMetadata.ts b/apps/cli/src/route/metadata/CreateMetadata.ts new file mode 100644 index 00000000..165dbabc --- /dev/null +++ b/apps/cli/src/route/metadata/CreateMetadata.ts @@ -0,0 +1,25 @@ +import type { Hono } from 'hono' +import type { CreateMetadataRequestBody, MediaMetadata } from '@core/types' +import { z } from 'zod' +import { getCore } from '../../core/getCore' +import { metadataProblemJson } from './problemDetails' + +const mediaMetadataSchema = z.custom( + (value) => typeof value === 'object' && value !== null, +) + +const createMetadataRequestSchema: z.ZodType = z.object({ + data: mediaMetadataSchema, +}) + +export function handleCreateMetadata(app: Hono): void { + app.post('/api/create-metadata', async (c) => { + try { + const body = createMetadataRequestSchema.parse(await c.req.json()) + const metadata = await getCore().createMetadata(body.data) + return c.json({ data: metadata }, 200) + } catch (error) { + return metadataProblemJson(c, error) + } + }) +} diff --git a/apps/cli/src/route/metadata/DeleteMetadata.test.ts b/apps/cli/src/route/metadata/DeleteMetadata.test.ts new file mode 100644 index 00000000..8033fbf4 --- /dev/null +++ b/apps/cli/src/route/metadata/DeleteMetadata.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import type { MediaMetadata } from '@core/types' +import { getCore, resetCoreForTests } from '../../core/getCore' +import { handleDeleteMetadata } from './DeleteMetadata' + +const metadata: MediaMetadata = { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + mediaFiles: [], +} + +describe('POST /api/delete-metadata', () => { + let appDataDir: string + let userDataDir: string + let previousAppDataDir: string | undefined + let previousUserDataDir: string | undefined + let app: Hono + + beforeEach(async () => { + previousAppDataDir = process.env.APP_DATA_DIR + previousUserDataDir = process.env.USER_DATA_DIR + appDataDir = mkdtempSync(join(tmpdir(), 'smm-delete-metadata-app-')) + userDataDir = mkdtempSync(join(tmpdir(), 'smm-delete-metadata-user-')) + process.env.APP_DATA_DIR = appDataDir + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleDeleteMetadata(app) + await getCore().createMetadata(metadata) + }) + + afterEach(() => { + resetCoreForTests() + if (previousAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = previousAppDataDir + if (previousUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = previousUserDataDir + rmSync(appDataDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + }) + + async function remove() { + return app.request('/api/delete-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: metadata.mediaFolderPath }), + }) + } + + it('returns success when deleting metadata twice', async () => { + for (const res of [await remove(), await remove()]) { + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ data: true }) + } + }) +}) diff --git a/apps/cli/src/route/metadata/DeleteMetadata.ts b/apps/cli/src/route/metadata/DeleteMetadata.ts new file mode 100644 index 00000000..ee52ccd6 --- /dev/null +++ b/apps/cli/src/route/metadata/DeleteMetadata.ts @@ -0,0 +1,21 @@ +import type { Hono } from 'hono' +import type { DeleteMetadataRequestBody } from '@core/types' +import { z } from 'zod' +import { getCore } from '../../core/getCore' +import { metadataProblemJson } from './problemDetails' + +const deleteMetadataRequestSchema: z.ZodType = z.object({ + path: z.string(), +}) + +export function handleDeleteMetadata(app: Hono): void { + app.post('/api/delete-metadata', async (c) => { + try { + const body = deleteMetadataRequestSchema.parse(await c.req.json()) + await getCore().deleteMetadata(body.path) + return c.json({ data: true }, 200) + } catch (error) { + return metadataProblemJson(c, error) + } + }) +} diff --git a/apps/cli/src/route/metadata/GetMetadata.test.ts b/apps/cli/src/route/metadata/GetMetadata.test.ts new file mode 100644 index 00000000..030a15a5 --- /dev/null +++ b/apps/cli/src/route/metadata/GetMetadata.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import type { MediaMetadata } from '@core/types' +import { resetCoreForTests } from '../../core/getCore' +import { handleCreateMetadata } from './CreateMetadata' +import { handleDeleteMetadata } from './DeleteMetadata' +import { handleGetMetadata } from './GetMetadata' +import { handleSetMetadata } from './SetMetadata' + +const metadata: MediaMetadata = { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + mediaFiles: [], +} + +describe('POST /api/get-metadata', () => { + let appDataDir: string + let userDataDir: string + let previousAppDataDir: string | undefined + let previousUserDataDir: string | undefined + let app: Hono + + beforeEach(() => { + previousAppDataDir = process.env.APP_DATA_DIR + previousUserDataDir = process.env.USER_DATA_DIR + appDataDir = mkdtempSync(join(tmpdir(), 'smm-get-metadata-app-')) + userDataDir = mkdtempSync(join(tmpdir(), 'smm-get-metadata-user-')) + process.env.APP_DATA_DIR = appDataDir + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleCreateMetadata(app) + handleSetMetadata(app) + handleDeleteMetadata(app) + handleGetMetadata(app) + }) + + afterEach(() => { + resetCoreForTests() + if (previousAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = previousAppDataDir + if (previousUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = previousUserDataDir + rmSync(appDataDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns 404 ProblemDetails when missing', async () => { + const res = await app.request('/api/get-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: '/no/such/folder' }), + }) + + expect(res.status).toBe(404) + expect(res.headers.get('content-type')).toContain('application/problem+json') + const body = (await res.json()) as { type: string } + expect(body.type).toBe('urn:smm:problem:metadata-not-found') + }) + + it('supports create, get, set, get, and delete roundtrip', async () => { + const post = (path: string, body: unknown) => + app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const create = await post('/api/create-metadata', { data: metadata }) + expect(create.status).toBe(200) + expect(await create.json()).toEqual({ data: metadata }) + + const firstGet = await post('/api/get-metadata', { path: metadata.mediaFolderPath }) + expect(firstGet.status).toBe(200) + expect(await firstGet.json()).toEqual({ data: metadata }) + + const set = await post('/api/set-metadata', { + path: metadata.mediaFolderPath, + patch: { type: 'movie-folder', movie: { database: 'TMDB', id: '2', name: 'Movie' } }, + }) + expect(set.status).toBe(200) + + const secondGet = await post('/api/get-metadata', { path: metadata.mediaFolderPath }) + expect(secondGet.status).toBe(200) + expect(await secondGet.json()).toMatchObject({ + data: { + mediaFolderPath: metadata.mediaFolderPath, + type: 'movie-folder', + movie: { database: 'TMDB', id: '2', name: 'Movie' }, + }, + }) + + const remove = await post('/api/delete-metadata', { path: metadata.mediaFolderPath }) + expect(remove.status).toBe(200) + expect(await remove.json()).toEqual({ data: true }) + }) +}) diff --git a/apps/cli/src/route/metadata/GetMetadata.ts b/apps/cli/src/route/metadata/GetMetadata.ts new file mode 100644 index 00000000..2819ce00 --- /dev/null +++ b/apps/cli/src/route/metadata/GetMetadata.ts @@ -0,0 +1,21 @@ +import type { Hono } from 'hono' +import type { GetMetadataRequestBody } from '@core/types' +import { z } from 'zod' +import { getCore } from '../../core/getCore' +import { metadataProblemJson } from './problemDetails' + +const getMetadataRequestSchema: z.ZodType = z.object({ + path: z.string(), +}) + +export function handleGetMetadata(app: Hono): void { + app.post('/api/get-metadata', async (c) => { + try { + const body = getMetadataRequestSchema.parse(await c.req.json()) + const metadata = await getCore().getMetadata(body.path) + return c.json({ data: metadata }, 200) + } catch (error) { + return metadataProblemJson(c, error) + } + }) +} diff --git a/apps/cli/src/route/metadata/SetMetadata.test.ts b/apps/cli/src/route/metadata/SetMetadata.test.ts new file mode 100644 index 00000000..c5818fcc --- /dev/null +++ b/apps/cli/src/route/metadata/SetMetadata.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { Hono } from 'hono' +import type { MediaMetadata } from '@core/types' +import { getCore, resetCoreForTests } from '../../core/getCore' +import { handleSetMetadata } from './SetMetadata' + +const metadata: MediaMetadata = { + mediaFolderPath: '/media/Show', + type: 'tvshow-folder', + mediaFiles: [], +} + +describe('POST /api/set-metadata', () => { + let appDataDir: string + let userDataDir: string + let previousAppDataDir: string | undefined + let previousUserDataDir: string | undefined + let app: Hono + + beforeEach(async () => { + previousAppDataDir = process.env.APP_DATA_DIR + previousUserDataDir = process.env.USER_DATA_DIR + appDataDir = mkdtempSync(join(tmpdir(), 'smm-set-metadata-app-')) + userDataDir = mkdtempSync(join(tmpdir(), 'smm-set-metadata-user-')) + process.env.APP_DATA_DIR = appDataDir + process.env.USER_DATA_DIR = userDataDir + resetCoreForTests() + app = new Hono() + handleSetMetadata(app) + await getCore().createMetadata(metadata) + }) + + afterEach(() => { + resetCoreForTests() + if (previousAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = previousAppDataDir + if (previousUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = previousUserDataDir + rmSync(appDataDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + }) + + it('returns 400 ProblemDetails for an illegal patch field', async () => { + const res = await app.request('/api/set-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: metadata.mediaFolderPath, + patch: { mediaFolderPath: '/media/Other' }, + }), + }) + + expect(res.status).toBe(400) + expect(res.headers.get('content-type')).toContain('application/problem+json') + const body = (await res.json()) as { type: string } + expect(body.type).toBe('urn:smm:problem:metadata-validation') + }) +}) diff --git a/apps/cli/src/route/metadata/SetMetadata.ts b/apps/cli/src/route/metadata/SetMetadata.ts new file mode 100644 index 00000000..1d290c7c --- /dev/null +++ b/apps/cli/src/route/metadata/SetMetadata.ts @@ -0,0 +1,26 @@ +import type { Hono } from 'hono' +import type { SetMetadataRequestBody } from '@core/types' +import { z } from 'zod' +import { getCore } from '../../core/getCore' +import { metadataProblemJson } from './problemDetails' + +const metadataPatchSchema = z.custom( + (value) => typeof value === 'object' && value !== null, +) + +const setMetadataRequestSchema: z.ZodType = z.object({ + path: z.string(), + patch: metadataPatchSchema, +}) + +export function handleSetMetadata(app: Hono): void { + app.post('/api/set-metadata', async (c) => { + try { + const body = setMetadataRequestSchema.parse(await c.req.json()) + const metadata = await getCore().setMetadata(body.path, body.patch) + return c.json({ data: metadata }, 200) + } catch (error) { + return metadataProblemJson(c, error) + } + }) +} diff --git a/apps/cli/src/route/metadata/problemDetails.ts b/apps/cli/src/route/metadata/problemDetails.ts new file mode 100644 index 00000000..3857c07e --- /dev/null +++ b/apps/cli/src/route/metadata/problemDetails.ts @@ -0,0 +1,58 @@ +import type { Context } from 'hono' +import type { ProblemDetails } from '@core/types' + +export function problemJson( + c: Context, + status: 400 | 404 | 409 | 500, + type: string, + title: string, + detail: string, +): Response { + const body: ProblemDetails = { + type, + title, + status, + detail, + instance: c.req.path, + } + return c.json(body, status, { + 'Content-Type': 'application/problem+json', + }) +} + +export function metadataProblemJson(c: Context, error: unknown): Response { + if (error instanceof Error && error.name === 'MetadataNotFoundError') { + return problemJson( + c, + 404, + 'urn:smm:problem:metadata-not-found', + 'Metadata not found', + error.message, + ) + } + if (error instanceof Error && error.name === 'MetadataAlreadyExistsError') { + return problemJson( + c, + 409, + 'urn:smm:problem:metadata-already-exists', + 'Metadata already exists', + error.message, + ) + } + if (error instanceof Error && error.name === 'MetadataValidationError') { + return problemJson( + c, + 400, + 'urn:smm:problem:metadata-validation', + 'Metadata validation failed', + error.message, + ) + } + return problemJson( + c, + 500, + 'urn:smm:problem:internal', + 'Internal server error', + error instanceof Error ? error.message : 'Unknown error', + ) +} From 1cd355101fa0c478518eb60e316a4bf36e2d5fe4 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:00:25 +0800 Subject: [PATCH 199/214] fix(cli): map metadata RPC validation failures to 400 Co-authored-by: Cursor --- .superpowers/sdd/task-4-fix-report.md | 23 ++++++++++++++ .../src/route/metadata/CreateMetadata.test.ts | 30 +++++++++++++++++++ apps/cli/src/route/metadata/CreateMetadata.ts | 9 +++++- .../src/route/metadata/GetMetadata.test.ts | 7 +++-- .../src/route/metadata/SetMetadata.test.ts | 17 +++++++++++ apps/cli/src/route/metadata/SetMetadata.ts | 11 ++++--- apps/cli/src/route/metadata/problemDetails.ts | 16 ++++++++-- apps/core/src/index.ts | 5 ++++ 8 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 .superpowers/sdd/task-4-fix-report.md diff --git a/.superpowers/sdd/task-4-fix-report.md b/.superpowers/sdd/task-4-fix-report.md new file mode 100644 index 00000000..1902b971 --- /dev/null +++ b/.superpowers/sdd/task-4-fix-report.md @@ -0,0 +1,23 @@ +# Task 4 Important Findings Fix Report + +## Status + +Fixed metadata RPC request validation so malformed JSON, invalid Zod input, and invalid Core metadata patches return 400 validation ProblemDetails. + +## Changes + +- Mapped `ZodError` and malformed-JSON `SyntaxError` to `urn:smm:problem:metadata-validation`. +- Exported and matched Core metadata errors with `instanceof` instead of error-name strings. +- Required create requests to contain a plain-object `data` with `mediaFolderPath: string`. +- Restricted set patches to a strict plain object containing only `type`, `mediaFiles`, `tvShow`, and `movie`. +- Added regressions for malformed JSON, missing `mediaFolderPath`, array patches, and complete 404 ProblemDetails fields. + +## Verification + +- Red phase: three regressions failed as expected (malformed JSON returned 500, missing `mediaFolderPath` returned 500, and `patch: []` returned 200). +- `pnpm exec vitest run src/route/metadata`: 4 files and 8 tests passed. +- IDE lint diagnostics for changed TypeScript files: none. + +## Scope + +No Task 5+ code or unrelated E2E/log artifacts were changed. diff --git a/apps/cli/src/route/metadata/CreateMetadata.test.ts b/apps/cli/src/route/metadata/CreateMetadata.test.ts index 8f204ea2..d984927a 100644 --- a/apps/cli/src/route/metadata/CreateMetadata.test.ts +++ b/apps/cli/src/route/metadata/CreateMetadata.test.ts @@ -50,6 +50,36 @@ describe('POST /api/create-metadata', () => { }) } + it('returns 400 validation ProblemDetails for malformed JSON', async () => { + const res = await app.request('/api/create-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"data":', + }) + + expect(res.status).toBe(400) + expect(res.headers.get('content-type')).toContain('application/problem+json') + expect(await res.json()).toMatchObject({ + type: 'urn:smm:problem:metadata-validation', + status: 400, + instance: '/api/create-metadata', + }) + }) + + it('returns 400 validation ProblemDetails when mediaFolderPath is missing', async () => { + const res = await app.request('/api/create-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ data: { type: 'tvshow-folder' } }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ + type: 'urn:smm:problem:metadata-validation', + status: 400, + }) + }) + it('returns 409 ProblemDetails when metadata already exists', async () => { expect((await create()).status).toBe(200) diff --git a/apps/cli/src/route/metadata/CreateMetadata.ts b/apps/cli/src/route/metadata/CreateMetadata.ts index 165dbabc..9af69409 100644 --- a/apps/cli/src/route/metadata/CreateMetadata.ts +++ b/apps/cli/src/route/metadata/CreateMetadata.ts @@ -5,7 +5,14 @@ import { getCore } from '../../core/getCore' import { metadataProblemJson } from './problemDetails' const mediaMetadataSchema = z.custom( - (value) => typeof value === 'object' && value !== null, + (value) => + typeof value === 'object' && + value !== null && + !Array.isArray(value), +).and( + z.object({ + mediaFolderPath: z.string(), + }).passthrough(), ) const createMetadataRequestSchema: z.ZodType = z.object({ diff --git a/apps/cli/src/route/metadata/GetMetadata.test.ts b/apps/cli/src/route/metadata/GetMetadata.test.ts index 030a15a5..20577f9d 100644 --- a/apps/cli/src/route/metadata/GetMetadata.test.ts +++ b/apps/cli/src/route/metadata/GetMetadata.test.ts @@ -57,8 +57,11 @@ describe('POST /api/get-metadata', () => { expect(res.status).toBe(404) expect(res.headers.get('content-type')).toContain('application/problem+json') - const body = (await res.json()) as { type: string } - expect(body.type).toBe('urn:smm:problem:metadata-not-found') + expect(await res.json()).toMatchObject({ + type: 'urn:smm:problem:metadata-not-found', + status: 404, + instance: '/api/get-metadata', + }) }) it('supports create, get, set, get, and delete roundtrip', async () => { diff --git a/apps/cli/src/route/metadata/SetMetadata.test.ts b/apps/cli/src/route/metadata/SetMetadata.test.ts index c5818fcc..3f9f4e17 100644 --- a/apps/cli/src/route/metadata/SetMetadata.test.ts +++ b/apps/cli/src/route/metadata/SetMetadata.test.ts @@ -58,4 +58,21 @@ describe('POST /api/set-metadata', () => { const body = (await res.json()) as { type: string } expect(body.type).toBe('urn:smm:problem:metadata-validation') }) + + it('returns 400 validation ProblemDetails when patch is an array', async () => { + const res = await app.request('/api/set-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: metadata.mediaFolderPath, + patch: [], + }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ + type: 'urn:smm:problem:metadata-validation', + status: 400, + }) + }) }) diff --git a/apps/cli/src/route/metadata/SetMetadata.ts b/apps/cli/src/route/metadata/SetMetadata.ts index 1d290c7c..706bef3d 100644 --- a/apps/cli/src/route/metadata/SetMetadata.ts +++ b/apps/cli/src/route/metadata/SetMetadata.ts @@ -1,12 +1,15 @@ import type { Hono } from 'hono' -import type { SetMetadataRequestBody } from '@core/types' +import type { MediaMetadata, SetMetadataRequestBody } from '@core/types' import { z } from 'zod' import { getCore } from '../../core/getCore' import { metadataProblemJson } from './problemDetails' -const metadataPatchSchema = z.custom( - (value) => typeof value === 'object' && value !== null, -) +const metadataPatchSchema: z.ZodType = z.object({ + type: z.custom().optional(), + mediaFiles: z.custom().optional(), + tvShow: z.custom().optional(), + movie: z.custom().optional(), +}).strict() const setMetadataRequestSchema: z.ZodType = z.object({ path: z.string(), diff --git a/apps/cli/src/route/metadata/problemDetails.ts b/apps/cli/src/route/metadata/problemDetails.ts index 3857c07e..2ab56891 100644 --- a/apps/cli/src/route/metadata/problemDetails.ts +++ b/apps/cli/src/route/metadata/problemDetails.ts @@ -1,5 +1,11 @@ import type { Context } from 'hono' import type { ProblemDetails } from '@core/types' +import { + MetadataAlreadyExistsError, + MetadataNotFoundError, + MetadataValidationError, +} from 'core-app' +import { ZodError } from 'zod' export function problemJson( c: Context, @@ -21,7 +27,7 @@ export function problemJson( } export function metadataProblemJson(c: Context, error: unknown): Response { - if (error instanceof Error && error.name === 'MetadataNotFoundError') { + if (error instanceof MetadataNotFoundError) { return problemJson( c, 404, @@ -30,7 +36,7 @@ export function metadataProblemJson(c: Context, error: unknown): Response { error.message, ) } - if (error instanceof Error && error.name === 'MetadataAlreadyExistsError') { + if (error instanceof MetadataAlreadyExistsError) { return problemJson( c, 409, @@ -39,7 +45,11 @@ export function metadataProblemJson(c: Context, error: unknown): Response { error.message, ) } - if (error instanceof Error && error.name === 'MetadataValidationError') { + if ( + error instanceof MetadataValidationError || + error instanceof ZodError || + error instanceof SyntaxError + ) { return problemJson( c, 400, diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 8cd5dd4f..c32b5f28 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -73,6 +73,11 @@ export { stripDeprecatedFiles, type PersistedMediaMetadata, } from "./pipeline/mediaMetadataValidation"; +export { + MetadataAlreadyExistsError, + MetadataNotFoundError, + MetadataValidationError, +} from "./pipeline/metadataErrors"; export { recognizeMediaFolder, type RecognitionDeps, type RecognitionResult } from "./pipeline/recognizeMediaFolder"; export { recognizeEpisodes, type RecognizedEpisode } from "./pipeline/recognizeEpisodes"; export { tryToRecognizeEpisodesPipeline, type TryToRecognizeEpisodesDeps } from "./pipeline/tryToRecognizeEpisodes"; From 90e72cff28d6f9f2777def9f761a56dfcfca6b2e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:03:35 +0800 Subject: [PATCH 200/214] fix(cli): validate metadata RPC field types Co-authored-by: Cursor --- .../src/route/metadata/CreateMetadata.test.ts | 16 ++++++++++++++++ apps/cli/src/route/metadata/CreateMetadata.ts | 19 +++++++------------ .../src/route/metadata/SetMetadata.test.ts | 17 +++++++++++++++++ apps/cli/src/route/metadata/SetMetadata.ts | 12 +++++------- .../cli/src/route/metadata/metadataSchemas.ts | 18 ++++++++++++++++++ 5 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 apps/cli/src/route/metadata/metadataSchemas.ts diff --git a/apps/cli/src/route/metadata/CreateMetadata.test.ts b/apps/cli/src/route/metadata/CreateMetadata.test.ts index d984927a..9001e82f 100644 --- a/apps/cli/src/route/metadata/CreateMetadata.test.ts +++ b/apps/cli/src/route/metadata/CreateMetadata.test.ts @@ -66,6 +66,22 @@ describe('POST /api/create-metadata', () => { }) }) + it('returns 400 validation ProblemDetails when type is invalid', async () => { + const res = await app.request('/api/create-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + data: { mediaFolderPath: '/media/Show', type: 42 }, + }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ + type: 'urn:smm:problem:metadata-validation', + status: 400, + }) + }) + it('returns 400 validation ProblemDetails when mediaFolderPath is missing', async () => { const res = await app.request('/api/create-metadata', { method: 'POST', diff --git a/apps/cli/src/route/metadata/CreateMetadata.ts b/apps/cli/src/route/metadata/CreateMetadata.ts index 9af69409..e3491bee 100644 --- a/apps/cli/src/route/metadata/CreateMetadata.ts +++ b/apps/cli/src/route/metadata/CreateMetadata.ts @@ -1,22 +1,17 @@ import type { Hono } from 'hono' -import type { CreateMetadataRequestBody, MediaMetadata } from '@core/types' +import type { CreateMetadataRequestBody } from '@core/types' import { z } from 'zod' import { getCore } from '../../core/getCore' +import { metadataPatchFieldSchemas } from './metadataSchemas' import { metadataProblemJson } from './problemDetails' -const mediaMetadataSchema = z.custom( - (value) => - typeof value === 'object' && - value !== null && - !Array.isArray(value), -).and( - z.object({ - mediaFolderPath: z.string(), - }).passthrough(), -) +const createMetadataDataSchema = z.object({ + mediaFolderPath: z.string().min(1), + ...metadataPatchFieldSchemas, +}).passthrough() const createMetadataRequestSchema: z.ZodType = z.object({ - data: mediaMetadataSchema, + data: createMetadataDataSchema, }) export function handleCreateMetadata(app: Hono): void { diff --git a/apps/cli/src/route/metadata/SetMetadata.test.ts b/apps/cli/src/route/metadata/SetMetadata.test.ts index 3f9f4e17..427dd1a6 100644 --- a/apps/cli/src/route/metadata/SetMetadata.test.ts +++ b/apps/cli/src/route/metadata/SetMetadata.test.ts @@ -43,6 +43,23 @@ describe('POST /api/set-metadata', () => { rmSync(userDataDir, { recursive: true, force: true }) }) + it('returns 400 validation ProblemDetails when type is invalid', async () => { + const res = await app.request('/api/set-metadata', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + path: metadata.mediaFolderPath, + patch: { type: 42 }, + }), + }) + + expect(res.status).toBe(400) + expect(await res.json()).toMatchObject({ + type: 'urn:smm:problem:metadata-validation', + status: 400, + }) + }) + it('returns 400 ProblemDetails for an illegal patch field', async () => { const res = await app.request('/api/set-metadata', { method: 'POST', diff --git a/apps/cli/src/route/metadata/SetMetadata.ts b/apps/cli/src/route/metadata/SetMetadata.ts index 706bef3d..60140d82 100644 --- a/apps/cli/src/route/metadata/SetMetadata.ts +++ b/apps/cli/src/route/metadata/SetMetadata.ts @@ -1,15 +1,13 @@ import type { Hono } from 'hono' -import type { MediaMetadata, SetMetadataRequestBody } from '@core/types' +import type { SetMetadataRequestBody } from '@core/types' import { z } from 'zod' import { getCore } from '../../core/getCore' +import { metadataPatchFieldSchemas } from './metadataSchemas' import { metadataProblemJson } from './problemDetails' -const metadataPatchSchema: z.ZodType = z.object({ - type: z.custom().optional(), - mediaFiles: z.custom().optional(), - tvShow: z.custom().optional(), - movie: z.custom().optional(), -}).strict() +const metadataPatchSchema: z.ZodType = z.object( + metadataPatchFieldSchemas, +).strict() const setMetadataRequestSchema: z.ZodType = z.object({ path: z.string(), diff --git a/apps/cli/src/route/metadata/metadataSchemas.ts b/apps/cli/src/route/metadata/metadataSchemas.ts new file mode 100644 index 00000000..fc6a4b72 --- /dev/null +++ b/apps/cli/src/route/metadata/metadataSchemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +export const metadataFolderTypeSchema = z.enum([ + 'music-folder', + 'tvshow-folder', + 'movie-folder', +]) + +export const metadataMediaFilesSchema = z.array(z.unknown()) + +export const metadataPlainObjectSchema = z.record(z.string(), z.unknown()) + +export const metadataPatchFieldSchemas = { + type: metadataFolderTypeSchema.optional(), + mediaFiles: metadataMediaFilesSchema.optional(), + tvShow: metadataPlainObjectSchema.optional(), + movie: metadataPlainObjectSchema.optional(), +} as const From e851c71fa993dae1d63b64f331ac6b63f46bbc4a Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:11:13 +0800 Subject: [PATCH 201/214] feat(ui): read/write media metadata via Core HTTP RPCs Co-authored-by: Cursor --- apps/ui/src/api/metadata.test.ts | 103 ++++++++++++++++ apps/ui/src/api/metadata.ts | 82 +++++++++++++ apps/ui/src/hooks/mediaMetadata/index.ts | 1 + .../useFetchMediaMetadataMutation.ts | 2 +- .../useMediaMetadataMutation.test.tsx | 113 ++++++++++++++++++ .../mediaMetadata/useMediaMetadataMutation.ts | 67 +++++++++++ .../mediaMetadata/useMediaMetadataQuery.ts | 20 +++- .../useUpdateMediaMetadataMutation.ts | 42 +++++-- apps/ui/src/lib/mediaMetadataQueryKeys.ts | 10 +- 9 files changed, 419 insertions(+), 21 deletions(-) create mode 100644 apps/ui/src/api/metadata.test.ts create mode 100644 apps/ui/src/api/metadata.ts create mode 100644 apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx create mode 100644 apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts diff --git a/apps/ui/src/api/metadata.test.ts b/apps/ui/src/api/metadata.test.ts new file mode 100644 index 00000000..8acfe316 --- /dev/null +++ b/apps/ui/src/api/metadata.test.ts @@ -0,0 +1,103 @@ +import type { MediaMetadata, ProblemDetails } from "@core/types" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + MetadataHttpError, + createMetadata, + deleteMetadata, + getMetadata, + setMetadata, +} from "./metadata" + +const metadata: MediaMetadata = { + mediaFolderPath: "/media/show", + type: "tvshow-folder", + mediaFiles: [], +} + +describe("metadata HTTP API", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("posts a path to get metadata and forwards the abort signal", async () => { + const signal = new AbortController().signal + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ data: metadata }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + + await expect(getMetadata("/media/show", signal)).resolves.toEqual(metadata) + expect(fetch).toHaveBeenCalledWith( + "/api/get-metadata", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ path: "/media/show" }), + signal, + }), + ) + }) + + it("throws MetadataHttpError containing ProblemDetails", async () => { + const problem: ProblemDetails = { + type: "https://smm/errors/metadata-not-found", + title: "Metadata not found", + status: 404, + detail: "No metadata for /media/missing", + instance: "/api/get-metadata", + } + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify(problem), { + status: 404, + headers: { "Content-Type": "application/problem+json" }, + }), + ) + + const error = await getMetadata("/media/missing").catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MetadataHttpError) + expect(error).toMatchObject({ status: 404, problem }) + }) + + it("uses create, set, and delete RPC request shapes", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(Response.json({ data: metadata })) + .mockResolvedValueOnce(Response.json({ data: { ...metadata, type: "movie-folder" } })) + .mockResolvedValueOnce(Response.json({ data: true })) + + await expect(createMetadata(metadata)).resolves.toEqual(metadata) + await expect( + setMetadata("/media/show", { + type: "movie-folder", + mediaFiles: [], + }), + ).resolves.toMatchObject({ type: "movie-folder" }) + await expect(deleteMetadata("/media/show")).resolves.toBeUndefined() + + expect(fetch).toHaveBeenNthCalledWith( + 1, + "/api/create-metadata", + expect.objectContaining({ body: JSON.stringify({ data: metadata }) }), + ) + expect(fetch).toHaveBeenNthCalledWith( + 2, + "/api/set-metadata", + expect.objectContaining({ + body: JSON.stringify({ + path: "/media/show", + patch: { type: "movie-folder", mediaFiles: [] }, + }), + }), + ) + expect(fetch).toHaveBeenNthCalledWith( + 3, + "/api/delete-metadata", + expect.objectContaining({ body: JSON.stringify({ path: "/media/show" }) }), + ) + }) +}) diff --git a/apps/ui/src/api/metadata.ts b/apps/ui/src/api/metadata.ts new file mode 100644 index 00000000..978fac30 --- /dev/null +++ b/apps/ui/src/api/metadata.ts @@ -0,0 +1,82 @@ +import type { + MediaMetadata, + MetadataSuccessResponseBody, + ProblemDetails, + SetMetadataRequestBody, +} from "@core/types" +import { apiFetch } from "@/lib/apiFetch" + +export type MetadataPatch = SetMetadataRequestBody["patch"] + +export class MetadataHttpError extends Error { + constructor( + public problem: ProblemDetails, + public status: number, + ) { + super(problem.detail || problem.title) + this.name = "MetadataHttpError" + } +} + +async function postMetadataRpc( + endpoint: string, + body: unknown, + signal?: AbortSignal, +): Promise { + const response = await apiFetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }) + + if (!response.ok) { + const problem = (await response.json()) as ProblemDetails + throw new MetadataHttpError(problem, response.status) + } + + return (await response.json()) as MetadataSuccessResponseBody +} + +function requireMetadata( + response: MetadataSuccessResponseBody, + endpoint: string, +): MediaMetadata { + if (!response.data || response.data === true) { + throw new Error(`${endpoint}: response.data is missing`) + } + return response.data +} + +export async function getMetadata( + path: string, + signal?: AbortSignal, +): Promise { + return requireMetadata( + await postMetadataRpc("/api/get-metadata", { path }, signal), + "/api/get-metadata", + ) +} + +export async function createMetadata( + data: MediaMetadata, +): Promise { + return requireMetadata( + await postMetadataRpc("/api/create-metadata", { data }), + "/api/create-metadata", + ) +} + +export async function setMetadata( + path: string, + patch: MetadataPatch, +): Promise { + return requireMetadata( + await postMetadataRpc("/api/set-metadata", { path, patch }), + "/api/set-metadata", + ) +} + +export async function deleteMetadata(path: string): Promise { + await postMetadataRpc("/api/delete-metadata", { path }) +} diff --git a/apps/ui/src/hooks/mediaMetadata/index.ts b/apps/ui/src/hooks/mediaMetadata/index.ts index b605b56c..93ecce37 100644 --- a/apps/ui/src/hooks/mediaMetadata/index.ts +++ b/apps/ui/src/hooks/mediaMetadata/index.ts @@ -5,5 +5,6 @@ export { } from "@/lib/mediaMetadataQueryKeys" export { useFetchMediaMetadataMutation } from "./useFetchMediaMetadataMutation" export { useInitializeMediaMetadataMutation } from "./useInitializeMediaMetadataMutation" +export { useMediaMetadataMutation } from "./useMediaMetadataMutation" export { useMediaMetadataQuery } from "./useMediaMetadataQuery" export { useUpdateMediaMetadataMutation } from "./useUpdateMediaMetadataMutation" diff --git a/apps/ui/src/hooks/mediaMetadata/useFetchMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useFetchMediaMetadataMutation.ts index f8ff2adf..d622195b 100644 --- a/apps/ui/src/hooks/mediaMetadata/useFetchMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useFetchMediaMetadataMutation.ts @@ -6,7 +6,7 @@ export function useFetchMediaMetadataMutation() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { path: string; traceId?: string }): Promise => { - const { queryKey, queryFn } = mediaMetadataReadQueryOptions(vars.path, { traceId: vars.traceId }) + const { queryKey, queryFn } = mediaMetadataReadQueryOptions(vars.path) return queryClient.fetchQuery({ queryKey, queryFn }) }, }) diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx new file mode 100644 index 00000000..430f17c8 --- /dev/null +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx @@ -0,0 +1,113 @@ +import type { PropsWithChildren } from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act, renderHook, waitFor } from "@testing-library/react" +import type { MediaMetadata, ProblemDetails } from "@core/types" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { + MetadataHttpError, + createMetadata, + deleteMetadata, + getMetadata, + setMetadata, +} from "@/api/metadata" +import { + mediaMetadataQueryKey, + normalizeMediaFolderPathForQuery, +} from "@/lib/mediaMetadataQueryKeys" +import { useMediaMetadataMutation } from "./useMediaMetadataMutation" +import { useMediaMetadataQuery } from "./useMediaMetadataQuery" + +vi.mock("@/api/metadata", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createMetadata: vi.fn(), + deleteMetadata: vi.fn(), + getMetadata: vi.fn(), + setMetadata: vi.fn(), + } +}) + +const metadata: MediaMetadata = { + mediaFolderPath: "/media/show", + type: "tvshow-folder", + mediaFiles: [], +} + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return ( + + {children} + + ) + } +} + +describe("metadata hooks", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("maps a metadata 404 to query data null", async () => { + const problem: ProblemDetails = { + type: "https://smm/errors/metadata-not-found", + title: "Metadata not found", + status: 404, + detail: "Missing", + instance: "/api/get-metadata", + } + vi.mocked(getMetadata).mockRejectedValue(new MetadataHttpError(problem, 404)) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + const { result } = renderHook( + () => useMediaMetadataQuery("C:\\media\\show"), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + }) + + it("create and set update the normalized metadata cache", async () => { + vi.mocked(createMetadata).mockResolvedValue(metadata) + const updated = { ...metadata, type: "movie-folder" as const } + vi.mocked(setMetadata).mockResolvedValue(updated) + const queryClient = new QueryClient() + const { result } = renderHook(() => useMediaMetadataMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(() => result.current.create(metadata)) + expect(queryClient.getQueryData(mediaMetadataQueryKey("/media/show"))).toEqual(metadata) + + await act(() => + result.current.set("C:\\media\\show", { + type: "movie-folder", + mediaFiles: [], + }), + ) + const windowsPathKey = mediaMetadataQueryKey( + normalizeMediaFolderPathForQuery("C:\\media\\show"), + ) + expect(queryClient.getQueryData(windowsPathKey)).toEqual(updated) + }) + + it("remove clears the normalized metadata cache", async () => { + vi.mocked(deleteMetadata).mockResolvedValue() + const queryClient = new QueryClient() + const windowsPathKey = mediaMetadataQueryKey( + normalizeMediaFolderPathForQuery("C:\\media\\show"), + ) + queryClient.setQueryData(windowsPathKey, metadata) + const { result } = renderHook(() => useMediaMetadataMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(() => result.current.remove("C:\\media\\show")) + + expect(queryClient.getQueryData(windowsPathKey)).toBeNull() + }) +}) diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts new file mode 100644 index 00000000..6feec016 --- /dev/null +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts @@ -0,0 +1,67 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import type { MediaMetadata } from "@core/types" +import { + createMetadata, + deleteMetadata, + setMetadata, + type MetadataPatch, +} from "@/api/metadata" +import { + mediaMetadataQueryKey, + normalizeMediaFolderPathForQuery, +} from "@/lib/mediaMetadataQueryKeys" + +function requireMetadataPath(metadata: MediaMetadata): string { + const path = normalizeMediaFolderPathForQuery(metadata.mediaFolderPath ?? "") + if (!path) { + throw new Error("useMediaMetadataMutation.create: missing mediaFolderPath") + } + return path +} + +export function useMediaMetadataMutation() { + const queryClient = useQueryClient() + + const createMutation = useMutation({ + mutationFn: createMetadata, + onSuccess: (metadata) => { + queryClient.setQueryData( + mediaMetadataQueryKey(requireMetadataPath(metadata)), + metadata, + ) + }, + }) + + const setMutation = useMutation({ + mutationFn: ({ path, patch }: { path: string; patch: MetadataPatch }) => + setMetadata(normalizeMediaFolderPathForQuery(path), patch), + onSuccess: (metadata, { path }) => { + queryClient.setQueryData( + mediaMetadataQueryKey(normalizeMediaFolderPathForQuery(path)), + metadata, + ) + }, + }) + + const removeMutation = useMutation({ + mutationFn: (path: string) => + deleteMetadata(normalizeMediaFolderPathForQuery(path)), + onSuccess: (_data, path) => { + queryClient.setQueryData( + mediaMetadataQueryKey(normalizeMediaFolderPathForQuery(path)), + null, + ) + }, + }) + + return { + create: createMutation.mutateAsync, + set: (path: string, patch: MetadataPatch) => + setMutation.mutateAsync({ path, patch }), + remove: removeMutation.mutateAsync, + deleteMetadata: removeMutation.mutateAsync, + createMutation, + setMutation, + removeMutation, + } +} diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts index 766d6108..5272a9dd 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts @@ -1,5 +1,6 @@ import { skipToken, useQuery } from "@tanstack/react-query" import type { MediaMetadata } from "@core/types" +import { MetadataHttpError } from "@/api/metadata" import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" /** Query key when no folder path — `queryFn: skipToken` skips fetch; must not call `mediaMetadataReadQueryOptions("")`. */ @@ -9,12 +10,23 @@ export interface UseMediaMetadataQueryOptions { defaultType?: MediaMetadata["type"] } -export function useMediaMetadataQuery(path: string | undefined, opts?: UseMediaMetadataQueryOptions) { +export function useMediaMetadataQuery(path: string | undefined, _opts?: UseMediaMetadataQueryOptions) { const trimmed = path?.trim() ?? "" - const readOpts = trimmed ? mediaMetadataReadQueryOptions(trimmed, { defaultType: opts?.defaultType }) : null + const readOpts = trimmed ? mediaMetadataReadQueryOptions(trimmed) : null - return useQuery({ + return useQuery({ queryKey: readOpts?.queryKey ?? noFolderMediaMetadataQueryKey, - queryFn: readOpts ? readOpts.queryFn : skipToken, + queryFn: readOpts + ? async (context) => { + try { + return await readOpts.queryFn(context) + } catch (error) { + if (error instanceof MetadataHttpError && error.status === 404) { + return null + } + throw error + } + } + : skipToken, }) } diff --git a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts index 2be93ef9..ae40f40b 100644 --- a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts @@ -1,11 +1,25 @@ import { useCallback } from "react" import { useMutation, useQueryClient } from "@tanstack/react-query" import type { MediaMetadata } from "@core/types" -import { mediaMetadataRepository } from "@/api/mediaMetadataRepository" +import { + createMetadata, + getMetadata, + MetadataHttpError, + setMetadata, + type MetadataPatch, +} from "@/api/metadata" import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" -import type { UIMediaMetadata } from "@/types/UIMediaMetadata" -/** Thin persist + cache update. Full `hasDomainChanged` parity with legacy actions is deferred to migration. */ +function toMetadataPatch(metadata: MediaMetadata): MetadataPatch { + return { + type: metadata.type, + mediaFiles: metadata.mediaFiles, + tvShow: metadata.tvShow, + movie: metadata.movie, + } +} + +/** Compatibility wrapper around the metadata HTTP RPC hooks. */ export function useUpdateMediaMetadataMutation() { const queryClient = useQueryClient() const mutation = useMutation({ @@ -22,8 +36,19 @@ export function useUpdateMediaMetadataMutation() { if (!folder) { throw new Error("useUpdateMediaMetadataMutation: missing folder path") } - await mediaMetadataRepository.write(vars.metadata as UIMediaMetadata, vars.traceId ? { traceId: vars.traceId } : {}) - return { folderPathPosix: folder, metadata: vars.metadata } + let persisted: MediaMetadata + try { + persisted = await setMetadata(folder, toMetadataPatch(vars.metadata)) + } catch (error) { + if (!(error instanceof MetadataHttpError) || error.status !== 404) { + throw error + } + persisted = await createMetadata({ + ...vars.metadata, + mediaFolderPath: folder, + }) + } + return { folderPathPosix: folder, metadata: persisted } }, onSuccess: ({ folderPathPosix, metadata }) => { queryClient.setQueryData( @@ -45,14 +70,9 @@ export function useUpdateMediaMetadataMutation() { const key = mediaMetadataQueryKey(pathPosix) const cached = queryClient.getQueryData(key) - const currentFromCacheOrRepo = cached ?? (await mediaMetadataRepository.read(pathPosix)) - const prev: MediaMetadata = { - ...(currentFromCacheOrRepo as MediaMetadata), - } - const next = typeof nextOrUpdater === "function" - ? nextOrUpdater(prev) + ? nextOrUpdater({ ...(cached ?? (await getMetadata(pathPosix))) }) : nextOrUpdater await mutation.mutateAsync({ diff --git a/apps/ui/src/lib/mediaMetadataQueryKeys.ts b/apps/ui/src/lib/mediaMetadataQueryKeys.ts index 43fd5536..9d3acd2a 100644 --- a/apps/ui/src/lib/mediaMetadataQueryKeys.ts +++ b/apps/ui/src/lib/mediaMetadataQueryKeys.ts @@ -1,8 +1,8 @@ import { Path } from "@core/path" import type { MediaMetadata } from "@core/types" -import { mediaMetadataRepository } from "@/api/mediaMetadataRepository" +import { getMetadata } from "@/api/metadata" -/** TanStack Query keys for per-folder persisted metadata (cache / disk via repository). */ +/** TanStack Query keys for per-folder persisted metadata. */ export function mediaMetadataQueryKey(folderPathPosix: string) { return ["mediaMetadata", folderPathPosix] as const } @@ -13,11 +13,11 @@ export function normalizeMediaFolderPathForQuery(path: string): string { } /** Shared options for `useQuery` / `queryClient.fetchQuery` so cache identity matches. */ -export function mediaMetadataReadQueryOptions(path: string, opts?: { traceId?: string; defaultType?: import("@core/types").MediaMetadata["type"] }) { +export function mediaMetadataReadQueryOptions(path: string) { const folderPathPosix = normalizeMediaFolderPathForQuery(path) return { queryKey: mediaMetadataQueryKey(folderPathPosix), - queryFn: (): Promise => - mediaMetadataRepository.read(folderPathPosix, opts ?? {}), + queryFn: ({ signal }: { signal?: AbortSignal } = {}): Promise => + getMetadata(folderPathPosix, signal), } } From 80ef8c5af7b97b638974847c239f22d5036bfabe Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:21:13 +0800 Subject: [PATCH 202/214] refactor(ui): stop persisting metadata via writeFile cache paths Co-authored-by: Cursor --- .superpowers/sdd/task-6-report.md | 58 +-- apps/ui/src/AppV2.tsx | 4 +- apps/ui/src/api/deleteMediaMetadata.ts | 27 -- apps/ui/src/api/index.ts | 7 - .../src/api/mediaMetadataRepository.test.ts | 214 ----------- apps/ui/src/api/mediaMetadataRepository.ts | 99 ------ apps/ui/src/api/readMediaMetadataV2.test.ts | 332 ------------------ apps/ui/src/api/readMediaMetadataV2.ts | 83 ----- apps/ui/src/api/writeMediaMatadata.ts | 23 -- apps/ui/src/api/writeMediaMetadata.ts | 36 -- apps/ui/src/components/v2/Sidebar.test.tsx | 6 +- apps/ui/src/components/v2/Sidebar.tsx | 4 +- 12 files changed, 16 insertions(+), 877 deletions(-) delete mode 100644 apps/ui/src/api/deleteMediaMetadata.ts delete mode 100644 apps/ui/src/api/index.ts delete mode 100644 apps/ui/src/api/mediaMetadataRepository.test.ts delete mode 100644 apps/ui/src/api/mediaMetadataRepository.ts delete mode 100644 apps/ui/src/api/readMediaMetadataV2.test.ts delete mode 100644 apps/ui/src/api/readMediaMetadataV2.ts delete mode 100644 apps/ui/src/api/writeMediaMatadata.ts delete mode 100644 apps/ui/src/api/writeMediaMetadata.ts diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md index 69f8fedb..af2c8212 100644 --- a/.superpowers/sdd/task-6-report.md +++ b/.superpowers/sdd/task-6-report.md @@ -2,59 +2,21 @@ ## Status -Implemented the single `create-rename-episode-plan` MCP/chat tool in -`core-routes`. It calls `createRenameEpisodePlanPipeline` with the host -`appDataDir` and filesystem adapter, writes a pending AI-created plan, -broadcasts `RenameFilesPlanReady`, and returns -`END_PLAN_TASK_SUCCESS_MESSAGE` with `planId`. +Completed. UI metadata deletion now uses the Core HTTP RPC client, and the obsolete path-based metadata repository, cache readers/writers, and their tests were removed. -The legacy begin/add/end rename tool builders and MCP handlers were removed. -The rename how-to text and CLI AI-tool registry alignment test now reference -the single tool. +## Changes -## TDD - -- Added MCP tests for successful plan creation with an in-memory filesystem. -- Added validation coverage for an empty `files` array. -- Added guidance coverage ensuring the how-to text names only the single tool. -- The initial red run failed because the old rename tool modules still imported - the removed Task 4 contract; after replacement, the focused tests passed. +- Redirected metadata deletion in `AppV2.tsx` and `components/v2/Sidebar.tsx` to `deleteMetadata`. +- Removed `metadataCacheFilePath`, `readMediaMetadataV2`, direct metadata `writeFile` persistence, and the unused repository facade. +- Removed the unused legacy `/api/writeMediaMetadata` UI wrapper and obsolete API index. ## Verification -- `packages/core-routes: pnpm test` — 34 files passed, 323 tests passed, - 1 skipped. -- `packages/core-routes: pnpm typecheck` — passed. -- `packages/core-routes: pnpm build` — passed. -- `apps/core: pnpm typecheck` — passed. -- `apps/cli: pnpm exec vitest run src/test/ai-tool-registry.test.ts` — - 3 tests passed. -- `git diff --check` — passed. - -## Commit - -`feat(core-routes): single create-rename-episode-plan MCP tool` +- `git grep -n -E "metadataCacheFilePath|writeMediaMetadata\(|readMediaMetadataV2|mediaMetadataRepository|deleteMediaMetadata" -- "apps/ui/src"`: no matches. +- `pnpm --filter ui typecheck`: passed. +- `pnpm --filter ui test -- --run`: passed, 203 files and 1,749 tests (1 file and 23 tests skipped). +- `git diff --check -- "apps/ui/src"`: passed. ## Concerns -None. The pre-existing dirty E2E and documentation files were not included in -this task. - -## Review fix: defaultBroadcast fallback - -**Issue:** `buildCreateRenameEpisodePlanTool` used `broadcast?.(...)`, so when the -host omitted optional `config.broadcast`, the required `RenameFilesPlanReady` -event was silently skipped. - -**Fix:** Restored the sibling-tool pattern (`const emit = broadcast ?? -defaultBroadcast`) from `endRenameTask` / `endRecognizeTask`. Plan-ready events -now always fire via injected callback or the no-op default. - -**Test:** Added `src/tools/createRenameEpisodePlan.test.ts` covering the -no-injected-callback path (spies on `defaultBroadcast`) and injected-broadcast -preference. - -**Verification:** `pnpm exec vitest run src/tools/createRenameEpisodePlan.test.ts -src/mcp/createRenameEpisodePlan.test.ts` — 2 files, 5 tests passed. - -**Commit:** `fix(core-routes): always broadcast RenameFilesPlanReady from create-rename-episode-plan` +- The full UI suite still emits pre-existing accessibility, React `act(...)`, and duplicate locale-key warnings; no test failures occurred. diff --git a/apps/ui/src/AppV2.tsx b/apps/ui/src/AppV2.tsx index 7c55f017..c70f02f4 100644 --- a/apps/ui/src/AppV2.tsx +++ b/apps/ui/src/AppV2.tsx @@ -8,7 +8,7 @@ import { useDialogs } from "@/providers/dialog-provider" import type { FileItem, FolderType } from "@/providers/dialog-provider" import { Toaster } from "./components/ui/sonner" import { toast } from "sonner" -import { mediaMetadataRepository } from "@/api/mediaMetadataRepository" +import { deleteMetadata } from "@/api/metadata" import { Assistant } from "./ai/Assistant" import { StatusBar } from "./components/StatusBar" import { AppWarningBanner } from "./components/AppWarningBanner" @@ -276,7 +276,7 @@ function AppV2Content() { // 2. Async delete; rollback on failure try { - await Promise.all(paths.map((path) => mediaMetadataRepository.delete(path, { traceId }))) + await Promise.all(paths.map((path) => deleteMetadata(path))) } catch (error) { console.error("[onDeleteSelected] Failed to delete some media metadata:", error) removedMetadata.forEach((metadata) => { diff --git a/apps/ui/src/api/deleteMediaMetadata.ts b/apps/ui/src/api/deleteMediaMetadata.ts deleted file mode 100644 index cf3b4819..00000000 --- a/apps/ui/src/api/deleteMediaMetadata.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { hello } from "./hello"; -import { deleteFile } from "./deleteFile"; -import { metadataCacheFilePath } from "./readMediaMetadataV2"; - -/** - * Delete the media metadata cache file for a media folder. - * - * Computes the cache file path from the bootstrap `appDataDir` - * (fetched via `hello()`) and the folder's POSIX path, then calls - * the unified `deleteFile()` API. Replaces the deprecated - * `/api/deleteMediaMetadata` route. - * - * The unified `deleteFile` API treats ENOENT (file already absent) - * as idempotent success, so this helper does not need to issue a - * warning for "metadata not found" cases. - */ -export async function deleteMediaMetadata(path: string): Promise { - const systemConfig = await hello(); - const metadataFilePath = metadataCacheFilePath( - systemConfig.appDataDir, - path, - ); - const result = await deleteFile(metadataFilePath); - if (result.error) { - throw new Error(`Failed to delete media metadata: ${result.error}`); - } -} \ No newline at end of file diff --git a/apps/ui/src/api/index.ts b/apps/ui/src/api/index.ts deleted file mode 100644 index 9996525e..00000000 --- a/apps/ui/src/api/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { readMediaMetadataV2 } from "./readMediaMetadataV2"; - -const api = { - readMediaMetadata: readMediaMetadataV2, -} - -export default api; \ No newline at end of file diff --git a/apps/ui/src/api/mediaMetadataRepository.test.ts b/apps/ui/src/api/mediaMetadataRepository.test.ts deleted file mode 100644 index c5ec2572..00000000 --- a/apps/ui/src/api/mediaMetadataRepository.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { MediaMetadataRepository } from './mediaMetadataRepository' -import type { MediaMetadata } from '@core/types' -import type { UIMediaMetadata } from '@/types/UIMediaMetadata' - -vi.mock('./readMediaMetadataV2', () => ({ - readMediaMetadataV2: vi.fn(), - metadataCacheFilePath: vi.fn(), -})) - -vi.mock('./writeMediaMetadata', () => ({ - writeMediaMetadata: vi.fn(), -})) - -vi.mock('./deleteMediaMetadata', () => ({ - deleteMediaMetadata: vi.fn(), -})) - -vi.mock('./listFiles', () => ({ - listFiles: vi.fn(), -})) - -vi.mock('@/lib/mediaMetadataRefreshUtils', () => ({ - mergeRefreshedMetadata: vi.fn(), -})) - -import { readMediaMetadataV2 } from './readMediaMetadataV2' -import { writeMediaMetadata } from './writeMediaMetadata' -import { deleteMediaMetadata } from './deleteMediaMetadata' -import { listFiles } from './listFiles' -import { mergeRefreshedMetadata } from '@/lib/mediaMetadataRefreshUtils' - -const mockReadMediaMetadataV2 = readMediaMetadataV2 as ReturnType -const mockWriteMediaMetadata = writeMediaMetadata as ReturnType -const mockDeleteMediaMetadata = deleteMediaMetadata as ReturnType -const mockListFiles = listFiles as ReturnType -const mockMergeRefreshedMetadata = mergeRefreshedMetadata as ReturnType - -const createMockMediaMetadata = (overrides?: Partial): MediaMetadata => ({ - mediaFolderPath: '/media/show1', - type: 'tvshow-folder', - tvShow: { - id: '1', - name: 'Show 1', - database: 'TMDB', - seasons: [], - }, - files: ['/media/show1/episode1.mp4'], - mediaFiles: [], - ...overrides, -}) - -const createMockUIMediaMetadata = (overrides?: Partial): UIMediaMetadata => ({ - ...createMockMediaMetadata(overrides), - status: 'idle', -} as UIMediaMetadata) - -describe('MediaMetadataRepository', () => { - let repository: MediaMetadataRepository - - beforeEach(() => { - vi.clearAllMocks() - repository = new MediaMetadataRepository() - }) - - describe('read', () => { - it('should read media metadata from cache', async () => { - const mockMetadata = createMockMediaMetadata() - mockReadMediaMetadataV2.mockResolvedValue(mockMetadata) - - const result = await repository.read('/media/show1') - - expect(mockReadMediaMetadataV2).toHaveBeenCalledWith('/media/show1', {}) - expect(result).toEqual(mockMetadata) - }) - - it('should pass traceId to read function', async () => { - const mockMetadata = createMockMediaMetadata() - mockReadMediaMetadataV2.mockResolvedValue(mockMetadata) - - await repository.read('/media/show1', { traceId: 'test-trace' }) - - expect(mockReadMediaMetadataV2).toHaveBeenCalledWith('/media/show1', { traceId: 'test-trace' }) - }) - }) - - describe('write', () => { - it('should write media metadata to cache', async () => { - const mockMetadata = createMockUIMediaMetadata() - mockWriteMediaMetadata.mockResolvedValue(undefined) - - await repository.write(mockMetadata) - - expect(mockWriteMediaMetadata).toHaveBeenCalledWith(mockMetadata, {}) - }) - - it('should pass traceId to write function', async () => { - const mockMetadata = createMockUIMediaMetadata() - mockWriteMediaMetadata.mockResolvedValue(undefined) - - await repository.write(mockMetadata, { traceId: 'test-trace' }) - - expect(mockWriteMediaMetadata).toHaveBeenCalledWith(mockMetadata, { traceId: 'test-trace' }) - }) - }) - - describe('delete', () => { - it('should delete media metadata from cache', async () => { - mockDeleteMediaMetadata.mockResolvedValue(undefined) - - await repository.delete('/media/show1') - - expect(mockDeleteMediaMetadata).toHaveBeenCalledWith('/media/show1') - }) - }) - - describe('refresh', () => { - it('should refresh metadata by reading and merging', async () => { - const currentMetadata = createMockUIMediaMetadata({ status: 'ok' }) - const refreshedMetadata = createMockMediaMetadata({ - tvShow: { id: '1', name: 'Updated Show', database: 'TMDB', seasons: [] }, - }) - const mergedMetadata = createMockUIMediaMetadata({ - tvShow: { id: '1', name: 'Updated Show', database: 'TMDB', seasons: [] }, - status: 'ok', - }) - - mockReadMediaMetadataV2.mockResolvedValue(refreshedMetadata) - mockMergeRefreshedMetadata.mockReturnValue(mergedMetadata) - - const result = await repository.refresh('/media/show1', currentMetadata) - - expect(mockReadMediaMetadataV2).toHaveBeenCalledWith('/media/show1', {}) - expect(mockMergeRefreshedMetadata).toHaveBeenCalledWith(refreshedMetadata, currentMetadata) - expect(result).toEqual(mergedMetadata) - }) - - it('should handle undefined current metadata', async () => { - const refreshedMetadata = createMockMediaMetadata() - const mergedMetadata = createMockUIMediaMetadata({ status: 'idle' }) - - mockReadMediaMetadataV2.mockResolvedValue(refreshedMetadata) - mockMergeRefreshedMetadata.mockReturnValue(mergedMetadata) - - const result = await repository.refresh('/media/show1', undefined) - - expect(mockMergeRefreshedMetadata).toHaveBeenCalledWith(refreshedMetadata, undefined) - expect(result).toEqual(mergedMetadata) - }) - }) - - describe('reloadAll', () => { - it('should reload all metadata for given folders', async () => { - const folders = ['/media/show1', '/media/show2'] - const currentMap = new Map([ - ['/media/show1', createMockUIMediaMetadata()], - ['/media/show2', createMockUIMediaMetadata()], - ]) - const refreshed1 = createMockMediaMetadata({ - mediaFolderPath: '/media/show1', - tvShow: { id: '1', name: 'Show 1 Updated', database: 'TMDB', seasons: [] }, - }) - const refreshed2 = createMockMediaMetadata({ - mediaFolderPath: '/media/show2', - tvShow: { id: '2', name: 'Show 2 Updated', database: 'TMDB', seasons: [] }, - }) - const merged1 = createMockUIMediaMetadata({ - mediaFolderPath: '/media/show1', - tvShow: { id: '1', name: 'Show 1 Updated', database: 'TMDB', seasons: [] }, - }) - const merged2 = createMockUIMediaMetadata({ - mediaFolderPath: '/media/show2', - tvShow: { id: '2', name: 'Show 2 Updated', database: 'TMDB', seasons: [] }, - }) - - mockReadMediaMetadataV2 - .mockResolvedValueOnce(refreshed1) - .mockResolvedValueOnce(refreshed2) - mockMergeRefreshedMetadata - .mockReturnValueOnce(merged1) - .mockReturnValueOnce(merged2) - - const result = await repository.reloadAll(folders, currentMap) - - expect(result).toEqual([merged1, merged2]) - expect(mockReadMediaMetadataV2).toHaveBeenCalledTimes(2) - expect(mockMergeRefreshedMetadata).toHaveBeenCalledTimes(2) - }) - }) - - describe('initialize', () => { - it('should initialize metadata for a folder', async () => { - // Mock read to throw error (no existing metadata) - mockReadMediaMetadataV2.mockRejectedValue(new Error('File not found')) - - // Mock listFiles to return some files - mockListFiles.mockResolvedValue({ - data: { - items: [ - { path: '/media/show1/episode1.mp4', isDirectory: false }, - { path: '/media/show1/episode2.mp4', isDirectory: false }, - ] - } - }) - - const result = await repository.initialize('/media/show1', 'tvshow-folder') - expect(result).toBeDefined() - expect(result.mediaFolderPath).toBe('/media/show1') - expect(result.type).toBe('tvshow-folder') - expect(result.status).toBe('idle') - expect(result.files).toEqual(['/media/show1/episode1.mp4', '/media/show1/episode2.mp4']) - }) - }) -}) \ No newline at end of file diff --git a/apps/ui/src/api/mediaMetadataRepository.ts b/apps/ui/src/api/mediaMetadataRepository.ts deleted file mode 100644 index c6303cd4..00000000 --- a/apps/ui/src/api/mediaMetadataRepository.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { MediaMetadata } from "@core/types"; -import type { UIMediaMetadata } from "@/types/UIMediaMetadata"; -import { readMediaMetadataV2 } from "./readMediaMetadataV2"; -import { writeMediaMetadata } from "./writeMediaMetadata"; -import { deleteMediaMetadata } from "./deleteMediaMetadata"; -import { listFiles } from "./listFiles"; -import { Path } from "@core/path"; -import { mergeRefreshedMetadata } from "@/lib/mediaMetadataRefreshUtils"; - -/** - * MediaMetadataRepository handles all data access operations for media metadata. - * This centralizes read/write/delete operations and encapsulates persistence rules. - */ -export class MediaMetadataRepository { - /** - * Read media metadata from cache, with fallback to blank metadata and file listing - */ - async read(pathPosix: string, options?: { traceId?: string; defaultType?: MediaMetadata["type"] }): Promise { - return readMediaMetadataV2(pathPosix, options || {}); - } - - /** - * Write media metadata to cache, applying persistence rules (e.g., clearing files) - */ - async write(metadata: UIMediaMetadata, options?: { traceId?: string }): Promise { - return writeMediaMetadata(metadata, options || {}); - } - - /** - * Delete media metadata from cache - */ - async delete(path: string, _options?: { traceId?: string }): Promise { - return deleteMediaMetadata(path); - } - - /** - * Refresh metadata by re-reading from cache and merging with current UI state - */ - async refresh(pathPosix: string, currentMetadata: UIMediaMetadata | undefined, options?: { traceId?: string }): Promise { - const response = await this.read(pathPosix, options); - return mergeRefreshedMetadata(response, currentMetadata); - } - - /** - * Reload all metadata for given folders - */ - async reloadAll(foldersInPosix: string[], currentMetadataMap: Map, options?: { traceId?: string }): Promise { - const promises = foldersInPosix.map(async (path) => { - const current = currentMetadataMap.get(path); - return this.refresh(path, current, options); - }); - - return Promise.all(promises); - } - - /** - * Initialize metadata for a folder by listing files and creating initial metadata - */ - async initialize(folderPathInPlatformFormat: string, type: "music-folder" | "tvshow-folder" | "movie-folder", options?: { traceId?: string }): Promise { - const folderPathInPosix = Path.posix(folderPathInPlatformFormat); - - // Check if metadata already exists - try { - const existing = await this.read(folderPathInPosix, options); - if (existing.type) { - return { ...existing, status: 'ok' }; - } - } catch { - // Metadata doesn't exist, continue with initialization - } - - // List files and create initial metadata - const listFilesResponse = await listFiles({ - path: folderPathInPlatformFormat, - recursively: true, - onlyFiles: true, - }); - - const files: string[] = []; - if (listFilesResponse.data?.items) { - files.push(...listFilesResponse.data.items.map(item => - Path.posix(typeof item === 'string' ? item : item.path) - )); - } - - const initialMetadata: UIMediaMetadata = { - mediaFolderPath: folderPathInPosix, - type, - files, - mediaFiles: [], - status: 'idle', - }; - - return initialMetadata; - } -} - -// Singleton instance -export const mediaMetadataRepository = new MediaMetadataRepository(); \ No newline at end of file diff --git a/apps/ui/src/api/readMediaMetadataV2.test.ts b/apps/ui/src/api/readMediaMetadataV2.test.ts deleted file mode 100644 index 2df7bb72..00000000 --- a/apps/ui/src/api/readMediaMetadataV2.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { readMediaMetadataV2 } from './readMediaMetadataV2' -import type { MediaMetadata } from '@core/types' -import { FileNotFoundError } from '@core/errors' - -// Mock the API functions -vi.mock('./hello', () => ({ - hello: vi.fn(), -})) - -vi.mock('./readFile', () => ({ - readFile: vi.fn(), -})) - -vi.mock('./listFiles', () => ({ - listFiles: vi.fn(), -})) - -import { hello } from './hello' -import { readFile } from './readFile' -import { listFiles } from './listFiles' - -describe('readMediaMetadataV2', () => { - let consoleErrorSpy: ReturnType - let consoleWarnSpy: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - consoleErrorSpy.mockRestore() - consoleWarnSpy.mockRestore() - }) - - it('should return mediaMetadata with mediaFolderPath and files set in happy flow', async () => { - const pathPosix = '/test/media/folder' - const appDataDir = '/app/data' - - const mockMediaMetadata: MediaMetadata = { - mediaFolderPath: pathPosix, - files: [], - mediaFiles: [], - } - - const mockFiles = [ - '/test/media/folder/video1.mp4', - '/test/media/folder/video2.mkv', - '/test/media/folder/subtitle.srt', - ] - - // Mock hello() to return system config - vi.mocked(hello).mockResolvedValue({ - uptime: 100, - version: '1.0.0', - userDataDir: '/user/data', - appDataDir: appDataDir, - logDir: '/app/logs', - }) - - // Mock readFile() to return the media metadata file content - vi.mocked(readFile).mockResolvedValue({ - data: JSON.stringify(mockMediaMetadata), - error: undefined, - }) - - // Mock listFiles() to return the list of files - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: pathPosix, - items: mockFiles.map(path => ({ - path, - size: 0, - mtime: 0, - isDirectory: false, - })), - size: mockFiles.length, - }, - error: undefined, - }) - - const result = await readMediaMetadataV2(pathPosix) - - // Check that mediaFolderPath is set - expect(result.mediaFolderPath).toBe(pathPosix) - - // Check that files is set - expect(result.files).toBeDefined() - expect(Array.isArray(result.files)).toBe(true) - expect(result.files?.length).toBe(mockFiles.length) - // Verify that files contains the expected paths (after Path.posix transformation) - expect(result.files).toContain(mockFiles[0]) - expect(result.files).toContain(mockFiles[1]) - expect(result.files).toContain(mockFiles[2]) - }) - - it('should return blank media metadata when readFile API call fails', async () => { - const pathPosix = '/test/media/folder' - const appDataDir = '/app/data' - - // Mock hello() to return system config - vi.mocked(hello).mockResolvedValue({ - uptime: 100, - version: '1.0.0', - userDataDir: '/user/data', - appDataDir: appDataDir, - logDir: '/app/logs', - }) - - // Mock readFile() to return FileNotFoundError - vi.mocked(readFile).mockResolvedValue({ - data: undefined, - error: `${FileNotFoundError}: /app/data/metadata/test_media_folder.json`, - }) - - // Mock listFiles() to return successfully (to isolate readFile failure) - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: pathPosix, - items: [{ - path: '/test/media/folder/video1.mp4', - size: 0, - mtime: 0, - isDirectory: false, - }], - size: 1, - }, - error: undefined, - }) - - const result = await readMediaMetadataV2(pathPosix) - - // Check that blank media metadata was returned - expect(result.mediaFolderPath).toBe(pathPosix) - expect(result.files).toBeDefined() - expect(Array.isArray(result.files)).toBe(true) - // Files should be set from listFiles, not from readFile - expect(result.files?.length).toBe(1) - expect(result.mediaFiles).toEqual([]) - }) - - it('should return blank media metadata when listFiles API call fails', async () => { - const pathPosix = '/test/media/folder' - const appDataDir = '/app/data' - - // Mock hello() to return system config - vi.mocked(hello).mockResolvedValue({ - uptime: 100, - version: '1.0.0', - userDataDir: '/user/data', - appDataDir: appDataDir, - logDir: '/app/logs', - }) - - // Mock readFile() to return FileNotFoundError (blank metadata scenario) - vi.mocked(readFile).mockResolvedValue({ - data: undefined, - error: `${FileNotFoundError}: /app/data/metadata/test_media_folder.json`, - }) - - vi.mocked(listFiles).mockResolvedValue({ - data: undefined, - error: 'Some error occurred', - }) - - const result = await readMediaMetadataV2(pathPosix) - - // Check that blank media metadata was returned (files should be empty array) - expect(result.mediaFolderPath).toBe(pathPosix) - expect(result.files).toBeDefined() - expect(Array.isArray(result.files)).toBe(true) - // Files should be empty array when listFiles fails - expect(result.files?.length).toBe(0) - expect(result.mediaFiles).toEqual([]) - }) - - it('should log unexpected response body error when readFile returns non-FileNotFoundError', async () => { - const pathPosix = '/test/media/folder' - const appDataDir = '/app/data' - const unexpectedError = 'Permission denied: /app/data/metadata/test_media_folder.json' - - // Mock hello() to return system config - vi.mocked(hello).mockResolvedValue({ - uptime: 100, - version: '1.0.0', - userDataDir: '/user/data', - appDataDir: appDataDir, - logDir: '/app/logs', - }) - - // Mock readFile() to return a non-FileNotFoundError error - vi.mocked(readFile).mockResolvedValue({ - data: undefined, - error: unexpectedError, - }) - - // Mock listFiles() to return successfully - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: pathPosix, - items: [{ - path: '/test/media/folder/video1.mp4', - size: 0, - mtime: 0, - isDirectory: false, - }], - size: 1, - }, - error: undefined, - }) - - const result = await readMediaMetadataV2(pathPosix) - - // Check that console.error was called with unexpected response body message - // Note: When there's an error AND no data, both error conditions are logged - expect(consoleErrorSpy).toHaveBeenCalledTimes(2) - expect(consoleErrorSpy).toHaveBeenCalledWith( - `[readMediaMetadataV2] unexpected response body: ${unexpectedError}` - ) - expect(consoleErrorSpy).toHaveBeenCalledWith( - `[readMediaMetadataV2] unexpected response body: no data` - ) - - // Check that blank media metadata was returned - expect(result.mediaFolderPath).toBe(pathPosix) - expect(result.files).toBeDefined() - expect(Array.isArray(result.files)).toBe(true) - expect(result.files?.length).toBe(1) - expect(result.mediaFiles).toEqual([]) - }) - - it('should log unexpected response body error when readFile returns no data', async () => { - const pathPosix = '/test/media/folder' - const appDataDir = '/app/data' - - // Mock hello() to return system config - vi.mocked(hello).mockResolvedValue({ - uptime: 100, - version: '1.0.0', - userDataDir: '/user/data', - appDataDir: appDataDir, - logDir: '/app/logs', - }) - - // Mock readFile() to return no data (data is undefined) - vi.mocked(readFile).mockResolvedValue({ - data: undefined, - error: undefined, - }) - - // Mock listFiles() to return successfully - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: pathPosix, - items: [{ - path: '/test/media/folder/video1.mp4', - size: 0, - mtime: 0, - isDirectory: false, - }], - size: 1, - }, - error: undefined, - }) - - const result = await readMediaMetadataV2(pathPosix) - - // Check that console.error was called with "no data" message - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('[readMediaMetadataV2] unexpected response body: no data') - ) - - // Check that blank media metadata was returned - expect(result.mediaFolderPath).toBe(pathPosix) - expect(result.files).toBeDefined() - expect(Array.isArray(result.files)).toBe(true) - expect(result.files?.length).toBe(1) - expect(result.mediaFiles).toEqual([]) - }) - - it('should log unexpected response body error when readFile returns empty string data', async () => { - const pathPosix = '/test/media/folder' - const appDataDir = '/app/data' - - // Mock hello() to return system config - vi.mocked(hello).mockResolvedValue({ - uptime: 100, - version: '1.0.0', - userDataDir: '/user/data', - appDataDir: appDataDir, - logDir: '/app/logs', - }) - - // Mock readFile() to return empty string data - vi.mocked(readFile).mockResolvedValue({ - data: '', - error: undefined, - }) - - // Mock listFiles() to return successfully - vi.mocked(listFiles).mockResolvedValue({ - data: { - path: pathPosix, - items: [{ - path: '/test/media/folder/video1.mp4', - size: 0, - mtime: 0, - isDirectory: false, - }], - size: 1, - }, - error: undefined, - }) - - const result = await readMediaMetadataV2(pathPosix) - - // Check that console.error was called with "no data" message - // (empty string is falsy, so it triggers the "no data" error) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('[readMediaMetadataV2] unexpected response body: no data') - ) - - // Check that blank media metadata was returned - expect(result.mediaFolderPath).toBe(pathPosix) - expect(result.files).toBeDefined() - expect(Array.isArray(result.files)).toBe(true) - expect(result.files?.length).toBe(1) - expect(result.mediaFiles).toEqual([]) - }) -}) diff --git a/apps/ui/src/api/readMediaMetadataV2.ts b/apps/ui/src/api/readMediaMetadataV2.ts deleted file mode 100644 index 040c7e7b..00000000 --- a/apps/ui/src/api/readMediaMetadataV2.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { MediaMetadata } from "@core/types"; -import { hello } from "./hello"; -import { readFile } from "./readFile"; -import { join } from "@/lib/path"; -import { FileNotFoundError, isError } from "@core/errors"; -import { listFiles } from "./listFiles"; -import { Path } from "@core/path"; -import { logger } from "@/lib/log"; - -export function metadataCacheFilePath(appDataDir: string, folderPathInPosix: string) { - const filename = folderPathInPosix.replace(/[/\\:?*|<>"]/g, '_') - return join(appDataDir, `metadata`, `${filename}.json`) -} - - -function blankMediaMetadata(path: string, defaultType?: MediaMetadata["type"]): MediaMetadata { - const mm: MediaMetadata = { - mediaFolderPath: path, - files: [], - mediaFiles: [], - } - if (defaultType) mm.type = defaultType - return mm -} - -export async function readMediaMetadataV2(pathPosix: string, { traceId, defaultType }: { traceId?: string; defaultType?: MediaMetadata["type"] } = {}): Promise { - - const systemConfig = await hello(); - const mediaMetadataFilePath = metadataCacheFilePath(systemConfig.appDataDir, pathPosix); - const readFileResponseBody = await readFile(mediaMetadataFilePath); - - let mediaMetadata: MediaMetadata = blankMediaMetadata(pathPosix, defaultType); - - if(readFileResponseBody.error) { - if(isError(readFileResponseBody.error, FileNotFoundError)) { - console.warn(`[readMediaMetadataV2]${traceId ? ` [${traceId}]` : ''} media metadata file not found: ${mediaMetadataFilePath}, skip a blank media metadata`) - } else { - console.error(`[readMediaMetadataV2]${traceId ? ` [${traceId}]` : ''} unexpected response body: ${readFileResponseBody.error}`); - } - } - - if(readFileResponseBody.data) { - mediaMetadata = JSON.parse(readFileResponseBody.data) as MediaMetadata; - } else { - console.error(`[readMediaMetadataV2]${traceId ? ` [${traceId}]` : ''} unexpected response body: no data`); - } - - const listFilesResponseBody = await listFiles({ - path: pathPosix, - recursively: true, - onlyFiles: true, - }); - - if(listFilesResponseBody.data !== undefined ) { - // Handle both old format (strings) and new format (objects with path property) - mediaMetadata.files = listFilesResponseBody.data.items.map(i => { - const path = typeof i === 'string' ? i : i.path; - return Path.posix(path); - }); - } - - console.log(`[readMediaMetadataV2]${traceId ? ` [${traceId}]` : ''} read media metadata: ${pathPosix}`, { - type: mediaMetadata.type, - filesCount: mediaMetadata.files?.length ?? 0, - mediaFilesCount: mediaMetadata.mediaFiles?.length ?? 0, - tvShowId: mediaMetadata.tvShow?.id, - tvShowSeasonCount: mediaMetadata.tvShow?.seasons?.length ?? 0, - }); - - logger.info({ - traceId, - stage: 'mediaMetadataRepository.read', - folder: pathPosix, - type: mediaMetadata.type, - filesCount: mediaMetadata.files?.length ?? 0, - mediaFilesCount: mediaMetadata.mediaFiles?.length ?? 0, - tvShowId: mediaMetadata.tvShow?.id, - tvShowSeasonCount: mediaMetadata.tvShow?.seasons?.length ?? 0, - cacheFile: mediaMetadataFilePath, - }, 'readMediaMetadataV2: returned') - - return mediaMetadata; -} \ No newline at end of file diff --git a/apps/ui/src/api/writeMediaMatadata.ts b/apps/ui/src/api/writeMediaMatadata.ts deleted file mode 100644 index c82171d7..00000000 --- a/apps/ui/src/api/writeMediaMatadata.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { MediaMetadata } from "@core/types"; -import type { WriteMediaMetadataRequestBody } from "@core/types"; -import { apiFetch } from '@/lib/apiFetch'; - - -export async function writeMediaMetadata(mediaMetadata: MediaMetadata): Promise { - const req: WriteMediaMetadataRequestBody = { - data: mediaMetadata, - } - - const resp = await apiFetch('/api/writeMediaMetadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - }); - - if (!resp.ok) { - throw new Error(`Failed to write media metadata: ${resp.statusText}`); - } - -} \ No newline at end of file diff --git a/apps/ui/src/api/writeMediaMetadata.ts b/apps/ui/src/api/writeMediaMetadata.ts deleted file mode 100644 index f9586608..00000000 --- a/apps/ui/src/api/writeMediaMetadata.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { MediaMetadata } from "@core/types"; -import { writeFile } from "./writeFile"; -import { metadataCacheFilePath } from "./readMediaMetadataV2"; -import { hello } from "./hello"; -import { logger } from "@/lib/log"; - -export async function writeMediaMetadata(mediaMetadata: MediaMetadata, { traceId }: { traceId?: string} = {}): Promise { - - if(!mediaMetadata.mediaFolderPath) { - throw new Error('Media folder path is required'); - } - - if(mediaMetadata.type === undefined) { - console.warn(`[writeMediaMetadata]${traceId ? ` [${traceId}]` : ''} media metadata type is undefined`); - } - - const systemConfig = await hello(); - const appDataDir = systemConfig.appDataDir; - const dst = structuredClone(mediaMetadata) as MediaMetadata - dst.files = [] - - const dstPath = metadataCacheFilePath(appDataDir, mediaMetadata.mediaFolderPath!) - const start = performance.now() - await writeFile(dstPath, JSON.stringify(dst, null, 4)) - const durationMs = Math.round(performance.now() - start) - - logger.info({ - traceId, - stage: 'mediaMetadataRepository.write', - folder: mediaMetadata.mediaFolderPath, - type: mediaMetadata.type, - mediaFileCount: mediaMetadata.mediaFiles?.length ?? 0, - path: dstPath, - durationMs, - }, 'writeMediaMetadata: persisted') -} \ No newline at end of file diff --git a/apps/ui/src/components/v2/Sidebar.test.tsx b/apps/ui/src/components/v2/Sidebar.test.tsx index 3ed51c75..7adb5f42 100644 --- a/apps/ui/src/components/v2/Sidebar.test.tsx +++ b/apps/ui/src/components/v2/Sidebar.test.tsx @@ -60,10 +60,8 @@ vi.mock("@/lib/utils", () => ({ nextTraceId: vi.fn(() => "trace-id"), })) -vi.mock("@/api/mediaMetadataRepository", () => ({ - mediaMetadataRepository: { - delete: vi.fn(), - }, +vi.mock("@/api/metadata", () => ({ + deleteMetadata: vi.fn(), })) vi.mock("@/lib/i18n", () => ({ diff --git a/apps/ui/src/components/v2/Sidebar.tsx b/apps/ui/src/components/v2/Sidebar.tsx index 3cd02496..633aff12 100644 --- a/apps/ui/src/components/v2/Sidebar.tsx +++ b/apps/ui/src/components/v2/Sidebar.tsx @@ -18,7 +18,7 @@ import { useDialogs } from "@/providers/dialog-provider" import { useConfig } from "@/hooks/userConfig" import { openInFileManagerApi } from "@/api/openInFileManager" import { nextTraceId } from "@/lib/utils" -import { mediaMetadataRepository } from "@/api/mediaMetadataRepository" +import { deleteMetadata } from "@/api/metadata" import { useTranslation } from "@/lib/i18n" import { isSmmV3Enabled } from "@/lib/localStorages" import { useFoldersQuery, useUnimportFolderMutation } from "@/hooks/folders" @@ -124,7 +124,7 @@ export function Sidebar({ onDeleteSelected }: SidebarProps) { const traceId = `Sidebar-onDeleteSelected-${nextTraceId()}` const deletedSet = new Set(paths.map((p) => Path.posix(p))) - await Promise.all(paths.map((path) => mediaMetadataRepository.delete(path, { traceId }))) + await Promise.all(paths.map((path) => deleteMetadata(path))) setAndSaveUserConfig(traceId, { ...userConfig, From c95bb108f29935bf65a859d73efd97996012ec3e Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:26:31 +0800 Subject: [PATCH 203/214] test(e2e): add browser helpers for metadata HTTP CRUD Co-authored-by: Cursor --- apps/e2e/test/lib/metadata-http.test.ts | 51 +++++++++ apps/e2e/test/lib/metadata-http.ts | 121 ++++++++++++++++++++ apps/e2e/test/lib/testbed.ts | 142 +++++++++--------------- 3 files changed, 224 insertions(+), 90 deletions(-) create mode 100644 apps/e2e/test/lib/metadata-http.test.ts create mode 100644 apps/e2e/test/lib/metadata-http.ts diff --git a/apps/e2e/test/lib/metadata-http.test.ts b/apps/e2e/test/lib/metadata-http.test.ts new file mode 100644 index 00000000..28eecfb9 --- /dev/null +++ b/apps/e2e/test/lib/metadata-http.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { metadataHttpRequestInPage } from './metadata-http' + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe('metadataHttpRequestInPage', () => { + test('posts JSON with the supplied bearer token', async () => { + let request: { input: string | URL | Request; init?: RequestInit } | undefined + globalThis.fetch = (async (input, init) => { + request = { input, init } + return new Response(JSON.stringify({ data: { mediaFolderPath: '/media/show' } }), { + status: 200, + }) + }) as typeof fetch + + const result = await metadataHttpRequestInPage( + '/api/get-metadata', + { path: '/media/show' }, + 'secret-token', + ) + + expect(request?.input).toBe('/api/get-metadata') + expect(request?.init).toMatchObject({ + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer secret-token', + }, + body: JSON.stringify({ path: '/media/show' }), + }) + expect(result).toEqual({ + ok: true, + status: 200, + text: JSON.stringify({ data: { mediaFolderPath: '/media/show' } }), + }) + }) + + test('returns a 404 response for the caller to map to null', async () => { + globalThis.fetch = (async () => new Response('missing', { + status: 404, + })) as unknown as typeof fetch + + await expect( + metadataHttpRequestInPage('/api/get-metadata', { path: '/missing' }), + ).resolves.toEqual({ ok: false, status: 404, text: 'missing' }) + }) +}) diff --git a/apps/e2e/test/lib/metadata-http.ts b/apps/e2e/test/lib/metadata-http.ts new file mode 100644 index 00000000..9a633515 --- /dev/null +++ b/apps/e2e/test/lib/metadata-http.ts @@ -0,0 +1,121 @@ +import type { + MediaMetadata, + MetadataSuccessResponseBody, + SetMetadataRequestBody, +} from '@smm/core/types' +import { browser } from '@wdio/globals' +import { ensureBrowserOnUiPage } from './browser-fs' + +export type MetadataPatch = SetMetadataRequestBody['patch'] + +type MetadataHttpResult = { + ok: boolean + status: number + text: string +} + +/** + * Runs in the browser page so metadata requests use the app's same-origin API. + * Keep this function closure-free because WebdriverIO serializes it. + */ +export async function metadataHttpRequestInPage( + endpoint: string, + body: unknown, + fallbackToken?: string, +): Promise { + const pageLocation = (globalThis as { location?: Location }).location + const storage = (globalThis as { localStorage?: Storage }).localStorage + const token = new URLSearchParams(pageLocation?.search ?? '').get('token') + ?? storage?.getItem('smm-auth-token') + ?? fallbackToken + const headers: Record = { + 'content-type': 'application/json', + } + if (token) { + headers.authorization = `Bearer ${token}` + } + + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + return { + ok: response.ok, + status: response.status, + text: await response.text(), + } +} + +async function postMetadataViaBrowser( + endpoint: string, + body: unknown, +): Promise<{ response: MetadataSuccessResponseBody | null; status: number }> { + await ensureBrowserOnUiPage() + const result = await browser.execute( + metadataHttpRequestInPage, + endpoint, + body, + process.env.SMM_AUTH_TOKEN, + ) as unknown as MetadataHttpResult + + if (!result.ok) { + if (result.status === 404) { + return { response: null, status: result.status } + } + throw new Error(`${endpoint} failed (${result.status}): ${result.text}`) + } + + return { + response: JSON.parse(result.text) as MetadataSuccessResponseBody, + status: result.status, + } +} + +function requireMetadata( + endpoint: string, + response: MetadataSuccessResponseBody | null, +): MediaMetadata { + if (!response?.data || response.data === true) { + throw new Error(`${endpoint}: response.data is missing`) + } + return response.data +} + +export async function getMetadataViaBrowser(folderPath: string): Promise { + const { response } = await postMetadataViaBrowser('/api/get-metadata', { path: folderPath }) + return response === null + ? null + : requireMetadata('/api/get-metadata', response) +} + +export async function createMetadataViaBrowser( + metadata: MediaMetadata, +): Promise { + const { response } = await postMetadataViaBrowser('/api/create-metadata', { data: metadata }) + return requireMetadata('/api/create-metadata', response) +} + +export async function setMetadataViaBrowser( + folderPath: string, + patch: MetadataPatch, +): Promise { + const { response } = await postMetadataViaBrowser('/api/set-metadata', { + path: folderPath, + patch, + }) + return requireMetadata('/api/set-metadata', response) +} + +export async function deleteMetadataViaBrowser(folderPath: string): Promise { + const { response, status } = await postMetadataViaBrowser('/api/delete-metadata', { + path: folderPath, + }) + if (response === null && status === 404) { + return + } + if (response?.data !== true) { + throw new Error('/api/delete-metadata: response.data is missing') + } +} diff --git a/apps/e2e/test/lib/testbed.ts b/apps/e2e/test/lib/testbed.ts index fb4b1ef5..d7903d0c 100644 --- a/apps/e2e/test/lib/testbed.ts +++ b/apps/e2e/test/lib/testbed.ts @@ -8,10 +8,9 @@ import * as path from 'node:path' import * as fs from 'node:fs' import { fileURLToPath } from 'node:url' import dotenv from 'dotenv' -import { setupTestMediaFolders, resetUserConfig as resetUserConfigV1, getUserConfigPath as getUserConfigPathV1, getMetadataDir, removeMetadataDir as removeMetadataDirV1, removeTestMediaTmpDir as removeTestMediaTmpDirV1, removePlansDir as removePlansDirV1, prepareMediaMetadata, removePlanFolder, hello } from '@smm/test' +import { setupTestMediaFolders, resetUserConfig as resetUserConfigV1, getUserConfigPath as getUserConfigPathV1, removeMetadataDir as removeMetadataDirV1, removeTestMediaTmpDir as removeTestMediaTmpDirV1, removePlansDir as removePlansDirV1, removePlanFolder, hello } from '@smm/test' import { Path } from '@smm/core' -import { createMediaMetadata as coreCreateMediaMetadata } from '@smm/core/mediaMetadata' -import type { MediaFileMetadata, MediaMetadata, UserConfig } from '@smm/core/types' +import type { MediaMetadata, UserConfig } from '@smm/core/types' import type { TestFolder } from 'test/actions/import-folders' import Sidebar from '../componentobjects/Sidebar' import StatusBar from '../componentobjects/StatusBar' @@ -20,11 +19,9 @@ import { ensureBrowserOnUiPage, fetchHelloPathsViaBrowser, joinPlatformPath, - readFileViaBrowser, resetUserConfigViaBrowser, setActiveTestbedOs, updateUserConfigViaBrowser, - writeFileViaBrowser, } from './browser-fs' import type { TestbedOs } from './ui-page-url' import { isOhosE2e, testbedOs as defaultTestbedOs } from './e2e-platform' @@ -36,10 +33,15 @@ import { type ResetUserConfigOption, type UserConfigUpdater, } from './testbed-core' +import { + createMetadataViaBrowser, + getMetadataViaBrowser, + setMetadataViaBrowser, +} from './metadata-http' export type { TestbedOs } from './ui-page-url' // Re-export for convenience (switched wrappers are `export async function` below) -export { setupTestMediaFolders, getMetadataDir } +export { setupTestMediaFolders } import { useEmbeddedHttpProxy, @@ -220,7 +222,21 @@ export function createBeforeHook(options: TestBedBeforeOptions = {}) { const tvshowFolderPosixPath = Path.posix(tvshowFolderPlatformPath); if(setupMediaMetadata) { - await prepareMediaMetadata(tvshowFolderPosixPath, '古见同学有交流障碍症.metadata.json') + const templatePath = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + 'test', + 'configs', + '古见同学有交流障碍症.metadata.json', + ) + const metadata = JSON.parse(fs.readFileSync(templatePath, 'utf-8')) as MediaMetadata + await writeMediaMetadata({ + ...metadata, + mediaFolderPath: tvshowFolderPosixPath, + }) } } @@ -411,66 +427,26 @@ export function createAfterHook(): () => Promise { } /** - * Assert that media metadata JSON for a given media folder path satisfies the provided predicate. - * + * Assert that metadata for a media folder satisfies the provided predicate. + * * @param mediaFolderPathInPlatformFormat Absolute media folder path in platform-specific format. - * It will be converted to POSIX and then to metadata cache file name. * @param predicate A function that receives the parsed JSON object and should return true when expectations are met. */ export async function expectMediaMetadataToBe( mediaFolderPathInPlatformFormat: string, - predicate: (json: any) => boolean + predicate: (json: MediaMetadata) => boolean ): Promise { - const metadataDir = await getMetadataDir() - - // Convert media folder path to POSIX format for metadata file naming - const mediaFolderPosix = Path.posix(mediaFolderPathInPlatformFormat) - const safeFileName = mediaFolderPosix.replace(/[\/\\:?*|<>"]/g, '_') - const metadataFilePath = path.join(metadataDir, `${safeFileName}.json`) - - // Wait a bit for metadata write to complete - const { setTimeout } = await import('node:timers/promises') - await setTimeout(2000) - - if (!fs.existsSync(metadataFilePath)) { - throw new Error(`Expect file "${metadataFilePath}" to exist but it didn't`) - } - - const metadataRaw = fs.readFileSync(metadataFilePath, 'utf-8') - const metadataJson = JSON.parse(metadataRaw) - - const ok = predicate(metadataJson) - if (!ok) { - const m: MediaMetadata = metadataJson as MediaMetadata; - const obj = { - ...m, - tvShow: m.tvShow !== undefined - ? { id: m.tvShow.id, name: m.tvShow.name, database: m.tvShow.database } - : undefined, - movie: m.movie !== undefined - ? { id: m.movie.id, name: m.movie.name, database: m.movie.database } - : undefined, - } - - throw new Error(`Media metadata for "${mediaFolderPathInPlatformFormat}" did not satisfy expectations.\nActual JSON: ${JSON.stringify(obj)}`) - } + await expectMediaMetadataViaBrowser(mediaFolderPathInPlatformFormat, predicate) } /** - * Same assertions as {@link expectMediaMetadataToBe}, but reads metadata via - * browser `POST /api/readFile` (works on ohos / electron / desktop). + * Poll metadata through browser `POST /api/get-metadata`. */ export async function expectMediaMetadataViaBrowser( mediaFolderPathInPlatformFormat: string, - predicate: (json: unknown) => boolean, + predicate: (json: MediaMetadata) => boolean, options?: { timeoutMs?: number; intervalMs?: number }, ): Promise { - const { appDataDir } = await fetchHelloPathsViaBrowser() - const metadataDir = joinPlatformPath(appDataDir, 'metadata') - const mediaFolderPosix = Path.posix(mediaFolderPathInPlatformFormat) - const safeFileName = mediaFolderPosix.replace(/[\/\\:?*|<>"]/g, '_') - const metadataFilePath = joinPlatformPath(metadataDir, `${safeFileName}.json`) - const timeoutMs = options?.timeoutMs ?? 90_000 const intervalMs = options?.intervalMs ?? 2_000 const deadline = Date.now() + timeoutMs @@ -478,19 +454,17 @@ export async function expectMediaMetadataViaBrowser( while (Date.now() < deadline) { try { - const metadataRaw = await readFileViaBrowser(metadataFilePath) - const metadataJson = JSON.parse(metadataRaw) as MediaMetadata - if (predicate(metadataJson)) { + const metadata = await getMetadataViaBrowser(mediaFolderPathInPlatformFormat) + if (metadata !== null && predicate(metadata)) { return } - const m = metadataJson const obj = { - ...m, - tvShow: m.tvShow !== undefined - ? { id: m.tvShow.id, name: m.tvShow.name, database: m.tvShow.database } + ...metadata, + tvShow: metadata?.tvShow !== undefined + ? { id: metadata.tvShow.id, name: metadata.tvShow.name, database: metadata.tvShow.database } : undefined, - movie: m.movie !== undefined - ? { id: m.movie.id, name: m.movie.name, database: m.movie.database } + movie: metadata?.movie !== undefined + ? { id: metadata.movie.id, name: metadata.movie.name, database: metadata.movie.database } : undefined, } lastError = new Error( @@ -503,18 +477,13 @@ export async function expectMediaMetadataViaBrowser( } throw new Error( - `expectMediaMetadataViaBrowser timed out after ${timeoutMs}ms for "${metadataFilePath}". ` + + `expectMediaMetadataViaBrowser timed out after ${timeoutMs}ms for "${mediaFolderPathInPlatformFormat}". ` + `Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`, ) } /** - * Write a media metadata JSON file into the app's metadata cache directory. - * - * The output path matches {@link expectMediaMetadataToBe}: `getMetadataDir()` plus - * a filename derived from the folder path (POSIX, invalid path chars replaced with `_`) + `.json`. - * - * @param mediaMetadata Must include `mediaFolderPath` (POSIX or platform path; normalized like in expect). + * Create or replace media metadata through the metadata HTTP API. */ export async function writeMediaMetadata(mediaMetadata: MediaMetadata): Promise { const folderPath = mediaMetadata.mediaFolderPath @@ -522,29 +491,22 @@ export async function writeMediaMetadata(mediaMetadata: MediaMetadata): Promise< throw new Error('writeMediaMetadata: mediaMetadata.mediaFolderPath is required') } - const mediaFolderPosix = Path.posix(folderPath) - const safeFileName = mediaFolderPosix.replace(/[\/\\:?*|<>"]/g, '_') - const payload = JSON.stringify(mediaMetadata, null, 4) - - if (TESTBED_V2) { - const { appDataDir } = await fetchHelloPathsViaBrowser() - const metadataFilePath = joinPlatformPath( - joinPlatformPath(appDataDir, 'metadata'), - `${safeFileName}.json`, - ) - await writeFileViaBrowser(metadataFilePath, payload) - console.log(`Wrote media metadata (v2) to ${metadataFilePath}`) - return + const normalized = { + ...mediaMetadata, + mediaFolderPath: Path.posix(folderPath), } - - const metadataDir = await getMetadataDir() - if (!fs.existsSync(metadataDir)) { - fs.mkdirSync(metadataDir, { recursive: true }) + const existing = await getMetadataViaBrowser(normalized.mediaFolderPath) + if (existing === null) { + await createMetadataViaBrowser(normalized) + } else { + await setMetadataViaBrowser(normalized.mediaFolderPath, { + mediaFiles: normalized.mediaFiles, + type: normalized.type, + tvShow: normalized.tvShow, + movie: normalized.movie, + }) } - - const metadataFilePath = path.join(metadataDir, `${safeFileName}.json`) - fs.writeFileSync(metadataFilePath, payload, 'utf-8') - console.log(`Wrote media metadata to ${metadataFilePath}`) + console.log(`Wrote media metadata through HTTP for ${normalized.mediaFolderPath}`) } /** From 9bf4064a9a38566edb9079d051953bd7f1978818 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:30:31 +0800 Subject: [PATCH 204/214] chore(cli): remove legacy read/write MediaMetadata file routes Retire /api/readMediaMetadata and /api/writeMediaMetadata now that UI and e2e callers use Core metadata RPCs or smm metadata --set. Co-authored-by: Cursor --- apps/cli/docs/MediaMetadataAPI.md | 84 +++++++++++++--------- apps/cli/server.ts | 4 -- apps/cli/src/route/mediaMetadata/read.ts | 86 ----------------------- apps/cli/src/route/mediaMetadata/write.ts | 64 ----------------- apps/e2e/cli/helpers.ts | 56 +++++++++------ apps/ui/src/api/readMediaMatadata.ts | 29 -------- apps/ui/src/lib/mediaMetadataUtils.ts | 74 ------------------- 7 files changed, 84 insertions(+), 313 deletions(-) delete mode 100644 apps/cli/src/route/mediaMetadata/read.ts delete mode 100644 apps/cli/src/route/mediaMetadata/write.ts delete mode 100644 apps/ui/src/api/readMediaMatadata.ts diff --git a/apps/cli/docs/MediaMetadataAPI.md b/apps/cli/docs/MediaMetadataAPI.md index 6e5df0fa..4e6ac129 100644 --- a/apps/cli/docs/MediaMetadataAPI.md +++ b/apps/cli/docs/MediaMetadataAPI.md @@ -1,87 +1,101 @@ # Media Metadata APIs -Media Metadata APIs provide operations for reading, writing, and deleting media metadata cache files. +Media metadata is managed through Core-backed RPC endpoints. -## POST /api/readMediaMetadata +## POST /api/get-metadata -Read media metadata from cache file. If the cache file does not exist, creates a new metadata object with file listing. +Read media metadata for an imported folder. Request body: ```typescript -interface ReadMediaMetadataRequestBody { - /** - * Absolute path of media folder in platform-specific format - */ +interface GetMetadataRequestBody { path: string; } ``` Response body: ```typescript -interface ReadMediaMetadataResponseBody { +interface MetadataSuccessResponseBody { data: MediaMetadata; - /** - * undefined - The request succeeded without error - * Folder Not Found - The media folder not found - * Media Metadata Not Found - The local cache file for media metadata not found - */ - error?: string; } ``` Status codes: -- `200`: The API called successfully, need to look at the ReadMediaMetadataResponseBody to see if request succeeded +- `200`: Metadata returned +- `404`: Metadata not found (Problem Details) -## POST /api/writeMediaMetadata +## POST /api/create-metadata -Write media metadata to cache file. +Create metadata for a folder that does not yet have a cache entry. Request body: ```typescript -interface WriteMediaMetadataRequestBody { +interface CreateMetadataRequestBody { data: MediaMetadata; } ``` Response body: ```typescript -interface WriteMediaMetadataResponseBody { +interface MetadataSuccessResponseBody { data: MediaMetadata; } ``` Status codes: -- `200`: Metadata successfully written -- `400`: Invalid request (missing mediaFolderPath) -- `500`: Internal server error (failed to create directory or write file) +- `200`: Metadata created +- `409`: Metadata already exists (Problem Details) -## POST /api/deleteMediaMetadata +## POST /api/set-metadata -Delete media metadata cache file. +Patch metadata fields for an existing folder. Request body: ```typescript -interface DeleteMediaMetadataRequestBody { - /** - * Absolute path of media folder in platform-specific format - */ +interface SetMetadataRequestBody { path: string; + patch: { + type?: 'music-folder' | 'tvshow-folder' | 'movie-folder'; + mediaFiles?: MediaFile[]; + tvShow?: TvShow; + movie?: Movie; + }; } ``` Response body: ```typescript -interface DeleteMediaMetadataResponseBody { +interface MetadataSuccessResponseBody { + data: MediaMetadata; } ``` Status codes: -- `200`: Metadata file successfully deleted -- `400`: Invalid request (missing path) -- `404`: Metadata file not found -- `500`: Internal server error (failed to delete file) +- `200`: Metadata updated +- `404`: Metadata not found (Problem Details) -## Metadata Cache Location +## POST /api/delete-metadata + +Delete media metadata cache for a folder. + +Request body: +```typescript +interface DeleteMetadataRequestBody { + path: string; +} +``` -All metadata cache files are stored in the `metadata` subdirectory of the user data directory. The cache file name is generated by replacing invalid filename characters in the folder path with underscores and appending `.json` extension. +Response body: +```typescript +interface MetadataSuccessResponseBody { + data: true; +} +``` + +Status codes: +- `200`: Metadata deleted +- `404`: Metadata not found (Problem Details) + +## Metadata Cache Location +Metadata cache files are stored under `{userDataDir}/metadata/`. The cache file name is generated by replacing invalid filename characters in the folder path with underscores and appending a `.json` extension. diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 05992f17..46b397d0 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -23,8 +23,6 @@ import { handleReadImage } from './src/route/ReadImage'; import { handleListFiles } from './src/route/ListFiles'; import { handleListDrives } from './src/route/ListDrives'; import { handleDownloadImage } from './src/route/DownloadImage'; -import { handleReadMediaMetadata } from '@/route/mediaMetadata/read'; -import { handleWriteMediaMetadata } from '@/route/mediaMetadata/write'; import { handleRenameFilesInMediaMetadata } from '@/route/mediaMetadata/renameFilesInMediaMetadata'; import { handleMatchMediaFilesToEpisodeRequest } from './src/route/ai'; import { handleDownloadImageAsFileRequest } from './src/route/downloadImageAsFileBridge'; @@ -278,8 +276,6 @@ export class Server { handleDownloadImage(this.app); handleListFiles(this.app); handleListDrives(this.app); - handleReadMediaMetadata(this.app); - handleWriteMediaMetadata(this.app); handleRenameFilesInMediaMetadata(this.app); handleMatchMediaFilesToEpisodeRequest(this.app); handleDownloadImageAsFileRequest(this.app); diff --git a/apps/cli/src/route/mediaMetadata/read.ts b/apps/cli/src/route/mediaMetadata/read.ts deleted file mode 100644 index 675464d3..00000000 --- a/apps/cli/src/route/mediaMetadata/read.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { MediaMetadata, ProblemDetails, ReadMediaMetadataRequestBody, ReadMediaMetadataResponseBody } from "@core/types"; -import type { Hono } from "hono"; -import { stat } from "node:fs/promises"; -import { Path } from "@core/path"; -import { listFiles } from "@/utils/files"; -import { findMediaMetadata } from "@/utils/mediaMetadata"; -import { metadataCacheFilePath } from "./utils"; -import { logger, logHttpReqIn, logHttpRespOut } from "../../../lib/logger"; - -export async function newMediaMetadata(folderPath: Path) { - const metadata: MediaMetadata = { - mediaFolderPath: folderPath.abs(), - files: [], - mediaFiles: [], - } - - const files = await listFiles(folderPath, true) - metadata.files = files - - return metadata -} - -export async function handleReadMediaMetadata(app: Hono) { - app.post('/api/readMediaMetadata', async (c) => { - const raw = await c.req.json() as ReadMediaMetadataRequestBody; - logHttpReqIn(c, raw); - const folderPath = raw.path; - - // Check if folder exists - try { - const stats = await stat(folderPath); - if (!stats.isDirectory()) { - const resp: ReadMediaMetadataResponseBody = { - data: {} as MediaMetadata, - error: `Folder Not Found: ${folderPath} is not a directory` - }; - logger.warn({ folder: folderPath }, '[readMediaMetadata] not a directory'); - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } - } catch (error) { - // Folder doesn't exist or can't be accessed - const resp: ReadMediaMetadataResponseBody = { - data: {} as MediaMetadata, - error: `Folder Not Found: ${folderPath} was not found` - }; - logger.error({ - err: error, - folder: folderPath, - }, '[readMediaMetadata] media folder not found'); - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } - - // Use findMediaMetadata to get the metadata - const data = await findMediaMetadata(folderPath); - - if (!data) { - const metadataFilePath = metadataCacheFilePath(Path.posix(folderPath)); - logger.warn({ folder: folderPath, metadataFilePath }, '[readMediaMetadata] metadata file not found'); - const resp: ReadMediaMetadataResponseBody = { - data: undefined, - error: `Media Metadata Not Found: ${metadataFilePath} was not found` - }; - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } - - // Update files list from the actual folder - data.files = await listFiles(new Path(folderPath), true); - logger.info({ - folder: folderPath, - type: data.type, - fileCount: data.files.length, - tvShowId: data.tvShow?.id, - movieId: data.movie?.id, - }, '[readMediaMetadata] served'); - - const resp: ReadMediaMetadataResponseBody = { - data - }; - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - }); -} - diff --git a/apps/cli/src/route/mediaMetadata/write.ts b/apps/cli/src/route/mediaMetadata/write.ts deleted file mode 100644 index 124ba927..00000000 --- a/apps/cli/src/route/mediaMetadata/write.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { MediaMetadata, WriteMediaMetadataRequestBody, WriteMediaMetadataResponseBody } from "@core/types"; -import type { Hono } from "hono"; -import { mkdir } from "fs/promises"; -import { mediaMetadataDir, metadataCacheFilePath } from "./utils"; -import { logger, logHttpReqIn, logHttpRespOut } from "../../../lib/logger"; - -export async function handleWriteMediaMetadata(app: Hono) { - app.post('/api/writeMediaMetadata', async (c) => { - const raw = await c.req.json() as WriteMediaMetadataRequestBody; - logHttpReqIn(c, raw); - const metadata = raw.data; - - if (!metadata.mediaFolderPath) { - const resp: WriteMediaMetadataResponseBody = { - data: {} as MediaMetadata, - error: 'Invalid Request: mediaFolderPath is required in metadata' - }; - logger.warn({ folder: metadata.mediaFolderPath }, '[writeMediaMetadata] rejected: missing mediaFolderPath'); - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } - - const metadataFilePath = metadataCacheFilePath(metadata.mediaFolderPath); - - // Ensure the metadata directory exists - try { - await mkdir(mediaMetadataDir, { recursive: true }); - } catch (error) { - const resp: WriteMediaMetadataResponseBody = { - data: {} as MediaMetadata, - error: `Create Directory Failed: ${error instanceof Error ? error.message : 'Unknown error'}` - }; - logger.error({ err: error, folder: metadata.mediaFolderPath, dir: mediaMetadataDir }, '[writeMediaMetadata] mkdir failed'); - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } - - // Write metadata to file - try { - await Bun.write(metadataFilePath, JSON.stringify(metadata, null, 2)); - logger.info({ - folder: metadata.mediaFolderPath, - type: metadata.type, - fileCount: metadata.files?.length ?? 0, - mediaFileCount: metadata.mediaFiles?.length ?? 0, - path: metadataFilePath, - }, '[writeMediaMetadata] persisted'); - const resp: WriteMediaMetadataResponseBody = { - data: metadata - }; - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } catch (error) { - const resp: WriteMediaMetadataResponseBody = { - data: {} as MediaMetadata, - error: `Write File Failed: ${error instanceof Error ? error.message : 'Unknown error'}` - }; - logger.error({ err: error, folder: metadata.mediaFolderPath, path: metadataFilePath }, '[writeMediaMetadata] Bun.write failed'); - logHttpRespOut(c, resp, 200); - return c.json(resp, 200); - } - }); -} - diff --git a/apps/e2e/cli/helpers.ts b/apps/e2e/cli/helpers.ts index 93c70945..6da4ac08 100644 --- a/apps/e2e/cli/helpers.ts +++ b/apps/e2e/cli/helpers.ts @@ -1,5 +1,6 @@ -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs' import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { $ } from 'bun' import { Path } from '@smm/core' @@ -56,19 +57,6 @@ export async function planFilePath(binary: string, planId: string): Promise"]/g, '_') - return join(appDataDir, 'metadata', `${filename}.json`) -} - -export async function resolveMetadataCachePath( - binary: string, - folderPathInPosix: string, -): Promise { - const dataDir = await resolveCoreDataDir(binary) - return metadataCacheFilePath(dataDir, folderPathInPosix) -} - /** CLI `metadata` output line for a linked media file (platform path separators). */ export function metadataMediaFileLine( folderPath: string, @@ -106,7 +94,7 @@ export type CreateAndImportInitializedFolderOptions = { updateMediaMetadata?: (mediaMetadata: MediaMetadata) => MediaMetadata } -/** Create fixture on disk, `add --skip-init`, seed metadata cache (POSIX paths, Core-aligned). */ +/** Create fixture on disk, `add --skip-init`, seed metadata via `smm metadata --set`. */ export async function createAndImportInitializedFolder( binary: string, folder: TestFolder, @@ -129,7 +117,6 @@ export async function createAndImportInitializedFolder( } else { const templateFileName = options.templateFileName ?? '天使降临到我身边.metadata.json' mediaMetadata = rewritePathsForFolder(loadTemplate(templateFileName), folderPath) - mediaMetadata.files = created.files.map((file) => Path.posix(join(folderPath, file))) } if (options.updateMediaMetadata) { @@ -137,15 +124,42 @@ export async function createAndImportInitializedFolder( } mediaMetadata.mediaFolderPath = Path.posix(folderPath) - const dataDir = await resolveCoreDataDir(binary) - const cachePath = metadataCacheFilePath(dataDir, Path.posix(folderPath)) - mkdirSync(dirname(cachePath), { recursive: true }) - const { files: _files, ...toPersist } = mediaMetadata - writeFileSync(cachePath, JSON.stringify(toPersist, null, 2)) + await seedMetadataViaCli(binary, folderPath, mediaMetadata) return created } +async function seedMetadataViaCli( + binary: string, + folderPath: string, + mediaMetadata: MediaMetadata, +): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), 'smm-meta-seed-')) + const jsonFile = join(tmpDir, 'metadata.json') + try { + writeFileSync( + jsonFile, + JSON.stringify( + { + type: mediaMetadata.type, + mediaFiles: mediaMetadata.mediaFiles, + tvShow: mediaMetadata.tvShow, + movie: mediaMetadata.movie, + }, + null, + 2, + ), + 'utf-8', + ) + const setResult = await $`${binary} metadata ${folderPath} --set ${jsonFile}`.nothrow() + if (setResult.exitCode !== 0) { + throw new Error(setResult.text()) + } + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } +} + export async function recognizeAndApply(binary: string, path: string): Promise { const recognized = await $`${binary} try-to-recognize ${path}`.nothrow() if (recognized.exitCode !== 0) { diff --git a/apps/ui/src/api/readMediaMatadata.ts b/apps/ui/src/api/readMediaMatadata.ts deleted file mode 100644 index 93d55e9f..00000000 --- a/apps/ui/src/api/readMediaMatadata.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ReadMediaMetadataRequestBody, ReadMediaMetadataResponseBody } from "@core/types"; -import { apiFetch } from '@/lib/apiFetch'; - -/** - * - * @param path platform-specific path - * @param signal optional AbortSignal to cancel the request - */ -export async function readMediaMetadataApi(path: string, signal?: AbortSignal): Promise { - const req: ReadMediaMetadataRequestBody = { - path: path, - } - - const resp = await apiFetch('/api/readMediaMetadata', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(req), - signal, - }); - - if (!resp.ok) { - throw new Error(`HTTP layer error: ${resp.status} ${resp.statusText}`); - } - - const data: ReadMediaMetadataResponseBody = await resp.json(); - return data; -} \ No newline at end of file diff --git a/apps/ui/src/lib/mediaMetadataUtils.ts b/apps/ui/src/lib/mediaMetadataUtils.ts index 64822982..ecc15953 100644 --- a/apps/ui/src/lib/mediaMetadataUtils.ts +++ b/apps/ui/src/lib/mediaMetadataUtils.ts @@ -2,7 +2,6 @@ import { listFiles } from "@/api/listFiles"; import type { MediaMetadata } from "@core/types"; import type { UIMediaMetadata } from "@/types/UIMediaMetadata"; import { Path } from "@core/path"; -import type { readMediaMetadataApi } from "@/api/readMediaMatadata"; import { createMediaMetadata } from "@core/mediaMetadata"; export async function createInitialMediaMetadata( @@ -79,76 +78,3 @@ export function findUpdatedMediaMetadata(old: MediaMetadata[], newItems: MediaMe return updated; } - -export async function loadUIMediaMetadata( - folderInPlatformFormat: string, - options: { - readMediaMetadataApi: (folderInPlatformFormat: string) => ReturnType, - listFilesApi: (options: { path: string, recursively?: boolean, onlyFiles?: boolean }, signal?: AbortSignal) => ReturnType, - callback: (mm: UIMediaMetadata) => void - }) { - - const { - readMediaMetadataApi, - listFilesApi, - callback, - } = options; - - - try { - - let mm: UIMediaMetadata = { - mediaFolderPath: Path.posix(folderInPlatformFormat), - status: 'loading', - } - - callback(mm) - - const resp = await readMediaMetadataApi(folderInPlatformFormat) - if (resp.error) { - console.error(`[loadUIMediaMetadata] Failed to read media metadata for folder: ${mm.mediaFolderPath}`, resp.error) - return - } - if (resp.data === undefined) { - console.error(`[loadUIMediaMetadata] Failed to read media metadata for folder: ${mm.mediaFolderPath}`, resp.error) - return - } - - - console.log(`[loadUIMediaMetadata] loaded media metadata for folder: ${folderInPlatformFormat}`) - mm = { - ...resp.data, - status: 'loading', - } - callback(mm) - - - const listFilesResp = await listFilesApi({ path: folderInPlatformFormat, recursively: true, onlyFiles: true }) - if (listFilesResp.error) { - console.error(`[loadUIMediaMetadata] Failed to list files for folder: ${mm.mediaFolderPath}`, listFilesResp.error) - return - } - if (listFilesResp.data === undefined) { - console.error(`[loadUIMediaMetadata] Failed to list files for folder: ${mm.mediaFolderPath}`, listFilesResp.error) - return - } - - console.log(`[loadUIMediaMetadata] loaded files for folder: ${folderInPlatformFormat}`) - mm = { - ...mm, - files: listFilesResp.data.items.map(item => Path.posix(item.path)), - status: 'ok', - } - callback(mm) - - -} catch (error) { - console.error(`[loadUIMediaMetadata] Failed to read media metadata for folder: ${folderInPlatformFormat}`, error) - callback({ - mediaFolderPath: Path.posix(folderInPlatformFormat), - status: 'error_loading_metadata', - }) - return -} - -} \ No newline at end of file From 6f37b448324f3dd3d2f8f2bdfc60b3911de82cca Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:48:44 +0800 Subject: [PATCH 205/214] fix(e2e): stabilize movie rename context menu on Windows Add shared Radix context-menu helpers and scope episode-table interactions so Movie-RenameVideoFile e2e passes after metadata CRUD refactor. Co-authored-by: Cursor --- .../common/movie/Movie-RenameVideoFile.e2e.ts | 90 +++++++------------ apps/e2e/test/componentobjects/Sidebar.ts | 11 ++- .../test/componentobjects/TVShowPanel.co.ts | 39 ++------ apps/e2e/test/lib/context-menu.ts | 86 ++++++++++++++++++ 4 files changed, 132 insertions(+), 94 deletions(-) create mode 100644 apps/e2e/test/lib/context-menu.ts diff --git a/apps/e2e/common/movie/Movie-RenameVideoFile.e2e.ts b/apps/e2e/common/movie/Movie-RenameVideoFile.e2e.ts index be5fe9b2..ba8f74f5 100644 --- a/apps/e2e/common/movie/Movie-RenameVideoFile.e2e.ts +++ b/apps/e2e/common/movie/Movie-RenameVideoFile.e2e.ts @@ -6,13 +6,12 @@ import { setup, cleanup } from 'test/lib/testbed' import { clearFolderViaBrowser, createAndImportFolderViaBrowser, - fileExistsViaBrowser, - joinPlatformPath, listFileNamesViaBrowser, resolveSmmTestFolderViaBrowser, } from 'test/lib/browser-fs' import { delay } from 'es-toolkit' import { waitUntilSelectedFolderReady } from 'test/lib/ui-media-folder-store' +import { clickContextMenuItem, rightClickElement } from 'test/lib/context-menu' import { testbedOs } from 'test/lib/e2e-platform' @@ -40,7 +39,6 @@ const RENAME_MENU_ITEM_LABELS = ['Rename', '重命名'] const SIDEBAR_FOLDER_DISPLAY_NAME = FOLDER_NAME async function waitForMoviePanelRenameButtonDisplayed() { - // Header shows Skeleton (no Rename) while status is initializing/loading. await waitUntilSelectedFolderReady(3 * 60 * 1000) await MoviePanelCO.searchbox.waitForTitleToBeOneOf(EXPECTED_MOVIE_TITLES, 3 * 60 * 1000) await browser.waitUntil( @@ -55,29 +53,31 @@ async function waitForMoviePanelRenameButtonDisplayed() { ) } -/** - * Wait for a context menu item with the given labels and click it. - */ -async function clickContextMenuItem(labels: string[]) { +async function waitForFilesInFolder( + folderPath: string, + options: { + mustInclude: string[] + mustExclude: string[] + timeout?: number + }, +): Promise { + const { mustInclude, mustExclude, timeout = 30_000 } = options await browser.waitUntil( async () => { - for (const label of labels) { - const item = await $(`[role="menuitem"]=${label}`) - if (await item.isDisplayed().catch(() => false)) return true - } - return false + const names = await listFileNamesViaBrowser(folderPath) + return ( + mustInclude.every((name) => names.includes(name)) && + mustExclude.every((name) => !names.includes(name)) + ) + }, + { + timeout, + interval: 500, + timeoutMsg: + `Rename did not update files in ${folderPath}. ` + + `Expected: ${mustInclude.join(', ')}; removed: ${mustExclude.join(', ')}`, }, - { timeout: 5000, interval: 200, timeoutMsg: `Context menu item [${labels.join(', ')}] did not appear` }, ) - for (const label of labels) { - const item = await $(`[role="menuitem"]=${label}`) - if (await item.isDisplayed().catch(() => false)) { - await item.waitForClickable({ timeout: 3000 }) - await item.click() - return - } - } - throw new Error(`Context menu item [${labels.join(', ')}] not found`) } /** @@ -122,7 +122,6 @@ describe('Movie - Rename Video File', () => { it('renames the video file and its associated files (subtitles, etc.) via context menu', async function () { this.timeout(6 * 60 * 1000) - // 1. Create movie folder with video and associated files sharing the same stem movieFolderPath = await createAndImportFolderViaBrowser( { folderName: FOLDER_NAME, @@ -131,21 +130,14 @@ describe('Movie - Rename Video File', () => { }, 'e2eTest:Movie Rename Video File', ) - console.log('Created movie folder with video + associated files:', movieFolderPath) await delay(3 * 1000) - // Wait for folder to appear in sidebar and select it const folderDisplayed = await Sidebar.waitForFolderName(SIDEBAR_FOLDER_DISPLAY_NAME, 30000) expect(folderDisplayed).toBe(true) - await Sidebar.clickFolder(SIDEBAR_FOLDER_DISPLAY_NAME) - await delay(1000) - - // Wait until movie panel is ready (Rename button in overview visible) + await Sidebar.waitForFolderSelected(SIDEBAR_FOLDER_DISPLAY_NAME, 30000) await waitForMoviePanelRenameButtonDisplayed() - console.log('Movie panel ready with TMDB data') - // 2. Wait for Files section and the video file row to be visible await browser.waitUntil( async () => { const fileDivs = await $$('div.truncate') @@ -158,59 +150,37 @@ describe('Movie - Rename Video File', () => { { timeout: 10000, interval: 300, timeoutMsg: 'Video file row did not appear in Movie Files section' }, ) - // 3. Right-click on the video file row to open the context menu const videoFileRow = await $(`//div[contains(@class,"truncate") and normalize-space(text())="${VIDEO_FILE_NAME}"]/ancestor::tr`) await videoFileRow.waitForDisplayed({ timeout: 5000 }) - await videoFileRow.click({ button: 'right' }) - await delay(300) - console.log('Right-clicked on movie video file element') - - // 4. Click "Rename" from the context menu + await rightClickElement(videoFileRow) await clickContextMenuItem(RENAME_MENU_ITEM_LABELS) - await delay(300) - console.log('Clicked Rename context menu item') - // 5. Rename dialog should open with the current file name pre-filled const dialogDisplayed = await RenameDialog.waitForDisplayed(5000) expect(dialogDisplayed).toBe(true) const inputValue = await RenameDialog.getInputValue() expect(inputValue).toBe(VIDEO_FILE_NAME) - console.log('Rename dialog shown with initial value:', inputValue) - // 6. Confirm button should be disabled while name is unchanged expect(await RenameDialog.isConfirmDisabled()).toBe(true) - console.log('Confirm button is disabled when name is unchanged (expected)') - // 7. Enter the new file name await RenameDialog.setInputValue(RENAMED_FILE_NAME) expect(await RenameDialog.getInputValue()).toBe(RENAMED_FILE_NAME) expect(await RenameDialog.isConfirmDisabled()).toBe(false) - console.log('Confirm button is enabled after name change (expected)') - // 8. Confirm the rename await RenameDialog.clickConfirm() await RenameDialog.waitForClosed() - console.log('Rename dialog closed after confirm') - // Allow time for the batch file system rename to complete - await delay(2000) + await waitForFilesInFolder(movieFolderPath, { + mustInclude: [RENAMED_FILE_NAME, ...RENAMED_ASSOCIATED_FILES], + mustExclude: [VIDEO_FILE_NAME, ...ASSOCIATED_FILES], + }) const filesInMediaFolder = await listFileNamesViaBrowser(movieFolderPath) - console.log('Files in media folder after rename:', filesInMediaFolder) - - // 9. Assert the video file was renamed expect(filesInMediaFolder).toContain(RENAMED_FILE_NAME) expect(filesInMediaFolder).not.toContain(VIDEO_FILE_NAME) - expect(await fileExistsViaBrowser(joinPlatformPath(movieFolderPath, RENAMED_FILE_NAME))).toBe(true) - console.log(`Video renamed: "${VIDEO_FILE_NAME}" -> "${RENAMED_FILE_NAME}"`) - // 10. Assert every associated file was renamed implicitly with the new stem for (let i = 0; i < ASSOCIATED_FILES.length; i++) { - const original = ASSOCIATED_FILES[i]! - const renamed = RENAMED_ASSOCIATED_FILES[i]! - expect(filesInMediaFolder).toContain(renamed) - expect(filesInMediaFolder).not.toContain(original) - console.log(`Associated file renamed implicitly: "${original}" -> "${renamed}"`) + expect(filesInMediaFolder).toContain(RENAMED_ASSOCIATED_FILES[i]!) + expect(filesInMediaFolder).not.toContain(ASSOCIATED_FILES[i]!) } }) }) diff --git a/apps/e2e/test/componentobjects/Sidebar.ts b/apps/e2e/test/componentobjects/Sidebar.ts index 2ee6c4d5..e0a0d4d7 100644 --- a/apps/e2e/test/componentobjects/Sidebar.ts +++ b/apps/e2e/test/componentobjects/Sidebar.ts @@ -103,9 +103,14 @@ class Sidebar { * @param folderName The name of the folder to click */ async clickFolder(folderName: string): Promise { - const folderElement = await this.getFolderByName(folderName) - await folderElement.waitForClickable({ timeout: 5000 }) - await folderElement.click() + // Click the row container (ContextMenuTrigger child), not the inner

label. + const folderRow = $( + `//*[@data-testid="sidebar-folder-name" and text()="${folderName}"]/ancestor::div[contains(@class,"cursor-pointer")][1]`, + ) + await folderRow.waitForExist({ timeout: 5000 }) + await folderRow.scrollIntoView() + // Radix ContextMenuTrigger often fails WebdriverIO "clickable" checks; JS click is reliable. + await folderRow.click({ js: true }) } /** diff --git a/apps/e2e/test/componentobjects/TVShowPanel.co.ts b/apps/e2e/test/componentobjects/TVShowPanel.co.ts index bf1c3520..e3f2fd23 100644 --- a/apps/e2e/test/componentobjects/TVShowPanel.co.ts +++ b/apps/e2e/test/componentobjects/TVShowPanel.co.ts @@ -1,6 +1,7 @@ /// import { browser } from '@wdio/globals' +import { clickContextMenuItem, rightClickElement } from '../lib/context-menu' import SearchboxCO from './Searchbox.co' /** Confirm button labels (en and zh-CN). */ @@ -246,10 +247,12 @@ class TVShowPanel { * This simulates a right-click on the corresponding table row. */ async openContextMenuForEpisode(episodeId: string): Promise { - const episodeIdCell = await $('td=' + episodeId) - await episodeIdCell.waitForDisplayed({ timeout: 10000 }) + const table = await $('[data-testid="tvshow-episode-table"]') + await table.waitForDisplayed({ timeout: 10_000 }) + const episodeIdCell = await table.$(`td=${episodeId}`) + await episodeIdCell.waitForDisplayed({ timeout: 10_000 }) const row = await episodeIdCell.parentElement() - await row.click({ button: 'right' }) + await rightClickElement(row) } /** @@ -266,35 +269,9 @@ class TVShowPanel { const labels = labelMap[labelEn] ?? [labelEn] await this.openContextMenuForEpisode(episodeId) - await browser.pause(300) console.log(`[TVShowPanel] Right-clicked on episode row ${episodeId}`) - - await browser.waitUntil( - async () => { - for (const label of labels) { - const item = await $(`[role="menuitem"]=${label}`) - if (await item.isDisplayed().catch(() => false)) return true - } - return false - }, - { - timeout: 5000, - interval: 200, - timeoutMsg: `Context menu item [${labels.join(', ')}] did not appear`, - } - ) - - for (const label of labels) { - const item = await $(`[role="menuitem"]=${label}`) - if (await item.isDisplayed().catch(() => false)) { - await item.waitForClickable({ timeout: 3000 }) - await item.click() - console.log(`[TVShowPanel] Clicked context menu item: ${label}`) - return - } - } - - throw new Error(`[TVShowPanel] Context menu item [${labels.join(', ')}] not found`) + await clickContextMenuItem(labels) + console.log(`[TVShowPanel] Clicked context menu item: ${labels.join(' / ')}`) } /** diff --git a/apps/e2e/test/lib/context-menu.ts b/apps/e2e/test/lib/context-menu.ts new file mode 100644 index 00000000..9329687d --- /dev/null +++ b/apps/e2e/test/lib/context-menu.ts @@ -0,0 +1,86 @@ +import { browser } from '@wdio/globals' + +const CONTEXT_MENU_CONTENT_SELECTOR = '[data-slot="context-menu-content"], [role="menu"]' + +/** + * Right-click an element reliably on Windows/Linux (Radix context menus). + * WebdriverIO's `click({ button: 'right' })` often fails to open Radix menus on Windows. + */ +export async function rightClickElement(element: WebdriverIO.Element): Promise { + await element.scrollIntoView() + await element.waitForDisplayed({ timeout: 10_000 }) + + await element.click({ button: 'right' }) + await browser.pause(200) + const menuAfterClick = await $(CONTEXT_MENU_CONTENT_SELECTOR) + if (await menuAfterClick.isDisplayed().catch(() => false)) { + return + } + + await browser.performActions([ + { + type: 'pointer', + id: 'pointer1', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', origin: element, x: 0, y: 0, duration: 0 }, + { type: 'pointerDown', button: 2 }, + { type: 'pointerUp', button: 2 }, + ], + }, + ]) + await browser.releaseActions() + await browser.pause(200) + + const menuAfterActions = await $(CONTEXT_MENU_CONTENT_SELECTOR) + if (await menuAfterActions.isDisplayed().catch(() => false)) { + return + } + + await browser.execute((el) => { + el.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + view: window, + button: 2, + buttons: 2, + }), + ) + }, element) + await browser.pause(200) +} + +export async function waitForContextMenuItem(labels: string[], timeout = 5000): Promise { + await browser.waitUntil( + async () => { + for (const label of labels) { + const byRole = await $(`[role="menuitem"]=${label}`) + if (await byRole.isDisplayed().catch(() => false)) return true + const bySlot = await $(`[data-slot="context-menu-item"]=${label}`) + if (await bySlot.isDisplayed().catch(() => false)) return true + } + return false + }, + { + timeout, + interval: 200, + timeoutMsg: `Context menu item [${labels.join(', ')}] did not appear`, + }, + ) +} + +export async function clickContextMenuItem(labels: string[]): Promise { + await waitForContextMenuItem(labels) + for (const label of labels) { + for (const selector of [`[role="menuitem"]=${label}`, `[data-slot="context-menu-item"]=${label}`]) { + const item = await $(selector) + if (await item.isDisplayed().catch(() => false)) { + await item.waitForClickable({ timeout: 3000 }) + await item.click() + return + } + } + } + throw new Error(`Context menu item [${labels.join(', ')}] not found`) +} From 5678e3a06a85454601d2e2737cc86d62ad857dd8 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 05:56:09 +0800 Subject: [PATCH 206/214] fix(core): strip legacy files on metadata read; strict create schema Co-authored-by: Cursor --- apps/cli/src/route/metadata/CreateMetadata.ts | 2 +- .../src/pipeline/mediaMetadataHelper.test.ts | 21 ++++++++++++++++++- .../src/pipeline/mediaMetadataValidation.ts | 7 +++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/route/metadata/CreateMetadata.ts b/apps/cli/src/route/metadata/CreateMetadata.ts index e3491bee..85d3f017 100644 --- a/apps/cli/src/route/metadata/CreateMetadata.ts +++ b/apps/cli/src/route/metadata/CreateMetadata.ts @@ -8,7 +8,7 @@ import { metadataProblemJson } from './problemDetails' const createMetadataDataSchema = z.object({ mediaFolderPath: z.string().min(1), ...metadataPatchFieldSchemas, -}).passthrough() +}).strict() const createMetadataRequestSchema: z.ZodType = z.object({ data: createMetadataDataSchema, diff --git a/apps/core/src/pipeline/mediaMetadataHelper.test.ts b/apps/core/src/pipeline/mediaMetadataHelper.test.ts index e3aa71e0..3c9aa373 100644 --- a/apps/core/src/pipeline/mediaMetadataHelper.test.ts +++ b/apps/core/src/pipeline/mediaMetadataHelper.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; +import type { MediaMetadata } from "@smm/core"; import type { FsPort } from "../ports/FsPort"; import { MediaMetadataHelper } from "./mediaMetadataHelper"; import { metadataCachePath } from "./paths"; -import { validatePersistedMediaMetadata } from "./mediaMetadataValidation"; +import { + stripDeprecatedFiles, + validatePersistedMediaMetadata, +} from "./mediaMetadataValidation"; function inMemoryFs(seed: Record = {}): FsPort { const files = new Map(Object.entries(seed)); @@ -100,3 +104,18 @@ describe("validatePersistedMediaMetadata", () => { }); }); }); + +describe("stripDeprecatedFiles", () => { + it("omits legacy files from disk payloads", () => { + const stripped = stripDeprecatedFiles({ + mediaFolderPath: "/m/Show", + type: "tvshow-folder", + files: ["/m/Show/a.mkv"], + } as MediaMetadata & { files?: string[] }); + expect(stripped).toEqual({ + mediaFolderPath: "/m/Show", + type: "tvshow-folder", + }); + expect("files" in stripped).toBe(false); + }); +}); diff --git a/apps/core/src/pipeline/mediaMetadataValidation.ts b/apps/core/src/pipeline/mediaMetadataValidation.ts index 3c2cc8c1..4d9fec96 100644 --- a/apps/core/src/pipeline/mediaMetadataValidation.ts +++ b/apps/core/src/pipeline/mediaMetadataValidation.ts @@ -81,7 +81,10 @@ export function validatePersistedMediaMetadata(value: unknown): PersistedMediaMe return result; } -/** Returns metadata suitable for persistence (identity after `files` removal from type). */ +/** Drop legacy on-disk `files` (removed from MediaMetadata) so reads never leak it. */ export function stripDeprecatedFiles(mm: MediaMetadata): PersistedMediaMetadata { - return mm; + const { files: _legacyFiles, ...rest } = mm as MediaMetadata & { + files?: unknown + } + return rest } From fa25b0ed709d287a06c70c731ff542d1f25fc1b0 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 12:48:51 +0800 Subject: [PATCH 207/214] ci: fix ci failure --- .../handleRenamePromptConfirmForTvShow.ts | 10 ++-- apps/ui/src/api/metadata.ts | 10 ++-- apps/ui/src/components/episode-file.tsx | 3 +- apps/ui/src/components/episode-section.tsx | 10 ++-- apps/ui/src/components/movie/MoviePanel.tsx | 13 ++--- .../ui/src/components/music/MusicHeaderV2.tsx | 7 +-- apps/ui/src/components/music/MusicPanel.tsx | 10 ++-- .../src/components/tv/TvShowEpisodeTable.tsx | 3 +- apps/ui/src/components/tv/TvShowPanel.tsx | 8 +-- apps/ui/src/components/tv/TvShowPanelUtils.ts | 11 ++-- .../ui/src/helpers/handleEpisodeFileSelect.ts | 16 +++--- apps/ui/src/helpers/loadNfo.ts | 8 +-- .../helpers/movie/MovieMediaMetadataUtils.ts | 10 ++-- .../movie/buildMovieFilesFromMediaMetadata.ts | 7 +-- .../useInitializeImportedMediaFolder.ts | 10 ++-- .../useRecognizeMovieByNfoMutation.ts | 8 +-- .../useRecognizeTvShowByNfoMutation.ts | 9 ++-- .../useInitializeMediaMetadataMutation.ts | 50 ++++++------------- .../mediaMetadata/useMediaMetadataQuery.ts | 5 +- .../lib/applyRenamePairsToUIMediaMetadata.ts | 15 +++--- apps/ui/src/lib/buildMovieEpisodeTableRows.ts | 7 +-- .../ui/src/lib/buildTvShowEpisodeTableRows.ts | 26 ++++++---- apps/ui/src/lib/mediaFolderFiles.ts | 48 ++++++++++++++++++ apps/ui/src/lib/mediaMetadataQueryKeys.ts | 11 ++-- apps/ui/src/lib/mediaMetadataUtils.ts | 26 ++++------ apps/ui/src/lib/music.ts | 7 +-- apps/ui/src/lib/recognizeEpisodes.ts | 25 +++++----- apps/ui/src/lib/utils.ts | 9 ++-- apps/ui/src/types/UIMediaMetadata.ts | 4 ++ packages/core-routes/src/renameFiles.test.ts | 2 - packages/core-routes/src/renameFolder.test.ts | 1 - 31 files changed, 227 insertions(+), 162 deletions(-) create mode 100644 apps/ui/src/lib/mediaFolderFiles.ts diff --git a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts index 25f6047e..79a83ace 100644 --- a/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts +++ b/apps/ui/src/actions/handleRenamePromptConfirmForTvShow.ts @@ -1,6 +1,7 @@ import type { RenameFilesPlan } from "@core/types/RenameFilesPlan" import { toast } from "sonner" -import type { MediaMetadata } from "@core/types" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import type { UIPlan } from "@/types/UIPlan" import type { UIRenameFilesPlan } from "@/types/UIRenameFilesPlan" import type { PersistUIMediaMetadataFn } from "@/types/persistUIMediaMetadata" @@ -15,7 +16,7 @@ export async function handleRenamePromptConfirmForTvShow( options: { planId: string plan: UIRenameFilesPlan - mediaMetadata: MediaMetadata + mediaMetadata: MediaMetadataWithFolderFiles selectedEpisodePaths: string[] renameFailedLabel: string noMediaPathErrorLabel: string @@ -36,7 +37,8 @@ export async function handleRenamePromptConfirmForTvShow( } = options const { setPlanById, persistUiMediaMetadata, renameFilesApi } = deps - if (!mediaMetadata.mediaFolderPath || !mediaMetadata.files) { + const folderFiles = getMediaFolderFiles(mediaMetadata) + if (!mediaMetadata.mediaFolderPath || folderFiles.length === 0) { console.warn("[rename] cannot apply rename — folder path or file list missing", { planId }) toast.error(noMediaPathErrorLabel) return @@ -53,7 +55,7 @@ export async function handleRenamePromptConfirmForTvShow( const { renameList } = await applyRenameFilesPlanForTvShow( { mediaFolderPath: mediaMetadata.mediaFolderPath, - localFiles: mediaMetadata.files, + localFiles: folderFiles, plan: actualPlan, traceId: renameTraceId, }, diff --git a/apps/ui/src/api/metadata.ts b/apps/ui/src/api/metadata.ts index 978fac30..7705147a 100644 --- a/apps/ui/src/api/metadata.ts +++ b/apps/ui/src/api/metadata.ts @@ -9,12 +9,14 @@ import { apiFetch } from "@/lib/apiFetch" export type MetadataPatch = SetMetadataRequestBody["patch"] export class MetadataHttpError extends Error { - constructor( - public problem: ProblemDetails, - public status: number, - ) { + problem: ProblemDetails + status: number + + constructor(problem: ProblemDetails, status: number) { super(problem.detail || problem.title) this.name = "MetadataHttpError" + this.problem = problem + this.status = status } } diff --git a/apps/ui/src/components/episode-file.tsx b/apps/ui/src/components/episode-file.tsx index 84c964d4..04f3a05e 100644 --- a/apps/ui/src/components/episode-file.tsx +++ b/apps/ui/src/components/episode-file.tsx @@ -12,6 +12,7 @@ import type { FileProps } from "@/lib/types" import { useTranslation } from "@/lib/i18n" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; interface EpisodeFileProps { file: FileProps @@ -211,7 +212,7 @@ export function EpisodeFile({ const newAbsolutePath = join(selectedMediaMetadata.mediaFolderPath, newRelativePath) // All files in the media folder (absolute POSIX paths from metadata) - const allMediaFiles = selectedMediaMetadata.files ?? [] + const allMediaFiles = getMediaFolderFiles(selectedMediaMetadata) // Compute renames for every file sharing the same stem as the video const assocRenames = computeAssociatedFileRenames(file.path, newAbsolutePath, allMediaFiles) diff --git a/apps/ui/src/components/episode-section.tsx b/apps/ui/src/components/episode-section.tsx index 7f6ef6f5..e48495b8 100644 --- a/apps/ui/src/components/episode-section.tsx +++ b/apps/ui/src/components/episode-section.tsx @@ -1,3 +1,4 @@ +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import type { TMDBTVShowDetails } from "@core/types" import { ChevronDown, Play, FileVideo, FileText, Music, Image as ImageIcon, Star, XCircle } from "lucide-react" import { cn } from "@/lib/utils" @@ -158,13 +159,14 @@ export function EpisodeSection({ // TODO: unable to calculate in preview match episode mode - if(selectedMediaMetadata === undefined - // || selectedMediaMetadata.mediaFiles === undefined - || selectedMediaMetadata.files === undefined) { + if(selectedMediaMetadata === undefined || selectedMediaMetadata === null) { return []; } - const localFiles = selectedMediaMetadata.files; + const localFiles = getMediaFolderFiles(selectedMediaMetadata); + if (localFiles.length === 0) { + return []; + } // Create a Set for fast lookup of existing files const localFilesSet = new Set(localFiles); diff --git a/apps/ui/src/components/movie/MoviePanel.tsx b/apps/ui/src/components/movie/MoviePanel.tsx index 2fa01b3e..acaf0de4 100644 --- a/apps/ui/src/components/movie/MoviePanel.tsx +++ b/apps/ui/src/components/movie/MoviePanel.tsx @@ -17,7 +17,8 @@ import { type MovieFileModel, } from "@/helpers/movie/buildMovieFilesFromMediaMetadata" import { buildMovieEpisodeTableRows, type MovieRenamePreviewData } from "@/lib/buildMovieEpisodeTableRows" -import type { MediaMetadata } from "@core/types" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import type { UIMediaFolderStatus } from "@/types/UIMediaFolder" import { MovieHeaderV2 } from "./MovieHeaderV2" import type { EpisodeTableLayout } from "../tv/TvShowPanelHeader" @@ -110,12 +111,12 @@ function MoviePanel() { /** * Frontend-processed media metadata. Adjustments here should not persist to backend. */ - const mediaMetadata: MediaMetadata | undefined = useMemo(() => { + const mediaMetadata: MediaMetadataWithFolderFiles | undefined = useMemo(() => { if (!queriedMediaMetadata) { return undefined } - const clone: MediaMetadata = structuredClone(queriedMediaMetadata) + const clone: MediaMetadataWithFolderFiles = structuredClone(queriedMediaMetadata) // move this step to Media Folder Initialization process return findMediaFilesForMovieMediaMetadata(clone) @@ -130,7 +131,7 @@ function MoviePanel() { }) const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - files: mediaMetadata?.files ?? [], + files: getMediaFolderFiles(mediaMetadata), }) const [movieFiles, setMovieFiles] = useState({ files: [] }) const latestMovieFiles = useLatest(movieFiles) @@ -257,8 +258,8 @@ function MoviePanel() { (args: SearchResultSelectedArgs) => { debug(`[MoviePanel] handleSelectResult() called`, { args }) - if (queriedMediaMetadata === undefined) { - console.error(`[MoviePanel] handleSelectResult() queriedMediaMetadata is undefined, skip`) + if (!queriedMediaMetadata) { + console.error(`[MoviePanel] handleSelectResult() queriedMediaMetadata is missing, skip`) return } diff --git a/apps/ui/src/components/music/MusicHeaderV2.tsx b/apps/ui/src/components/music/MusicHeaderV2.tsx index d4d25769..7322c076 100644 --- a/apps/ui/src/components/music/MusicHeaderV2.tsx +++ b/apps/ui/src/components/music/MusicHeaderV2.tsx @@ -7,10 +7,11 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu" import { useTranslation } from "@/lib/i18n" -import type { MediaMetadata } from "@core/types" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" export interface MusicHeaderV2Props { - selectedMediaMetadata?: MediaMetadata + selectedMediaMetadata?: MediaMetadataWithFolderFiles onDownloadClick?: () => void onTranscribeClick?: () => void onTranslateClick?: () => void @@ -56,7 +57,7 @@ export function MusicHeaderV2({ const { t } = useTranslation(["components", "common"]) const folderName = selectedMediaMetadata?.mediaFolderPath?.split("/").pop() || "Music" - const trackCount = selectedMediaMetadata?.files?.length ?? 0 + const trackCount = getMediaFolderFiles(selectedMediaMetadata).length const folderReady = !!selectedMediaMetadata?.mediaFolderPath const transcribeDisabled = diff --git a/apps/ui/src/components/music/MusicPanel.tsx b/apps/ui/src/components/music/MusicPanel.tsx index fd4c9235..28798812 100644 --- a/apps/ui/src/components/music/MusicPanel.tsx +++ b/apps/ui/src/components/music/MusicPanel.tsx @@ -3,6 +3,8 @@ import { useMediaMetadataQuery } from "@/hooks/mediaMetadata"; import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation"; import { useUpdateMediaMetadataMutation } from "@/hooks/mediaMetadata/useUpdateMediaMetadataMutation"; import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; import type { MediaMetadata } from "@core/types"; import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; import { @@ -89,7 +91,7 @@ export function MusicPanel() { uiFolderRow, ]); - const mediaMetadata = queriedMediaMetadata; + const mediaMetadata = queriedMediaMetadata ?? undefined; const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation(); const { mutateAsync: saveMediaMetadata } = useUpdateMediaMetadataMutation(); @@ -419,7 +421,7 @@ export function MusicPanel() { return; } - const currentFiles = mediaMetadata.files ?? []; + const currentFiles = getMediaFolderFiles(mediaMetadata); const trackPathPosix = Path.posix(trackPath); const fileIndex = currentFiles.findIndex((file) => file === trackPathPosix); @@ -589,7 +591,7 @@ export function MusicPanel() { void showSubtitleMenu?: boolean showDownloadButton?: boolean diff --git a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx index edacb8af..c080d547 100644 --- a/apps/ui/src/components/tv/TvShowEpisodeTable.tsx +++ b/apps/ui/src/components/tv/TvShowEpisodeTable.tsx @@ -37,6 +37,7 @@ import { cn } from "@/lib/utils" import { computeAssociatedFileRenames } from "../episode-file" import { useFetchMediaMetadataMutation } from "@/hooks/mediaMetadata/useFetchMediaMetadataMutation" import { useMediaMetadataQuery } from "@/hooks/mediaMetadata/useMediaMetadataQuery" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import { isSmmV3Enabled } from "@/lib/localStorages" export interface TvShowEpisodeDividerRow { @@ -904,7 +905,7 @@ export function TvShowEpisodeTable({ to: newAbsolutePath, }) } else { - const allMediaFiles = selectedMediaMetadata.files ?? [] + const allMediaFiles = getMediaFolderFiles(selectedMediaMetadata) const assocRenames = computeAssociatedFileRenames(row.videoFile, newAbsolutePath, allMediaFiles) await renameFiles({ files: [ diff --git a/apps/ui/src/components/tv/TvShowPanel.tsx b/apps/ui/src/components/tv/TvShowPanel.tsx index c5fc234f..7c048b30 100644 --- a/apps/ui/src/components/tv/TvShowPanel.tsx +++ b/apps/ui/src/components/tv/TvShowPanel.tsx @@ -3,7 +3,9 @@ import { useMediaMetadataQuery } from "@/hooks/mediaMetadata" import { useSelectTvShowForFolderMutation } from "@/hooks/useSelectTvShowForFolderMutation" import { normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" import { useState, useEffect, useCallback, useMemo } from "react" -import type { MediaMetadata, TMDBTVShow, TMDBTVShowDetails } from "@core/types" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { TMDBTVShow, TMDBTVShowDetails } from "@core/types" import type { SearchResultSelectedArgs } from "../MediaDatabaseSearchbox" import { useTranslation } from "@/lib/i18n" import { TvShowPanelPrompts } from "./TvShowPanelPrompts" @@ -66,7 +68,7 @@ function TvShowPanel() { [folders, selectedFolder], ) - const mediaMetadata: MediaMetadata | undefined = queriedMediaMetadata + const mediaMetadata: MediaMetadataWithFolderFiles | undefined = queriedMediaMetadata ?? undefined const uiStatus: UIMediaFolderStatus = useMemo(() => { if (isMediaMetadataError) return "error_loading_metadata" @@ -92,7 +94,7 @@ function TvShowPanel() { const { mutateAsync: fetchMediaMetadata } = useFetchMediaMetadataMutation() const videoRenameFlow = useRenameVideoFileFlow({ mediaFolderPath: mediaMetadata?.mediaFolderPath, - files: mediaMetadata?.files ?? [], + files: getMediaFolderFiles(mediaMetadata), mode: "episode", }) diff --git a/apps/ui/src/components/tv/TvShowPanelUtils.ts b/apps/ui/src/components/tv/TvShowPanelUtils.ts index 063f0627..5f30af74 100644 --- a/apps/ui/src/components/tv/TvShowPanelUtils.ts +++ b/apps/ui/src/components/tv/TvShowPanelUtils.ts @@ -1,3 +1,5 @@ +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; import type { MediaFileMetadata, MediaMetadata, PrimaryDatabase, TMDBEpisode, TMDBTVShowDetails, TvShowMediaMetadata } from "@core/types"; import { type UIMediaMetadata } from "@/types/UIMediaMetadata"; import { extname, join } from "@/lib/path"; @@ -935,18 +937,19 @@ export async function executeRenamePlan( * The caller (addTmpPlan) will add id, task, status, and tmp fields */ export async function buildTemporaryRecognitionPlanAsync( - mediaMetadata: MediaMetadata, + mediaMetadata: MediaMetadataWithFolderFiles, ): Promise<(Partial & { mediaFolderPath: string; files: RecognizedFile[] }) | null> { + const folderFiles = getMediaFolderFiles(mediaMetadata) console.log("[recognize] build temporary plan started", { mediaFolderPath: mediaMetadata.mediaFolderPath, - fileCount: mediaMetadata.files?.length ?? 0, + fileCount: folderFiles.length, tvShowId: mediaMetadata.tvShow?.id, }) - if (!mediaMetadata.mediaFolderPath || !mediaMetadata.files || !mediaMetadata.tvShow) { + if (!mediaMetadata.mediaFolderPath || folderFiles.length === 0 || !mediaMetadata.tvShow) { console.warn("[recognize] build temporary plan aborted: missing prerequisites", { hasMediaFolderPath: !!mediaMetadata.mediaFolderPath, - hasFiles: !!mediaMetadata.files, + hasFiles: folderFiles.length > 0, hasTvShow: !!mediaMetadata.tvShow, }) return null diff --git a/apps/ui/src/helpers/handleEpisodeFileSelect.ts b/apps/ui/src/helpers/handleEpisodeFileSelect.ts index 18cd4871..c62838ae 100644 --- a/apps/ui/src/helpers/handleEpisodeFileSelect.ts +++ b/apps/ui/src/helpers/handleEpisodeFileSelect.ts @@ -1,4 +1,5 @@ -import type { MediaMetadata } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import { Path } from "@core/path"; import { updateMediaFileMetadatas } from "@/components/tv/TvShowPanelUtils"; @@ -17,14 +18,15 @@ import { updateMediaFileMetadatas } from "@/components/tv/TvShowPanelUtils"; * @param filePath */ export function handleEpisodeFileSelect( - mm: MediaMetadata, + mm: MediaMetadataWithFolderFiles, seasonNumber: number, episodeNumber: number, filePath: string, onError: (error: string) => void -): MediaMetadata { +): MediaMetadataWithFolderFiles { - if (!mm.files) { + const files = getMediaFolderFiles(mm) + if (files.length === 0) { onError("Files list is not available"); return mm; } @@ -44,15 +46,15 @@ export function handleEpisodeFileSelect( const isWindows = Path.isWindows(); const normalizedSelectedPath = isWindows ? filePathInPosix.toLowerCase() : filePathInPosix; - const normalizedFiles = mm.files.map(f => isWindows ? f.toLowerCase() : f); + const normalizedFiles = files.map((f: string) => isWindows ? f.toLowerCase() : f); let fileFound = false; let matchedFile = ""; - for (let i = 0; i < mm.files.length; i++) { + for (let i = 0; i < files.length; i++) { const normalizedFile = normalizedFiles[i]; if (normalizedFile === normalizedSelectedPath) { fileFound = true; - matchedFile = mm.files[i]; + matchedFile = files[i]; break; } } diff --git a/apps/ui/src/helpers/loadNfo.ts b/apps/ui/src/helpers/loadNfo.ts index 730a9092..8bb45f32 100644 --- a/apps/ui/src/helpers/loadNfo.ts +++ b/apps/ui/src/helpers/loadNfo.ts @@ -1,6 +1,8 @@ import { readFile } from "@/api/readFile" import NFO from "@/lib/nfo" -import type { MediaMetadata, TMDBTVShowDetails } from "@core/types" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { TMDBTVShowDetails } from "@core/types" /** * Extracts the path portion from a TMDB image URL. @@ -89,8 +91,8 @@ export function nfoToTmdbTVShowDetails(nfo: NFO): TMDBTVShowDetails { return tvShowDetails } -export async function loadNfo(mediaMetadata: MediaMetadata): Promise { - const nfoFilePath = mediaMetadata.files?.find(file => file.endsWith('/tvshow.nfo')) +export async function loadNfo(mediaMetadata: MediaMetadataWithFolderFiles): Promise { + const nfoFilePath = getMediaFolderFiles(mediaMetadata).find((file: string) => file.endsWith('/tvshow.nfo')) if(nfoFilePath === undefined) { console.log(`[loadNfo] no nfo file found in media metadata`) diff --git a/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts b/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts index e30e13e9..62d8ea42 100644 --- a/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts +++ b/apps/ui/src/helpers/movie/MovieMediaMetadataUtils.ts @@ -1,7 +1,8 @@ -import type { MediaMetadata } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; import { videoFileExtensions } from "../../lib/utils"; import { extname } from "../../lib/path"; -export function findMediaFilesForMovieMediaMetadata(mediaMetadata: MediaMetadata): MediaMetadata { +export function findMediaFilesForMovieMediaMetadata(mediaMetadata: MediaMetadataWithFolderFiles): MediaMetadataWithFolderFiles { if(!mediaMetadata.mediaFolderPath) { console.log('[findMediaFilesForMovieMediaMetadata] Media folder path is required, skipping post processing'); @@ -15,14 +16,15 @@ export function findMediaFilesForMovieMediaMetadata(mediaMetadata: MediaMetadata return mediaMetadata } - if(mediaMetadata.files === undefined || mediaMetadata.files === null || mediaMetadata.files.length === 0) { + const folderFiles = getMediaFolderFiles(mediaMetadata) + if(folderFiles.length === 0) { console.log('[findMediaFilesForMovieMediaMetadata] No files found in media folder, skipping post processing', { mediaFolderPath: mediaMetadata.mediaFolderPath, }); return mediaMetadata } - const videoFiles = findVideoFiles(mediaMetadata.files); + const videoFiles = findVideoFiles(folderFiles); mediaMetadata.mediaFiles = videoFiles.map(path => ({ absolutePath: path, diff --git a/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts b/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts index f6b1f601..6499ce5d 100644 --- a/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts +++ b/apps/ui/src/helpers/movie/buildMovieFilesFromMediaMetadata.ts @@ -1,4 +1,5 @@ -import type { MediaMetadata } from "@core/types" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import type { FileProps } from "@/lib/types" import { basename, join } from "@/lib/path" import { findAssociatedFiles, imageFileExtensions } from "@/lib/utils" @@ -66,7 +67,7 @@ function findMovieFolderAssociatedFiles(allFiles: string[]): Array<{ } export function buildMovieFilesFromMediaMetadata( - mediaMetadata: MediaMetadata | undefined, + mediaMetadata: MediaMetadataWithFolderFiles | undefined, ): MovieFileModel | undefined { if (!mediaMetadata?.mediaFolderPath) { return undefined @@ -75,7 +76,7 @@ export function buildMovieFilesFromMediaMetadata( const mediaFolderPath = mediaMetadata.mediaFolderPath const files: FileProps[] = [] const addedPaths = new Set() - const allFilePaths = mediaMetadata.files ?? [] + const allFilePaths = getMediaFolderFiles(mediaMetadata) for (const file of mediaMetadata.mediaFiles ?? []) { const videoPath = file.absolutePath diff --git a/apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts b/apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts index 02c83a85..7f591cf4 100644 --- a/apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts +++ b/apps/ui/src/hooks/initialization/useInitializeImportedMediaFolder.ts @@ -28,6 +28,8 @@ import { useRecognizeMovieByTvdbIdInFolderNameMutation } from "@/hooks/initializ import { recognizeEpisodes as recognizeEpisodesAsync } from "@/lib/recognizeEpisodes"; import { extname } from "@/lib/path"; import { videoFileExtensions } from "@core/utils"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; import type { MediaFileMetadata, MediaMetadata, MovieMediaMetadata, TvShowMediaMetadata } from "@core/types"; import { withTimeout } from "es-toolkit"; import { logger } from "@/lib/log"; @@ -185,15 +187,15 @@ export function useInitializeImportedMediaFolder() { /** First video file in `mm.files` (by `videoFileExtensions`), or empty. Movie entries only set `absolutePath`. */ const recognizeMovieEpisode = useCallback( - async (mm: MediaMetadata, traceId: string): Promise => { - const files = mm.files; - if (!files || files.length === 0) { + async (mm: MediaMetadataWithFolderFiles, traceId: string): Promise => { + const files = getMediaFolderFiles(mm); + if (files.length === 0) { logger.warn( `[${traceId}] unable to recognize movie episode after recognizing media folder` ); return []; } - const firstVideo = files.find((path) => + const firstVideo = files.find((path: string) => videoFileExtensions.includes(extname(path).toLowerCase()) ); if (firstVideo === undefined) { diff --git a/apps/ui/src/hooks/initialization/useRecognizeMovieByNfoMutation.ts b/apps/ui/src/hooks/initialization/useRecognizeMovieByNfoMutation.ts index a30d5ca5..0fb22193 100644 --- a/apps/ui/src/hooks/initialization/useRecognizeMovieByNfoMutation.ts +++ b/apps/ui/src/hooks/initialization/useRecognizeMovieByNfoMutation.ts @@ -2,7 +2,9 @@ import { Path } from "@core/path"; import { readFile } from "@/api/readFile"; import { parseMovieNfo } from "@/lib/nfo"; import { useMutation } from "@tanstack/react-query"; -import type { MediaMetadata, MovieMediaMetadata, PreferMediaLanguage } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import type { MovieMediaMetadata, PreferMediaLanguage } from "@core/types"; import { useGetTmdbMovieMutation } from "@/hooks/useGetTmdbMovieMutation"; import { useGetTvdbMovieMutation } from "@/hooks/useGetTvdbMovieMutation"; @@ -12,7 +14,7 @@ export function useRecognizeMovieByNfoMutation() { const mutation = useMutation({ mutationFn: async (_variables: { - mediaMetadata: MediaMetadata + mediaMetadata: MediaMetadataWithFolderFiles language: PreferMediaLanguage }): Promise => { const m = _variables.mediaMetadata; @@ -22,7 +24,7 @@ export function useRecognizeMovieByNfoMutation() { console.warn(`[useRecognizeMovieByNfoMutation] mediaMetadata is not a movie-folder: ${m.type}`) } - const movieNfoFilePathInPosix = m.files?.find(file => file.endsWith('/movie.nfo')) + const movieNfoFilePathInPosix = getMediaFolderFiles(m).find((file: string) => file.endsWith('/movie.nfo')) if(movieNfoFilePathInPosix === undefined) { console.warn(`[useRecognizeMovieByNfoMutation] movie.nfo not found`) return undefined diff --git a/apps/ui/src/hooks/initialization/useRecognizeTvShowByNfoMutation.ts b/apps/ui/src/hooks/initialization/useRecognizeTvShowByNfoMutation.ts index ae6cecef..d37cd912 100644 --- a/apps/ui/src/hooks/initialization/useRecognizeTvShowByNfoMutation.ts +++ b/apps/ui/src/hooks/initialization/useRecognizeTvShowByNfoMutation.ts @@ -1,9 +1,10 @@ -import type { MediaMetadata } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles"; +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; +import type { PreferMediaLanguage, TvShowMediaMetadata } from "@core/types"; import { Path } from "@core/path"; import { readFile } from "@/api/readFile"; import { parseTvShowNfo } from "@/lib/nfo"; import { useMutation } from "@tanstack/react-query"; -import type { PreferMediaLanguage, TvShowMediaMetadata } from "@core/types"; import { useGetTmdbTvShowMutation } from "@/hooks/useGetTmdbTvShowMutation"; import { useGetTvdbTvShowMutation } from "@/hooks/useGetTvdbTvShowMutation"; @@ -15,7 +16,7 @@ export function useRecognizeTvShowByNfoMutation() { const mutation = useMutation({ mutationFn: async (_variables: { - mediaMetadata: MediaMetadata + mediaMetadata: MediaMetadataWithFolderFiles language: PreferMediaLanguage }): Promise => { @@ -26,7 +27,7 @@ export function useRecognizeTvShowByNfoMutation() { console.warn(`[useRecognizeTvShowByNfoMutation] mediaMetadata is not a tvshow-folder: ${m.type}`) } - const tvShowNfoFilePathInPosix = m.files?.find(file => file.endsWith('/tvshow.nfo')) + const tvShowNfoFilePathInPosix = getMediaFolderFiles(m).find((file: string) => file.endsWith('/tvshow.nfo')) if(tvShowNfoFilePathInPosix === undefined) { console.warn(`[useRecognizeTvShowByNfoMutation] tvshow.nfo not found`) return undefined diff --git a/apps/ui/src/hooks/mediaMetadata/useInitializeMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useInitializeMediaMetadataMutation.ts index 8f6113b1..75a3717a 100644 --- a/apps/ui/src/hooks/mediaMetadata/useInitializeMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useInitializeMediaMetadataMutation.ts @@ -1,68 +1,48 @@ import { useMutation } from "@tanstack/react-query" import { Path } from "@core/path" -import type { MediaMetadata } from "@core/types" -import { listFiles } from "@/api/listFiles" +import { createMediaMetadata } from "@core/mediaMetadata" +import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" import { logger } from "@/lib/log" +import type { UIMediaMetadata } from "@/types/UIMediaMetadata" + type MediaFolderType = "music-folder" | "tvshow-folder" | "movie-folder" export function useInitializeMediaMetadataMutation() { - return useMutation({ mutationFn: async (vars: { folderPathInPlatformFormat: string type: MediaFolderType traceId?: string - }): Promise => { - - const mm: MediaMetadata = { - mediaFolderPath: Path.posix(vars.folderPathInPlatformFormat), - type: vars.type, + }): Promise => { + const mm: UIMediaMetadata = { + status: "idle", + ...createMediaMetadata(Path.posix(vars.folderPathInPlatformFormat), vars.type), files: [], mediaFiles: [], } logger.info({ traceId: vars.traceId, - stage: 'initialize.listFiles', + stage: "initialize.listFiles", folder: vars.folderPathInPlatformFormat, type: vars.type, - }, 'listFiles: scanning folder') + }, "listFiles: scanning folder") const start = performance.now() - const files = await listFiles({ path: vars.folderPathInPlatformFormat, recursively: true, onlyFiles: true }) + const filePaths = await listMediaFolderFilePaths(vars.folderPathInPlatformFormat) const durationMs = Math.round(performance.now() - start) - if(files.error) { - logger.error({ - traceId: vars.traceId, - stage: 'initialize.listFiles', - folder: vars.folderPathInPlatformFormat, - durationMs, - error: files.error, - }, 'listFiles: failed') - throw new Error(`Failed to list files: ${files.error}`) - } - if(files.data === undefined) { - logger.error({ - traceId: vars.traceId, - stage: 'initialize.listFiles', - folder: vars.folderPathInPlatformFormat, - durationMs, - }, 'listFiles: response.data is undefined') - throw new Error(`Failed to list files: response.data is undefined`) - } - mm.files = files.data.items.map(item => Path.posix(item.path)) + mm.files = filePaths logger.info({ traceId: vars.traceId, - stage: 'initialize.listFiles', + stage: "initialize.listFiles", folder: vars.folderPathInPlatformFormat, fileCount: mm.files.length, durationMs, - }, 'listFiles: done') + }, "listFiles: done") - return mm; + return mm }, }) } - diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts index 5272a9dd..93acd2ee 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataQuery.ts @@ -1,7 +1,8 @@ import { skipToken, useQuery } from "@tanstack/react-query" -import type { MediaMetadata } from "@core/types" import { MetadataHttpError } from "@/api/metadata" import { mediaMetadataReadQueryOptions } from "@/lib/mediaMetadataQueryKeys" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@core/types" /** Query key when no folder path — `queryFn: skipToken` skips fetch; must not call `mediaMetadataReadQueryOptions("")`. */ const noFolderMediaMetadataQueryKey = ["mediaMetadata", null] as const @@ -14,7 +15,7 @@ export function useMediaMetadataQuery(path: string | undefined, _opts?: UseMedia const trimmed = path?.trim() ?? "" const readOpts = trimmed ? mediaMetadataReadQueryOptions(trimmed) : null - return useQuery({ + return useQuery({ queryKey: readOpts?.queryKey ?? noFolderMediaMetadataQueryKey, queryFn: readOpts ? async (context) => { diff --git a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts index c7a6b4c8..e1a98763 100644 --- a/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts +++ b/apps/ui/src/lib/applyRenamePairsToUIMediaMetadata.ts @@ -1,4 +1,5 @@ -import type { MediaFileMetadata, MediaMetadata } from "@core/types" +import type { MediaFileMetadata } from "@core/types" +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" import { Path } from "@core/path" function pathKey(p: string): string { @@ -10,20 +11,20 @@ function pathKey(p: string): string { } /** - * Apply completed on-disk renames to in-memory MediaMetadata (mediaFiles, files list). + * Apply completed on-disk renames to in-memory metadata (mediaFiles, UI folder file list). * Pairs must match what was passed to `/api/renameFiles` (POSIX paths as stored in metadata). */ export function applyRenamePairsToUIMediaMetadata( - metadata: MediaMetadata, - pairs: Array<{ from: string; to: string }> -): MediaMetadata { + metadata: MediaMetadataWithFolderFiles, + pairs: Array<{ from: string; to: string }>, +): MediaMetadataWithFolderFiles { const map = new Map() for (const { from, to } of pairs) { map.set(pathKey(from), to) } const remap = (p: string) => map.get(pathKey(p)) ?? p - const next: MediaMetadata = { ...metadata } + const next: MediaMetadataWithFolderFiles = { ...metadata } if (Array.isArray(next.files)) { next.files = next.files.map(remap) } @@ -35,7 +36,7 @@ export function applyRenamePairsToUIMediaMetadata( absolutePath: remap(mf.absolutePath), subtitleFilePaths: mf.subtitleFilePaths?.map(remap), audioFilePaths: mf.audioFilePaths?.map(remap), - }) + }), ) } return next diff --git a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts index e2a841c3..d47d8106 100644 --- a/apps/ui/src/lib/buildMovieEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildMovieEpisodeTableRows.ts @@ -1,5 +1,6 @@ import type { TvShowEpisodeDataRow, TvShowEpisodeTableRow } from "@/components/tv/TvShowEpisodeTable"; -import type { MediaMetadata } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; import type { UIMediaFolderStatus } from "@/types/UIMediaFolder"; import { basename, join } from "@/lib/path"; import { findAssociatedFiles } from "@/lib/utils"; @@ -19,7 +20,7 @@ export interface MovieRenamePreviewData { * - One episode data row (S01E01) with video + stem-matched associated files */ export function buildMovieEpisodeTableRows( - mm: MediaMetadata, + mm: MediaMetadataWithFolderFiles, uiStatus: UIMediaFolderStatus, t: (key: string) => string, options?: { @@ -44,7 +45,7 @@ export function buildMovieEpisodeTableRows( const rows: TvShowEpisodeTableRow[] = []; const mediaFolderPath = mm.mediaFolderPath; const videoFile = mm.mediaFiles[0]; // Only the first/main video file - const allFiles = mm.files ?? []; + const allFiles = getMediaFolderFiles(mm); // ── Folder-level file rows (mirrors TvShowPanel's buildFolderFileRows) ── diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts index 86da65e2..34eba9a6 100644 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts @@ -1,5 +1,6 @@ import type { TvShowEpisodeDataRow, TvShowEpisodeTableRow, TvShowFolderFileRow } from "@/components/tv/TvShowEpisodeTable"; -import type { MediaMetadata } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles"; import { basename, join } from "@/lib/path"; import type { UIRecognizeMediaFilePlan } from "@/types/UIRecognizeMediaFilePlan"; import { findAssociatedFiles } from "@/lib/utils"; @@ -42,7 +43,7 @@ function buildFolderFileRows(files: string[]): TvShowFolderFileRow[] { } export function buildTvShowEpisodeTableRows( - mm: MediaMetadata, + mm: MediaMetadataWithFolderFiles, uiStatus: UIMediaFolderStatus, t: (key: string) => string, ): TvShowEpisodeTableRow[] { @@ -72,8 +73,9 @@ export function buildTvShowEpisodeTableRows( }] } - if (mm.files && mm.mediaFolderPath) { - rows.push(...buildFolderFileRows(mm.files)) + const folderFiles = getMediaFolderFiles(mm) + if (folderFiles.length > 0 && mm.mediaFolderPath) { + rows.push(...buildFolderFileRows(folderFiles)) } if (mm.tvShow !== undefined) { @@ -87,7 +89,7 @@ export function buildTvShowEpisodeTableRows( return rows } -export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata) { +export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadataWithFolderFiles) { const rows: TvShowEpisodeTableRow[] = [] @@ -118,7 +120,8 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata) { let subtitleFile: { path: string; newPath?: string } | undefined let nfoFile: { path: string; newPath?: string } | undefined - if (mediaFile && _in_mm.mediaFolderPath && _in_mm.files) { + const folderFiles = getMediaFolderFiles(_in_mm) + if (mediaFile && _in_mm.mediaFolderPath && folderFiles.length > 0) { // Get video file path videoFile = { path: mediaFile.absolutePath, @@ -126,7 +129,7 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata) { } // Find associated files - const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, _in_mm.files, mediaFile.absolutePath) + const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) for (const file of associatedFiles) { const filePath = join(_in_mm.mediaFolderPath, file.path) @@ -167,7 +170,7 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadata) { return rows; } -export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata) { +export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadataWithFolderFiles) { const rows: TvShowEpisodeTableRow[] = [] @@ -198,7 +201,8 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata) { let subtitleFile: { path: string; newPath?: string } | undefined let nfoFile: { path: string; newPath?: string } | undefined - if (mediaFile && _in_mm.mediaFolderPath && _in_mm.files) { + const folderFiles = getMediaFolderFiles(_in_mm) + if (mediaFile && _in_mm.mediaFolderPath && folderFiles.length > 0) { // Get video file path videoFile = { path: mediaFile.absolutePath, @@ -206,7 +210,7 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata) { } // Find associated files - const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, _in_mm.files, mediaFile.absolutePath) + const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) for (const file of associatedFiles) { const filePath = join(_in_mm.mediaFolderPath, file.path) @@ -248,7 +252,7 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadata) { } export function buildTvShowEpisodeTableRowsForPlan( - mm: MediaMetadata, + mm: MediaMetadataWithFolderFiles, uiStatus: UIMediaFolderStatus, plan: UIRenameFilesPlan | UIRecognizeMediaFilePlan, t: (key: string) => string diff --git a/apps/ui/src/lib/mediaFolderFiles.ts b/apps/ui/src/lib/mediaFolderFiles.ts new file mode 100644 index 00000000..a77e61ae --- /dev/null +++ b/apps/ui/src/lib/mediaFolderFiles.ts @@ -0,0 +1,48 @@ +import type { MediaMetadata } from "@core/types" +import { listFiles } from "@/api/listFiles" +import { Path } from "@core/path" + +/** Live folder listing from `listFiles`; not persisted in metadata cache. */ +export type MediaMetadataWithFolderFiles = MediaMetadata & { + files?: string[] +} + +export function getMediaFolderFiles( + mm: MediaMetadataWithFolderFiles | null | undefined, +): string[] { + return mm?.files ?? [] +} + +export async function listMediaFolderFilePaths( + folderPath: string, + signal?: AbortSignal, +): Promise { + const result = await listFiles( + { path: folderPath, recursively: true, onlyFiles: true }, + signal, + ) + if (result.error) { + throw new Error(`Failed to list files: ${result.error}`) + } + if (result.data === undefined) { + throw new Error("Failed to list files: response.data is undefined") + } + return result.data.items.map((item) => Path.posix(item.path)) +} + +/** Attach live folder file paths to persisted metadata for UI consumers. */ +export async function hydrateMediaMetadataWithFolderFiles( + metadata: MediaMetadata, + signal?: AbortSignal, +): Promise { + const folderPath = metadata.mediaFolderPath + if (!folderPath) { + return metadata + } + try { + const files = await listMediaFolderFilePaths(folderPath, signal) + return { ...metadata, files } + } catch { + return metadata + } +} diff --git a/apps/ui/src/lib/mediaMetadataQueryKeys.ts b/apps/ui/src/lib/mediaMetadataQueryKeys.ts index 9d3acd2a..3696e4f0 100644 --- a/apps/ui/src/lib/mediaMetadataQueryKeys.ts +++ b/apps/ui/src/lib/mediaMetadataQueryKeys.ts @@ -1,6 +1,9 @@ import { Path } from "@core/path" -import type { MediaMetadata } from "@core/types" import { getMetadata } from "@/api/metadata" +import { + hydrateMediaMetadataWithFolderFiles, + type MediaMetadataWithFolderFiles, +} from "@/lib/mediaFolderFiles" /** TanStack Query keys for per-folder persisted metadata. */ export function mediaMetadataQueryKey(folderPathPosix: string) { @@ -17,7 +20,9 @@ export function mediaMetadataReadQueryOptions(path: string) { const folderPathPosix = normalizeMediaFolderPathForQuery(path) return { queryKey: mediaMetadataQueryKey(folderPathPosix), - queryFn: ({ signal }: { signal?: AbortSignal } = {}): Promise => - getMetadata(folderPathPosix, signal), + queryFn: async ({ signal }: { signal?: AbortSignal } = {}): Promise => { + const metadata = await getMetadata(folderPathPosix, signal) + return hydrateMediaMetadataWithFolderFiles(metadata, signal) + }, } } diff --git a/apps/ui/src/lib/mediaMetadataUtils.ts b/apps/ui/src/lib/mediaMetadataUtils.ts index ecc15953..7a516b12 100644 --- a/apps/ui/src/lib/mediaMetadataUtils.ts +++ b/apps/ui/src/lib/mediaMetadataUtils.ts @@ -1,8 +1,7 @@ -import { listFiles } from "@/api/listFiles"; -import type { MediaMetadata } from "@core/types"; -import type { UIMediaMetadata } from "@/types/UIMediaMetadata"; -import { Path } from "@core/path"; -import { createMediaMetadata } from "@core/mediaMetadata"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { listMediaFolderFilePaths } from "@/lib/mediaFolderFiles" +import { createMediaMetadata } from "@core/mediaMetadata" +import type { UIMediaMetadata } from "@/types/UIMediaMetadata" export async function createInitialMediaMetadata( folderPathInPlatformFormat: string, @@ -16,15 +15,8 @@ export async function createInitialMediaMetadata( ...options?.mediaMetadataProps }; - const files = await listFiles({ path: folderPathInPlatformFormat, recursively: true, onlyFiles: true }, options?.abortSignal) - if(files.error) { - throw new Error(`Failed to list files: ${files.error}`); - } - if(files.data === undefined) { - throw new Error(`Failed to list files: response.data is undefined`); - } - - mm.files = files.data.items.map(item => Path.posix(item.path)); + const files = await listMediaFolderFilePaths(folderPathInPlatformFormat, options?.abortSignal) + mm.files = files; return mm; } @@ -37,9 +29,9 @@ export async function createInitialMediaMetadata( * @param newItems * @returns */ -export function findUpdatedMediaMetadata(old: MediaMetadata[], newItems: MediaMetadata[]): MediaMetadata[] { +export function findUpdatedMediaMetadata(old: MediaMetadataWithFolderFiles[], newItems: MediaMetadataWithFolderFiles[]): MediaMetadataWithFolderFiles[] { const oldByPath = new Map(old.filter(m => m.mediaFolderPath).map(m => [m.mediaFolderPath!, m])); - const updated: MediaMetadata[] = []; + const updated: MediaMetadataWithFolderFiles[] = []; for (const item of newItems) { const path = item.mediaFolderPath; @@ -54,7 +46,7 @@ export function findUpdatedMediaMetadata(old: MediaMetadata[], newItems: MediaMe } // Compare relevant metadata fields - const fieldsToCompare: (keyof MediaMetadata)[] = [ + const fieldsToCompare: (keyof MediaMetadataWithFolderFiles)[] = [ 'mediaFolderPath', 'files', 'tvShow', diff --git a/apps/ui/src/lib/music.ts b/apps/ui/src/lib/music.ts index 1c172042..5be4f5f1 100644 --- a/apps/ui/src/lib/music.ts +++ b/apps/ui/src/lib/music.ts @@ -1,14 +1,15 @@ import type { MusicFileProps, MusicMediaMetadata } from "@/types/MusicMediaMetadata"; -import type { MediaMetadata } from "@core/types"; +import type { MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" import type { Track } from "@/components/MediaPlayer"; import { Path } from "@core/path"; import { extensions } from "@core/utils"; import { pathToFileURL } from "@core/url"; -export function newMusicMediaMetadata(mm: MediaMetadata): MusicMediaMetadata { +export function newMusicMediaMetadata(mm: MediaMetadataWithFolderFiles): MusicMediaMetadata { return { ...mm, - musicFiles: buildMusicFilePropsArray(mm.files ?? []), + musicFiles: buildMusicFilePropsArray(getMediaFolderFiles(mm)), } } diff --git a/apps/ui/src/lib/recognizeEpisodes.ts b/apps/ui/src/lib/recognizeEpisodes.ts index 6877eb37..688a44ef 100644 --- a/apps/ui/src/lib/recognizeEpisodes.ts +++ b/apps/ui/src/lib/recognizeEpisodes.ts @@ -1,7 +1,8 @@ import { uniq } from 'es-toolkit'; import { extname, basename } from './path'; import { videoFileExtensions } from './utils'; -import type { MediaMetadata } from '@core/types'; +import type { MediaMetadataWithFolderFiles } from '@/lib/mediaFolderFiles'; +import { getMediaFolderFiles } from '@/lib/mediaFolderFiles'; export interface RecognizedEpisode { season: number, @@ -128,10 +129,9 @@ export function pattern4( return ret; } -export function buildEpisodes(mm: MediaMetadata): { season: number, episode: number }[] { - if( mm.files === undefined - || mm.files === null - || mm.files.length === 0 +export function buildEpisodes(mm: MediaMetadataWithFolderFiles): { season: number, episode: number }[] { + const files = getMediaFolderFiles(mm) + if( files.length === 0 || mm.tvShow === undefined || mm.tvShow.seasons === undefined || mm.tvShow.seasons.length === 0 @@ -216,18 +216,17 @@ export function excludeFiles(files: string[]) { * @returns */ export function recognizeEpisodes( - mm: MediaMetadata, + mm: MediaMetadataWithFolderFiles, ): RecognizedEpisode[] { const startTime = performance.now(); + const files = getMediaFolderFiles(mm) console.log('[recognize] start episode matching', { mediaFolderPath: mm.mediaFolderPath, - fileCount: mm.files?.length ?? 0, + fileCount: files.length, }) - if( mm.files === undefined - || mm.files === null - || mm.files.length === 0 + if( files.length === 0 || mm.tvShow === undefined || mm.tvShow.seasons === undefined || mm.tvShow.seasons.length === 0 @@ -239,7 +238,7 @@ export function recognizeEpisodes( try { - let videoFiles = mm.files.filter(isVideoFile); + let videoFiles = files.filter(isVideoFile); videoFiles = excludeFiles(videoFiles); if(videoFiles.length === 0) { @@ -282,10 +281,10 @@ type WorkerMessage = { type: 'result'; id: number; payload: RecognizedEpisode[] * Run recognizeEpisodes in a Web Worker to avoid blocking the main thread. * Uses a singleton worker; concurrent calls are serialized. */ -export function recognizeEpisodesAsync(mm: MediaMetadata): Promise { +export function recognizeEpisodesAsync(mm: MediaMetadataWithFolderFiles): Promise { console.log('[recognize] recognizeEpisodesAsync started', { mediaFolderPath: mm.mediaFolderPath, - fileCount: mm.files?.length ?? 0, + fileCount: getMediaFolderFiles(mm).length, }) return new Promise((resolve, reject) => { const id = nextRequestId++; diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 954d96eb..aac2b3ea 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -import { type MediaMetadata, type MediaFileMetadata, RenameRuleVariables, type RenameRule, type TMDBSeason } from "@core/types" +import { type MediaMetadataWithFolderFiles } from "@/lib/mediaFolderFiles" +import { getMediaFolderFiles } from "@/lib/mediaFolderFiles" +import type { MediaMetadata } from "@core/types" +import { type MediaFileMetadata, RenameRuleVariables, type RenameRule, type TMDBSeason } from "@core/types" import { basename, extname, relative, join, dirname } from "@/lib/path" import { Path } from "@core/path" import { listFilesApi } from "@/api/listFiles" @@ -139,7 +142,7 @@ function getRelativePath(absolutePath: string, mediaFolderPath: string | undefin * Build TvShowEpisodesProps from MediaMetadata */ export function buildTvShowEpisodesPropsFromMediaMetadata( - mediaMetadata: MediaMetadata | null | undefined, + mediaMetadata: MediaMetadataWithFolderFiles | null | undefined, renameRule: RenameRule | undefined ): TvShowEpisodesProps { if (!mediaMetadata) { @@ -183,7 +186,7 @@ export function buildTvShowEpisodesPropsFromMediaMetadata( tag: "VID", newPath: '' } - episodeProps.associatedFiles = findAssociatedFiles(mediaFolderPath!, mediaMetadata.files ?? [], videoFilePath.absolutePath); + episodeProps.associatedFiles = findAssociatedFiles(mediaFolderPath!, getMediaFolderFiles(mediaMetadata), videoFilePath.absolutePath); if(renameRule) { episodeProps.videoFilePath.newPath = generateNameByRenameRule(mediaMetadata, renameRule, videoFilePath) } diff --git a/apps/ui/src/types/UIMediaMetadata.ts b/apps/ui/src/types/UIMediaMetadata.ts index e6739c82..5929fdb0 100644 --- a/apps/ui/src/types/UIMediaMetadata.ts +++ b/apps/ui/src/types/UIMediaMetadata.ts @@ -1,6 +1,10 @@ import type { MediaMetadata } from "@core/types"; export interface UIMediaMetadataProps { + /** + * Live folder file paths from `listFiles`; UI-only, not persisted in metadata cache. + */ + files?: string[], /** * Indicates it's a media metadata created for testing purposes. * In some logic, test media metadata handle differently diff --git a/packages/core-routes/src/renameFiles.test.ts b/packages/core-routes/src/renameFiles.test.ts index 4598e84c..bdc68d66 100644 --- a/packages/core-routes/src/renameFiles.test.ts +++ b/packages/core-routes/src/renameFiles.test.ts @@ -29,7 +29,6 @@ describe("doRenameFiles", () => { const metadata: MediaMetadata = { mediaFolderPath: Path.posix(mediaDir), type: "tvshow-folder", - files: [Path.posix(fromFile)], mediaFiles: [ { absolutePath: Path.posix(fromFile), @@ -85,7 +84,6 @@ describe("doRenameFiles", () => { "utf-8", ); const metadata = JSON.parse(metadataRaw) as MediaMetadata; - expect(metadata.files).toEqual([Path.posix(toFile)]); expect(metadata.mediaFiles?.[0]?.absolutePath).toBe(Path.posix(toFile)); expect(broadcast).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/core-routes/src/renameFolder.test.ts b/packages/core-routes/src/renameFolder.test.ts index d0076d34..11e9d6f6 100644 --- a/packages/core-routes/src/renameFolder.test.ts +++ b/packages/core-routes/src/renameFolder.test.ts @@ -23,7 +23,6 @@ describe("doRenameFolder", () => { const metadata: MediaMetadata = { mediaFolderPath: Path.posix(fromDir), type: "tvshow-folder", - files: [Path.posix(join(fromDir, "S01E01.mkv"))], mediaFiles: [ { absolutePath: Path.posix(join(fromDir, "S01E01.mkv")), From b6a07ce51984b09f7506e432f0b29d57fe5e6fa7 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 12:51:38 +0800 Subject: [PATCH 208/214] docs: update AGENTS.md --- AGENTS.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85bca901..f6f751b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,22 +147,6 @@ pnpm ci # 构建 + 测试 + 类型检查 维护者发布 **Electron 桌面版** 与 **Docker 镜像** 的流程见 [docs/dev/release.md](./docs/dev/release.md)(共用 Git tag、单 GitHub Release 多产物、Docker 发版前 E2E gate 校验)。 -## 开发原则 - -### UI 乐观更新策略 - -为了提供最佳的 UX, 本应用假设后台操作总是会成功. 开发者应该: -1. 先更新UI状态 -2. 执行后台操作(异步计算, API 调用, 等待回调等) -3. 如果后台操作失败, 回滚UI状态, 并弹出合适的错误提示 - -### 开发阶段 - -本项目定义了如下开发阶段 - -**功能探索** 该阶段开发者对新功能没有完整的技术图景, 开发时专注于快速实现功能, 并交付测试. 不需要写单元测试, 不需要 typecheck. -**功能交付** 该阶段开发者对新功能有确定的需求, 开发时需要考虑代码质量, 并编写单元测试. - ### 核心术语 **媒体文件夹(Media Folder)** 保存了电视剧, 动画, 电影或音乐的本地文件夹 @@ -223,13 +207,6 @@ pnpm ci # 构建 + 测试 + 类型检查 ### 执行测试 - -**手动测试** -进入 `apps/e2e` 目录, 并执行 -``` -pnpm run wdio --spec ./test/specs/[test file].e2e.ts -``` - **自动化测试/AI Agent测试** 在项目根目录执行 ``` From 0d08c7bc69c69d4651d6b5920ac638e616bb6558 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 13:05:28 +0800 Subject: [PATCH 209/214] ci: fix ci failure --- AGENTS.md | 6 ++++ apps/cli/lib/log.ts | 1 - apps/cli/src/cli/metadata.test.ts | 20 ++++------- apps/cli/src/route/RenameEpisodeFile.test.ts | 35 +++++++------------ apps/cli/src/route/RenameFolderV3.test.ts | 28 ++++++--------- apps/cli/src/route/UnimportFolder.test.ts | 28 ++++++--------- .../cli/src/route/metadata/metadataSchemas.ts | 35 ++++++++++++++++--- apps/cli/src/tools/matchEpisode.ts | 15 ++++++-- apps/cli/src/utils/mediaMetadataUtils.ts | 12 ++----- apps/cli/test/helpers/cliTestEnv.ts | 35 +++++++++++++++++++ apps/cli/test/helpers/testFolders.test.ts | 16 +++------ apps/cli/test/helpers/testFolders.ts | 12 ++++--- apps/e2e/test/componentobjects/Sidebar.ts | 2 +- apps/e2e/test/lib/context-menu.ts | 2 +- 14 files changed, 142 insertions(+), 105 deletions(-) create mode 100644 apps/cli/test/helpers/cliTestEnv.ts diff --git a/AGENTS.md b/AGENTS.md index f6f751b0..f709301c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,12 @@ Electron 桌面应用, 主要目录结构: - WebdriverIO 9 - Mocha +## 代码改动 + +**Post Change** run build and typecheck script after code change +**Pre Commit** run build, typecheck, and unit tests before commit + + ## 常用命令 ```bash diff --git a/apps/cli/lib/log.ts b/apps/cli/lib/log.ts index 47d587b3..082086cf 100644 --- a/apps/cli/lib/log.ts +++ b/apps/cli/lib/log.ts @@ -2,7 +2,6 @@ import type { MediaMetadata } from "@core/types"; export function mediaMetadataToString(mediaMetadata: MediaMetadata): string { const obj: Record = structuredClone(mediaMetadata) as Record; - obj.files = mediaMetadata.files === undefined ? undefined : `<${mediaMetadata.files?.length ?? 0} files>`; obj.mediaFiles = mediaMetadata.mediaFiles === undefined ? undefined : `<${mediaMetadata.mediaFiles?.length ?? 0} media files>`; if (mediaMetadata.tvShow !== undefined) { obj.tvShow = `<${mediaMetadata.tvShow.name}>`; diff --git a/apps/cli/src/cli/metadata.test.ts b/apps/cli/src/cli/metadata.test.ts index f2d8a9ea..fe5b84f5 100644 --- a/apps/cli/src/cli/metadata.test.ts +++ b/apps/cli/src/cli/metadata.test.ts @@ -3,21 +3,18 @@ import type { MockInstance } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' +import { installCliTestEnv, restoreCliTestEnv, type CliTestEnv } from '../../test/helpers/cliTestEnv' import { resetCoreForTests } from '../core/getCore' describe('smm metadata', () => { - let userDataDir: string + let env: CliTestEnv let mediaFolder: string - let prevUserDataDir: string | undefined let logSpy: MockInstance<(...args: any[]) => void> let errorSpy: MockInstance<(...args: any[]) => void> beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-meta-cli-')) + env = installCliTestEnv('smm-meta-cli') mediaFolder = mkdtempSync(join(tmpdir(), 'smm-meta-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) @@ -26,11 +23,8 @@ describe('smm metadata', () => { logSpy.mockRestore() errorSpy.mockRestore() vi.restoreAllMocks() - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) rmSync(mediaFolder, { recursive: true, force: true }) + restoreCliTestEnv(env) }) function output(): string { @@ -47,7 +41,7 @@ describe('smm metadata', () => { it('exits 1 when metadata cache is missing', async () => { writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: [mediaFolder] }), 'utf-8', ) @@ -58,7 +52,7 @@ describe('smm metadata', () => { expect(code).toBe(1) expect(errorSpy.mock.calls.map((c) => String(c[0])).join('\n')).toMatch( - /No metadata cache/i, + /Metadata not found/i, ) }) @@ -69,7 +63,7 @@ describe('smm metadata', () => { logSpy.mockClear() errorSpy.mockClear() - const jsonFile = join(userDataDir, 'mm.json') + const jsonFile = join(env.userDataDir, 'mm.json') writeFileSync( jsonFile, JSON.stringify({ diff --git a/apps/cli/src/route/RenameEpisodeFile.test.ts b/apps/cli/src/route/RenameEpisodeFile.test.ts index 44556a9c..7ce46c73 100644 --- a/apps/cli/src/route/RenameEpisodeFile.test.ts +++ b/apps/cli/src/route/RenameEpisodeFile.test.ts @@ -12,6 +12,8 @@ import { join } from 'path' import { Hono } from 'hono' import { Path } from '@core/path' import { handleRenameEpisodeFile } from './RenameEpisodeFile' +import { metadataCachePath } from '../../test/helpers/testFolders' +import { installCliTestEnv, restoreCliTestEnv, type CliTestEnv } from '../../test/helpers/cliTestEnv' import { resetCoreForTests } from '../core/getCore' vi.mock('@/utils/socketIO', () => ({ @@ -19,30 +21,23 @@ vi.mock('@/utils/socketIO', () => ({ })) describe('POST /api/rename-episode-file', () => { - let userDataDir: string + let env: CliTestEnv let mediaDir: string let mediaFolder: string - let prevUserDataDir: string | undefined let app: Hono beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-rename-episode-ud-')) + env = installCliTestEnv('smm-rename-episode') mediaDir = mkdtempSync(join(tmpdir(), 'smm-rename-episode-media-')) mediaFolder = join(mediaDir, 'Show') mkdirSync(mediaFolder) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() app = new Hono() handleRenameEpisodeFile(app) }) afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) rmSync(mediaDir, { recursive: true, force: true }) + restoreCliTestEnv(env) }) async function post(body: unknown) { @@ -61,7 +56,7 @@ describe('POST /api/rename-episode-file', () => { const folderPosix = Path.posix(mediaFolder) writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: [mediaFolder], tmdb: {}, @@ -72,10 +67,9 @@ describe('POST /api/rename-episode-file', () => { }), 'utf-8', ) - const cacheName = folderPosix.replace(/[/\\:?*|<>"]/g, '_') - mkdirSync(join(userDataDir, 'metadata'), { recursive: true }) + mkdirSync(join(env.appDataDir, 'metadata'), { recursive: true }) writeFileSync( - join(userDataDir, 'metadata', `${cacheName}.json`), + metadataCachePath(env.appDataDir, folderPosix), JSON.stringify({ mediaFolderPath: folderPosix, type: 'tvshow-folder', @@ -131,9 +125,8 @@ describe('POST /api/rename-episode-file', () => { expect(existsSync(srt)).toBe(false) const folderPosix = Path.posix(mediaFolder) - const cacheName = folderPosix.replace(/[/\\:?*|<>"]/g, '_') const mm = JSON.parse( - readFileSync(join(userDataDir, 'metadata', `${cacheName}.json`), 'utf-8'), + readFileSync(metadataCachePath(env.appDataDir, folderPosix), 'utf-8'), ) as { mediaFiles: Array<{ absolutePath: string }> } expect(mm.mediaFiles[0]?.absolutePath).toBe(Path.posix(to)) }) @@ -165,7 +158,7 @@ describe('POST /api/rename-episode-file', () => { const folderPosix = Path.posix(movieDir) writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: [movieDir], tmdb: {}, @@ -176,14 +169,12 @@ describe('POST /api/rename-episode-file', () => { }), 'utf-8', ) - const cacheName = folderPosix.replace(/[/\\:?*|<>"]/g, '_') - mkdirSync(join(userDataDir, 'metadata'), { recursive: true }) + mkdirSync(join(env.appDataDir, 'metadata'), { recursive: true }) writeFileSync( - join(userDataDir, 'metadata', `${cacheName}.json`), + metadataCachePath(env.appDataDir, folderPosix), JSON.stringify({ mediaFolderPath: folderPosix, type: 'movie-folder', - files: [Path.posix(video), Path.posix(srt), Path.posix(enSrt)], mediaFiles: [{ absolutePath: Path.posix(video) }], movie: { database: 'TMDB', id: '615453', name: 'Ne Zha' }, }), @@ -209,7 +200,7 @@ describe('POST /api/rename-episode-file', () => { expect(existsSync(enSrt)).toBe(false) const mm = JSON.parse( - readFileSync(join(userDataDir, 'metadata', `${cacheName}.json`), 'utf-8'), + readFileSync(metadataCachePath(env.appDataDir, folderPosix), 'utf-8'), ) as { mediaFiles: Array<{ absolutePath: string }> } expect(mm.mediaFiles[0]?.absolutePath).toBe(Path.posix(to)) }) diff --git a/apps/cli/src/route/RenameFolderV3.test.ts b/apps/cli/src/route/RenameFolderV3.test.ts index 57f5214c..3cda2114 100644 --- a/apps/cli/src/route/RenameFolderV3.test.ts +++ b/apps/cli/src/route/RenameFolderV3.test.ts @@ -12,6 +12,8 @@ import { join } from 'path' import { Hono } from 'hono' import { Path } from '@core/path' import { handleRenameFolderV3 } from './RenameFolderV3' +import { metadataCachePath } from '../../test/helpers/testFolders' +import { installCliTestEnv, restoreCliTestEnv, type CliTestEnv } from '../../test/helpers/cliTestEnv' import { resetCoreForTests } from '../core/getCore' vi.mock('@/events/userConfigUpdatedEvent', () => ({ @@ -22,27 +24,20 @@ vi.mock('@/utils/socketIO', () => ({ })) describe('POST /api/rename-folder', () => { - let userDataDir: string + let env: CliTestEnv let mediaDir: string - let prevUserDataDir: string | undefined let app: Hono beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-rename-folder-v3-ud-')) + env = installCliTestEnv('smm-rename-folder-v3') mediaDir = mkdtempSync(join(tmpdir(), 'smm-rename-folder-v3-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() app = new Hono() handleRenameFolderV3(app) }) afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) rmSync(mediaDir, { recursive: true, force: true }) + restoreCliTestEnv(env) }) async function post(body: unknown) { @@ -61,7 +56,7 @@ describe('POST /api/rename-folder', () => { const fromPosix = Path.posix(from) writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: [from], tmdb: {}, @@ -72,15 +67,12 @@ describe('POST /api/rename-folder', () => { }), 'utf-8', ) - const cacheName = fromPosix.replace(/[/\\:?*|<>"]/g, '_') - const cacheDir = join(userDataDir, 'metadata') - mkdirSync(cacheDir, { recursive: true }) + mkdirSync(join(env.appDataDir, 'metadata'), { recursive: true }) writeFileSync( - join(cacheDir, `${cacheName}.json`), + metadataCachePath(env.appDataDir, fromPosix), JSON.stringify({ mediaFolderPath: fromPosix, type: 'tvshow-folder', - files: [Path.posix(join(from, 'S01E01.mkv'))], mediaFiles: [{ absolutePath: Path.posix(join(from, 'S01E01.mkv')) }], }), 'utf-8', @@ -95,7 +87,7 @@ describe('POST /api/rename-folder', () => { expect(existsSync(to)).toBe(true) expect(existsSync(join(to, 'S01E01.mkv'))).toBe(true) - const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { + const saved = JSON.parse(readFileSync(join(env.userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } expect(saved.folders.map((f) => Path.posix(f))).toEqual([Path.posix(to)]) @@ -110,7 +102,7 @@ describe('POST /api/rename-folder', () => { it('returns Error Reason when folder is not managed', async () => { writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: [], tmdb: {}, diff --git a/apps/cli/src/route/UnimportFolder.test.ts b/apps/cli/src/route/UnimportFolder.test.ts index f0b642f1..02a19ffb 100644 --- a/apps/cli/src/route/UnimportFolder.test.ts +++ b/apps/cli/src/route/UnimportFolder.test.ts @@ -4,27 +4,22 @@ import { tmpdir } from 'os' import { join } from 'path' import { Hono } from 'hono' import { handleUnimportFolder } from './UnimportFolder' +import { metadataCachePath } from '../../test/helpers/testFolders' +import { installCliTestEnv, restoreCliTestEnv, type CliTestEnv } from '../../test/helpers/cliTestEnv' import { resetCoreForTests } from '../core/getCore' describe('POST /api/unimport-folder', () => { - let userDataDir: string - let prevUserDataDir: string | undefined + let env: CliTestEnv let app: Hono beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-unimport-folder-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() + env = installCliTestEnv('smm-unimport-folder') app = new Hono() handleUnimportFolder(app) }) afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) + restoreCliTestEnv(env) }) async function post(body: unknown) { @@ -37,13 +32,12 @@ describe('POST /api/unimport-folder', () => { it('removes the folder from smm.json and deletes metadata cache', async () => { writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: ['/media/A', '/media/B'] }), 'utf-8', ) - const cacheDir = join(userDataDir, 'metadata') - mkdirSync(cacheDir, { recursive: true }) - const cacheFile = join(cacheDir, '_media_A.json') + mkdirSync(join(env.appDataDir, 'metadata'), { recursive: true }) + const cacheFile = metadataCachePath(env.appDataDir, '/media/A') writeFileSync(cacheFile, JSON.stringify({ mediaFolderPath: '/media/A' }), 'utf-8') resetCoreForTests() @@ -51,14 +45,14 @@ describe('POST /api/unimport-folder', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ data: { path: '/media/A' } }) - const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } + const saved = JSON.parse(readFileSync(join(env.userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } expect(saved.folders).toEqual(['/media/B']) expect(existsSync(cacheFile)).toBe(false) }) it('succeeds when the folder is not imported', async () => { writeFileSync( - join(userDataDir, 'smm.json'), + join(env.userDataDir, 'smm.json'), JSON.stringify({ folders: ['/media/Keep'] }), 'utf-8', ) @@ -68,7 +62,7 @@ describe('POST /api/unimport-folder', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ data: { path: '/media/Missing' } }) - const saved = JSON.parse(readFileSync(join(userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } + const saved = JSON.parse(readFileSync(join(env.userDataDir, 'smm.json'), 'utf-8')) as { folders: string[] } expect(saved.folders).toEqual(['/media/Keep']) }) diff --git a/apps/cli/src/route/metadata/metadataSchemas.ts b/apps/cli/src/route/metadata/metadataSchemas.ts index fc6a4b72..5b1ace53 100644 --- a/apps/cli/src/route/metadata/metadataSchemas.ts +++ b/apps/cli/src/route/metadata/metadataSchemas.ts @@ -1,3 +1,8 @@ +import type { + MediaFileMetadata, + MovieMediaMetadata, + TvShowMediaMetadata, +} from '@core/types' import { z } from 'zod' export const metadataFolderTypeSchema = z.enum([ @@ -6,13 +11,35 @@ export const metadataFolderTypeSchema = z.enum([ 'movie-folder', ]) -export const metadataMediaFilesSchema = z.array(z.unknown()) +const mediaFileMetadataSchema: z.ZodType = z.object({ + absolutePath: z.string().min(1), + seasonNumber: z.number().int().optional(), + episodeNumber: z.number().int().optional(), + subtitleFilePaths: z.array(z.string()).optional(), + audioFilePaths: z.array(z.string()).optional(), +}) -export const metadataPlainObjectSchema = z.record(z.string(), z.unknown()) +export const metadataMediaFilesSchema: z.ZodType = z.array( + mediaFileMetadataSchema, +) + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export const tvShowMediaMetadataSchema: z.ZodType = z.custom( + (val) => isPlainObject(val), + { message: 'tvShow must be an object' }, +) + +export const movieMediaMetadataSchema: z.ZodType = z.custom( + (val) => isPlainObject(val), + { message: 'movie must be an object' }, +) export const metadataPatchFieldSchemas = { type: metadataFolderTypeSchema.optional(), mediaFiles: metadataMediaFilesSchema.optional(), - tvShow: metadataPlainObjectSchema.optional(), - movie: metadataPlainObjectSchema.optional(), + tvShow: tvShowMediaMetadataSchema.optional(), + movie: movieMediaMetadataSchema.optional(), } as const diff --git a/apps/cli/src/tools/matchEpisode.ts b/apps/cli/src/tools/matchEpisode.ts index 77b52ff3..e11b4876 100644 --- a/apps/cli/src/tools/matchEpisode.ts +++ b/apps/cli/src/tools/matchEpisode.ts @@ -2,6 +2,7 @@ import { z } from 'zod/v3'; import { Path } from '@core/path'; import type { MediaFileMetadata, MediaMetadata } from '@core/types'; import { metadataCacheFilePath, mediaMetadataDir } from '../route/mediaMetadata/utils'; +import { doListFiles } from '../route/ListFiles'; import { mkdir } from 'fs/promises'; import { broadcast } from '../utils/socketIO'; @@ -88,10 +89,18 @@ Please **ensure** you call "ask-for-confirmation" to get user confirmation befor return { error: `Error Reason: folderPath "${folderPathInPosix}" does not match metadata folder path "${mediaMetadata.mediaFolderPath}"` }; } - // 3. Check if path is valid + // 3. Check if path is valid (live folder listing, not persisted metadata) const pathInPosix = Path.posix(path); - const files = mediaMetadata.files; - if (!files || !files.includes(pathInPosix)) { + const listResult = await doListFiles({ + path: folderPathInPosix, + recursively: true, + onlyFiles: true, + }); + if (listResult.error) { + return { error: `Error Reason: Failed to list files in media folder: ${listResult.error}` }; + } + const files = listResult.data?.items.map((item) => Path.posix(item.path)) ?? []; + if (!files.includes(pathInPosix)) { return { error: `Error Reason: path "${pathInPosix}" is not a file in the media folder` }; } diff --git a/apps/cli/src/utils/mediaMetadataUtils.ts b/apps/cli/src/utils/mediaMetadataUtils.ts index 4f2e20bb..d7d10962 100644 --- a/apps/cli/src/utils/mediaMetadataUtils.ts +++ b/apps/cli/src/utils/mediaMetadataUtils.ts @@ -7,9 +7,8 @@ const logger = pino(); /** * Rename media folder in media metadata, which means: - * 1. Rename folder in mediaMetadata.files - * 2. Rename folder in mediaMetadata.mediaFiles.absolutePath - * + * 1. Rename folder in mediaMetadata.mediaFiles.absolutePath + * * This method assumes the "from" and "to" path are valid. * Devleoper need to do validation before calling this method. * @@ -47,13 +46,6 @@ export function renameMediaFolderInMediaMetadata(mediaMetadata: MediaMetadata, f } } - // Update files array - if (clone.files === null) { - clone.files = null; - } else if (clone.files !== undefined) { - clone.files = clone.files.map(file => replaceFolderPrefix(file)); - } - // Update mediaFiles array clone.mediaFiles = clone.mediaFiles?.map(mediaFile => { const updatedAbsolutePath = replaceFolderPrefix(mediaFile.absolutePath); diff --git a/apps/cli/test/helpers/cliTestEnv.ts b/apps/cli/test/helpers/cliTestEnv.ts new file mode 100644 index 00000000..73cfa45d --- /dev/null +++ b/apps/cli/test/helpers/cliTestEnv.ts @@ -0,0 +1,35 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { resetCoreForTests } from '../../src/core/getCore' + +export type CliTestEnv = { + userDataDir: string + appDataDir: string + previousUserDataDir: string | undefined + previousAppDataDir: string | undefined +} + +/** Isolate user config (smm.json) and metadata cache (app data dir) for CLI tests. */ +export function installCliTestEnv(prefix: string): CliTestEnv { + const env: CliTestEnv = { + previousUserDataDir: process.env.USER_DATA_DIR, + previousAppDataDir: process.env.APP_DATA_DIR, + userDataDir: mkdtempSync(join(tmpdir(), `${prefix}-user-`)), + appDataDir: mkdtempSync(join(tmpdir(), `${prefix}-app-`)), + } + process.env.USER_DATA_DIR = env.userDataDir + process.env.APP_DATA_DIR = env.appDataDir + resetCoreForTests() + return env +} + +export function restoreCliTestEnv(env: CliTestEnv): void { + resetCoreForTests() + if (env.previousUserDataDir === undefined) delete process.env.USER_DATA_DIR + else process.env.USER_DATA_DIR = env.previousUserDataDir + if (env.previousAppDataDir === undefined) delete process.env.APP_DATA_DIR + else process.env.APP_DATA_DIR = env.previousAppDataDir + rmSync(env.userDataDir, { recursive: true, force: true }) + rmSync(env.appDataDir, { recursive: true, force: true }) +} diff --git a/apps/cli/test/helpers/testFolders.test.ts b/apps/cli/test/helpers/testFolders.test.ts index 71560740..94870891 100644 --- a/apps/cli/test/helpers/testFolders.test.ts +++ b/apps/cli/test/helpers/testFolders.test.ts @@ -2,28 +2,22 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { resetCoreForTests, smm } from './smm' +import { smm } from './smm' import { createAndImportInitializedFolder, musicFolder } from './testFolders' +import { installCliTestEnv, restoreCliTestEnv, type CliTestEnv } from './cliTestEnv' describe('createAndImportInitializedFolder', () => { - let userDataDir: string + let env: CliTestEnv let mediaDir: string - let prevUserDataDir: string | undefined beforeEach(() => { - prevUserDataDir = process.env.USER_DATA_DIR - userDataDir = mkdtempSync(join(tmpdir(), 'smm-init-folder-ud-')) + env = installCliTestEnv('smm-init-folder') mediaDir = mkdtempSync(join(tmpdir(), 'smm-init-folder-media-')) - process.env.USER_DATA_DIR = userDataDir - resetCoreForTests() }) afterEach(() => { - resetCoreForTests() - if (prevUserDataDir === undefined) delete process.env.USER_DATA_DIR - else process.env.USER_DATA_DIR = prevUserDataDir - rmSync(userDataDir, { recursive: true, force: true }) rmSync(mediaDir, { recursive: true, force: true }) + restoreCliTestEnv(env) }) it('skip-init imports then writes metadata that smm metadata can read', async () => { diff --git a/apps/cli/test/helpers/testFolders.ts b/apps/cli/test/helpers/testFolders.ts index 8e350112..eb9ecd76 100644 --- a/apps/cli/test/helpers/testFolders.ts +++ b/apps/cli/test/helpers/testFolders.ts @@ -29,9 +29,9 @@ const repoRoot = join(helpersDir, '../../../..') const MEDIA_METADATA_TEMPLATES_DIR = join(repoRoot, 'test', 'templates', 'mediaMetadatas') /** Same sanitization as Core `metadataCachePath` / core-routes cache. */ -export function metadataCachePath(userDataDir: string, folderPathInPosix: string): string { +export function metadataCachePath(appDataDir: string, folderPathInPosix: string): string { const filename = folderPathInPosix.replace(/[/\\:?*|<>"]/g, '_') - return join(userDataDir, 'metadata', `${filename}.json`) + return join(appDataDir, 'metadata', `${filename}.json`) } /** Sibling path with " - Renamed" suffix (same naming as e2e RenameFolder). */ @@ -91,7 +91,6 @@ export async function createAndImportInitializedFolder( } else { const templateFileName = options.templateFileName ?? '天使降临到我身边.metadata.json' mediaMetadata = rewritePathsForFolder(loadTemplate(templateFileName), folderPath) - mediaMetadata.files = created.files.map((file) => Path.posix(join(folderPath, file))) } if (options.updateMediaMetadata) { @@ -99,7 +98,12 @@ export async function createAndImportInitializedFolder( } mediaMetadata.mediaFolderPath = Path.posix(folderPath) - await getCore().createMetadata(mediaMetadata) + await getCore().setMetadata(folderPath, { + type: mediaMetadata.type, + mediaFiles: mediaMetadata.mediaFiles, + tvShow: mediaMetadata.tvShow, + movie: mediaMetadata.movie, + }) return created } diff --git a/apps/e2e/test/componentobjects/Sidebar.ts b/apps/e2e/test/componentobjects/Sidebar.ts index e0a0d4d7..1ba7bc1e 100644 --- a/apps/e2e/test/componentobjects/Sidebar.ts +++ b/apps/e2e/test/componentobjects/Sidebar.ts @@ -110,7 +110,7 @@ class Sidebar { await folderRow.waitForExist({ timeout: 5000 }) await folderRow.scrollIntoView() // Radix ContextMenuTrigger often fails WebdriverIO "clickable" checks; JS click is reliable. - await folderRow.click({ js: true }) + await browser.execute((el) => (el as HTMLElement).click(), await folderRow) } /** diff --git a/apps/e2e/test/lib/context-menu.ts b/apps/e2e/test/lib/context-menu.ts index 9329687d..28c4135b 100644 --- a/apps/e2e/test/lib/context-menu.ts +++ b/apps/e2e/test/lib/context-menu.ts @@ -6,7 +6,7 @@ const CONTEXT_MENU_CONTENT_SELECTOR = '[data-slot="context-menu-content"], [role * Right-click an element reliably on Windows/Linux (Radix context menus). * WebdriverIO's `click({ button: 'right' })` often fails to open Radix menus on Windows. */ -export async function rightClickElement(element: WebdriverIO.Element): Promise { +export async function rightClickElement(element: ChainablePromiseElement): Promise { await element.scrollIntoView() await element.waitForDisplayed({ timeout: 10_000 }) From 3863646749f10831209b80ffc2f2357d527313ed Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 14:03:44 +0800 Subject: [PATCH 210/214] fix(ci): resolve create-metadata strict schema and plan path mismatches Strip UI-only status/files before create-metadata, and point CLI e2e plan lookups at appDataDir so Linux CI matches Core persistence. Co-authored-by: Cursor --- apps/e2e/cli/base.ts | 6 ++--- apps/e2e/cli/helpers.ts | 9 ++++--- apps/e2e/test/lib/metadata-http.ts | 10 ++++++- apps/ui/src/api/metadata.test.ts | 26 +++++++++++++++++++ apps/ui/src/api/metadata.ts | 10 ++++++- .../useUpdateMediaMetadataMutation.ts | 3 ++- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/e2e/cli/base.ts b/apps/e2e/cli/base.ts index 9b6e1486..83d13a24 100644 --- a/apps/e2e/cli/base.ts +++ b/apps/e2e/cli/base.ts @@ -32,9 +32,9 @@ export type CliTestBedSetupOptions = Omit { const body = await runCliHello(binary) - // CLI Core stores config, metadata, and plans under userDataDir (see getCore.ts). - // hello.appDataDir is the reported XDG data dir and may differ on Linux. - return { userDataDir: body.userDataDir, appDataDir: body.userDataDir } + // Config (smm.json) → userDataDir; metadata cache + plans → appDataDir. + // On Linux these differ (~/.config/smm vs ~/.local/share/smm). + return { userDataDir: body.userDataDir, appDataDir: body.appDataDir } } } diff --git a/apps/e2e/cli/helpers.ts b/apps/e2e/cli/helpers.ts index 6da4ac08..935c092a 100644 --- a/apps/e2e/cli/helpers.ts +++ b/apps/e2e/cli/helpers.ts @@ -46,13 +46,14 @@ export function parsePlanId(stdout: string): string { return planId } -async function resolveCoreDataDir(binary: string): Promise { - const { userDataDir } = await runCliHello(binary) - return userDataDir +/** App data root where Core persists metadata cache and plans. */ +async function resolveAppDataDir(binary: string): Promise { + const { appDataDir } = await runCliHello(binary) + return appDataDir } export async function planFilePath(binary: string, planId: string): Promise { - const dataDir = await resolveCoreDataDir(binary) + const dataDir = await resolveAppDataDir(binary) const filename = `${planId}.plan.json` return join(dataDir, 'plans', filename) } diff --git a/apps/e2e/test/lib/metadata-http.ts b/apps/e2e/test/lib/metadata-http.ts index 9a633515..d0b31a3c 100644 --- a/apps/e2e/test/lib/metadata-http.ts +++ b/apps/e2e/test/lib/metadata-http.ts @@ -93,7 +93,15 @@ export async function getMetadataViaBrowser(folderPath: string): Promise { - const { response } = await postMetadataViaBrowser('/api/create-metadata', { data: metadata }) + // Strict create schema rejects UI-only keys (files, status, …). + const payload: MediaMetadata = { + mediaFolderPath: metadata.mediaFolderPath, + type: metadata.type, + mediaFiles: metadata.mediaFiles, + tvShow: metadata.tvShow, + movie: metadata.movie, + } + const { response } = await postMetadataViaBrowser('/api/create-metadata', { data: payload }) return requireMetadata('/api/create-metadata', response) } diff --git a/apps/ui/src/api/metadata.test.ts b/apps/ui/src/api/metadata.test.ts index 8acfe316..e63c8359 100644 --- a/apps/ui/src/api/metadata.test.ts +++ b/apps/ui/src/api/metadata.test.ts @@ -100,4 +100,30 @@ describe("metadata HTTP API", () => { expect.objectContaining({ body: JSON.stringify({ path: "/media/show" }) }), ) }) + + it("strips UI-only keys before create-metadata", async () => { + vi.mocked(fetch).mockResolvedValue(Response.json({ data: metadata })) + + await createMetadata({ + ...metadata, + // UIMediaMetadata fields must not reach the strict Zod create schema + status: "ok", + files: ["/media/show/a.mkv"], + } as MediaMetadata & { status: string; files: string[] }) + + expect(fetch).toHaveBeenCalledWith( + "/api/create-metadata", + expect.objectContaining({ + body: JSON.stringify({ + data: { + mediaFolderPath: metadata.mediaFolderPath, + type: metadata.type, + mediaFiles: metadata.mediaFiles, + tvShow: undefined, + movie: undefined, + }, + }), + }), + ) + }) }) diff --git a/apps/ui/src/api/metadata.ts b/apps/ui/src/api/metadata.ts index 7705147a..ec48f9da 100644 --- a/apps/ui/src/api/metadata.ts +++ b/apps/ui/src/api/metadata.ts @@ -63,8 +63,16 @@ export async function getMetadata( export async function createMetadata( data: MediaMetadata, ): Promise { + // Strict create schema rejects UI-only keys (files, status, …). + const payload: MediaMetadata = { + mediaFolderPath: data.mediaFolderPath, + type: data.type, + mediaFiles: data.mediaFiles, + tvShow: data.tvShow, + movie: data.movie, + } return requireMetadata( - await postMetadataRpc("/api/create-metadata", { data }), + await postMetadataRpc("/api/create-metadata", { data: payload }), "/api/create-metadata", ) } diff --git a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts index ae40f40b..786cfb5d 100644 --- a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts @@ -43,9 +43,10 @@ export function useUpdateMediaMetadataMutation() { if (!(error instanceof MetadataHttpError) || error.status !== 404) { throw error } + // Only persisted fields — UI-only keys like status/files must not be sent. persisted = await createMetadata({ - ...vars.metadata, mediaFolderPath: folder, + ...toMetadataPatch(vars.metadata), }) } return { folderPathPosix: folder, metadata: persisted } From df2e72b6e67295afefc6bef5fb3ee4a2cc54b891 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 14:21:03 +0800 Subject: [PATCH 211/214] fix(docker): copy apps/core into UI build image BrowserNetworkPort imports NetworkPort from apps/core; ui.Dockerfile only copied packages/core, so Docker UI builds failed tsc while host CI passed. Co-authored-by: Cursor --- apps/docker/ui.Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/docker/ui.Dockerfile b/apps/docker/ui.Dockerfile index e093a91a..50bf3c34 100644 --- a/apps/docker/ui.Dockerfile +++ b/apps/docker/ui.Dockerfile @@ -16,6 +16,8 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # Copy source packages needed for ui build COPY packages/core packages/core COPY packages/tvdb4 packages/tvdb4 +# BrowserNetworkPort imports NetworkPort types from apps/core (core-app) +COPY apps/core apps/core COPY apps/ui apps/ui # pnpm --frozen-lockfile tolerates missing workspace members: only packages From b8a314b9cb63583ecf6209f445baa9673993c5c6 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 16:39:56 +0800 Subject: [PATCH 212/214] fix(cli): point MCP, chat, and plan routes at appDataDir Metadata caches and plans live under getAppDataDir(); Linux XDG splits that from userDataDir, so MCP/AI/TV e2e looked for files Core never wrote. Co-authored-by: Cursor --- apps/cli/server.ts | 4 ++-- apps/cli/src/coreRoutesServer.test.ts | 4 ++-- apps/cli/src/coreRoutesServer.ts | 4 ++-- apps/cli/src/mcp/mcp.ts | 4 ++-- apps/cli/src/route/Plans.test.ts | 6 +++--- apps/cli/src/route/Plans.ts | 4 ++-- apps/cli/src/route/RenameEpisodesPlan.test.ts | 4 ++-- apps/cli/src/route/RenameEpisodesPlan.ts | 4 ++-- apps/cli/src/tools/recognizeMediaFilesTool.ts | 4 ++-- test/mcp/lib/mcpServer.ts | 7 +++---- 10 files changed, 22 insertions(+), 23 deletions(-) diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 46b397d0..530c340a 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -9,7 +9,7 @@ import { setSocketIOManager, acknowledge, broadcast } from './src/utils/socketIO import { handleChatRequest as handleChatRequestCoreRoutes } from './src/route/chatRoute'; import { handleReadFile } from './src/route/ReadFile'; import { createAIProvider } from './lib/ai-provider.ts'; -import { getUserConfig, getUserDataDir } from './src/utils/config.ts'; +import { getUserConfig, getAppDataDir } from './src/utils/config.ts'; import { handleIsFolderAvailable } from './src/route/IsFolderAvailable'; import { handleWriteFile } from './src/route/WriteFile'; import { handleRenameFiles } from './src/route/RenameFiles'; @@ -254,7 +254,7 @@ export class Server { // the Hono shell here is a thin adapter that wires the cli-specific // AI provider factory, user-config reader, and Socket.IO helpers. handleChatRequestCoreRoutes(this.app, { - appDataDir: getUserDataDir(), + appDataDir: getAppDataDir(), logger: createSocketIOLogger(), createAIProvider: (userConfig) => createAIProvider(userConfig), getUserConfig: () => getUserConfig(), diff --git a/apps/cli/src/coreRoutesServer.test.ts b/apps/cli/src/coreRoutesServer.test.ts index ff452dae..fc0a3c88 100644 --- a/apps/cli/src/coreRoutesServer.test.ts +++ b/apps/cli/src/coreRoutesServer.test.ts @@ -59,14 +59,14 @@ describe("startCoreRoutesServer", () => { vi.clearAllMocks(); }); - it("passes Core's userDataDir to the shared plan routes", async () => { + it("passes Core's appDataDir to the shared plan routes", async () => { const server = await startCoreRoutesServer(); try { const config = createCoreRoutesRequestHandler.mock.calls[0]?.[0] as { appDataDir: string; }; - expect(config.appDataDir).toBe("/core/user-data"); + expect(config.appDataDir).toBe("/metadata/app-data"); } finally { await stopCoreRoutesServer(server); } diff --git a/apps/cli/src/coreRoutesServer.ts b/apps/cli/src/coreRoutesServer.ts index a599dbb1..66628093 100644 --- a/apps/cli/src/coreRoutesServer.ts +++ b/apps/cli/src/coreRoutesServer.ts @@ -5,7 +5,7 @@ import { type CoreRoutesLogger, } from "@smm/core-routes"; import { buildAllowlist } from "@/utils/buildAllowlist"; -import { getUserDataDir } from "@/utils/config"; +import { getAppDataDir } from "@/utils/config"; import { buildHelloHttpResponse } from "@/cli/helloHttp"; import { logger } from "../lib/logger"; import { broadcast } from "@/utils/socketIO"; @@ -25,7 +25,7 @@ export async function startCoreRoutesServer( ): Promise { const port = resolveCoreRoutesPort(); const allowlist = await buildAllowlist(); - const appDataDir = getUserDataDir(); + const appDataDir = getAppDataDir(); const handler = createCoreRoutesRequestHandler( { allowlist, diff --git a/apps/cli/src/mcp/mcp.ts b/apps/cli/src/mcp/mcp.ts index 9fe87c3f..124f8904 100644 --- a/apps/cli/src/mcp/mcp.ts +++ b/apps/cli/src/mcp/mcp.ts @@ -25,7 +25,7 @@ import { END_RECOGNIZE_TASK, } from "@smm/core/types/ai-tools/recognizeMediaFileTask"; import { GET_EPISODES } from "@smm/core/types/ai-tools/getEpisodes"; -import { getUserDataDir } from "@/utils/config"; +import { getAppDataDir, getUserDataDir } from "@/utils/config"; import { acknowledge, broadcast } from "@/utils/socketIO"; import { logger } from "../../lib/logger"; import { getLocalizedToolDescription } from "@/i18n/helpers"; @@ -140,7 +140,7 @@ async function buildMcpConfig(): Promise { const { getCore } = await import("@/core/getCore"); return { getUserConfig, - appDataDir: getUserDataDir(), + appDataDir: getAppDataDir(), userDataDir: getUserDataDir(), acknowledge: (message, timeoutMs) => acknowledge(message as Parameters[0], timeoutMs), diff --git a/apps/cli/src/route/Plans.test.ts b/apps/cli/src/route/Plans.test.ts index 44b4d36f..9a3ff29e 100644 --- a/apps/cli/src/route/Plans.test.ts +++ b/apps/cli/src/route/Plans.test.ts @@ -36,7 +36,7 @@ describe('POST /api/createPlan', () => { rmSync(appDataDir, { recursive: true, force: true }) }) - it('stores plans in the Core userDataDir when appDataDir differs', async () => { + it('stores plans in appDataDir when it differs from userDataDir', async () => { const id = '00000000-0000-4000-8000-000000000003' const response = await app.request('/api/createPlan', { method: 'POST', @@ -51,7 +51,7 @@ describe('POST /api/createPlan', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ data: { plan: { id } } }) - expect(existsSync(join(userDataDir, 'plans', `${id}.plan.json`))).toBe(true) - expect(existsSync(join(appDataDir, 'plans', `${id}.plan.json`))).toBe(false) + expect(existsSync(join(appDataDir, 'plans', `${id}.plan.json`))).toBe(true) + expect(existsSync(join(userDataDir, 'plans', `${id}.plan.json`))).toBe(false) }) }) diff --git a/apps/cli/src/route/Plans.ts b/apps/cli/src/route/Plans.ts index 8173083b..ec2cadb7 100644 --- a/apps/cli/src/route/Plans.ts +++ b/apps/cli/src/route/Plans.ts @@ -8,7 +8,7 @@ import { } from '@smm/core-routes'; import { logger } from '../../lib/logger'; import { buildAllowlist } from '@/utils/buildAllowlist'; -import { getUserDataDir } from '@/utils/config'; +import { getAppDataDir } from '@/utils/config'; const coreRoutesLogger = { debug: (obj: Record, msg?: string) => logger.debug(obj, msg), @@ -21,7 +21,7 @@ async function buildConfig(): Promise { const allowlist = await buildAllowlist(); return { allowlist, - appDataDir: getUserDataDir(), + appDataDir: getAppDataDir(), logger: coreRoutesLogger, }; } diff --git a/apps/cli/src/route/RenameEpisodesPlan.test.ts b/apps/cli/src/route/RenameEpisodesPlan.test.ts index 286896d4..7b53c35e 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.test.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.test.ts @@ -16,7 +16,7 @@ vi.mock('@/utils/socketIO', () => ({ vi.mock('@/utils/config', async (importOriginal) => ({ ...(await importOriginal()), - getUserDataDir: () => 'C:/smm-data', + getAppDataDir: () => 'C:/smm-app-data', })) import { handleRenameEpisodesPlan } from './RenameEpisodesPlan' @@ -67,7 +67,7 @@ describe('POST /api/create-rename-episode-plan', () => { event: 'renameFilesPlanReady', data: { taskId: 'plan-1', - planFilePath: '/C:/smm-data/plans/plan-1.plan.json', + planFilePath: '/C:/smm-app-data/plans/plan-1.plan.json', }, }) }) diff --git a/apps/cli/src/route/RenameEpisodesPlan.ts b/apps/cli/src/route/RenameEpisodesPlan.ts index 1aaa7a3e..de1db5a1 100644 --- a/apps/cli/src/route/RenameEpisodesPlan.ts +++ b/apps/cli/src/route/RenameEpisodesPlan.ts @@ -9,7 +9,7 @@ import { import { formatToolError } from '@core/ai-tool/toolResult' import { getCore } from '../core/getCore' import { broadcast } from '@/utils/socketIO' -import { getUserDataDir } from '@/utils/config' +import { getAppDataDir } from '@/utils/config' import { logger } from '../../lib/logger' export interface TryToRenameEpisodesRequestBody { @@ -94,7 +94,7 @@ export async function createRenameEpisodePlanFromBody( const plan = await getCore().createRenameEpisodePlan(mediaFolderPath, files, { creator }) if (creator === 'ai') { - const planFilePath = Path.posix(`${getUserDataDir()}/plans/${plan.id}.plan.json`) + const planFilePath = Path.posix(`${getAppDataDir()}/plans/${plan.id}.plan.json`) const data: RenameFilesPlanReadyRequestData = { taskId: plan.id, planFilePath, diff --git a/apps/cli/src/tools/recognizeMediaFilesTool.ts b/apps/cli/src/tools/recognizeMediaFilesTool.ts index 101cf6c8..7df57511 100644 --- a/apps/cli/src/tools/recognizeMediaFilesTool.ts +++ b/apps/cli/src/tools/recognizeMediaFilesTool.ts @@ -1,7 +1,7 @@ import { broadcast } from "../utils/socketIO"; import { RecognizeMediaFilePlanReady, type RecognizeMediaFilePlanReadyRequestData } from "@core/event-types"; import type { RecognizeMediaFilePlan, RecognizedFile } from "@core/types/RecognizeMediaFilePlan"; -import { getUserDataDir } from "@/utils/config"; +import { getAppDataDir } from "@/utils/config"; import path from "path"; import { mkdir, readdir, stat } from "fs/promises"; import { Path } from "@core/path"; @@ -13,7 +13,7 @@ const logger = pino(); * Get the path to the plans directory */ function getPlansDir(): string { - return path.join(getUserDataDir(), 'plans'); + return path.join(getAppDataDir(), 'plans'); } /** diff --git a/test/mcp/lib/mcpServer.ts b/test/mcp/lib/mcpServer.ts index 037a7e14..cf4ebbba 100644 --- a/test/mcp/lib/mcpServer.ts +++ b/test/mcp/lib/mcpServer.ts @@ -49,10 +49,9 @@ export function findFreePort(): Promise { */ export async function startMcpServer(): Promise { const baseDir = await mkdtemp(join(tmpdir(), 'smm-mcp-')) - // The real app keeps app data (metadata/plans) alongside user config on - // Windows/macOS. Core routes metadata writes to `appDataDir`, while - // `getCore()` resolves `appDataDir = getUserDataDir()` — so point both at - // the same dir to mirror production and keep scrape/metadata consistent. + // Isolate USER_DATA_DIR (smm.json) from APP_DATA_DIR (metadata/plans). + // Production Linux uses distinct XDG paths; keep them equal here so the + // MCP integration tests don't depend on host XDG layout. const userDataDir = join(baseDir, 'user-data') const appDataDir = userDataDir const logDir = join(baseDir, 'logs') From 97c5f497b8d6e01efa9aaa86d4dbd391c0ee489d Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 18:28:26 +0800 Subject: [PATCH 213/214] fix(ui): keep live folder files after metadata persist Persist mutations were writing API metadata into the query cache without UI-only files, and TV rows hid videoFile when that listing was empty. Preserve files across setQueryData, show video from mediaFiles, and pass userDataDir into chat tools for the XDG config path split. Co-authored-by: Cursor --- apps/cli/server.ts | 3 +- apps/cli/src/route/coreRoutesConfig.ts | 4 +- .../useMediaMetadataMutation.test.tsx | 66 +++++++++++++++ .../mediaMetadata/useMediaMetadataMutation.ts | 14 ++-- .../useUpdateMediaMetadataMutation.ts | 10 ++- .../lib/buildTvShowEpisodeTableRows.test.ts | 20 +++++ .../ui/src/lib/buildTvShowEpisodeTableRows.ts | 84 +++++++++---------- apps/ui/src/lib/mediaFolderFiles.test.ts | 48 +++++++++++ apps/ui/src/lib/mediaFolderFiles.ts | 13 +++ apps/ui/src/lib/mediaMetadataQueryKeys.ts | 19 +++++ packages/core-routes/src/chatTypes.ts | 7 ++ packages/core-routes/src/tools/index.ts | 10 +-- 12 files changed, 240 insertions(+), 58 deletions(-) create mode 100644 apps/ui/src/lib/mediaFolderFiles.test.ts diff --git a/apps/cli/server.ts b/apps/cli/server.ts index 530c340a..ca5e3486 100644 --- a/apps/cli/server.ts +++ b/apps/cli/server.ts @@ -9,7 +9,7 @@ import { setSocketIOManager, acknowledge, broadcast } from './src/utils/socketIO import { handleChatRequest as handleChatRequestCoreRoutes } from './src/route/chatRoute'; import { handleReadFile } from './src/route/ReadFile'; import { createAIProvider } from './lib/ai-provider.ts'; -import { getUserConfig, getAppDataDir } from './src/utils/config.ts'; +import { getUserConfig, getAppDataDir, getUserDataDir } from './src/utils/config.ts'; import { handleIsFolderAvailable } from './src/route/IsFolderAvailable'; import { handleWriteFile } from './src/route/WriteFile'; import { handleRenameFiles } from './src/route/RenameFiles'; @@ -255,6 +255,7 @@ export class Server { // AI provider factory, user-config reader, and Socket.IO helpers. handleChatRequestCoreRoutes(this.app, { appDataDir: getAppDataDir(), + userDataDir: getUserDataDir(), logger: createSocketIOLogger(), createAIProvider: (userConfig) => createAIProvider(userConfig), getUserConfig: () => getUserConfig(), diff --git a/apps/cli/src/route/coreRoutesConfig.ts b/apps/cli/src/route/coreRoutesConfig.ts index 7028ed3a..f3f57f3a 100644 --- a/apps/cli/src/route/coreRoutesConfig.ts +++ b/apps/cli/src/route/coreRoutesConfig.ts @@ -1,6 +1,6 @@ import type { ChatConfig, CoreRoutesConfig, CoreRoutesLogger } from '@smm/core-routes' import { buildAllowlist } from '@/utils/buildAllowlist' -import { getAppDataDir } from '@/utils/config' +import { getAppDataDir, getUserDataDir } from '@/utils/config' import { buildHelloOptions } from '../../tasks/HelloTask' import { createAIProvider } from '../../lib/ai-provider' import { getUserConfig } from '@/utils/config' @@ -28,9 +28,11 @@ export async function buildCoreRoutesConfig( export function buildChatConfig( logger: CoreRoutesLogger, appDataDir: string, + userDataDir: string = getUserDataDir(), ): ChatConfig { return { appDataDir, + userDataDir, logger, createAIProvider: (userConfig) => createAIProvider(userConfig), getUserConfig: () => getUserConfig(), diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx index 430f17c8..5f882c43 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.test.tsx @@ -16,6 +16,7 @@ import { } from "@/lib/mediaMetadataQueryKeys" import { useMediaMetadataMutation } from "./useMediaMetadataMutation" import { useMediaMetadataQuery } from "./useMediaMetadataQuery" +import { useUpdateMediaMetadataMutation } from "./useUpdateMediaMetadataMutation" vi.mock("@/api/metadata", async (importOriginal) => { const actual = await importOriginal() @@ -95,6 +96,71 @@ describe("metadata hooks", () => { expect(queryClient.getQueryData(windowsPathKey)).toEqual(updated) }) + it("preserves live folder files when setMetadata returns persisted metadata", async () => { + const withFiles = { + ...metadata, + files: ["/media/show/S01E01.mkv"], + } + const persisted = { + ...metadata, + mediaFiles: [ + { + absolutePath: "/media/show/S01E01.mkv", + seasonNumber: 1, + episodeNumber: 1, + }, + ], + } + vi.mocked(setMetadata).mockResolvedValue(persisted) + const queryClient = new QueryClient() + queryClient.setQueryData(mediaMetadataQueryKey("/media/show"), withFiles) + const { result } = renderHook(() => useMediaMetadataMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(() => + result.current.set("/media/show", { + mediaFiles: persisted.mediaFiles, + }), + ) + + expect(queryClient.getQueryData(mediaMetadataQueryKey("/media/show"))).toEqual({ + ...persisted, + files: ["/media/show/S01E01.mkv"], + }) + }) + + it("preserves files from the metadata being saved when the cache is empty", async () => { + const incoming = { + ...metadata, + files: ["/media/show/S01E01.mkv"], + mediaFiles: [ + { + absolutePath: "/media/show/S01E01.mkv", + seasonNumber: 1, + episodeNumber: 1, + }, + ], + } + const persisted = { + mediaFolderPath: incoming.mediaFolderPath, + type: incoming.type, + mediaFiles: incoming.mediaFiles, + } + vi.mocked(setMetadata).mockResolvedValue(persisted) + const queryClient = new QueryClient() + const { result } = renderHook(() => useUpdateMediaMetadataMutation(), { + wrapper: createWrapper(queryClient), + }) + + await act(() => result.current.persistMediaMetadata("/media/show", incoming)) + + expect(queryClient.getQueryData(mediaMetadataQueryKey("/media/show"))).toEqual({ + ...persisted, + files: ["/media/show/S01E01.mkv"], + }) + }) + it("remove clears the normalized metadata cache", async () => { vi.mocked(deleteMetadata).mockResolvedValue() const queryClient = new QueryClient() diff --git a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts index 6feec016..9ac25dd8 100644 --- a/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useMediaMetadataMutation.ts @@ -9,6 +9,7 @@ import { import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery, + setPersistedMetadataQueryData, } from "@/lib/mediaMetadataQueryKeys" function requireMetadataPath(metadata: MediaMetadata): string { @@ -24,10 +25,12 @@ export function useMediaMetadataMutation() { const createMutation = useMutation({ mutationFn: createMetadata, - onSuccess: (metadata) => { - queryClient.setQueryData( - mediaMetadataQueryKey(requireMetadataPath(metadata)), + onSuccess: (metadata, variables) => { + setPersistedMetadataQueryData( + queryClient, + requireMetadataPath(metadata), metadata, + variables, ) }, }) @@ -36,8 +39,9 @@ export function useMediaMetadataMutation() { mutationFn: ({ path, patch }: { path: string; patch: MetadataPatch }) => setMetadata(normalizeMediaFolderPathForQuery(path), patch), onSuccess: (metadata, { path }) => { - queryClient.setQueryData( - mediaMetadataQueryKey(normalizeMediaFolderPathForQuery(path)), + setPersistedMetadataQueryData( + queryClient, + normalizeMediaFolderPathForQuery(path), metadata, ) }, diff --git a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts index 786cfb5d..f3dd9f49 100644 --- a/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts +++ b/apps/ui/src/hooks/mediaMetadata/useUpdateMediaMetadataMutation.ts @@ -8,7 +8,7 @@ import { setMetadata, type MetadataPatch, } from "@/api/metadata" -import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery } from "@/lib/mediaMetadataQueryKeys" +import { mediaMetadataQueryKey, normalizeMediaFolderPathForQuery, setPersistedMetadataQueryData } from "@/lib/mediaMetadataQueryKeys" function toMetadataPatch(metadata: MediaMetadata): MetadataPatch { return { @@ -51,10 +51,12 @@ export function useUpdateMediaMetadataMutation() { } return { folderPathPosix: folder, metadata: persisted } }, - onSuccess: ({ folderPathPosix, metadata }) => { - queryClient.setQueryData( - mediaMetadataQueryKey(folderPathPosix), + onSuccess: ({ folderPathPosix, metadata }, vars) => { + setPersistedMetadataQueryData( + queryClient, + folderPathPosix, metadata, + vars.metadata, ) }, }) diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts index 44090d8f..3b7961dd 100644 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts +++ b/apps/ui/src/lib/buildTvShowEpisodeTableRows.test.ts @@ -436,6 +436,26 @@ describe('buildTvShowEpisodeTableRows', () => { }) describe('buildTvShowEpisodeTableRows with tmdb/tvdb branches', () => { + it('shows the episode video from mediaFiles when live folder files are missing', () => { + const mm = { + mediaFolderPath: '/media/show', + mediaFiles: [ + { + absolutePath: '/media/show/S01E01.mkv', + seasonNumber: 1, + episodeNumber: 1, + }, + ], + tvShow: tvShowForPlanTests(), + } as MediaMetadata + + const rows = buildTvShowEpisodeTableRows(mm, 'ok', (key) => key) + const ep = rows.find((row) => row.type === 'episode' && row.season === 1 && row.episode === 1) as TvShowEpisodeDataRow + + expect(ep.videoFile).toBe('/media/show/S01E01.mkv') + expect(ep.checked).toBe(true) + }) + it('includes fanart row when tmdbTvShow branch is used', () => { const mm = { mediaFolderPath: '/media/show', diff --git a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts index 34eba9a6..f0d470b8 100644 --- a/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts +++ b/apps/ui/src/lib/buildTvShowEpisodeTableRows.ts @@ -120,31 +120,31 @@ export function _buildTvShowEpisodeTableRowsFromTmdb(_in_mm: MediaMetadataWithFo let subtitleFile: { path: string; newPath?: string } | undefined let nfoFile: { path: string; newPath?: string } | undefined - const folderFiles = getMediaFolderFiles(_in_mm) - if (mediaFile && _in_mm.mediaFolderPath && folderFiles.length > 0) { - // Get video file path + if (mediaFile) { videoFile = { path: mediaFile.absolutePath, newPath: undefined } - - // Find associated files - const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) - - for (const file of associatedFiles) { - const filePath = join(_in_mm.mediaFolderPath, file.path) - const fileType = mapTagToFileType(file.tag) - - switch (fileType) { - case 'poster': - thumbnailFile = { path: filePath } - break - case 'subtitle': - subtitleFile = { path: filePath } - break - case 'nfo': - nfoFile = { path: filePath } - break + + const folderFiles = getMediaFolderFiles(_in_mm) + if (_in_mm.mediaFolderPath && folderFiles.length > 0) { + const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) + + for (const file of associatedFiles) { + const filePath = join(_in_mm.mediaFolderPath, file.path) + const fileType = mapTagToFileType(file.tag) + + switch (fileType) { + case 'poster': + thumbnailFile = { path: filePath } + break + case 'subtitle': + subtitleFile = { path: filePath } + break + case 'nfo': + nfoFile = { path: filePath } + break + } } } } @@ -201,31 +201,31 @@ export function _buildTvShowEpisodeTableRowsFromTvdb(_in_mm: MediaMetadataWithFo let subtitleFile: { path: string; newPath?: string } | undefined let nfoFile: { path: string; newPath?: string } | undefined - const folderFiles = getMediaFolderFiles(_in_mm) - if (mediaFile && _in_mm.mediaFolderPath && folderFiles.length > 0) { - // Get video file path + if (mediaFile) { videoFile = { path: mediaFile.absolutePath, newPath: undefined } - - // Find associated files - const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) - - for (const file of associatedFiles) { - const filePath = join(_in_mm.mediaFolderPath, file.path) - const fileType = mapTagToFileType(file.tag) - - switch (fileType) { - case 'poster': - thumbnailFile = { path: filePath } - break - case 'subtitle': - subtitleFile = { path: filePath } - break - case 'nfo': - nfoFile = { path: filePath } - break + + const folderFiles = getMediaFolderFiles(_in_mm) + if (_in_mm.mediaFolderPath && folderFiles.length > 0) { + const associatedFiles = findAssociatedFiles(_in_mm.mediaFolderPath, folderFiles, mediaFile.absolutePath) + + for (const file of associatedFiles) { + const filePath = join(_in_mm.mediaFolderPath, file.path) + const fileType = mapTagToFileType(file.tag) + + switch (fileType) { + case 'poster': + thumbnailFile = { path: filePath } + break + case 'subtitle': + subtitleFile = { path: filePath } + break + case 'nfo': + nfoFile = { path: filePath } + break + } } } } diff --git a/apps/ui/src/lib/mediaFolderFiles.test.ts b/apps/ui/src/lib/mediaFolderFiles.test.ts new file mode 100644 index 00000000..50db8397 --- /dev/null +++ b/apps/ui/src/lib/mediaFolderFiles.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import type { MediaMetadata } from "@core/types" +import { withLiveFolderFiles } from "./mediaFolderFiles" + +const persisted: MediaMetadata = { + mediaFolderPath: "/media/show", + type: "tvshow-folder", + mediaFiles: [], +} + +describe("withLiveFolderFiles", () => { + it("keeps live files from previous cache when persisted metadata omits them", () => { + expect( + withLiveFolderFiles(persisted, { + ...persisted, + files: ["/media/show/S01E01.mkv"], + }), + ).toEqual({ + ...persisted, + files: ["/media/show/S01E01.mkv"], + }) + }) + + it("keeps live files from the incoming save payload when cache is empty", () => { + expect( + withLiveFolderFiles(persisted, undefined, { + ...persisted, + files: ["/media/show/S01E01.mkv"], + }), + ).toEqual({ + ...persisted, + files: ["/media/show/S01E01.mkv"], + }) + }) + + it("prefers previous cache files over incoming files", () => { + expect( + withLiveFolderFiles( + persisted, + { ...persisted, files: ["/media/show/cached.mkv"] }, + { ...persisted, files: ["/media/show/incoming.mkv"] }, + ), + ).toEqual({ + ...persisted, + files: ["/media/show/cached.mkv"], + }) + }) +}) diff --git a/apps/ui/src/lib/mediaFolderFiles.ts b/apps/ui/src/lib/mediaFolderFiles.ts index a77e61ae..cb3be151 100644 --- a/apps/ui/src/lib/mediaFolderFiles.ts +++ b/apps/ui/src/lib/mediaFolderFiles.ts @@ -13,6 +13,19 @@ export function getMediaFolderFiles( return mm?.files ?? [] } +/** Keep the UI-only live listing when replacing cache with persisted metadata. */ +export function withLiveFolderFiles( + persisted: MediaMetadata, + previous: MediaMetadataWithFolderFiles | null | undefined, + incoming?: MediaMetadataWithFolderFiles, +): MediaMetadataWithFolderFiles { + const files = previous?.files ?? incoming?.files + if (files === undefined) { + return persisted + } + return { ...persisted, files } +} + export async function listMediaFolderFilePaths( folderPath: string, signal?: AbortSignal, diff --git a/apps/ui/src/lib/mediaMetadataQueryKeys.ts b/apps/ui/src/lib/mediaMetadataQueryKeys.ts index 3696e4f0..6db07b0e 100644 --- a/apps/ui/src/lib/mediaMetadataQueryKeys.ts +++ b/apps/ui/src/lib/mediaMetadataQueryKeys.ts @@ -1,7 +1,10 @@ import { Path } from "@core/path" +import type { MediaMetadata } from "@core/types" +import type { QueryClient } from "@tanstack/react-query" import { getMetadata } from "@/api/metadata" import { hydrateMediaMetadataWithFolderFiles, + withLiveFolderFiles, type MediaMetadataWithFolderFiles, } from "@/lib/mediaFolderFiles" @@ -15,6 +18,22 @@ export function normalizeMediaFolderPathForQuery(path: string): string { return Path.posix(path) } +/** + * Write persisted metadata into the query cache without dropping the + * UI-only live folder listing (`files`). + */ +export function setPersistedMetadataQueryData( + queryClient: QueryClient, + folderPathPosix: string, + persisted: MediaMetadata, + incoming?: MediaMetadata, +): void { + const key = mediaMetadataQueryKey(folderPathPosix) + queryClient.setQueryData(key, (prev) => + withLiveFolderFiles(persisted, prev, incoming), + ) +} + /** Shared options for `useQuery` / `queryClient.fetchQuery` so cache identity matches. */ export function mediaMetadataReadQueryOptions(path: string) { const folderPathPosix = normalizeMediaFolderPathForQuery(path) diff --git a/packages/core-routes/src/chatTypes.ts b/packages/core-routes/src/chatTypes.ts index 85c5f11c..8abbf380 100644 --- a/packages/core-routes/src/chatTypes.ts +++ b/packages/core-routes/src/chatTypes.ts @@ -108,4 +108,11 @@ export interface ChatConfig { * tools. Required when those tools are used. */ appDataDir: string; + /** + * User data dir used to locate `smm.json`. On Linux (XDG) this + * differs from `appDataDir`. Chat tools that check managed folders + * (`get-episodes`, `rename-folder`) must read config from here. + * Defaults to `appDataDir` when omitted. + */ + userDataDir?: string; } diff --git a/packages/core-routes/src/tools/index.ts b/packages/core-routes/src/tools/index.ts index a15d013f..396e2f18 100644 --- a/packages/core-routes/src/tools/index.ts +++ b/packages/core-routes/src/tools/index.ts @@ -141,15 +141,15 @@ export function createChatTools(args: CreateChatToolsArgs): ChatTools { coreRoutesConfig?.broadcast ?? config.broadcast ?? defaultBroadcast; // Build a synthetic `CoreRoutesConfig` for tools that need - // `appDataDir` / `allowlist` resolution (e.g. `getMediaMetadata`, - // `getEpisodes`, `renameFolder`). The chat config exposes - // `appDataDir` directly so the host can set it without providing - // a full `CoreRoutesConfig`. + // `appDataDir` / `userDataDir` resolution (e.g. `getMediaMetadata`, + // `getEpisodes`, `renameFolder`). On Linux these dirs differ + // (`smm.json` lives in userDataDir). Falls back to `appDataDir` + // when the host omits `userDataDir`. const syntheticConfig: CoreRoutesConfig = coreRoutesConfig ?? { allowlist: [], hello: { version: "0.0.0", - userDataDir: config.appDataDir, + userDataDir: config.userDataDir ?? config.appDataDir, appDataDir: config.appDataDir, logDir: "", tmpDir: "", From b70792ccf2c880ed7e710fcd4ebb26bd832edac9 Mon Sep 17 00:00:00 2001 From: Lawrence Ching Date: Sun, 30 Aug 2026 20:18:17 +0800 Subject: [PATCH 214/214] fix(ci): drop stale httpproxy specs and files assert common/httpproxy was removed; Docker job matched zero specs. Metadata API no longer returns UI-only files, so InitializeTvShowByTvdb must not expect files: []. Co-authored-by: Cursor --- .github/workflows/ci.yml | 10 +++++----- apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 050da374..1e5f5c9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,7 +177,9 @@ jobs: spec: "./test/specs/ai/*.e2e.ts" artifact_prefix: ai - name: Run Config and HTTP Proxy Tests - spec: "./common/config/*.e2e.ts ./common/httpproxy/*.e2e.ts" + # Proxy-through-custom-host specs live under common/config (*WithHttpProxy). + # common/httpproxy/* was removed in 78ad8f00. + spec: "./common/config/*.e2e.ts" artifact_prefix: config-httpproxy - name: Run MCP Tests spec: "./common/mcp/*.e2e.ts" @@ -349,11 +351,9 @@ jobs: matrix: include: - name: Docker E2E (config) + # Includes *WithHttpProxy specs (former common/httpproxy suite). spec: "./common/config/*.e2e.ts" artifact_prefix: docker-config - - name: Docker E2E (httpproxy) - spec: "./common/httpproxy/*.e2e.ts" - artifact_prefix: docker-httpproxy - name: Docker E2E (mcp) spec: "./common/mcp/*.e2e.ts" artifact_prefix: docker-mcp @@ -419,7 +419,7 @@ jobs: run: gunzip -c smm-latest.tar.gz | docker load - name: Require API key secrets for suites that need them - if: matrix.artifact_prefix == 'docker-httpproxy' || matrix.artifact_prefix == 'docker-media-init' + if: matrix.artifact_prefix == 'docker-config' || matrix.artifact_prefix == 'docker-media-init' run: | missing=0 if [ -z "${TMDB_API_KEY:-}" ]; then diff --git a/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts b/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts index 92a45d12..56c6a89a 100644 --- a/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts +++ b/apps/e2e/common/tv/InitializeTvShowByTvdb.e2e.ts @@ -210,7 +210,6 @@ S01E12 - - - -`) expect(m).toEqual({ mediaFolderPath: expect.any(String), type: 'tvshow-folder', - files: [], mediaFiles: [ { absolutePath: expect.stringContaining('S01E01.mkv'),