chore: import upstream snapshot with attribution
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: orpc-contract-first
|
||||
description: Guide for implementing oRPC contract-first API patterns in Dify frontend. Triggers when creating new API contracts, adding service endpoints, integrating TanStack Query with typed contracts, or migrating legacy service calls to oRPC. Use for all API layer work in web/contract and web/service directories.
|
||||
---
|
||||
|
||||
# oRPC Contract-First Development
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
web/contract/
|
||||
├── base.ts # Base contract (inputStructure: 'detailed')
|
||||
├── router.ts # Router composition & type exports
|
||||
├── marketplace.ts # Marketplace contracts
|
||||
└── console/ # Console contracts by domain
|
||||
├── system.ts
|
||||
└── billing.ts
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Create contract** in `web/contract/console/{domain}.ts`
|
||||
- Import `base` from `../base` and `type` from `@orpc/contract`
|
||||
- Define route with `path`, `method`, `input`, `output`
|
||||
|
||||
2. **Register in router** at `web/contract/router.ts`
|
||||
- Import directly from domain file (no barrel files)
|
||||
- Nest by API prefix: `billing: { invoices, bindPartnerStack }`
|
||||
|
||||
3. **Create hooks** in `web/service/use-{domain}.ts`
|
||||
- Use `consoleQuery.{group}.{contract}.queryKey()` for query keys
|
||||
- Use `consoleClient.{group}.{contract}()` for API calls
|
||||
|
||||
## Key Rules
|
||||
|
||||
- **Input structure**: Always use `{ params, query?, body? }` format
|
||||
- **Path params**: Use `{paramName}` in path, match in `params` object
|
||||
- **Router nesting**: Group by API prefix (e.g., `/billing/*` → `billing: {}`)
|
||||
- **No barrel files**: Import directly from specific files
|
||||
- **Types**: Import from `@/types/`, use `type<T>()` helper
|
||||
|
||||
## Type Export
|
||||
|
||||
```typescript
|
||||
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>
|
||||
```
|
||||
@@ -0,0 +1,500 @@
|
||||
---
|
||||
name: release-pro-max
|
||||
description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送".
|
||||
---
|
||||
|
||||
# Release Skills
|
||||
|
||||
Universal release workflow supporting any project type with multi-language changelog.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Just run `/release-pro-max` - auto-detects your project configuration.
|
||||
|
||||
## Supported Projects
|
||||
|
||||
| Project Type | Version File | Auto-Detected |
|
||||
|--------------|--------------|---------------|
|
||||
| Node.js | package.json | ✓ |
|
||||
| Python | pyproject.toml | ✓ |
|
||||
| Rust | Cargo.toml | ✓ |
|
||||
| Claude Plugin | marketplace.json | ✓ |
|
||||
| Generic | VERSION / version.txt | ✓ |
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--dry-run` | Preview changes without executing |
|
||||
| `--major` | Force major version bump |
|
||||
| `--minor` | Force minor version bump |
|
||||
| `--patch` | Force patch version bump |
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Detect Project Configuration
|
||||
|
||||
1. Check for `.releaserc.yml` (optional config override)
|
||||
2. Auto-detect version file by scanning (priority order):
|
||||
- `package.json` (Node.js)
|
||||
- `pyproject.toml` (Python)
|
||||
- `Cargo.toml` (Rust)
|
||||
- `marketplace.json` or `.claude-plugin/marketplace.json` (Claude Plugin)
|
||||
- `VERSION` or `version.txt` (Generic)
|
||||
3. Scan for changelog files using glob patterns:
|
||||
- `CHANGELOG*.md`
|
||||
- `HISTORY*.md`
|
||||
- `CHANGES*.md`
|
||||
4. Identify language of each changelog by filename suffix
|
||||
5. Display detected configuration
|
||||
|
||||
**Language Detection Rules**:
|
||||
|
||||
Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code.
|
||||
|
||||
| Pattern | Example | Language |
|
||||
|---------|---------|----------|
|
||||
| No suffix | `CHANGELOG.md` | en (default) |
|
||||
| `_{LANG}` (uppercase) | `CHANGELOG_CN.md`, `CHANGELOG_JP.md` | Corresponding language |
|
||||
| `.{lang}` (lowercase) | `CHANGELOG.zh.md`, `CHANGELOG.ja.md` | Corresponding language |
|
||||
| `.{lang-region}` | `CHANGELOG.zh-CN.md` | Corresponding region variant |
|
||||
|
||||
Common language codes: `zh` (Chinese), `ja` (Japanese), `ko` (Korean), `de` (German), `fr` (French), `es` (Spanish).
|
||||
|
||||
**Output Example**:
|
||||
```
|
||||
Project detected:
|
||||
Version file: package.json (1.2.3)
|
||||
Changelogs:
|
||||
- CHANGELOG.md (en)
|
||||
- CHANGELOG.zh.md (zh)
|
||||
- CHANGELOG.ja.md (ja)
|
||||
```
|
||||
|
||||
### Step 2: Analyze Changes Since Last Tag
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
git log ${LAST_TAG}..HEAD --oneline
|
||||
git diff ${LAST_TAG}..HEAD --stat
|
||||
```
|
||||
|
||||
Categorize by conventional commit types:
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| feat | New features |
|
||||
| fix | Bug fixes |
|
||||
| docs | Documentation |
|
||||
| refactor | Code refactoring |
|
||||
| perf | Performance improvements |
|
||||
| test | Test changes |
|
||||
| style | Formatting, styling |
|
||||
| chore | Maintenance (skip in changelog) |
|
||||
|
||||
> **Note**: This categorization is for internal analysis only. When writing changelog entries (Step 4), ALL descriptions must be rewritten in user-facing language. See Step 4 writing guidelines.
|
||||
|
||||
**Breaking Change Detection**:
|
||||
- Commit message starts with `BREAKING CHANGE`
|
||||
- Commit body/footer contains `BREAKING CHANGE:`
|
||||
- Removed public APIs, renamed exports, changed interfaces
|
||||
|
||||
If breaking changes detected, warn user: "Breaking changes detected. Consider major version bump (--major flag)."
|
||||
|
||||
### Step 3: Determine Version Bump
|
||||
|
||||
Rules (in priority order):
|
||||
1. User flag `--major/--minor/--patch` → Use specified
|
||||
2. BREAKING CHANGE detected → Major bump (1.x.x → 2.0.0)
|
||||
3. `feat:` commits present → Minor bump (1.2.x → 1.3.0)
|
||||
4. Otherwise → Patch bump (1.2.3 → 1.2.4)
|
||||
|
||||
Display version change: `1.2.3 → 1.3.0`
|
||||
|
||||
### Step 4: Generate Multi-language Changelogs
|
||||
|
||||
For each detected changelog file:
|
||||
|
||||
1. **Identify language** from filename suffix
|
||||
2. **Detect third-party contributors**:
|
||||
- Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:"%H %s"`
|
||||
- For each merged PR, identify the PR author via `gh pr view <number> --json author --jq '.author.login'`
|
||||
- Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`)
|
||||
- If PR author ≠ repo owner → third-party contributor
|
||||
3. **Generate content in that language**:
|
||||
- Section titles in target language
|
||||
- Change descriptions written naturally in target language (not translated)
|
||||
- Date format: YYYY-MM-DD (universal)
|
||||
- **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry
|
||||
4. **Insert at file head** (preserve existing content)
|
||||
|
||||
**⚠️ CRITICAL: User-Facing Writing Guidelines**
|
||||
|
||||
Changelog is written for **end users**, NOT developers. Every entry must describe what changed **from the user's perspective** and highlight the value it brings. Follow these rules strictly:
|
||||
|
||||
- **DO NOT** include any technical/programming terms: no "refactor", "component", "module", "API", "SDK", "runtime", "middleware", "state management", "IPC", "store", "hook", "cache invalidation", "dependency injection", etc.
|
||||
- **DO NOT** mention internal code structure: no file names, function names, class names, variable names, database tables, or architecture details.
|
||||
- **DO NOT** include engineering process items: no "code cleanup", "refactoring", "migration", "dependency update", "CI/CD", "build optimization", "type safety improvement", etc.
|
||||
- **DO** describe what the user can now do, see, or experience differently.
|
||||
- **DO** use plain, everyday language that any non-technical person can understand.
|
||||
- **DO** focus on user benefits and outcomes, not implementation details.
|
||||
|
||||
**How to transform technical commits into user-facing entries**:
|
||||
|
||||
| Technical commit | User-facing entry |
|
||||
|-----------------|-------------------|
|
||||
| `refactor: reorganize desktop component structure` | *(skip — no user-visible change)* |
|
||||
| `feat: add OAuth2 authentication module` | 支持使用第三方账号登录(如 Google、GitHub) |
|
||||
| `fix: fix memory leak in connection pool` | 修复了长时间使用后应用变卡的问题 |
|
||||
| `perf: optimize image loading pipeline` | 图片加载速度更快了 |
|
||||
| `feat: improve document import and library actions` | 导入文档更方便,书库管理操作更顺手 |
|
||||
| `fix: update sign-in and sign-up form components` | 优化了登录和注册页面的体验 |
|
||||
| `refactor: improve IPC flow and store sync` | *(skip — no user-visible change)* |
|
||||
|
||||
**Filtering rules**:
|
||||
- **Skip entirely**: commits that are purely internal (refactor, code cleanup, test-only, CI/CD, dependency updates) with NO user-visible effect.
|
||||
- **Rewrite**: commits that have user-visible effects but are described technically — rewrite them in user language.
|
||||
- Only `feat` and `fix` type changes typically produce user-facing entries. `perf` entries are included only when the improvement is noticeable to users.
|
||||
- If after filtering, a section would be empty, omit that section entirely.
|
||||
|
||||
**Section Title Translations** (built-in, user-facing only):
|
||||
|
||||
| Type | en | zh | ja | ko | de | fr | es |
|
||||
|------|----|----|----|----|----|----|-----|
|
||||
| feat | What's New | 新增功能 | 新機能 | 새로운 기능 | Neuigkeiten | Nouveautés | Novedades |
|
||||
| fix | Improvements | 改进与修复 | 改善 | 개선 사항 | Verbesserungen | Améliorations | Mejoras |
|
||||
| perf | Faster & Smoother | 更快更流畅 | パフォーマンス向上 | 더 빠르게 | Schneller & Besser | Plus rapide | Más rápido |
|
||||
| breaking | Important Changes | 重要变更 | 重要な変更 | 중요 변경사항 | Wichtige Änderungen | Changements importants | Cambios importantes |
|
||||
|
||||
> **Note**: `docs`, `refactor`, `test`, `style`, `chore` types are **excluded** from the changelog. They are internal engineering concerns with no direct user value.
|
||||
|
||||
**Changelog Format**:
|
||||
|
||||
```markdown
|
||||
## {VERSION} - {YYYY-MM-DD}
|
||||
|
||||
### What's New
|
||||
- User-facing description of what they can now do (by @username)
|
||||
|
||||
### Improvements
|
||||
- User-facing description of what got better
|
||||
```
|
||||
|
||||
Only include sections that have changes. Omit empty sections. Remember: every line must be understandable by a non-technical user.
|
||||
|
||||
**Third-Party Attribution Rules**:
|
||||
- Only add `(by @username)` for contributors who are NOT the repo owner
|
||||
- Use GitHub username with `@` prefix
|
||||
- Place at the end of the changelog entry line
|
||||
- Apply to all languages consistently (always use `(by @username)` format, not translated)
|
||||
|
||||
**Multi-language Example**:
|
||||
|
||||
English (CHANGELOG.md):
|
||||
```markdown
|
||||
## 1.3.0 - 2026-01-22
|
||||
|
||||
### What's New
|
||||
- Sign in with your Google or GitHub account (by @contributor1)
|
||||
- Link your existing account with third-party login
|
||||
|
||||
### Improvements
|
||||
- Fixed an issue where the app would slow down after extended use
|
||||
```
|
||||
|
||||
Chinese (CHANGELOG.zh.md):
|
||||
```markdown
|
||||
## 1.3.0 - 2026-01-22
|
||||
|
||||
### 新增功能
|
||||
- 支持使用 Google 或 GitHub 账号登录 (by @contributor1)
|
||||
- 可以将已有账号与第三方登录方式绑定
|
||||
|
||||
### 改进与修复
|
||||
- 修复了长时间使用后应用变卡的问题
|
||||
```
|
||||
|
||||
Japanese (CHANGELOG.ja.md):
|
||||
```markdown
|
||||
## 1.3.0 - 2026-01-22
|
||||
|
||||
### 新機能
|
||||
- GoogleやGitHubアカウントでログインできるようになりました (by @contributor1)
|
||||
- 既存アカウントとサードパーティログインの連携が可能に
|
||||
|
||||
### 改善
|
||||
- 長時間使用時にアプリが遅くなる問題を修正
|
||||
```
|
||||
|
||||
### Step 5: Group Changes by Skill/Module
|
||||
|
||||
Analyze commits since last tag and group by affected skill/module:
|
||||
|
||||
1. **Identify changed files** per commit
|
||||
2. **Group by skill/module**:
|
||||
- `skills/<skill-name>/*` → Group under that skill
|
||||
- Root files (CLAUDE.md, etc.) → Group as "project"
|
||||
- Multiple skills in one commit → Split into multiple groups
|
||||
3. **For each group**, identify related README updates needed
|
||||
|
||||
**Example Grouping**:
|
||||
```
|
||||
baoyu-cover-image:
|
||||
- feat: add new style options
|
||||
- fix: handle transparent backgrounds
|
||||
→ README updates: options table
|
||||
|
||||
baoyu-comic:
|
||||
- refactor: improve panel layout algorithm
|
||||
→ No README updates needed
|
||||
|
||||
project:
|
||||
- docs: update CLAUDE.md architecture section
|
||||
```
|
||||
|
||||
### Step 6: Commit Each Skill/Module Separately
|
||||
|
||||
For each skill/module group (in order of changes):
|
||||
|
||||
1. **Check README updates needed**:
|
||||
- Scan `README*.md` for mentions of this skill/module
|
||||
- Verify options/flags documented correctly
|
||||
- Update usage examples if syntax changed
|
||||
- Update feature descriptions if behavior changed
|
||||
|
||||
2. **Stage and commit**:
|
||||
```bash
|
||||
git add skills/<skill-name>/*
|
||||
git add README.md README.zh.md # If updated for this skill
|
||||
git commit -m "<type>(<skill-name>): <meaningful description>"
|
||||
```
|
||||
|
||||
3. **Commit message format**:
|
||||
- Use conventional commit format: `<type>(<scope>): <description>`
|
||||
- `<type>`: feat, fix, refactor, docs, perf, etc.
|
||||
- `<scope>`: skill name or "project"
|
||||
- `<description>`: Clear, meaningful description of changes
|
||||
|
||||
**Example Commits**:
|
||||
```bash
|
||||
git commit -m "feat(baoyu-cover-image): add watercolor and minimalist styles"
|
||||
git commit -m "fix(baoyu-comic): improve panel layout for long dialogues"
|
||||
git commit -m "docs(project): update architecture documentation"
|
||||
```
|
||||
|
||||
**Common README Updates Needed**:
|
||||
| Change Type | README Section to Check |
|
||||
|-------------|------------------------|
|
||||
| New options/flags | Options table, usage examples |
|
||||
| Renamed options | Options table, usage examples |
|
||||
| New features | Feature description, examples |
|
||||
| Breaking changes | Migration notes, deprecation warnings |
|
||||
| Restructured internals | Architecture section (if exposed to users) |
|
||||
|
||||
### Step 7: Generate Changelog and Update Version
|
||||
|
||||
1. **Generate multi-language changelogs** (as described in Step 4)
|
||||
2. **Update version file**:
|
||||
- Read version file (JSON/TOML/text)
|
||||
- Update version number
|
||||
- Write back (preserve formatting)
|
||||
|
||||
**Version Paths by File Type**:
|
||||
|
||||
| File | Path |
|
||||
|------|------|
|
||||
| package.json | `$.version` |
|
||||
| pyproject.toml | `project.version` |
|
||||
| Cargo.toml | `package.version` |
|
||||
| marketplace.json | `$.metadata.version` |
|
||||
| VERSION / version.txt | Direct content |
|
||||
|
||||
### Step 8: User Confirmation
|
||||
|
||||
Before creating the release commit, ask user to confirm:
|
||||
|
||||
**Use AskUserQuestion with two questions**:
|
||||
|
||||
1. **Version bump** (single select):
|
||||
- Show recommended version based on Step 3 analysis
|
||||
- Options: recommended (with label), other semver options
|
||||
- Example: `1.2.3 → 1.3.0 (Recommended)`, `1.2.3 → 1.2.4`, `1.2.3 → 2.0.0`
|
||||
|
||||
2. **Push to remote** (single select):
|
||||
- Options: "Yes, push after commit", "No, keep local only"
|
||||
|
||||
**Example Output Before Confirmation**:
|
||||
```
|
||||
Commits created:
|
||||
1. feat(baoyu-cover-image): add watercolor and minimalist styles
|
||||
2. fix(baoyu-comic): improve panel layout for long dialogues
|
||||
3. docs(project): update architecture documentation
|
||||
|
||||
Changelog preview (en):
|
||||
## 1.3.0 - 2026-01-22
|
||||
### What's New
|
||||
- New cover styles available: watercolor and minimalist
|
||||
### Improvements
|
||||
- Comics with longer conversations now display more cleanly
|
||||
|
||||
Ready to create release commit and tag.
|
||||
```
|
||||
|
||||
### Step 9: Create Release Commit and Tag
|
||||
|
||||
After user confirmation:
|
||||
|
||||
1. **Stage version and changelog files**:
|
||||
```bash
|
||||
git add <version-file>
|
||||
git add CHANGELOG*.md
|
||||
```
|
||||
|
||||
2. **Create release commit**:
|
||||
```bash
|
||||
git commit -m "chore: release v{VERSION}"
|
||||
```
|
||||
|
||||
3. **Create tag**:
|
||||
```bash
|
||||
git tag v{VERSION}
|
||||
```
|
||||
|
||||
4. **Push if user confirmed** (Step 8):
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin v{VERSION}
|
||||
```
|
||||
|
||||
**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.
|
||||
|
||||
**Post-Release Output**:
|
||||
```
|
||||
Release v1.3.0 created.
|
||||
|
||||
Commits:
|
||||
1. feat(baoyu-cover-image): add watercolor and minimalist styles
|
||||
2. fix(baoyu-comic): improve panel layout for long dialogues
|
||||
3. docs(project): update architecture documentation
|
||||
4. chore: release v1.3.0
|
||||
|
||||
Tag: v1.3.0
|
||||
Status: Pushed to origin # or "Local only - run git push when ready"
|
||||
```
|
||||
|
||||
## Configuration (.releaserc.yml)
|
||||
|
||||
Optional config file in project root to override defaults:
|
||||
|
||||
```yaml
|
||||
# .releaserc.yml - Optional configuration
|
||||
|
||||
# Version file (auto-detected if not specified)
|
||||
version:
|
||||
file: package.json
|
||||
path: $.version # JSONPath for JSON, dotted path for TOML
|
||||
|
||||
# Changelog files (auto-detected if not specified)
|
||||
changelog:
|
||||
files:
|
||||
- path: CHANGELOG.md
|
||||
lang: en
|
||||
- path: CHANGELOG.zh.md
|
||||
lang: zh
|
||||
- path: CHANGELOG.ja.md
|
||||
lang: ja
|
||||
|
||||
# Section mapping (conventional commit type → changelog section)
|
||||
# Use null to skip a type in changelog
|
||||
# Sections should use user-facing names, not technical terms
|
||||
sections:
|
||||
feat: "What's New"
|
||||
fix: Improvements
|
||||
perf: "Faster & Smoother"
|
||||
docs: null # Skip — internal only
|
||||
refactor: null # Skip — internal only
|
||||
test: null # Skip — internal only
|
||||
chore: null # Skip — internal only
|
||||
|
||||
# Commit message format
|
||||
commit:
|
||||
message: "chore: release v{version}"
|
||||
|
||||
# Tag format
|
||||
tag:
|
||||
prefix: v # Results in v1.0.0
|
||||
sign: false
|
||||
|
||||
# Additional files to include in release commit
|
||||
include:
|
||||
- README.md
|
||||
- package.json
|
||||
```
|
||||
|
||||
## Dry-Run Mode
|
||||
|
||||
When `--dry-run` is specified:
|
||||
|
||||
```
|
||||
=== DRY RUN MODE ===
|
||||
|
||||
Project detected:
|
||||
Version file: package.json (1.2.3)
|
||||
Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh)
|
||||
|
||||
Last tag: v1.2.3
|
||||
Proposed version: v1.3.0
|
||||
|
||||
Changes grouped by skill/module:
|
||||
baoyu-cover-image:
|
||||
- feat: add watercolor style
|
||||
- feat: add minimalist style
|
||||
→ Commit: feat(baoyu-cover-image): add watercolor and minimalist styles
|
||||
→ README updates: options table
|
||||
|
||||
baoyu-comic:
|
||||
- fix: panel layout for long dialogues
|
||||
→ Commit: fix(baoyu-comic): improve panel layout for long dialogues
|
||||
→ No README updates
|
||||
|
||||
Changelog preview (en):
|
||||
## 1.3.0 - 2026-01-22
|
||||
### What's New
|
||||
- New cover styles available: watercolor and minimalist
|
||||
### Improvements
|
||||
- Comics with longer conversations now display more cleanly
|
||||
|
||||
Changelog preview (zh):
|
||||
## 1.3.0 - 2026-01-22
|
||||
### 新增功能
|
||||
- 新增封面风格:水彩和极简
|
||||
### 改进与修复
|
||||
- 长对话的漫画排版更加美观
|
||||
|
||||
Commits to create:
|
||||
1. feat(baoyu-cover-image): add watercolor and minimalist styles
|
||||
2. fix(baoyu-comic): improve panel layout for long dialogues
|
||||
3. chore: release v1.3.0
|
||||
|
||||
No changes made. Run without --dry-run to execute.
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/release-pro-max # Auto-detect version bump
|
||||
/release-pro-max --dry-run # Preview only
|
||||
/release-pro-max --minor # Force minor bump
|
||||
/release-pro-max --patch # Force patch bump
|
||||
/release-pro-max --major # Force major bump (with confirmation)
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger this skill when user requests:
|
||||
- "release", "发布", "create release", "new version", "新版本"
|
||||
- "bump version", "update version", "更新版本"
|
||||
- "prepare release"
|
||||
- "push to remote" (with uncommitted changes)
|
||||
|
||||
**Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Ultracite Code Standards
|
||||
|
||||
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- **Format code**: `pnpm dlx ultracite fix`
|
||||
- **Check for issues**: `pnpm dlx ultracite check`
|
||||
- **Diagnose setup**: `pnpm dlx ultracite doctor`
|
||||
|
||||
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
|
||||
|
||||
### Type Safety & Explicitness
|
||||
|
||||
- Use explicit types for function parameters and return values when they enhance clarity
|
||||
- Prefer `unknown` over `any` when the type is genuinely unknown
|
||||
- Use const assertions (`as const`) for immutable values and literal types
|
||||
- Leverage TypeScript's type narrowing instead of type assertions
|
||||
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
|
||||
|
||||
### Modern JavaScript/TypeScript
|
||||
|
||||
- Use arrow functions for callbacks and short functions
|
||||
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
|
||||
- Prefer template literals over string concatenation
|
||||
- Use destructuring for object and array assignments
|
||||
- Use `const` by default, `let` only when reassignment is needed, never `var`
|
||||
|
||||
### Async & Promises
|
||||
|
||||
- Always `await` promises in async functions - don't forget to use the return value
|
||||
- Use `async/await` syntax instead of promise chains for better readability
|
||||
- Handle errors appropriately in async code with try-catch blocks
|
||||
- Don't use async functions as Promise executors
|
||||
|
||||
### React & JSX
|
||||
|
||||
- Use function components over class components
|
||||
- Call hooks at the top level only, never conditionally
|
||||
- Specify all dependencies in hook dependency arrays correctly
|
||||
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
|
||||
- Nest children between opening and closing tags instead of passing as props
|
||||
- Don't define components inside other components
|
||||
- Use semantic HTML and ARIA attributes for accessibility:
|
||||
- Provide meaningful alt text for images
|
||||
- Use proper heading hierarchy
|
||||
- Add labels for form inputs
|
||||
- Include keyboard event handlers alongside mouse events
|
||||
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
|
||||
|
||||
### Error Handling & Debugging
|
||||
|
||||
- Remove `console.log`, `debugger`, and `alert` statements from production code
|
||||
- Throw `Error` objects with descriptive messages, not strings or other values
|
||||
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
|
||||
- Prefer early returns over nested conditionals for error cases
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep functions focused and under reasonable cognitive complexity limits
|
||||
- Extract complex conditions into well-named boolean variables
|
||||
- Use early returns to reduce nesting
|
||||
- Prefer simple conditionals over nested ternary operators
|
||||
- Group related code together and separate concerns
|
||||
|
||||
### Security
|
||||
|
||||
- Add `rel="noopener"` when using `target="_blank"` on links
|
||||
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
|
||||
- Don't use `eval()` or assign directly to `document.cookie`
|
||||
- Validate and sanitize user input
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid spread syntax in accumulators within loops
|
||||
- Use top-level regex literals instead of creating them in loops
|
||||
- Prefer specific imports over namespace imports
|
||||
- Avoid barrel files (index files that re-export everything)
|
||||
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
|
||||
|
||||
### Framework-Specific Guidance
|
||||
|
||||
**Next.js:**
|
||||
- Use Next.js `<Image>` component for images
|
||||
- Use `next/head` or App Router metadata API for head elements
|
||||
- Use Server Components for async data fetching instead of async Client Components
|
||||
|
||||
**React 19+:**
|
||||
- Use ref as a prop instead of `React.forwardRef`
|
||||
|
||||
**Solid/Svelte/Vue/Qwik:**
|
||||
- Use `class` and `for` attributes (not `className` or `htmlFor`)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Write assertions inside `it()` or `test()` blocks
|
||||
- Avoid done callbacks in async tests - use async/await instead
|
||||
- Don't use `.only` or `.skip` in committed code
|
||||
- Keep test suites reasonably flat - avoid excessive `describe` nesting
|
||||
|
||||
## When Biome Can't Help
|
||||
|
||||
Biome's linter will catch most issues automatically. Focus your attention on:
|
||||
|
||||
1. **Business logic correctness** - Biome can't validate your algorithms
|
||||
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
|
||||
3. **Architecture decisions** - Component structure, data flow, and API design
|
||||
4. **Edge cases** - Handle boundary conditions and error states
|
||||
5. **User experience** - Accessibility, performance, and usability considerations
|
||||
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
|
||||
|
||||
---
|
||||
|
||||
Most formatting and common issues are automatically fixed by Biome. Run `pnpm dlx ultracite fix` before committing to ensure compliance.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": "pnpm dlx ultracite fix"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.git
|
||||
.context
|
||||
dist
|
||||
out
|
||||
build
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/out
|
||||
**/.turbo
|
||||
@@ -0,0 +1,10 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Bug Report
|
||||
description: Report a problem or regression
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Observed behavior and screenshots
|
||||
placeholder: What actually happened
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or screenshots
|
||||
description: Paste relevant logs or add screenshots
|
||||
placeholder: Attach files or paste logs here
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: app_version
|
||||
attributes:
|
||||
label: App version
|
||||
description: The VidBee version (e.g., 1.2.3)
|
||||
placeholder: 1.2.3
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os_version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
|
||||
placeholder: macOS 14.2
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea or improvement
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What problem are you trying to solve?
|
||||
placeholder: I want to...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the feature or change you want
|
||||
placeholder: It would be great if...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other solutions or workarounds you considered
|
||||
placeholder: I tried...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots
|
||||
placeholder: Links, screenshots, or related issues
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,201 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload_artifacts:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: 'Whether to upload build artifacts'
|
||||
secrets:
|
||||
MAC_CERT_P12_BASE64:
|
||||
required: false
|
||||
MAC_CERT_P12_PASSWORD:
|
||||
required: false
|
||||
APPLE_API_KEY_ID:
|
||||
required: false
|
||||
APPLE_API_ISSUER:
|
||||
required: false
|
||||
APPLE_API_KEY_P8_BASE64:
|
||||
required: false
|
||||
VITE_GLITCHTIP_DSN:
|
||||
required: false
|
||||
SENTRY_URL:
|
||||
required: false
|
||||
SENTRY_ORG:
|
||||
required: false
|
||||
SENTRY_PROJECT:
|
||||
required: false
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows
|
||||
os: windows-latest
|
||||
build_script: pnpm run build:win
|
||||
mac_ffmpeg_mode: native
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
build_script: pnpm run build:mac
|
||||
mac_ffmpeg_mode: universal
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
build_script: pnpm run build:linux
|
||||
mac_ffmpeg_mode: native
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.1.2
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check && pnpm run typecheck
|
||||
|
||||
- name: Setup macOS signing
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
|
||||
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
|
||||
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "SIGNING_AVAILABLE=false" >> "$GITHUB_ENV"
|
||||
|
||||
# Check if all required secrets are present
|
||||
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
|
||||
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
|
||||
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
|
||||
echo "::notice::macOS signing secrets not available, skipping code signing setup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
|
||||
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
|
||||
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
|
||||
|
||||
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
|
||||
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
|
||||
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
|
||||
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
|
||||
echo "SIGNING_AVAILABLE=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Stamp release version and channel from tag
|
||||
if: github.ref_type == 'tag'
|
||||
shell: bash
|
||||
# Stamp the tag version into package.json and, for prerelease tags, set the publish
|
||||
# channel so electron-builder emits preview*.yml instead of latest*.yml (its GitHub
|
||||
# provider does not derive the channel from the version on its own).
|
||||
run: node apps/desktop/scripts/stamp-release.mjs "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Build application
|
||||
env:
|
||||
VIDBEE_MAC_FFMPEG_MODE: ${{ matrix.mac_ffmpeg_mode }}
|
||||
VITE_GLITCHTIP_DSN: ${{ secrets.VITE_GLITCHTIP_DSN }}
|
||||
VITE_GLITCHTIP_ENVIRONMENT: production
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: ${{ matrix.build_script }}
|
||||
|
||||
- name: Verify Windows release artifacts
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dist = "apps/desktop/dist"
|
||||
$requiredPatterns = @("*-setup.exe", "*-portable.exe", "*-windows-portable.zip")
|
||||
foreach ($pattern in $requiredPatterns) {
|
||||
$matches = Get-ChildItem -Path $dist -Filter $pattern -File
|
||||
if ($matches.Count -eq 0) {
|
||||
Write-Error "Missing Windows release artifact matching $pattern"
|
||||
exit 1
|
||||
}
|
||||
$matches | ForEach-Object {
|
||||
Write-Host "Found Windows release artifact: $($_.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Verify macOS codesign and notarization
|
||||
if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apps_found=0
|
||||
while IFS= read -r app; do
|
||||
apps_found=1
|
||||
echo "Verifying codesign for $app"
|
||||
codesign --verify --deep --strict --verbose=2 "$app"
|
||||
spctl -a -t exec -vv "$app"
|
||||
echo "Validating notarization ticket for $app"
|
||||
xcrun stapler validate "$app"
|
||||
done < <(find apps/desktop/dist -type d -name "*.app" -prune -print)
|
||||
|
||||
if [[ "$apps_found" -eq 0 ]]; then
|
||||
echo "::error::No .app bundles found in dist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dmgs_found=0
|
||||
while IFS= read -r dmg; do
|
||||
dmgs_found=1
|
||||
echo "Submitting DMG for notarization: $dmg"
|
||||
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
|
||||
echo "Stapling notarization ticket for $dmg"
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Validating notarization ticket for $dmg"
|
||||
xcrun stapler validate "$dmg"
|
||||
done < <(find apps/desktop/dist -type f -name "*.dmg" -print)
|
||||
|
||||
if [[ "$dmgs_found" -eq 0 ]]; then
|
||||
echo "::notice::No DMG artifacts found to validate"
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: inputs.upload_artifacts == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.os }}
|
||||
path: |
|
||||
apps/desktop/dist/*.exe
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.AppImage
|
||||
apps/desktop/dist/*.snap
|
||||
apps/desktop/dist/*.deb
|
||||
apps/desktop/dist/*.rpm
|
||||
apps/desktop/dist/*.tar.gz
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,11 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
@@ -0,0 +1,122 @@
|
||||
name: CLI Publish
|
||||
|
||||
# Publishes @vidbee/cli to npm when a `cli-vX.Y.Z` tag is pushed. Standalone
|
||||
# from the Desktop release pipeline (NEX-148): the CLI ships independently
|
||||
# so a `yt-dlp` probe-flag fix doesn't have to wait for a Desktop release.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cli-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to publish (cli-vX.Y.Z). When empty, builds from main but skips publish.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve version from tag
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
ref="${GITHUB_REF_NAME}"
|
||||
if [[ -n "${{ github.event.inputs.tag }}" ]]; then
|
||||
ref="${{ github.event.inputs.tag }}"
|
||||
fi
|
||||
if [[ "$ref" == cli-v* ]]; then
|
||||
version="${ref#cli-v}"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$version" == *-* ]]; then
|
||||
echo "tag=next" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.1.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter ./apps/cli... --frozen-lockfile
|
||||
|
||||
- name: Verify package.json version matches tag
|
||||
if: steps.meta.outputs.publish == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${{ steps.meta.outputs.version }}"
|
||||
pkg_version=$(node -p "require('./apps/cli/package.json').version")
|
||||
if [[ "$pkg_version" != "$tag_version" ]]; then
|
||||
echo "::error::Tag $tag_version does not match apps/cli/package.json version $pkg_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run CLI tests
|
||||
run: pnpm --filter @vidbee/cli test
|
||||
|
||||
- name: Run CLI typecheck
|
||||
run: pnpm --filter @vidbee/cli typecheck
|
||||
|
||||
- name: Build CLI bundle
|
||||
run: pnpm --filter @vidbee/cli build
|
||||
|
||||
- name: Verify dist artifacts exist
|
||||
run: pnpm --filter @vidbee/cli verify-dist
|
||||
|
||||
- name: Pack tarball (sanity-check files whitelist)
|
||||
run: |
|
||||
cd apps/cli
|
||||
pnpm pack --pack-destination ../..
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.meta.outputs.publish == 'true'
|
||||
run: |
|
||||
cd apps/cli
|
||||
pnpm publish --no-git-checks --access public --tag ${{ steps.meta.outputs.tag }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.meta.outputs.publish == 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: '@vidbee/cli ${{ steps.meta.outputs.version }}'
|
||||
body: |
|
||||
`@vidbee/cli@${{ steps.meta.outputs.version }}` published to npm.
|
||||
|
||||
Install:
|
||||
```sh
|
||||
npm install -g @vidbee/cli@${{ steps.meta.outputs.version }}
|
||||
pnpm add -g @vidbee/cli@${{ steps.meta.outputs.version }}
|
||||
bun install -g @vidbee/cli@${{ steps.meta.outputs.version }}
|
||||
```
|
||||
|
||||
See `apps/cli/CHANGELOG.md` for release notes.
|
||||
files: |
|
||||
*.tgz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Docker Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'apps/api/**'
|
||||
- 'apps/desktop/resources/drizzle/**'
|
||||
- 'apps/web/**'
|
||||
- 'packages/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- app: api
|
||||
dockerfile: apps/api/Dockerfile
|
||||
image_suffix: api
|
||||
- app: web
|
||||
dockerfile: apps/web/Dockerfile
|
||||
image_suffix: web
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/vidbee-${{ matrix.image_suffix }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VITE_API_URL=${{ vars.VITE_API_URL || 'http://localhost:3100' }}
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Build Extension
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload_artifacts:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: 'Whether to upload build artifacts'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.1.2
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter ./apps/extension...
|
||||
|
||||
- name: Build extension
|
||||
run: pnpm --filter ./apps/extension build
|
||||
|
||||
- name: Build extension zip
|
||||
run: pnpm --filter ./apps/extension zip
|
||||
|
||||
- name: Upload extension artifacts
|
||||
if: inputs.upload_artifacts == true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-extension
|
||||
path: apps/extension/.output/*.zip
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Publish Extension
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- apps/extension/package.json
|
||||
|
||||
jobs:
|
||||
detect-version:
|
||||
if: vars.ENABLE_EXTENSION_CI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.version_check.outputs.changed }}
|
||||
current_version: ${{ steps.version_check.outputs.current_version }}
|
||||
previous_version: ${{ steps.version_check.outputs.previous_version }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if extension version changed
|
||||
id: version_check
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
before_sha="${{ github.event.before }}"
|
||||
current_version=$(node -e "const fs = require('fs'); const v = JSON.parse(fs.readFileSync('apps/extension/package.json', 'utf8')).version; process.stdout.write(v);")
|
||||
previous_version=""
|
||||
if git cat-file -e "${before_sha}:apps/extension/package.json" 2>/dev/null; then
|
||||
previous_version=$(git show "${before_sha}:apps/extension/package.json" | node -e "let data=''; process.stdin.on('data', d => data += d); process.stdin.on('end', () => { const v = JSON.parse(data).version; process.stdout.write(v); });")
|
||||
fi
|
||||
|
||||
echo "current_version=${current_version}" >> "$GITHUB_OUTPUT"
|
||||
echo "previous_version=${previous_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ -n "${previous_version}" && "${current_version}" == "${previous_version}" ]]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
submit:
|
||||
if: vars.ENABLE_EXTENSION_CI == 'true' && needs.detect-version.outputs.changed == 'true'
|
||||
needs: detect-version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.1.2
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter ./apps/extension...
|
||||
|
||||
- name: Build extension
|
||||
run: pnpm --filter ./apps/extension build
|
||||
|
||||
- name: Zip extensions
|
||||
run: pnpm --filter ./apps/extension zip
|
||||
|
||||
- name: Submit to stores
|
||||
run: |
|
||||
pnpm --dir apps/extension wxt submit \
|
||||
--chrome-zip .output/*-chrome.zip
|
||||
env:
|
||||
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
|
||||
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
|
||||
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||
CHROME_PUBLISH_TARGET: "default"
|
||||
CHROME_SKIP_SUBMIT_REVIEW: false
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Build and Release Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*.*.*
|
||||
workflow_dispatch:
|
||||
|
||||
# Never run two releases for the same ref at once, and never cancel an in-progress
|
||||
# release (cancelling mid-publish could leave a half-uploaded GitHub release).
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
secrets: inherit
|
||||
|
||||
release:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
# Only publish a GitHub release for tag pushes. A manual workflow_dispatch run
|
||||
# has no tag, so it acts as a build-only smoke test (artifacts only, no release).
|
||||
if: github.ref_type == 'tag'
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect release type
|
||||
id: release_meta
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_NAME}" == *-preview.* ]]; then
|
||||
echo "is_preview=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_preview=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: dist-*
|
||||
merge-multiple: true
|
||||
path: dist/
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ steps.release_meta.outputs.is_preview == 'true' }}
|
||||
files: |
|
||||
dist/*.exe
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
dist/*.AppImage
|
||||
dist/*.snap
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/*.tar.gz
|
||||
dist/*.yml
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Notify Cloudflare Pages
|
||||
if: steps.release_meta.outputs.is_preview != 'true'
|
||||
env:
|
||||
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
|
||||
run: |
|
||||
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"
|
||||
@@ -0,0 +1,26 @@
|
||||
name: 'translator'
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
discussion:
|
||||
types: [created, edited]
|
||||
discussion_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
IS_MODIFY_TITLE: true
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Auto Release yt-dlp Patch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ytdlp-auto-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
update_available: ${{ steps.check.outputs.update_available }}
|
||||
release_version: ${{ steps.prepare.outputs.release_version }}
|
||||
latest_ytdlp_version: ${{ steps.prepare.outputs.latest_ytdlp_version }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Check for yt-dlp updates
|
||||
id: check
|
||||
env:
|
||||
YTDLP_RELEASE_TOKEN: ${{ github.token }}
|
||||
run: node apps/desktop/scripts/ytdlp-auto-release.mjs check
|
||||
|
||||
- name: Prepare patch release
|
||||
if: steps.check.outputs.update_available == 'true'
|
||||
id: prepare
|
||||
env:
|
||||
YTDLP_RELEASE_TOKEN: ${{ github.token }}
|
||||
run: node apps/desktop/scripts/ytdlp-auto-release.mjs prepare
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.check.outputs.update_available == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.1.2
|
||||
|
||||
- name: Install Dependencies
|
||||
if: steps.check.outputs.update_available == 'true'
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Validate release changes
|
||||
if: steps.check.outputs.update_available == 'true'
|
||||
run: pnpm run check
|
||||
|
||||
- name: Upload release changes
|
||||
if: steps.check.outputs.update_available == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ytdlp-release-changes
|
||||
path: |
|
||||
apps/desktop/package.json
|
||||
apps/desktop/changelogs/CHANGELOG.md
|
||||
apps/desktop/release-metadata.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Report up-to-date status
|
||||
if: steps.check.outputs.update_available != 'true'
|
||||
run: echo "yt-dlp is already up to date. No patch release is needed."
|
||||
|
||||
publish:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.update_available == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
|
||||
LATEST_YTDLP_VERSION: ${{ needs.prepare.outputs.latest_ytdlp_version }}
|
||||
steps:
|
||||
- name: Check release token
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${GH_TOKEN}" ]; then
|
||||
echo "ACCESS_TOKEN secret is required to push the release commit and tag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.ACCESS_TOKEN }}
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Download release changes
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ytdlp-release-changes
|
||||
path: .ytdlp-release-changes
|
||||
|
||||
- name: Apply release changes
|
||||
run: |
|
||||
cp .ytdlp-release-changes/package.json apps/desktop/package.json
|
||||
cp .ytdlp-release-changes/release-metadata.json apps/desktop/release-metadata.json
|
||||
cp .ytdlp-release-changes/changelogs/CHANGELOG.md apps/desktop/changelogs/CHANGELOG.md
|
||||
rm -rf .ytdlp-release-changes
|
||||
|
||||
- name: Commit release changes
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add apps/desktop/package.json apps/desktop/changelogs/CHANGELOG.md apps/desktop/release-metadata.json
|
||||
git commit -m "chore(release): publish v${RELEASE_VERSION} with yt-dlp ${LATEST_YTDLP_VERSION}"
|
||||
git tag "v${RELEASE_VERSION}"
|
||||
|
||||
- name: Push release commit and tag
|
||||
run: |
|
||||
git push origin HEAD:${DEFAULT_BRANCH}
|
||||
git push origin "v${RELEASE_VERSION}"
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
node_modules
|
||||
/dist
|
||||
apps/desktop/dist
|
||||
apps/web/dist
|
||||
apps/api/.data/
|
||||
out
|
||||
.conductor/
|
||||
.wxt
|
||||
.output
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
.cache/
|
||||
.pnpm-home/
|
||||
.pnpm-store/
|
||||
.claude-plugin/
|
||||
.superset/
|
||||
config.json
|
||||
labels/
|
||||
statuses/
|
||||
|
||||
.env
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
# Check if there are any staged files
|
||||
if [ -z "$(git diff --cached --name-only)" ]; then
|
||||
echo "No staged files to format"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Store the hash of staged changes to detect modifications
|
||||
STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)
|
||||
|
||||
# Save list of staged files (handling all file states)
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
PARTIALLY_STAGED=$(git diff --name-only)
|
||||
|
||||
# Stash unstaged changes to preserve working directory
|
||||
# --keep-index keeps staged changes in working tree
|
||||
git stash push --quiet --keep-index --message "pre-commit-stash" || true
|
||||
STASHED=$?
|
||||
|
||||
# Run formatter on the staged files
|
||||
pnpm --filter ./apps/desktop run fix
|
||||
FORMAT_EXIT_CODE=$?
|
||||
|
||||
# Restore working directory state
|
||||
if [ $STASHED -eq 0 ]; then
|
||||
# Re-stage the formatted files
|
||||
if [ -n "$STAGED_FILES" ]; then
|
||||
echo "$STAGED_FILES" | while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Restore unstaged changes
|
||||
git stash pop --quiet || true
|
||||
|
||||
# Restore partial staging if files were partially staged
|
||||
if [ -n "$PARTIALLY_STAGED" ]; then
|
||||
for file in $PARTIALLY_STAGED; do
|
||||
if [ -f "$file" ] && echo "$STAGED_FILES" | grep -q "^$file$"; then
|
||||
# File was partially staged - need to unstage the unstaged parts
|
||||
git restore --staged "$file" 2>/dev/null || true
|
||||
git add -p "$file" < /dev/null 2>/dev/null || git add "$file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
# No stash was created, just re-add the formatted files
|
||||
if [ -n "$STAGED_FILES" ]; then
|
||||
echo "$STAGED_FILES" | while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if staged files actually changed
|
||||
NEW_STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)
|
||||
if [ "$STAGED_HASH" != "$NEW_STAGED_HASH" ]; then
|
||||
echo "✨ Files formatted by Ultracite"
|
||||
fi
|
||||
|
||||
exit $FORMAT_EXIT_CODE
|
||||
@@ -0,0 +1 @@
|
||||
22.22.3
|
||||
@@ -0,0 +1,4 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
shamefully-hoist=true
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"i18n-ally.localesPaths": ["apps/desktop/src/renderer/src/locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.formatOnPaste": true,
|
||||
"emmet.showExpandedAbbreviation": "never",
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[graphql]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[mdx]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
1. use pnpm instead of npm
|
||||
2. use pnpm run check after tasks to check code
|
||||
3. Support i18n. When writing business logic, initially only translate the English version of en.json
|
||||
4. use English for comments&console
|
||||
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
||||
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
|
||||
7. If there is an error when running `pnpm run check:i18n`, please complete the missing corresponding translation files and fields. Ensure the translation is done into the corresponding language, rather than directly copying the English version.
|
||||
8. The UI/UX of the web and desktop versions in apps must be consistent, with the only difference being the platform. Try to share the UI and schema as much as possible
|
||||
|
||||
# Ultracite Code Standards
|
||||
|
||||
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- **Format code**: `pnpm dlx ultracite fix`
|
||||
- **Check for issues**: `pnpm dlx ultracite check`
|
||||
- **Diagnose setup**: `pnpm dlx ultracite doctor`
|
||||
|
||||
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
|
||||
|
||||
### Type Safety & Explicitness
|
||||
|
||||
- Use explicit types for function parameters and return values when they enhance clarity
|
||||
- Prefer `unknown` over `any` when the type is genuinely unknown
|
||||
- Use const assertions (`as const`) for immutable values and literal types
|
||||
- Leverage TypeScript's type narrowing instead of type assertions
|
||||
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
|
||||
|
||||
### Modern JavaScript/TypeScript
|
||||
|
||||
- Use arrow functions for callbacks and short functions
|
||||
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
|
||||
- Prefer template literals over string concatenation
|
||||
- Use destructuring for object and array assignments
|
||||
- Use `const` by default, `let` only when reassignment is needed, never `var`
|
||||
|
||||
### Async & Promises
|
||||
|
||||
- Always `await` promises in async functions - don't forget to use the return value
|
||||
- Use `async/await` syntax instead of promise chains for better readability
|
||||
- Handle errors appropriately in async code with try-catch blocks
|
||||
- Don't use async functions as Promise executors
|
||||
|
||||
### React & JSX
|
||||
|
||||
- Use function components over class components
|
||||
- Call hooks at the top level only, never conditionally
|
||||
- Specify all dependencies in hook dependency arrays correctly
|
||||
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
|
||||
- Nest children between opening and closing tags instead of passing as props
|
||||
- Don't define components inside other components
|
||||
- Use semantic HTML and ARIA attributes for accessibility:
|
||||
- Provide meaningful alt text for images
|
||||
- Use proper heading hierarchy
|
||||
- Add labels for form inputs
|
||||
- Include keyboard event handlers alongside mouse events
|
||||
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
|
||||
|
||||
### Error Handling & Debugging
|
||||
|
||||
- Remove `console.log`, `debugger`, and `alert` statements from production code
|
||||
- Throw `Error` objects with descriptive messages, not strings or other values
|
||||
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
|
||||
- Prefer early returns over nested conditionals for error cases
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep functions focused and under reasonable cognitive complexity limits
|
||||
- Extract complex conditions into well-named boolean variables
|
||||
- Use early returns to reduce nesting
|
||||
- Prefer simple conditionals over nested ternary operators
|
||||
- Group related code together and separate concerns
|
||||
|
||||
### Security
|
||||
|
||||
- Add `rel="noopener"` when using `target="_blank"` on links
|
||||
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
|
||||
- Don't use `eval()` or assign directly to `document.cookie`
|
||||
- Validate and sanitize user input
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid spread syntax in accumulators within loops
|
||||
- Use top-level regex literals instead of creating them in loops
|
||||
- Prefer specific imports over namespace imports
|
||||
- Avoid barrel files (index files that re-export everything)
|
||||
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
|
||||
|
||||
### Framework-Specific Guidance
|
||||
|
||||
**Next.js:**
|
||||
- Use Next.js `<Image>` component for images
|
||||
- Use `next/head` or App Router metadata API for head elements
|
||||
- Use Server Components for async data fetching instead of async Client Components
|
||||
|
||||
**React 19+:**
|
||||
- Use ref as a prop instead of `React.forwardRef`
|
||||
|
||||
**Solid/Svelte/Vue/Qwik:**
|
||||
- Use `class` and `for` attributes (not `className` or `htmlFor`)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Write assertions inside `it()` or `test()` blocks
|
||||
- Avoid done callbacks in async tests - use async/await instead
|
||||
- Don't use `.only` or `.skip` in committed code
|
||||
- Keep test suites reasonably flat - avoid excessive `describe` nesting
|
||||
|
||||
## When Biome Can't Help
|
||||
|
||||
Biome's linter will catch most issues automatically. Focus your attention on:
|
||||
|
||||
1. **Business logic correctness** - Biome can't validate your algorithms
|
||||
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
|
||||
3. **Architecture decisions** - Component structure, data flow, and API design
|
||||
4. **Edge cases** - Handle boundary conditions and error states
|
||||
5. **User experience** - Accessibility, performance, and usability considerations
|
||||
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
|
||||
|
||||
---
|
||||
|
||||
Most formatting and common issues are automatically fixed by Biome. Run `pnpm dlx ultracite fix` before committing to ensure compliance.
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# Contributing to VidBee
|
||||
|
||||
Thank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.
|
||||
|
||||
## Getting Ready
|
||||
- Use Node.js 18+ and pnpm 8+.
|
||||
- Install dependencies with `pnpm install`.
|
||||
- Run `pnpm dev` to test changes locally.
|
||||
|
||||
## Tech Stack
|
||||
- Runtime: Electron 38, electron-vite, electron-builder.
|
||||
- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
|
||||
- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
|
||||
|
||||
## Local Development
|
||||
- Use `pnpm install` to pull dependencies after cloning.
|
||||
- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.
|
||||
- Preview the production build locally with `pnpm start`.
|
||||
|
||||
## Useful Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `pnpm run typecheck` | Type-check the main and renderer projects. |
|
||||
| `pnpm build` | Run type checks and produce production bundles. |
|
||||
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
|
||||
| `pnpm build:unpack` | Produce unpacked output directories for inspection. |
|
||||
| `pnpm run check` | Format and lint the codebase with Biome. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
apps/desktop/src/
|
||||
|-- main/ # Electron main process, IPC services, configuration
|
||||
|-- preload/ # Context bridge and preload helpers
|
||||
`-- renderer/
|
||||
|-- src/
|
||||
| |-- pages/ # Application routes (Home, Settings, Playlist, etc.)
|
||||
| |-- components/ # UI components, download views, shared controls
|
||||
| |-- data/ # Static datasets such as popularSites.ts
|
||||
| |-- hooks/ # Custom hooks and global atoms
|
||||
| |-- lib/ # Utilities shared across the renderer
|
||||
| `-- assets/ # Global styles and icons
|
||||
`-- index.html
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.
|
||||
- Only update strings in `apps/desktop/src/renderer/src/locales/en.json`; maintainers handle the other locales.
|
||||
- Keep copy edits focused and avoid removing translation keys without discussion.
|
||||
|
||||
## Configuration and Storage
|
||||
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
|
||||
- User-facing preferences such as download paths and themes live in `apps/desktop/src/main/settings.ts` and related services.
|
||||
- Logs are recorded with `electron-log` to simplify troubleshooting.
|
||||
|
||||
## Packaging
|
||||
- Build production bundles with `pnpm build`.
|
||||
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
|
||||
- Use `pnpm build:unpack` to generate unpacked directories under `apps/desktop/dist/` for manual inspection.
|
||||
- Bundle `yt-dlp` under `apps/desktop/resources/` and `ffmpeg/ffprobe` under `apps/desktop/resources/ffmpeg/` before packaging so merges and audio extraction work out of the box.
|
||||
|
||||
## Releasing & Update Channels
|
||||
|
||||
VidBee ships two auto-update channels. Auto-updates use the GitHub provider
|
||||
(`apps/desktop/electron-builder.yml`); electron-builder derives the channel from the
|
||||
version's prerelease label, so the tag is the single source of truth.
|
||||
|
||||
| Channel | Lane | Command | Tag / version | electron-builder output | GitHub release |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Stable | `latest` | `pnpm release` | `vX.Y.Z` | `latest*.yml` | normal release |
|
||||
| Preview | `preview` | `pnpm release:preview` | `vX.Y.Z-preview.N` | `preview*.yml` | prerelease |
|
||||
|
||||
- Both commands run `pnpm run check` and then `bumpp`, which bumps `apps/desktop/package.json`,
|
||||
commits, tags, and pushes. The tag push triggers `.github/workflows/release.yml`, which builds
|
||||
every platform, publishes the GitHub release (marked **prerelease** for `-preview.` tags), and
|
||||
notifies Cloudflare Pages for stable releases only.
|
||||
- Before building, CI runs `apps/desktop/scripts/stamp-release.mjs` (see `build.yml`): it stamps
|
||||
the tag's version into `package.json` and, for `-preview.` tags, sets the electron-builder
|
||||
publish channel so the build emits `preview*.yml` instead of `latest*.yml`. (The GitHub
|
||||
provider does not derive the channel from the version on its own, so this is required.)
|
||||
- A manual `workflow_dispatch` run of the release workflow is a build-only smoke test: it
|
||||
produces artifacts but does **not** publish a release (no tag).
|
||||
|
||||
Users opt into preview builds with the **Preview channel** switch on the in-app About page
|
||||
(`betaProgram` setting). Turning it on moves the user to the `preview` channel and downloads the
|
||||
next prerelease; turning it off returns them to `latest` but keeps their current build until the
|
||||
next stable release catches up (no forced downgrade).
|
||||
|
||||
## Working on Changes
|
||||
- Keep each pull request focused on a single problem or feature.
|
||||
- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.
|
||||
- Write comments and console messages in English only.
|
||||
- When updating copy in the app, adjust strings in `apps/desktop/src/renderer/src/locales/en.json`; other locale files are handled by maintainers.
|
||||
|
||||
## Opening Issues
|
||||
- Search existing issues to avoid duplicates.
|
||||
- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.
|
||||
|
||||
## Submitting Pull Requests
|
||||
- Explain the motivation and impact of the change in the description.
|
||||
- Mention any user facing updates or migrations.
|
||||
- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.
|
||||
|
||||
We appreciate every contribution that keeps VidBee simple and reliable.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 VidBee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,161 @@
|
||||
<div align="left">
|
||||
<a href="https://github.com/nexmoe/VidBee">
|
||||
<img src="apps/desktop/build/icon.png" alt="Logo" width="80" height="80">
|
||||
</a>
|
||||
|
||||
<h3>VidBee</h3>
|
||||
<p>
|
||||
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
|
||||
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
|
||||
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
|
||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/download-queue.png" alt="VidBee Download Queue" width="46%"/></a>
|
||||
<br />
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs, including RSS auto-download automation that automatically subscribes to feeds and downloads new videos from your favorite creators in the background.
|
||||
|
||||
## 👋🏻 Getting Started
|
||||
|
||||
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
|
||||
|
||||
[📥 Download VidBee](https://vidbee.org/download/) | [📚 Documentation](https://docs.vidbee.org)
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
|
||||
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🌍 Global Video Download Support
|
||||
|
||||
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
|
||||
|
||||

|
||||
|
||||
### 🎨 Best-in-class UI Experience
|
||||
|
||||
Modern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.
|
||||
|
||||

|
||||
|
||||
### 📡 RSS Auto Download
|
||||
|
||||
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
|
||||
|
||||
### 🎞️ Configurable Output Containers
|
||||
|
||||
Pick between **Auto (MP4/MKV)**, **MP4**, **MKV**, **WebM**, or yt-dlp's **Original** defaults for one-click downloads — no more wondering whether you'll get an MP4 or a WebM. See [Formats & Containers](https://docs.vidbee.org/formats) for the full breakdown.
|
||||
|
||||
## 🌐 Supported Sites
|
||||
|
||||
VidBee supports 1000+ video and audio platforms through yt-dlp. For the complete list of supported sites, visit [https://vidbee.org/supported-sites/](https://vidbee.org/supported-sites/)
|
||||
|
||||
## 🧱 Web + API (Docker-ready)
|
||||
|
||||
This monorepo now includes:
|
||||
|
||||
- `packages/downloader-core`: Shared yt-dlp/ffmpeg download core
|
||||
- `apps/api`: Fastify API server with oRPC and SSE events
|
||||
- `apps/web`: TanStack Start web client using oRPC
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
pnpm run start:web
|
||||
```
|
||||
|
||||
This command starts `apps/api` and `apps/web` together.
|
||||
|
||||
Run with Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Run with GitHub Container Registry images:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
image: ghcr.io/nexmoe/vidbee-api:latest
|
||||
environment:
|
||||
VIDBEE_API_HOST: 0.0.0.0
|
||||
VIDBEE_API_PORT: 3100
|
||||
VIDBEE_DOWNLOAD_DIR: /data/downloads
|
||||
VIDBEE_HISTORY_STORE_PATH: /data/vidbee/vidbee.db
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- vidbee-downloads:/data/downloads
|
||||
- vidbee-data:/data/vidbee
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
image: ghcr.io/nexmoe/vidbee-web:latest
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
vidbee-downloads:
|
||||
vidbee-data:
|
||||
```
|
||||
|
||||
Stop services:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Optional env vars (via `.env`):
|
||||
|
||||
```bash
|
||||
VIDBEE_API_PORT=3100
|
||||
VIDBEE_WEB_PORT=3000
|
||||
VITE_API_URL=http://localhost:3100
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
You are welcome to join the open source community to build together. For more details, check out:
|
||||
|
||||
- Monorepo apps:
|
||||
- `apps/desktop`: VidBee desktop app (Electron)
|
||||
- `apps/docs`: Documentation site (Fumapress)
|
||||
- `apps/extension`: Browser extension (WXT)
|
||||
- `apps/desktop/docs/glitchtip.md`: GlitchTip and `sentry-cli` setup for desktop monitoring
|
||||
- [Contributing Guide](./CONTRIBUTING.md)
|
||||
- [DeepWiki Documentation](https://deepwiki.com/nexmoe/VidBee)
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
|
||||
|
||||
## 🙏 Thanks
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
|
||||
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
|
||||
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
|
||||
- [React](https://react.dev/) - The UI library
|
||||
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`nexmoe/VidBee`
|
||||
- 原始仓库:https://github.com/nexmoe/VidBee
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,46 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=${PNPM_HOME}:${PATH}
|
||||
ENV YTDLP_PATH=/usr/bin/yt-dlp
|
||||
ENV FFMPEG_PATH=/usr/bin/ffmpeg
|
||||
|
||||
RUN apk add --no-cache yt-dlp ffmpeg python3 make g++
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/downloader-core/package.json packages/downloader-core/package.json
|
||||
COPY packages/task-queue/package.json packages/task-queue/package.json
|
||||
COPY packages/subscriptions-core/package.json packages/subscriptions-core/package.json
|
||||
|
||||
RUN pnpm install --filter "{./apps/api}..." --frozen-lockfile
|
||||
|
||||
COPY apps/api apps/api
|
||||
COPY apps/desktop/resources/drizzle apps/desktop/resources/drizzle
|
||||
COPY packages/db packages/db
|
||||
COPY packages/downloader-core packages/downloader-core
|
||||
COPY packages/task-queue packages/task-queue
|
||||
COPY packages/subscriptions-core packages/subscriptions-core
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
ENV VIDBEE_API_HOST=0.0.0.0
|
||||
ENV VIDBEE_API_PORT=3100
|
||||
ENV VIDBEE_DOWNLOAD_DIR=/data/downloads
|
||||
# Persistence is enabled by default so the `before-start` history migration
|
||||
# (legacy download_history → tasks) is actually visible at runtime, and so
|
||||
# crash-recovery works across container restarts. Mount /data/downloads as
|
||||
# a persistent volume in production (the Dockerfile already declares it).
|
||||
# To revert to the old stateless behavior set VIDBEE_PERSIST_QUEUE=0.
|
||||
ENV VIDBEE_PERSIST_QUEUE=1
|
||||
|
||||
VOLUME ["/data/downloads"]
|
||||
|
||||
# `before-start` runs migrate-history.ts (legacy download_history → tasks)
|
||||
# and then boots the API. The script is idempotent so re-runs are safe.
|
||||
CMD ["pnpm", "--filter", "./apps/api", "run", "before-start"]
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/ensure-node-sqlite.mjs && tsx watch src/index.ts",
|
||||
"start": "node scripts/ensure-node-sqlite.mjs && tsx src/index.ts",
|
||||
"migrate-history": "tsx scripts/migrate-history.ts",
|
||||
"before-start": "node scripts/ensure-node-sqlite.mjs && tsx scripts/migrate-history.ts && tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "ultracite check && pnpm run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@orpc/openapi": "^1.13.5",
|
||||
"@orpc/server": "^1.13.5",
|
||||
"@orpc/zod": "^1.13.5",
|
||||
"@vidbee/db": "workspace:*",
|
||||
"@vidbee/downloader-core": "workspace:*",
|
||||
"@vidbee/subscriptions-core": "workspace:*",
|
||||
"@vidbee/task-queue": "workspace:*",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"fastify": "^5.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.18.6",
|
||||
"drizzle-kit": "0.31.7",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.2",
|
||||
"ultracite": "7.1.5"
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
// GitHub issue #368: the desktop app rebuilds the shared, pnpm-hoisted
|
||||
// better-sqlite3 for Electron's ABI (NODE_MODULE_VERSION 139). The API runs on
|
||||
// plain Node (ABI 137) and then crashes loading the same binary. This mirrors
|
||||
// apps/desktop/scripts/ensure-native-deps.mjs for the Node side: detect the ABI
|
||||
// mismatch and rebuild better-sqlite3 for the current Node before the API boots.
|
||||
const checkScript =
|
||||
"const Database=require('better-sqlite3');const db=new Database(':memory:');db.close()"
|
||||
|
||||
function canLoadBetterSqlite3WithNode() {
|
||||
try {
|
||||
// Run in a child node so a failed native load cannot crash this process.
|
||||
execSync(`node -e "${checkScript}"`, { stdio: 'pipe' })
|
||||
return true
|
||||
} catch (error) {
|
||||
const details = error.stderr?.toString().trim() || error.message
|
||||
console.warn(`[native-deps] better-sqlite3 (Node) check failed: ${details}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (canLoadBetterSqlite3WithNode()) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.log('[native-deps] Rebuilding better-sqlite3 for Node...')
|
||||
execSync('pnpm rebuild better-sqlite3', { stdio: 'inherit' })
|
||||
|
||||
if (!canLoadBetterSqlite3WithNode()) {
|
||||
throw new Error('[native-deps] better-sqlite3 is still unavailable for Node after rebuild')
|
||||
}
|
||||
|
||||
console.log('[native-deps] better-sqlite3 is ready for Node')
|
||||
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* One-shot migration: copy every row from the legacy `download_history`
|
||||
* table into the new task-queue `tasks` table as a terminal record with
|
||||
* `status_reason = 'legacy-import'`.
|
||||
*
|
||||
* Idempotent: re-running it is a no-op for rows that have already been
|
||||
* imported (matched by id).
|
||||
*
|
||||
* Containerized hosts wire this into a `before_start` hook so a fresh
|
||||
* container picks up legacy history transparently. On migration failure
|
||||
* the legacy DB is left untouched and the new task-queue DB rolls back
|
||||
* to the pre-migration state.
|
||||
*
|
||||
* Usage:
|
||||
* tsx apps/api/scripts/migrate-history.ts
|
||||
*
|
||||
* Env vars (all optional):
|
||||
* VIDBEE_HISTORY_STORE_PATH – legacy sqlite db path (default uses
|
||||
* $VIDBEE_DOWNLOAD_DIR/.vidbee/vidbee.db)
|
||||
* VIDBEE_TASK_QUEUE_DB – task-queue sqlite db path (default uses
|
||||
* $VIDBEE_DOWNLOAD_DIR/.vidbee/task-queue.db)
|
||||
* VIDBEE_DOWNLOAD_DIR – default download directory
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { TASK_QUEUE_DDL_V1 } from '@vidbee/db/task-queue'
|
||||
import {
|
||||
PRIORITY_BACKGROUND,
|
||||
type Task,
|
||||
type TaskOutput,
|
||||
type TaskStatus
|
||||
} from '@vidbee/task-queue'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
|
||||
|
||||
const trim = (v?: string | null): string | undefined => {
|
||||
const t = v?.trim()
|
||||
return t && t.length > 0 ? t : undefined
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_DIR = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
const downloadDir =
|
||||
trim(process.env.VIDBEE_DOWNLOAD_DIR) ??
|
||||
trim(process.env.DOWNLOAD_DIR) ??
|
||||
DEFAULT_DOWNLOAD_DIR
|
||||
|
||||
const legacyDbPath =
|
||||
trim(process.env.VIDBEE_HISTORY_STORE_PATH) ?? path.join(downloadDir, '.vidbee', 'vidbee.db')
|
||||
|
||||
const taskQueueDbPath =
|
||||
trim(process.env.VIDBEE_TASK_QUEUE_DB) ?? path.join(downloadDir, '.vidbee', 'task-queue.db')
|
||||
|
||||
interface LegacyHistoryRow {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
thumbnail: string | null
|
||||
type: string // 'video' | 'audio'
|
||||
status: string // 'completed' | 'error' | 'cancelled'
|
||||
download_path: string | null
|
||||
saved_file_name: string | null
|
||||
file_size: number | null
|
||||
duration: number | null
|
||||
downloaded_at: number
|
||||
completed_at: number | null
|
||||
sort_key: number
|
||||
error: string | null
|
||||
yt_dlp_command: string | null
|
||||
yt_dlp_log: string | null
|
||||
description: string | null
|
||||
channel: string | null
|
||||
uploader: string | null
|
||||
view_count: number | null
|
||||
tags: string | null
|
||||
origin: string | null
|
||||
subscription_id: string | null
|
||||
selected_format: string | null
|
||||
playlist_id: string | null
|
||||
playlist_title: string | null
|
||||
playlist_index: number | null
|
||||
playlist_size: number | null
|
||||
}
|
||||
|
||||
const TERMINAL_LEGACY: Record<string, TaskStatus | undefined> = {
|
||||
completed: 'completed',
|
||||
error: 'failed',
|
||||
cancelled: 'cancelled'
|
||||
}
|
||||
|
||||
function rowToTask(row: LegacyHistoryRow): Task | null {
|
||||
const status = TERMINAL_LEGACY[row.status]
|
||||
if (!status) return null
|
||||
|
||||
const filePath =
|
||||
row.download_path && row.saved_file_name
|
||||
? path.join(row.download_path, row.saved_file_name)
|
||||
: (row.download_path ?? '')
|
||||
|
||||
const output: TaskOutput | null =
|
||||
status === 'completed'
|
||||
? {
|
||||
filePath,
|
||||
size: row.file_size ?? 0,
|
||||
durationMs: row.duration != null ? row.duration * 1000 : null,
|
||||
sha256: null
|
||||
}
|
||||
: null
|
||||
|
||||
const completedAt = row.completed_at ?? row.downloaded_at
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.type === 'audio' ? 'audio' : 'video',
|
||||
parentId: null,
|
||||
input: {
|
||||
url: row.url,
|
||||
kind: row.type === 'audio' ? 'audio' : 'video',
|
||||
title: row.title,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
subscriptionId: row.subscription_id ?? undefined,
|
||||
playlistId: row.playlist_id ?? undefined,
|
||||
playlistIndex: row.playlist_index ?? undefined,
|
||||
options: {
|
||||
type: row.type === 'audio' ? 'audio' : 'video',
|
||||
title: row.title,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
channel: row.channel ?? undefined,
|
||||
uploader: row.uploader ?? undefined,
|
||||
viewCount: row.view_count ?? undefined,
|
||||
tags: parseTags(row.tags),
|
||||
duration: row.duration ?? undefined,
|
||||
playlistTitle: row.playlist_title ?? undefined,
|
||||
playlistSize: row.playlist_size ?? undefined,
|
||||
downloadPath: row.download_path ?? undefined,
|
||||
fileSize: row.file_size ?? undefined,
|
||||
startedAt: row.downloaded_at,
|
||||
completedAt
|
||||
}
|
||||
},
|
||||
priority: PRIORITY_BACKGROUND,
|
||||
groupKey: row.subscription_id ? `sub:${row.subscription_id}` : safeHost(row.url),
|
||||
status,
|
||||
prevStatus: null,
|
||||
statusReason: 'legacy-import',
|
||||
enteredStatusAt: completedAt,
|
||||
attempt: 0,
|
||||
maxAttempts: 0,
|
||||
nextRetryAt: null,
|
||||
progress: {
|
||||
percent: status === 'completed' ? 1 : null,
|
||||
bytesDownloaded: row.file_size ?? null,
|
||||
bytesTotal: row.file_size ?? null,
|
||||
speedBps: null,
|
||||
etaMs: null,
|
||||
ticks: 0
|
||||
},
|
||||
output,
|
||||
lastError:
|
||||
status === 'failed'
|
||||
? {
|
||||
category: 'unknown',
|
||||
exitCode: null,
|
||||
rawMessage: row.error ?? 'legacy-import',
|
||||
uiMessageKey: 'errors.legacy_import',
|
||||
uiActionHints: [],
|
||||
retryable: false,
|
||||
suggestedRetryAfterMs: null
|
||||
}
|
||||
: null,
|
||||
pid: null,
|
||||
pidStartedAt: null,
|
||||
createdAt: row.downloaded_at,
|
||||
updatedAt: completedAt
|
||||
}
|
||||
}
|
||||
|
||||
function parseTags(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
const tags = value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
return tags.length > 0 ? tags : undefined
|
||||
}
|
||||
|
||||
function safeHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).host || 'unknown'
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function bindTask(task: Task): unknown[] {
|
||||
return [
|
||||
task.id,
|
||||
task.kind,
|
||||
task.parentId,
|
||||
task.status,
|
||||
task.prevStatus,
|
||||
task.statusReason,
|
||||
task.enteredStatusAt,
|
||||
task.priority,
|
||||
task.groupKey,
|
||||
task.attempt,
|
||||
task.maxAttempts,
|
||||
task.nextRetryAt,
|
||||
task.pid,
|
||||
task.pidStartedAt,
|
||||
task.createdAt,
|
||||
task.updatedAt,
|
||||
JSON.stringify(task.input),
|
||||
JSON.stringify(task.progress),
|
||||
task.output ? JSON.stringify(task.output) : null,
|
||||
task.lastError ? JSON.stringify(task.lastError) : null
|
||||
]
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!fs.existsSync(legacyDbPath)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[migrate-history] no legacy DB at ${legacyDbPath}; nothing to do.`)
|
||||
return
|
||||
}
|
||||
// Warn loudly if persistence is disabled — without it, the runtime API uses
|
||||
// an in-memory persist adapter and never reads the SQLite file we are about
|
||||
// to populate. The migration would silently appear to "succeed" but users
|
||||
// would see zero rows in /rpc/history/list. See bug #5 in NEX-124 review.
|
||||
const persistFlag = (process.env.VIDBEE_PERSIST_QUEUE ?? '').trim()
|
||||
const persistEnabled = persistFlag === '1' || persistFlag.toLowerCase() === 'true'
|
||||
if (!persistEnabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[migrate-history] WARNING: VIDBEE_PERSIST_QUEUE is not enabled. The ' +
|
||||
'migrated rows will not be visible at runtime because the API will use ' +
|
||||
'an in-memory queue. Set VIDBEE_PERSIST_QUEUE=1 (default in the bundled ' +
|
||||
'Dockerfile) to read this database back.'
|
||||
)
|
||||
}
|
||||
fs.mkdirSync(path.dirname(taskQueueDbPath), { recursive: true })
|
||||
|
||||
const legacyDb = new Database(legacyDbPath, { readonly: true, fileMustExist: true })
|
||||
// Do NOT set journal_mode on a readonly handle — it's a write op and fails
|
||||
// with SQLITE_READONLY. The legacy DB is opened read-only by design so we
|
||||
// never mutate user data during migration.
|
||||
|
||||
const queueDb = new Database(taskQueueDbPath, { timeout: 5000 })
|
||||
queueDb.pragma('journal_mode = WAL')
|
||||
queueDb.pragma('foreign_keys = ON')
|
||||
queueDb.exec(TASK_QUEUE_DDL_V1)
|
||||
|
||||
// Mark in-flight migration so callers can detect partial state.
|
||||
queueDb
|
||||
.prepare(
|
||||
`INSERT INTO schema_meta (key, value) VALUES ('migration_in_progress','api-v1')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||
)
|
||||
.run()
|
||||
|
||||
const tableCheck = legacyDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='download_history'")
|
||||
.get() as { name?: string } | undefined
|
||||
|
||||
if (!tableCheck?.name) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[migrate-history] legacy download_history table not present; nothing to do.')
|
||||
queueDb
|
||||
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
|
||||
.run()
|
||||
legacyDb.close()
|
||||
queueDb.close()
|
||||
return
|
||||
}
|
||||
|
||||
const rows = legacyDb
|
||||
.prepare('SELECT * FROM download_history ORDER BY sort_key ASC')
|
||||
.all() as LegacyHistoryRow[]
|
||||
|
||||
const insertOrSkip = queueDb.prepare(`
|
||||
INSERT INTO tasks (
|
||||
id, kind, parent_id, status, prev_status, status_reason, entered_status_at,
|
||||
priority, group_key, attempt, max_attempts, next_retry_at, pid,
|
||||
pid_started_at, created_at, updated_at, input_json, progress_json,
|
||||
output_json, last_error_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
`)
|
||||
|
||||
const tx = queueDb.transaction((tasks: Task[]) => {
|
||||
let imported = 0
|
||||
for (const task of tasks) {
|
||||
const result = insertOrSkip.run(...bindTask(task))
|
||||
if (result.changes > 0) imported += 1
|
||||
}
|
||||
return imported
|
||||
})
|
||||
|
||||
let mappedTasks = 0
|
||||
let skippedRows = 0
|
||||
const tasks: Task[] = []
|
||||
for (const row of rows) {
|
||||
const task = rowToTask(row)
|
||||
if (task) {
|
||||
tasks.push(task)
|
||||
mappedTasks += 1
|
||||
} else {
|
||||
skippedRows += 1
|
||||
}
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
try {
|
||||
imported = tx(tasks) as number
|
||||
} catch (err) {
|
||||
// Roll back the migration marker; leave already-imported rows alone (the
|
||||
// unique-id INSERT is idempotent and re-running picks up where we stopped).
|
||||
queueDb
|
||||
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
|
||||
.run()
|
||||
legacyDb.close()
|
||||
queueDb.close()
|
||||
throw err
|
||||
}
|
||||
|
||||
queueDb
|
||||
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
|
||||
.run()
|
||||
|
||||
// Best-effort rename to mark the legacy DB as migrated. Container hosts
|
||||
// can pre-bind the mount as read-only; ignore EROFS.
|
||||
const migratedPath = `${legacyDbPath}.migrated`
|
||||
try {
|
||||
if (!fs.existsSync(migratedPath)) {
|
||||
fs.copyFileSync(legacyDbPath, migratedPath)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[migrate-history] could not stamp legacy DB as migrated:', err)
|
||||
}
|
||||
|
||||
legacyDb.close()
|
||||
queueDb.close()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[migrate-history] legacy rows: ${rows.length}, mapped: ${mappedTasks}, skipped: ${skippedRows}, imported: ${imported} (already-present skipped)`
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[migrate-history] failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createApiServer } from './server'
|
||||
|
||||
const host = process.env.VIDBEE_API_HOST?.trim() || '0.0.0.0'
|
||||
const portValue = Number(process.env.VIDBEE_API_PORT ?? '')
|
||||
const port = Number.isInteger(portValue) && portValue > 0 ? portValue : 3100
|
||||
|
||||
const server = await createApiServer()
|
||||
|
||||
try {
|
||||
await server.listen({ host, port })
|
||||
server.log.info(`VidBee API server listening on http://${host}:${port}`)
|
||||
} catch (error) {
|
||||
server.log.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
server.log.info(`Received ${signal}, shutting down API server`)
|
||||
await server.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT')
|
||||
})
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM')
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
|
||||
const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../../../desktop/resources/drizzle')
|
||||
|
||||
export const runDatabaseMigrations = (database: BetterSQLite3Database): void => {
|
||||
if (!existsSync(MIGRATIONS_FOLDER)) {
|
||||
throw new Error(`API migrations folder not found: ${MIGRATIONS_FOLDER}`)
|
||||
}
|
||||
|
||||
migrate(database, { migrationsFolder: MIGRATIONS_FOLDER })
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Public surface for the rpc-router and server bootstrap. Re-exports the
|
||||
* TaskQueueAPI singleton (and lifecycle hooks) that replaces the previous
|
||||
* `DownloaderCore` instance after NEX-131.
|
||||
*
|
||||
* The legacy `DownloaderCore` class is no longer instantiated by the API;
|
||||
* routes that need yt-dlp metadata (videoInfo / playlist.info) use the
|
||||
* stateless `fetchVideoInfo` / `fetchPlaylistInfo` helpers in
|
||||
* `./yt-dlp-info.ts`.
|
||||
*/
|
||||
export {
|
||||
apiDefaultDownloadDir as downloadDir,
|
||||
apiMaxConcurrent as maxConcurrent,
|
||||
isTaskQueuePersistent,
|
||||
startTaskQueue,
|
||||
stopTaskQueue,
|
||||
taskQueue,
|
||||
taskQueueExecutor
|
||||
} from './task-queue-host'
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Narrow @vidbee/task-queue's host-neutral LegacyTaskProjection into the
|
||||
* concrete `DownloadTask` shape that the public downloaderContract returns.
|
||||
* Any field absent from DownloadTask is dropped here, not in the shared
|
||||
* projection — Desktop carries some of the same fields but renders them
|
||||
* differently.
|
||||
*/
|
||||
import { projectTaskToLegacy } from '@vidbee/task-queue'
|
||||
import type { Task } from '@vidbee/task-queue'
|
||||
import type { DownloadTask } from '@vidbee/downloader-core'
|
||||
|
||||
export function projectTaskForApi(task: Readonly<Task>): DownloadTask {
|
||||
const proj = projectTaskToLegacy(task)
|
||||
const out: DownloadTask = {
|
||||
id: proj.id,
|
||||
url: proj.url,
|
||||
title: proj.title,
|
||||
thumbnail: proj.thumbnail,
|
||||
type: proj.type,
|
||||
status: proj.status,
|
||||
createdAt: proj.createdAt,
|
||||
startedAt: proj.startedAt,
|
||||
completedAt: proj.completedAt,
|
||||
duration: proj.duration,
|
||||
fileSize: proj.fileSize,
|
||||
speed: proj.speed,
|
||||
downloadPath: proj.downloadPath,
|
||||
savedFileName: proj.savedFileName,
|
||||
description: proj.description,
|
||||
channel: proj.channel,
|
||||
uploader: proj.uploader,
|
||||
viewCount: proj.viewCount,
|
||||
tags: proj.tags,
|
||||
playlistId: proj.playlistId,
|
||||
playlistTitle: proj.playlistTitle,
|
||||
playlistIndex: proj.playlistIndex,
|
||||
playlistSize: proj.playlistSize,
|
||||
error: proj.error,
|
||||
internalStatus: proj.internalStatus,
|
||||
subStatus: proj.subStatus,
|
||||
statusReason: proj.statusReason,
|
||||
errorCategory: proj.errorCategory,
|
||||
uiMessageKey: proj.uiMessageKey,
|
||||
nextRetryAt: proj.nextRetryAt,
|
||||
attempt: proj.attempt,
|
||||
maxAttempts: proj.maxAttempts
|
||||
}
|
||||
if (proj.progress) {
|
||||
out.progress = {
|
||||
percent: proj.progress.percent,
|
||||
currentSpeed: proj.progress.currentSpeed,
|
||||
eta: proj.progress.eta,
|
||||
downloaded: proj.progress.downloaded,
|
||||
total: proj.progress.total
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { constants as fsConstants } from 'node:fs'
|
||||
import { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { implement, ORPCError } from '@orpc/server'
|
||||
import { downloaderContract } from '@vidbee/downloader-core'
|
||||
import type { DownloadTask } from '@vidbee/downloader-core'
|
||||
import type { Task, TaskStatus } from '@vidbee/task-queue'
|
||||
|
||||
import { projectTaskForApi } from './projection'
|
||||
import { taskQueue, taskQueueExecutor } from './downloader'
|
||||
import { webSettingsStore } from './web-settings-store'
|
||||
import { fetchPlaylistInfo, fetchVideoInfo } from './yt-dlp-info'
|
||||
|
||||
const os = implement(downloaderContract)
|
||||
const WEB_SETTINGS_FILES_DIR = path.resolve(process.cwd(), '.data', 'web-settings-files')
|
||||
const MAX_WEB_SETTINGS_FILE_BYTES = 1_000_000
|
||||
const MANAGED_SETTINGS_FILE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const SAFE_FILE_NAME_REGEX = /[^A-Za-z0-9._-]+/g
|
||||
type ManagedSettingsFileKind = 'cookies' | 'config'
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set<TaskStatus>(['completed', 'failed', 'cancelled'])
|
||||
const NON_TERMINAL_TASK_STATUSES = new Set<TaskStatus>([
|
||||
'queued',
|
||||
'running',
|
||||
'processing',
|
||||
'paused',
|
||||
'retry-scheduled'
|
||||
])
|
||||
|
||||
const toErrorMessage = (error: unknown, fallbackMessage: string): string => {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message
|
||||
}
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
const runProcess = (command: string, args: string[]): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => resolve(code === 0))
|
||||
})
|
||||
|
||||
const pathExists = async (targetPath: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(targetPath, fsConstants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isPathWithinBase = (basePath: string, targetPath: string): boolean => {
|
||||
const normalizedBase = path.resolve(basePath)
|
||||
const normalizedTarget = path.resolve(targetPath)
|
||||
const relativePath = path.relative(normalizedBase, normalizedTarget)
|
||||
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
||||
}
|
||||
|
||||
const openFileWithSystem = async (targetPath: string): Promise<boolean> => {
|
||||
if (process.platform === 'darwin') return runProcess('open', [targetPath])
|
||||
if (process.platform === 'win32') return runProcess('cmd', ['/c', 'start', '', targetPath])
|
||||
return runProcess('xdg-open', [targetPath])
|
||||
}
|
||||
|
||||
const openFileLocationWithSystem = async (targetPath: string): Promise<boolean> => {
|
||||
if (process.platform === 'darwin') return runProcess('open', ['-R', targetPath])
|
||||
if (process.platform === 'win32') return runProcess('explorer', [`/select,${targetPath}`])
|
||||
return runProcess('xdg-open', [path.dirname(targetPath)])
|
||||
}
|
||||
|
||||
const copyFileToClipboardWithSystem = async (targetPath: string): Promise<boolean> => {
|
||||
if (process.platform === 'darwin') {
|
||||
const escapedPath = targetPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
return runProcess('osascript', ['-e', `set the clipboard to (POSIX file "${escapedPath}")`])
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
const escapedPath = targetPath.replace(/'/g, "''")
|
||||
return runProcess('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Set-Clipboard -Path '${escapedPath}'`
|
||||
])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const listServerDirectories = async (
|
||||
rawPath: string | undefined
|
||||
): Promise<{
|
||||
currentPath: string
|
||||
parentPath: string | null
|
||||
directories: { name: string; path: string }[]
|
||||
}> => {
|
||||
const requestedPath = rawPath?.trim()
|
||||
const candidatePath = requestedPath && requestedPath.length > 0 ? requestedPath : process.cwd()
|
||||
const currentPath = path.resolve(candidatePath)
|
||||
|
||||
const pathInfo = await stat(currentPath)
|
||||
if (!pathInfo.isDirectory()) {
|
||||
throw new Error('Path is not a directory.')
|
||||
}
|
||||
|
||||
const entries = await readdir(currentPath, { withFileTypes: true })
|
||||
const directories = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: path.join(currentPath, entry.name)
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const parsed = path.parse(currentPath)
|
||||
const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath)
|
||||
|
||||
return { currentPath, parentPath, directories }
|
||||
}
|
||||
|
||||
const sanitizeUploadedFileName = (fileName: string, fallbackFileName: string): string => {
|
||||
const normalized = path
|
||||
.basename(fileName.trim())
|
||||
.replace(SAFE_FILE_NAME_REGEX, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
if (!normalized) return fallbackFileName
|
||||
return normalized.slice(0, 120)
|
||||
}
|
||||
|
||||
const storeWebSettingsFile = async (
|
||||
kind: 'cookies' | 'config',
|
||||
fileName: string,
|
||||
content: string
|
||||
): Promise<string> => {
|
||||
const contentBuffer = Buffer.from(content, 'utf-8')
|
||||
if (contentBuffer.byteLength > MAX_WEB_SETTINGS_FILE_BYTES) {
|
||||
throw new Error('Uploaded file is too large.')
|
||||
}
|
||||
|
||||
const destinationDir = path.join(WEB_SETTINGS_FILES_DIR, kind)
|
||||
await mkdir(destinationDir, { recursive: true })
|
||||
|
||||
const fallbackFileName = kind === 'cookies' ? 'cookies.txt' : 'config.txt'
|
||||
const safeFileName = sanitizeUploadedFileName(fileName, fallbackFileName)
|
||||
const storedFileName = `${Date.now()}-${randomUUID()}-${safeFileName}`
|
||||
const destinationPath = path.join(destinationDir, storedFileName)
|
||||
|
||||
await writeFile(destinationPath, contentBuffer)
|
||||
return destinationPath
|
||||
}
|
||||
|
||||
const resolveManagedSettingsFilePath = (
|
||||
rawPath: string,
|
||||
kind: ManagedSettingsFileKind
|
||||
): string | null => {
|
||||
const trimmedPath = rawPath.trim()
|
||||
if (!trimmedPath) return null
|
||||
const resolvedPath = path.resolve(trimmedPath)
|
||||
const managedDirectory = path.join(WEB_SETTINGS_FILES_DIR, kind)
|
||||
if (!isPathWithinBase(managedDirectory, resolvedPath)) return null
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
const pruneManagedSettingsFiles = async (
|
||||
kind: ManagedSettingsFileKind,
|
||||
referencedPaths: string[]
|
||||
): Promise<void> => {
|
||||
const managedDirectory = path.join(WEB_SETTINGS_FILES_DIR, kind)
|
||||
const keepPaths = new Set<string>()
|
||||
for (const rawPath of referencedPaths) {
|
||||
const managedPath = resolveManagedSettingsFilePath(rawPath, kind)
|
||||
if (managedPath) keepPaths.add(managedPath)
|
||||
}
|
||||
let entries: { isFile: () => boolean; name: string }[] = []
|
||||
try {
|
||||
entries = await readdir(managedDirectory, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
const candidatePath = path.resolve(path.join(managedDirectory, entry.name))
|
||||
if (keepPaths.has(candidatePath)) continue
|
||||
try {
|
||||
const candidateInfo = await stat(candidatePath)
|
||||
if (now - candidateInfo.mtimeMs < MANAGED_SETTINGS_FILE_RETENTION_MS) continue
|
||||
await rm(candidatePath, { force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors to keep upload and settings updates resilient.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerManagedSettingsFilePrune = (
|
||||
kind: ManagedSettingsFileKind,
|
||||
newlyUploadedPath: string
|
||||
): void => {
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await webSettingsStore.get()
|
||||
const currentSettingsPath = kind === 'cookies' ? settings.cookiesPath : settings.configPath
|
||||
await pruneManagedSettingsFiles(kind, [newlyUploadedPath, currentSettingsPath])
|
||||
} catch {
|
||||
// Ignore cleanup errors to keep upload and settings updates resilient.
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const PLAYLIST_GROUP_PREFIX = 'playlist_group_'
|
||||
|
||||
const projectTask = (task: Readonly<Task>): DownloadTask => projectTaskForApi(task)
|
||||
|
||||
const listTasksByStatuses = (statuses: ReadonlySet<TaskStatus>): DownloadTask[] => {
|
||||
const tasks: Task[] = []
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
const page = taskQueue.list({ limit: 200, cursor })
|
||||
for (const t of page.tasks) {
|
||||
if (statuses.has(t.status)) tasks.push(t)
|
||||
}
|
||||
cursor = page.nextCursor
|
||||
} while (cursor)
|
||||
return tasks
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.map(projectTask)
|
||||
}
|
||||
|
||||
export const rpcRouter = os.router({
|
||||
status: os.status.handler(() => {
|
||||
const stats = taskQueue.stats()
|
||||
return {
|
||||
ok: true,
|
||||
version: '1.0.0',
|
||||
active: stats.running,
|
||||
pending: stats.queued
|
||||
}
|
||||
}),
|
||||
|
||||
videoInfo: os.videoInfo.handler(async ({ input }) => {
|
||||
try {
|
||||
const video = await fetchVideoInfo(input.url, input.settings)
|
||||
return { video }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to fetch video info.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
playlist: {
|
||||
info: os.playlist.info.handler(async ({ input }) => {
|
||||
try {
|
||||
const playlist = await fetchPlaylistInfo(input.url, input.settings)
|
||||
return { playlist }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to fetch playlist info.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
download: os.playlist.download.handler(async ({ input }) => {
|
||||
try {
|
||||
const playlist = await fetchPlaylistInfo(input.url, input.settings)
|
||||
const groupId = `${PLAYLIST_GROUP_PREFIX}${Date.now()}_${randomUUID().slice(0, 8)}`
|
||||
|
||||
if (playlist.entryCount === 0) {
|
||||
return {
|
||||
result: {
|
||||
groupId,
|
||||
playlistId: playlist.id,
|
||||
playlistTitle: playlist.title,
|
||||
type: input.type,
|
||||
totalCount: 0,
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
entries: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let selected = playlist.entries
|
||||
if (input.entryIds && input.entryIds.length > 0) {
|
||||
const ids = new Set(input.entryIds)
|
||||
selected = playlist.entries.filter((e) => ids.has(e.id))
|
||||
} else {
|
||||
const requestedStart = Math.max((input.startIndex ?? 1) - 1, 0)
|
||||
const requestedEnd = input.endIndex
|
||||
? Math.min(input.endIndex - 1, playlist.entryCount - 1)
|
||||
: playlist.entryCount - 1
|
||||
const rangeStart = Math.min(requestedStart, requestedEnd)
|
||||
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
||||
selected = playlist.entries.slice(rangeStart, rangeEnd + 1)
|
||||
}
|
||||
|
||||
const created: Array<{
|
||||
downloadId: string
|
||||
entryId: string
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
}> = []
|
||||
|
||||
for (const entry of selected) {
|
||||
const result = await taskQueue.add({
|
||||
input: {
|
||||
url: entry.url,
|
||||
kind: input.type === 'audio' ? 'audio' : 'video',
|
||||
title: entry.title,
|
||||
thumbnail: entry.thumbnail,
|
||||
playlistId: groupId,
|
||||
playlistIndex: entry.index,
|
||||
options: {
|
||||
type: input.type,
|
||||
format: input.format,
|
||||
audioFormat: input.audioFormat,
|
||||
audioFormatIds: input.audioFormatIds,
|
||||
customDownloadPath: input.customDownloadPath,
|
||||
customFilenameTemplate: input.customFilenameTemplate,
|
||||
containerFormat: input.containerFormat,
|
||||
settings: input.settings,
|
||||
title: entry.title,
|
||||
thumbnail: entry.thumbnail,
|
||||
playlistTitle: playlist.title,
|
||||
playlistSize: selected.length
|
||||
}
|
||||
},
|
||||
groupKey: `playlist:${groupId}`
|
||||
})
|
||||
created.push({
|
||||
downloadId: result.id,
|
||||
entryId: entry.id,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
index: entry.index
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
groupId,
|
||||
playlistId: playlist.id,
|
||||
playlistTitle: playlist.title,
|
||||
type: input.type,
|
||||
totalCount: selected.length,
|
||||
startIndex: selected[0]?.index ?? 0,
|
||||
endIndex: selected.at(-1)?.index ?? 0,
|
||||
entries: created
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to start playlist download.')
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
downloads: {
|
||||
create: os.downloads.create.handler(async ({ input }) => {
|
||||
try {
|
||||
const result = await taskQueue.add({
|
||||
input: {
|
||||
url: input.url,
|
||||
kind: input.type === 'audio' ? 'audio' : 'video',
|
||||
title: input.title,
|
||||
thumbnail: input.thumbnail,
|
||||
playlistId: input.playlistId,
|
||||
playlistIndex: input.playlistIndex,
|
||||
options: {
|
||||
type: input.type,
|
||||
format: input.format,
|
||||
audioFormat: input.audioFormat,
|
||||
audioFormatIds: input.audioFormatIds,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
customDownloadPath: input.customDownloadPath,
|
||||
customFilenameTemplate: input.customFilenameTemplate,
|
||||
containerFormat: input.containerFormat,
|
||||
settings: input.settings,
|
||||
title: input.title,
|
||||
thumbnail: input.thumbnail,
|
||||
description: input.description,
|
||||
channel: input.channel,
|
||||
uploader: input.uploader,
|
||||
viewCount: input.viewCount,
|
||||
tags: input.tags ? [...input.tags] : undefined,
|
||||
duration: input.duration,
|
||||
playlistTitle: input.playlistTitle,
|
||||
playlistSize: input.playlistSize
|
||||
}
|
||||
}
|
||||
})
|
||||
const created = taskQueue.get(result.id)
|
||||
if (!created) {
|
||||
throw new Error('Failed to read back created task.')
|
||||
}
|
||||
return { download: projectTask(created) }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to create download.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
list: os.downloads.list.handler(() => {
|
||||
return { downloads: listTasksByStatuses(NON_TERMINAL_TASK_STATUSES) }
|
||||
}),
|
||||
cancel: os.downloads.cancel.handler(async ({ input }) => {
|
||||
try {
|
||||
const task = taskQueue.get(input.id)
|
||||
if (!task) return { cancelled: false }
|
||||
await taskQueue.cancel(input.id)
|
||||
return { cancelled: true }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to cancel download.')
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
history: {
|
||||
list: os.history.list.handler(() => {
|
||||
return { history: listTasksByStatuses(TERMINAL_TASK_STATUSES) }
|
||||
}),
|
||||
removeItems: os.history.removeItems.handler(async ({ input }) => {
|
||||
let removed = 0
|
||||
for (const rawId of input.ids) {
|
||||
const id = rawId.trim()
|
||||
if (!id) continue
|
||||
const task = taskQueue.get(id)
|
||||
if (!task) continue
|
||||
try {
|
||||
await taskQueue.removeFromHistory(id)
|
||||
removed += 1
|
||||
} catch {
|
||||
// Non-terminal tasks throw; skip them quietly to match legacy behavior.
|
||||
}
|
||||
}
|
||||
return { removed }
|
||||
}),
|
||||
removeByPlaylist: os.history.removeByPlaylist.handler(async ({ input }) => {
|
||||
const playlistId = input.playlistId.trim()
|
||||
if (!playlistId) return { removed: 0 }
|
||||
let removed = 0
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
const page = taskQueue.list({ limit: 200, cursor })
|
||||
for (const t of page.tasks) {
|
||||
if (t.input.playlistId === playlistId && TERMINAL_TASK_STATUSES.has(t.status)) {
|
||||
try {
|
||||
await taskQueue.removeFromHistory(t.id)
|
||||
removed += 1
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = page.nextCursor
|
||||
} while (cursor)
|
||||
return { removed }
|
||||
})
|
||||
},
|
||||
|
||||
files: {
|
||||
exists: os.files.exists.handler(async ({ input }) => {
|
||||
try {
|
||||
const resolvedPath = path.resolve(input.path)
|
||||
return { exists: await pathExists(resolvedPath) }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to check file existence.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
listDirectories: os.files.listDirectories.handler(async ({ input }) => {
|
||||
try {
|
||||
return await listServerDirectories(input.path)
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to list server directories.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
openFile: os.files.openFile.handler(async ({ input }) => {
|
||||
try {
|
||||
const resolvedPath = path.resolve(input.path)
|
||||
const exists = await pathExists(resolvedPath)
|
||||
if (!exists) return { success: false }
|
||||
return { success: await openFileWithSystem(resolvedPath) }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to open file.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
openFileLocation: os.files.openFileLocation.handler(async ({ input }) => {
|
||||
try {
|
||||
const resolvedPath = path.resolve(input.path)
|
||||
const exists = await pathExists(resolvedPath)
|
||||
if (!exists) return { success: false }
|
||||
return { success: await openFileLocationWithSystem(resolvedPath) }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to open file location.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
copyFileToClipboard: os.files.copyFileToClipboard.handler(async ({ input }) => {
|
||||
try {
|
||||
const resolvedPath = path.resolve(input.path)
|
||||
const exists = await pathExists(resolvedPath)
|
||||
if (!exists) return { success: false }
|
||||
return { success: await copyFileToClipboardWithSystem(resolvedPath) }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to copy file to clipboard.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
deleteFile: os.files.deleteFile.handler(async ({ input }) => {
|
||||
try {
|
||||
const settings = await webSettingsStore.get()
|
||||
const managedDownloadPath = settings.downloadPath.trim()
|
||||
if (!managedDownloadPath) {
|
||||
throw new ORPCError('FORBIDDEN', {
|
||||
message: 'Deleting files is disabled until a download path is configured.'
|
||||
})
|
||||
}
|
||||
const resolvedPath = path.resolve(input.path)
|
||||
if (!isPathWithinBase(managedDownloadPath, resolvedPath)) {
|
||||
throw new ORPCError('FORBIDDEN', {
|
||||
message: 'Refusing to delete files outside the managed download directory.'
|
||||
})
|
||||
}
|
||||
const exists = await pathExists(resolvedPath)
|
||||
if (!exists) return { success: false }
|
||||
await rm(resolvedPath)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to delete file.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
uploadSettingsFile: os.files.uploadSettingsFile.handler(async ({ input }) => {
|
||||
try {
|
||||
const storedPath = await storeWebSettingsFile(input.kind, input.fileName, input.content)
|
||||
triggerManagedSettingsFilePrune(input.kind, storedPath)
|
||||
return { path: storedPath }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to upload settings file.')
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
settings: {
|
||||
get: os.settings.get.handler(async () => {
|
||||
try {
|
||||
const settings = await webSettingsStore.get()
|
||||
return { settings }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to read settings.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
set: os.settings.set.handler(async ({ input }) => {
|
||||
try {
|
||||
const settings = await webSettingsStore.set(input.settings)
|
||||
return { settings }
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to save settings.')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// `taskQueueExecutor` is intentionally re-exported from this module so the
|
||||
// integration test (packages/task-queue/__integration__/three-host-equivalence)
|
||||
// can introspect host wiring without reaching into apps/api directly.
|
||||
export { taskQueueExecutor }
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ServerResponse } from 'node:http'
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000
|
||||
|
||||
export class SseHub {
|
||||
private readonly clients = new Set<ServerResponse>()
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null
|
||||
|
||||
addClient(client: ServerResponse): void {
|
||||
this.clients.add(client)
|
||||
client.write('event: connected\ndata: {"ok":true}\n\n')
|
||||
this.ensureHeartbeatTimer()
|
||||
}
|
||||
|
||||
removeClient(client: ServerResponse): void {
|
||||
this.clients.delete(client)
|
||||
if (this.clients.size === 0) {
|
||||
this.clearHeartbeatTimer()
|
||||
}
|
||||
}
|
||||
|
||||
publish(event: string, payload: unknown): void {
|
||||
if (this.clients.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.stringify(payload)
|
||||
const message = `event: ${event}\ndata: ${data}\n\n`
|
||||
|
||||
for (const client of this.clients) {
|
||||
client.write(message)
|
||||
}
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const client of this.clients) {
|
||||
client.end()
|
||||
}
|
||||
this.clients.clear()
|
||||
this.clearHeartbeatTimer()
|
||||
}
|
||||
|
||||
private ensureHeartbeatTimer(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
for (const client of this.clients) {
|
||||
client.write(': heartbeat\n\n')
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private clearHeartbeatTimer(): void {
|
||||
if (!this.heartbeatTimer) {
|
||||
return
|
||||
}
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* apps/api host for @vidbee/subscriptions-core (NEX-132 Phase B · Web/API).
|
||||
*
|
||||
* Owns the single `SubscriptionsApi` the `/rpc/subscriptions/*` routes feed
|
||||
* into, and bridges subscription items into the shared task-queue (priority
|
||||
* 10, group-keyed by subscription id, kind 'subscription-item').
|
||||
*
|
||||
* Storage: a dedicated `subscriptions.db` (sibling of `task-queue.db`).
|
||||
* Sharing the SQLite file with Desktop is what makes the leader-election
|
||||
* meaningful; on standalone API deployments the API simply always wins the
|
||||
* lease and runs feed-checks itself.
|
||||
*
|
||||
* Operational env vars:
|
||||
* VIDBEE_SUBSCRIPTIONS_DB – override the default subscriptions.db path
|
||||
*
|
||||
* Existing task-queue env vars (`VIDBEE_DOWNLOAD_DIR`,
|
||||
* `VIDBEE_PERSIST_QUEUE`, …) are reused via the shared host wiring.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
import { SUBSCRIPTIONS_DDL_V1 } from '@vidbee/db/subscriptions'
|
||||
import {
|
||||
RssParserFeedFetcher,
|
||||
SubscriptionsApi,
|
||||
createSqliteMetaStore,
|
||||
createSqliteSubscriptionsStore
|
||||
} from '@vidbee/subscriptions-core'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
|
||||
import { apiDefaultDownloadDir, taskQueue } from './task-queue-host'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const trimEnv = (name: string): string | undefined => {
|
||||
const v = process.env[name]?.trim()
|
||||
return v && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
const resolveDbPath = (): string => {
|
||||
const override = trimEnv('VIDBEE_SUBSCRIPTIONS_DB')
|
||||
if (override) return override
|
||||
return path.join(apiDefaultDownloadDir, '.vidbee', 'subscriptions.db')
|
||||
}
|
||||
|
||||
let api: SubscriptionsApi | null = null
|
||||
let started = false
|
||||
|
||||
/**
|
||||
* Open (or reuse) the singleton SubscriptionsApi for the API host.
|
||||
*
|
||||
* The drizzle handle is intentionally held inside the closure: we want a
|
||||
* single long-lived better-sqlite3 connection so leader-election CAS runs
|
||||
* inside one process's transaction, not split across re-opened handles.
|
||||
*/
|
||||
export const getApiSubscriptions = (): SubscriptionsApi => {
|
||||
if (api) return api
|
||||
const dbPath = resolveDbPath()
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true })
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
|
||||
const sqlite = new Database(dbPath, { timeout: 5000 })
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
sqlite.exec(SUBSCRIPTIONS_DDL_V1)
|
||||
const db = drizzle(sqlite)
|
||||
|
||||
const store = createSqliteSubscriptionsStore({ db })
|
||||
const metaStore = createSqliteMetaStore({ db })
|
||||
|
||||
api = new SubscriptionsApi({
|
||||
kind: 'api',
|
||||
pid: process.pid,
|
||||
store,
|
||||
metaStore,
|
||||
fetcher: new RssParserFeedFetcher(),
|
||||
enqueueItem: async ({ subscription, item }) => {
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
const result = await taskQueue.add({
|
||||
input: {
|
||||
url: item.url,
|
||||
kind: 'subscription-item',
|
||||
title: item.title,
|
||||
...(item.thumbnail !== undefined ? { thumbnail: item.thumbnail } : {}),
|
||||
subscriptionId: subscription.id,
|
||||
options: {
|
||||
origin: 'subscription',
|
||||
subscriptionId: subscription.id,
|
||||
itemId: item.id,
|
||||
...(subscription.downloadDirectory
|
||||
? { customDownloadPath: subscription.downloadDirectory }
|
||||
: {}),
|
||||
...(subscription.namingTemplate
|
||||
? { customFilenameTemplate: subscription.namingTemplate }
|
||||
: {}),
|
||||
tags
|
||||
}
|
||||
},
|
||||
priority: 10,
|
||||
groupKey: subscription.id
|
||||
})
|
||||
return result.id
|
||||
},
|
||||
log: (level, msg, meta) => {
|
||||
// The API uses fastify's logger via stdout; emit through console here
|
||||
// so the message lands in the same stream without binding to fastify.
|
||||
const line = meta === undefined ? msg : `${msg} ${JSON.stringify(meta)}`
|
||||
if (level === 'error') console.error(`subscriptions: ${line}`)
|
||||
else if (level === 'warn') console.warn(`subscriptions: ${line}`)
|
||||
else console.info(`subscriptions: ${line}`)
|
||||
}
|
||||
})
|
||||
return api
|
||||
}
|
||||
|
||||
export const startApiSubscriptions = async (): Promise<void> => {
|
||||
if (started) return
|
||||
await getApiSubscriptions().start()
|
||||
started = true
|
||||
}
|
||||
|
||||
export const stopApiSubscriptions = async (): Promise<void> => {
|
||||
if (!started) return
|
||||
await api?.stop()
|
||||
started = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a subscription and cancel any non-terminal tasks the queue still
|
||||
* holds for it (NEX-132 regression checklist: "用户暂停 / 删除订阅后,已经
|
||||
* 入队但未开始的 subscription-item 任务被自动取消").
|
||||
*
|
||||
* Iterates the task list and cancels every queued/running/processing/paused
|
||||
* task whose `groupKey` matches the subscription id. Errors on individual
|
||||
* cancellations are swallowed so a stuck task can't block subscription
|
||||
* removal.
|
||||
*/
|
||||
export const removeApiSubscription = async (id: string): Promise<void> => {
|
||||
await getApiSubscriptions().remove({ id })
|
||||
let cursor: string | null = null
|
||||
do {
|
||||
const page = taskQueue.list({ groupKey: id, limit: 200, cursor })
|
||||
for (const task of page.tasks) {
|
||||
if (
|
||||
task.status === 'queued' ||
|
||||
task.status === 'running' ||
|
||||
task.status === 'processing' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'retry-scheduled'
|
||||
) {
|
||||
try {
|
||||
await taskQueue.cancel(task.id)
|
||||
} catch {
|
||||
// Best-effort: ignore tasks that already moved to a terminal state.
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = page.nextCursor
|
||||
} while (cursor)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* oRPC router that mounts `subscriptionContract` on the API server.
|
||||
*
|
||||
* Each route is a 1:1 forward into the singleton `SubscriptionsApi`. The
|
||||
* server.ts wiring exposes this router under `/rpc/subscriptions/*`.
|
||||
*/
|
||||
import { ORPCError, implement } from '@orpc/server'
|
||||
import {
|
||||
SUBSCRIPTION_DUPLICATE_FEED_ERROR,
|
||||
subscriptionContract
|
||||
} from '@vidbee/subscriptions-core'
|
||||
|
||||
import { getApiSubscriptions, removeApiSubscription } from './subscriptions-host'
|
||||
|
||||
const os = implement(subscriptionContract)
|
||||
|
||||
const toErrorMessage = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const isDuplicateFeedError = (error: unknown): boolean =>
|
||||
error instanceof Error && error.message === SUBSCRIPTION_DUPLICATE_FEED_ERROR
|
||||
|
||||
export const subscriptionsRouter = os.router({
|
||||
list: os.list.handler(async () => {
|
||||
return getApiSubscriptions().list()
|
||||
}),
|
||||
|
||||
get: os.get.handler(async ({ input }) => {
|
||||
try {
|
||||
return await getApiSubscriptions().get(input)
|
||||
} catch (error) {
|
||||
throw new ORPCError('NOT_FOUND', {
|
||||
message: toErrorMessage(error, 'Subscription not found.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
resolve: os.resolve.handler(async ({ input }) => {
|
||||
return getApiSubscriptions().resolve(input)
|
||||
}),
|
||||
|
||||
add: os.add.handler(async ({ input }) => {
|
||||
try {
|
||||
return await getApiSubscriptions().add(input)
|
||||
} catch (error) {
|
||||
if (isDuplicateFeedError(error)) {
|
||||
throw new ORPCError('CONFLICT', {
|
||||
message: SUBSCRIPTION_DUPLICATE_FEED_ERROR
|
||||
})
|
||||
}
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to create subscription.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
update: os.update.handler(async ({ input }) => {
|
||||
try {
|
||||
return await getApiSubscriptions().update(input)
|
||||
} catch (error) {
|
||||
if (isDuplicateFeedError(error)) {
|
||||
throw new ORPCError('CONFLICT', {
|
||||
message: SUBSCRIPTION_DUPLICATE_FEED_ERROR
|
||||
})
|
||||
}
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to update subscription.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
remove: os.remove.handler(async ({ input }) => {
|
||||
await removeApiSubscription(input.id)
|
||||
return {}
|
||||
}),
|
||||
|
||||
refresh: os.refresh.handler(async ({ input }) => {
|
||||
try {
|
||||
return await getApiSubscriptions().refresh(input)
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to refresh subscription.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
itemsList: os.itemsList.handler(async ({ input }) => {
|
||||
try {
|
||||
return await getApiSubscriptions().itemsList(input)
|
||||
} catch (error) {
|
||||
throw new ORPCError('NOT_FOUND', {
|
||||
message: toErrorMessage(error, 'Subscription not found.')
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
itemsQueue: os.itemsQueue.handler(async ({ input }) => {
|
||||
try {
|
||||
return await getApiSubscriptions().itemsQueue(input)
|
||||
} catch (error) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
||||
message: toErrorMessage(error, 'Failed to queue subscription item.')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export type SubscriptionsRouter = typeof subscriptionsRouter
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* apps/api host for @vidbee/task-queue.
|
||||
*
|
||||
* Constructs the single TaskQueueAPI used by /rpc/* and /events for the
|
||||
* Web/API surface, plus a thin yt-dlp metadata client used by `videoInfo`
|
||||
* and `playlist.info` (those calls are stateless and bypass the queue).
|
||||
*
|
||||
* Operational env vars (preserved from the pre-NEX-131 surface):
|
||||
* VIDBEE_DOWNLOAD_DIR – default download dir for new tasks
|
||||
* VIDBEE_MAX_CONCURRENT – Scheduler.maxConcurrency
|
||||
* VIDBEE_HISTORY_STORE_PATH – legacy history sqlite path; only used by
|
||||
* scripts/migrate-history.ts now
|
||||
* VIDBEE_PERSIST_QUEUE=1 – switch from in-memory to SQLite-backed
|
||||
* TaskQueue (matches Desktop crash recovery)
|
||||
* VIDBEE_TASK_QUEUE_DB – override task-queue sqlite path
|
||||
* YTDLP_PATH / FFMPEG_PATH – binary overrides (unchanged)
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
MemoryPersistAdapter,
|
||||
SqlitePersistAdapter,
|
||||
TaskQueueAPI
|
||||
} from '@vidbee/task-queue'
|
||||
import { TASK_QUEUE_DDL_V1 } from '@vidbee/db/task-queue'
|
||||
import { YtDlpExecutor } from '@vidbee/downloader-core'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const DEFAULT_DOWNLOAD_DIR_FALLBACK = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
|
||||
const trimEnv = (name: string): string | undefined => {
|
||||
const v = process.env[name]?.trim()
|
||||
return v && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
export const apiDefaultDownloadDir =
|
||||
trimEnv('VIDBEE_DOWNLOAD_DIR') ?? trimEnv('DOWNLOAD_DIR') ?? DEFAULT_DOWNLOAD_DIR_FALLBACK
|
||||
|
||||
const parsedMaxConcurrent = Number(trimEnv('VIDBEE_MAX_CONCURRENT') ?? '')
|
||||
export const apiMaxConcurrent =
|
||||
Number.isFinite(parsedMaxConcurrent) && parsedMaxConcurrent > 0 ? parsedMaxConcurrent : 4
|
||||
|
||||
const persistEnabled = trimEnv('VIDBEE_PERSIST_QUEUE') === '1'
|
||||
|
||||
const taskQueueDbPath =
|
||||
trimEnv('VIDBEE_TASK_QUEUE_DB') ?? path.join(apiDefaultDownloadDir, '.vidbee', 'task-queue.db')
|
||||
|
||||
fs.mkdirSync(apiDefaultDownloadDir, { recursive: true })
|
||||
|
||||
let cachedYtDlpPath: string | null = null
|
||||
const resolveYtDlpPath = (): string => {
|
||||
if (cachedYtDlpPath && fs.existsSync(cachedYtDlpPath)) return cachedYtDlpPath
|
||||
const envPath = trimEnv('YTDLP_PATH')
|
||||
if (envPath && fs.existsSync(envPath)) {
|
||||
cachedYtDlpPath = envPath
|
||||
return envPath
|
||||
}
|
||||
// Fall back to PATH lookup via execSync `which yt-dlp` / `where yt-dlp`.
|
||||
try {
|
||||
const out = require('node:child_process')
|
||||
.execSync(process.platform === 'win32' ? 'where yt-dlp' : 'which yt-dlp', {
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((s: string) => s.trim())
|
||||
.find((s: string) => s.length > 0)
|
||||
if (out && fs.existsSync(out)) {
|
||||
cachedYtDlpPath = out
|
||||
return out
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
throw new Error('yt-dlp binary not found. Set YTDLP_PATH or install yt-dlp in PATH.')
|
||||
}
|
||||
|
||||
let cachedFfmpegLocation: string | null | undefined
|
||||
const resolveFfmpegLocation = (): string | undefined => {
|
||||
if (cachedFfmpegLocation !== undefined) return cachedFfmpegLocation ?? undefined
|
||||
const envPath = trimEnv('FFMPEG_PATH')
|
||||
if (envPath) {
|
||||
try {
|
||||
const stats = fs.statSync(envPath)
|
||||
if (stats.isDirectory()) {
|
||||
cachedFfmpegLocation = envPath
|
||||
return envPath
|
||||
}
|
||||
const dir = path.dirname(envPath)
|
||||
cachedFfmpegLocation = dir
|
||||
return dir
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
for (const candidate of ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']) {
|
||||
if (fs.existsSync(path.join(candidate, 'ffmpeg'))) {
|
||||
cachedFfmpegLocation = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
cachedFfmpegLocation = null
|
||||
return undefined
|
||||
}
|
||||
|
||||
const executor = new YtDlpExecutor({
|
||||
resolveYtDlpPath,
|
||||
resolveFfmpegLocation,
|
||||
defaultDownloadDir: apiDefaultDownloadDir
|
||||
})
|
||||
|
||||
const buildPersistAdapter = () => {
|
||||
if (!persistEnabled) return new MemoryPersistAdapter()
|
||||
fs.mkdirSync(path.dirname(taskQueueDbPath), { recursive: true })
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
|
||||
const db = new Database(taskQueueDbPath, { timeout: 5000 }) as unknown as {
|
||||
exec: (sql: string) => void
|
||||
prepare: (sql: string) => unknown
|
||||
pragma: (sql: string, opts?: { simple?: boolean }) => unknown
|
||||
transaction: (fn: (...args: unknown[]) => unknown) => unknown
|
||||
close: () => void
|
||||
}
|
||||
db.exec(TASK_QUEUE_DDL_V1)
|
||||
return new SqlitePersistAdapter({
|
||||
// SqlitePersistAdapter accepts a structurally-typed db; better-sqlite3
|
||||
// matches the shape.
|
||||
db: db as unknown as ConstructorParameters<typeof SqlitePersistAdapter>[0]['db']
|
||||
})
|
||||
}
|
||||
|
||||
export const taskQueue = new TaskQueueAPI({
|
||||
persist: buildPersistAdapter(),
|
||||
executor,
|
||||
maxConcurrency: apiMaxConcurrent
|
||||
})
|
||||
|
||||
export const taskQueueExecutor = executor
|
||||
|
||||
let started = false
|
||||
export const startTaskQueue = async (): Promise<void> => {
|
||||
if (started) return
|
||||
await taskQueue.start()
|
||||
started = true
|
||||
}
|
||||
|
||||
export const stopTaskQueue = async (): Promise<void> => {
|
||||
if (!started) return
|
||||
await taskQueue.stop()
|
||||
started = false
|
||||
}
|
||||
|
||||
export const isTaskQueuePersistent = persistEnabled
|
||||
export const taskQueueDbFile = taskQueueDbPath
|
||||
@@ -0,0 +1,73 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { WebAppSettingsSchema } from '@vidbee/downloader-core'
|
||||
|
||||
const STORAGE_DIR = path.resolve(process.cwd(), '.data')
|
||||
const STORAGE_FILE = path.join(STORAGE_DIR, 'web-settings.json')
|
||||
|
||||
const defaultWebSettings = WebAppSettingsSchema.parse({
|
||||
downloadPath: '',
|
||||
maxConcurrentDownloads: 5,
|
||||
browserForCookies: 'none',
|
||||
cookiesPath: '',
|
||||
proxy: '',
|
||||
configPath: '',
|
||||
betaProgram: false,
|
||||
language: 'en',
|
||||
theme: 'system',
|
||||
oneClickDownload: false,
|
||||
oneClickDownloadType: 'video',
|
||||
oneClickQuality: 'best',
|
||||
oneClickContainer: 'auto',
|
||||
closeToTray: true,
|
||||
autoUpdate: true,
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
enableAnalytics: true,
|
||||
embedSubs: true,
|
||||
embedThumbnail: false,
|
||||
embedMetadata: true,
|
||||
embedChapters: true,
|
||||
shareWatermark: false
|
||||
})
|
||||
|
||||
type WebAppSettings = typeof defaultWebSettings
|
||||
|
||||
class WebSettingsStore {
|
||||
private settings = defaultWebSettings
|
||||
private initialized = false
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
|
||||
try {
|
||||
const raw = await readFile(STORAGE_FILE, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const result = WebAppSettingsSchema.safeParse(parsed)
|
||||
if (result.success) {
|
||||
this.settings = result.data
|
||||
}
|
||||
} catch {
|
||||
this.settings = defaultWebSettings
|
||||
}
|
||||
}
|
||||
|
||||
async get(): Promise<WebAppSettings> {
|
||||
await this.ensureInitialized()
|
||||
return this.settings
|
||||
}
|
||||
|
||||
async set(nextSettings: WebAppSettings): Promise<WebAppSettings> {
|
||||
await this.ensureInitialized()
|
||||
const validated = WebAppSettingsSchema.parse(nextSettings)
|
||||
await mkdir(STORAGE_DIR, { recursive: true })
|
||||
await writeFile(STORAGE_FILE, JSON.stringify(validated), 'utf-8')
|
||||
this.settings = validated
|
||||
return this.settings
|
||||
}
|
||||
}
|
||||
|
||||
export const webSettingsStore = new WebSettingsStore()
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Stateless yt-dlp metadata client used by `videoInfo` and `playlist.info`.
|
||||
* Replaces the equivalent calls on `DownloaderCore`, which the API layer
|
||||
* no longer instantiates after NEX-131.
|
||||
*/
|
||||
import { execSync, spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
|
||||
import {
|
||||
buildPlaylistInfoArgs,
|
||||
buildVideoInfoArgs
|
||||
} from '@vidbee/downloader-core'
|
||||
import type {
|
||||
DownloadRuntimeSettings,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
} from '@vidbee/downloader-core'
|
||||
|
||||
interface RawVideoInfo {
|
||||
id?: string
|
||||
title?: string
|
||||
thumbnail?: string | null
|
||||
duration?: number | null
|
||||
extractor_key?: string | null
|
||||
webpage_url?: string | null
|
||||
description?: string | null
|
||||
view_count?: number | null
|
||||
uploader?: string | null
|
||||
tags?: unknown
|
||||
formats?: Array<{
|
||||
format_id?: string | null
|
||||
ext?: string | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
fps?: number | null
|
||||
vcodec?: string | null
|
||||
acodec?: string | null
|
||||
filesize?: number | null
|
||||
filesize_approx?: number | null
|
||||
format_note?: string | null
|
||||
tbr?: number | null
|
||||
quality?: number | null
|
||||
protocol?: string | null
|
||||
language?: string | null
|
||||
video_ext?: string | null
|
||||
audio_ext?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
interface RawPlaylistEntry {
|
||||
id?: string | null
|
||||
title?: string | null
|
||||
url?: string | null
|
||||
webpage_url?: string | null
|
||||
original_url?: string | null
|
||||
ie_key?: string | null
|
||||
thumbnail?: string | null
|
||||
}
|
||||
|
||||
interface RawPlaylistInfo {
|
||||
id?: string | null
|
||||
title?: string | null
|
||||
entries?: RawPlaylistEntry[]
|
||||
}
|
||||
|
||||
const trim = (v?: string | null): string => v?.trim() ?? ''
|
||||
const optString = (v: unknown): string | undefined => {
|
||||
if (typeof v !== 'string') return undefined
|
||||
const t = v.trim()
|
||||
return t.length ? t : undefined
|
||||
}
|
||||
const optNumber = (v: unknown): number | undefined =>
|
||||
typeof v === 'number' && !Number.isNaN(v) ? v : undefined
|
||||
|
||||
const optStringArray = (v: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(v)) return undefined
|
||||
const list = v
|
||||
.filter((e): e is string => typeof e === 'string')
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e.length > 0)
|
||||
return list.length ? list : undefined
|
||||
}
|
||||
|
||||
const isHttpUrl = (v?: string | null): boolean => {
|
||||
if (!v) return false
|
||||
try {
|
||||
const u = new URL(v)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveEntryUrl = (entry: RawPlaylistEntry): string | undefined => {
|
||||
if (isHttpUrl(entry.url)) return optString(entry.url)
|
||||
if (isHttpUrl(entry.webpage_url)) return optString(entry.webpage_url)
|
||||
if (isHttpUrl(entry.original_url)) return optString(entry.original_url)
|
||||
if (entry.url) {
|
||||
const id = entry.url.trim()
|
||||
const ie = entry.ie_key?.toLowerCase() ?? ''
|
||||
if (ie.includes('youtube')) return `https://www.youtube.com/watch?v=${id}`
|
||||
if (ie.includes('youtubemusic')) return `https://music.youtube.com/watch?v=${id}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cachedYtDlpPath: string | null = null
|
||||
const resolveYtDlpPath = (): string => {
|
||||
if (cachedYtDlpPath && fs.existsSync(cachedYtDlpPath)) return cachedYtDlpPath
|
||||
const env = trim(process.env.YTDLP_PATH)
|
||||
if (env && fs.existsSync(env)) {
|
||||
cachedYtDlpPath = env
|
||||
return env
|
||||
}
|
||||
try {
|
||||
const out = execSync(process.platform === 'win32' ? 'where yt-dlp' : 'which yt-dlp', {
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.length > 0)
|
||||
if (out && fs.existsSync(out)) {
|
||||
cachedYtDlpPath = out
|
||||
return out
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
throw new Error('yt-dlp binary not found. Set YTDLP_PATH or install yt-dlp in PATH.')
|
||||
}
|
||||
|
||||
const runYtDlp = (args: string[]): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const ytDlp = resolveYtDlpPath()
|
||||
const child = spawn(ytDlp, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (c) => {
|
||||
stdout += c.toString()
|
||||
})
|
||||
child.stderr.on('data', (c) => {
|
||||
stderr += c.toString()
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => {
|
||||
if (code === 0 && stdout.trim()) resolve(stdout)
|
||||
else reject(new Error(stderr.trim() || `yt-dlp exited with code ${code ?? -1}`))
|
||||
})
|
||||
})
|
||||
|
||||
const parseVideoInfoPayload = (stdout: string): RawVideoInfo => {
|
||||
try {
|
||||
return JSON.parse(stdout) as RawVideoInfo
|
||||
} catch (err) {
|
||||
const firstLine = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('{') || line.startsWith('['))
|
||||
if (!firstLine) throw err
|
||||
return JSON.parse(firstLine) as RawVideoInfo
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVideoInfo(
|
||||
url: string,
|
||||
settings: DownloadRuntimeSettings = {}
|
||||
): Promise<VideoInfo> {
|
||||
const target = url.trim()
|
||||
if (!target) throw new Error('URL is required.')
|
||||
const args = buildVideoInfoArgs(target, settings)
|
||||
const stdout = await runYtDlp(args)
|
||||
const raw = parseVideoInfoPayload(stdout)
|
||||
const formats: VideoFormat[] = (raw.formats ?? []).map((f) => ({
|
||||
formatId: f.format_id ?? 'unknown',
|
||||
ext: f.ext ?? 'unknown',
|
||||
width: optNumber(f.width),
|
||||
height: optNumber(f.height),
|
||||
fps: optNumber(f.fps),
|
||||
vcodec: optString(f.vcodec),
|
||||
acodec: optString(f.acodec),
|
||||
filesize: optNumber(f.filesize),
|
||||
filesizeApprox: optNumber(f.filesize_approx),
|
||||
formatNote: optString(f.format_note),
|
||||
tbr: optNumber(f.tbr),
|
||||
quality: optNumber(f.quality),
|
||||
protocol: optString(f.protocol),
|
||||
language: optString(f.language),
|
||||
videoExt: optString(f.video_ext),
|
||||
audioExt: optString(f.audio_ext)
|
||||
}))
|
||||
return {
|
||||
id: raw.id ?? target,
|
||||
title: raw.title ?? target,
|
||||
thumbnail: optString(raw.thumbnail),
|
||||
duration: optNumber(raw.duration),
|
||||
extractorKey: optString(raw.extractor_key),
|
||||
webpageUrl: optString(raw.webpage_url),
|
||||
description: optString(raw.description),
|
||||
viewCount: optNumber(raw.view_count),
|
||||
uploader: optString(raw.uploader),
|
||||
tags: optStringArray(raw.tags),
|
||||
formats
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPlaylistInfo(
|
||||
url: string,
|
||||
settings: DownloadRuntimeSettings = {}
|
||||
): Promise<PlaylistInfo> {
|
||||
const target = url.trim()
|
||||
if (!target) throw new Error('URL is required.')
|
||||
const args = buildPlaylistInfoArgs(target, settings)
|
||||
const stdout = await runYtDlp(args)
|
||||
const raw = JSON.parse(stdout) as RawPlaylistInfo
|
||||
const rawEntries = Array.isArray(raw.entries) ? raw.entries : []
|
||||
const entries = rawEntries
|
||||
.map((entry, index) => {
|
||||
const resolvedUrl = resolveEntryUrl(entry)
|
||||
if (!resolvedUrl) return null
|
||||
return {
|
||||
id: optString(entry.id) ?? `${index + 1}`,
|
||||
title: optString(entry.title) ?? `Entry ${index + 1}`,
|
||||
url: resolvedUrl,
|
||||
index: index + 1,
|
||||
thumbnail: optString(entry.thumbnail)
|
||||
}
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => Boolean(e))
|
||||
return {
|
||||
id: optString(raw.id) ?? target,
|
||||
title: optString(raw.title) ?? 'Playlist',
|
||||
entries,
|
||||
entryCount: entries.length
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import net from 'node:net'
|
||||
import cors from '@fastify/cors'
|
||||
import { OpenAPIHandler } from '@orpc/openapi/fastify'
|
||||
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
|
||||
import { RPCHandler } from '@orpc/server/fastify'
|
||||
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
|
||||
import Fastify from 'fastify'
|
||||
import { startTaskQueue, stopTaskQueue, taskQueue } from './lib/downloader'
|
||||
import { projectTaskForApi } from './lib/projection'
|
||||
import { rpcRouter } from './lib/rpc-router'
|
||||
import { SseHub } from './lib/sse'
|
||||
import { startApiSubscriptions, stopApiSubscriptions } from './lib/subscriptions-host'
|
||||
import { subscriptionsRouter } from './lib/subscriptions-router'
|
||||
|
||||
const MAX_PROXY_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_PROXY_REDIRECTS = 5
|
||||
|
||||
const isPrivateIpv4 = (ip: string): boolean => {
|
||||
const octets = ip.split('.').map((value) => Number.parseInt(value, 10))
|
||||
if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [a, b] = octets
|
||||
if (a === 10) {
|
||||
return true
|
||||
}
|
||||
if (a === 127) {
|
||||
return true
|
||||
}
|
||||
if (a === 169 && b === 254) {
|
||||
return true
|
||||
}
|
||||
if (a === 172 && b >= 16 && b <= 31) {
|
||||
return true
|
||||
}
|
||||
if (a === 192 && b === 168) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isPrivateIpv6 = (ip: string): boolean => {
|
||||
const normalized = ip.toLowerCase()
|
||||
if (normalized === '::1') {
|
||||
return true
|
||||
}
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) {
|
||||
return true
|
||||
}
|
||||
if (normalized.startsWith('fe80:')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isBlockedHost = async (url: URL): Promise<boolean> => {
|
||||
const hostname = url.hostname.trim().toLowerCase()
|
||||
if (!hostname) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '0.0.0.0') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (net.isIP(hostname) === 4) {
|
||||
return isPrivateIpv4(hostname)
|
||||
}
|
||||
if (net.isIP(hostname) === 6) {
|
||||
return isPrivateIpv6(hostname)
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true })
|
||||
if (records.length === 0) {
|
||||
return true
|
||||
}
|
||||
for (const record of records) {
|
||||
if (record.family === 4 && isPrivateIpv4(record.address)) {
|
||||
return true
|
||||
}
|
||||
if (record.family === 6 && isPrivateIpv6(record.address)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const parseRemoteImageUrl = (value: string): URL | null => {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const createApiServer = async () => {
|
||||
await startTaskQueue()
|
||||
await startApiSubscriptions()
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
const fastify = Fastify({
|
||||
logger: true,
|
||||
disableRequestLogging: isDev
|
||||
})
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: true,
|
||||
methods: ['GET', 'POST', 'OPTIONS']
|
||||
})
|
||||
|
||||
const rpcHandler = new RPCHandler(rpcRouter)
|
||||
const subscriptionsRpcHandler = new RPCHandler(subscriptionsRouter)
|
||||
const openApiHandler = new OpenAPIHandler(rpcRouter, {
|
||||
plugins: [
|
||||
new OpenAPIReferencePlugin({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
docsProvider: 'swagger',
|
||||
docsPath: '/docs',
|
||||
specPath: '/openapi.json',
|
||||
docsTitle: 'VidBee API Reference',
|
||||
specGenerateOptions: {
|
||||
info: {
|
||||
title: 'VidBee API',
|
||||
version: '1.0.0'
|
||||
},
|
||||
servers: [{ url: '/openapi' }]
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const sseHub = new SseHub()
|
||||
|
||||
// Bridge TaskQueue events → /events SSE. The web client speaks the legacy
|
||||
// task-updated / queue-updated payload shape; we project from internal
|
||||
// Task → DownloadTask using the shared projection so both Desktop IPC and
|
||||
// API SSE present the same fields for the same task.
|
||||
const NON_TERMINAL = new Set(['queued', 'running', 'processing', 'paused', 'retry-scheduled'])
|
||||
const publishQueueUpdated = (): void => {
|
||||
const downloads = taskQueue
|
||||
.list({ limit: 200 })
|
||||
.tasks.filter((t) => NON_TERMINAL.has(t.status))
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
.map(projectTaskForApi)
|
||||
sseHub.publish('queue-updated', { downloads })
|
||||
}
|
||||
|
||||
taskQueue.on('snapshot-changed', (e) => {
|
||||
sseHub.publish('task-updated', { task: projectTaskForApi(e.task) })
|
||||
publishQueueUpdated()
|
||||
})
|
||||
taskQueue.on('progress', (e) => {
|
||||
const t = taskQueue.get(e.taskId)
|
||||
if (t) sseHub.publish('task-updated', { task: projectTaskForApi(t) })
|
||||
})
|
||||
taskQueue.on('transition', (e) => {
|
||||
if (e.to === 'queued' || e.to === 'cancelled' || e.to === 'completed' || e.to === 'failed') {
|
||||
publishQueueUpdated()
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/health', async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
fastify.get<{ Querystring: { url?: string } }>('/images/proxy', async (request, reply) => {
|
||||
const sourceUrl = request.query.url?.trim()
|
||||
if (!sourceUrl) {
|
||||
return reply.code(400).send({ message: 'Missing url query parameter.' })
|
||||
}
|
||||
|
||||
const parsedUrl = parseRemoteImageUrl(sourceUrl)
|
||||
if (!parsedUrl) {
|
||||
return reply.code(400).send({ message: 'Invalid remote image URL.' })
|
||||
}
|
||||
|
||||
let response: Response | null = null
|
||||
let currentUrl = parsedUrl
|
||||
|
||||
for (let redirectCount = 0; redirectCount <= MAX_PROXY_REDIRECTS; redirectCount++) {
|
||||
if (await isBlockedHost(currentUrl)) {
|
||||
return reply.code(400).send({ message: 'Remote host is not allowed.' })
|
||||
}
|
||||
|
||||
try {
|
||||
response = await fetch(currentUrl.toString(), {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
redirect: 'manual'
|
||||
})
|
||||
} catch {
|
||||
return reply.code(502).send({ message: 'Failed to fetch remote image.' })
|
||||
}
|
||||
|
||||
const locationHeader = response.headers.get('location')
|
||||
const isRedirect =
|
||||
response.status >= 300 &&
|
||||
response.status < 400 &&
|
||||
typeof locationHeader === 'string' &&
|
||||
locationHeader.length > 0
|
||||
if (!isRedirect) {
|
||||
break
|
||||
}
|
||||
|
||||
currentUrl = new URL(locationHeader, currentUrl)
|
||||
response.body?.cancel()
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return reply.code(502).send({ message: 'Failed to fetch remote image.' })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return reply.code(502).send({
|
||||
message: `Remote image request failed with status ${response.status}.`
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return reply.code(415).send({ message: 'Remote resource is not an image.' })
|
||||
}
|
||||
|
||||
const contentLengthHeader = response.headers.get('content-length')
|
||||
if (contentLengthHeader) {
|
||||
const declaredSize = Number.parseInt(contentLengthHeader, 10)
|
||||
if (Number.isFinite(declaredSize) && declaredSize > MAX_PROXY_IMAGE_BYTES) {
|
||||
return reply.code(413).send({ message: 'Remote image is too large.' })
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return reply.code(502).send({ message: 'Remote image response body is empty.' })
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
totalBytes += value.byteLength
|
||||
if (totalBytes > MAX_PROXY_IMAGE_BYTES) {
|
||||
await reader.cancel()
|
||||
return reply.code(413).send({ message: 'Remote image is too large.' })
|
||||
}
|
||||
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.concat(chunks, totalBytes)
|
||||
const cacheControl = response.headers.get('cache-control')
|
||||
const etag = response.headers.get('etag')
|
||||
const lastModified = response.headers.get('last-modified')
|
||||
|
||||
reply.header('Content-Type', contentType)
|
||||
reply.header('Content-Length', imageBuffer.length.toString())
|
||||
reply.header('Cache-Control', cacheControl ?? 'public, max-age=3600')
|
||||
if (etag) {
|
||||
reply.header('ETag', etag)
|
||||
}
|
||||
if (lastModified) {
|
||||
reply.header('Last-Modified', lastModified)
|
||||
}
|
||||
|
||||
return reply.send(imageBuffer)
|
||||
})
|
||||
|
||||
fastify.get('/events', async (request, reply) => {
|
||||
const requestOrigin = request.headers.origin?.trim()
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Access-Control-Allow-Origin': requestOrigin || '*'
|
||||
}
|
||||
|
||||
if (requestOrigin) {
|
||||
responseHeaders.Vary = 'Origin'
|
||||
}
|
||||
|
||||
reply.hijack()
|
||||
reply.raw.writeHead(200, responseHeaders)
|
||||
|
||||
const response = reply.raw as ServerResponse
|
||||
sseHub.addClient(response)
|
||||
|
||||
request.raw.on('close', () => {
|
||||
sseHub.removeClient(response)
|
||||
})
|
||||
})
|
||||
|
||||
// Subscriptions live behind their own oRPC handler so the contract surface
|
||||
// mirrors `subscriptionContract` 1:1 (NEX-132). The match runs before the
|
||||
// generic `/rpc/*` handler because Fastify applies the most-specific route
|
||||
// wins rule for `/rpc/subscriptions/*`.
|
||||
fastify.all('/rpc/subscriptions/*', async (request, reply) => {
|
||||
await subscriptionsRpcHandler.handle(request, reply, {
|
||||
prefix: '/rpc/subscriptions'
|
||||
})
|
||||
})
|
||||
|
||||
fastify.all('/rpc/*', async (request, reply) => {
|
||||
await rpcHandler.handle(request, reply, {
|
||||
prefix: '/rpc'
|
||||
})
|
||||
})
|
||||
|
||||
fastify.all('/docs', async (request, reply) => {
|
||||
await openApiHandler.handle(request, reply, {
|
||||
prefix: '/'
|
||||
})
|
||||
})
|
||||
|
||||
fastify.all('/openapi.json', async (request, reply) => {
|
||||
await openApiHandler.handle(request, reply, {
|
||||
prefix: '/'
|
||||
})
|
||||
})
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
sseHub.closeAll()
|
||||
await stopApiSubscriptions()
|
||||
await stopTaskQueue()
|
||||
})
|
||||
|
||||
return fastify
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `@vidbee/cli` are documented in this file.
|
||||
|
||||
The CLI follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), with versioning **decoupled from VidBee Desktop**. CLI bug-fixes and new yt-dlp probe-flag support ship on the CLI's own cadence; Desktop continues on `1.x`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0] - 2026-05-02
|
||||
|
||||
First standalone release of `@vidbee/cli`. Tracks NEX-133 (CLI full-stack) and NEX-148 (standalone-only distribution).
|
||||
|
||||
### Added
|
||||
- argv parser that splits `--vidbee-*` flags + `:` subcommands from yt-dlp passthrough; unknown `--vidbee-*` tokens fail with exit 2 instead of being silently forwarded.
|
||||
- Probe vs download mode detection covering every public yt-dlp probe-class flag and alias.
|
||||
- `:status`, `:download list/status/logs/cancel/pause/resume/retry`, `:history list/remove` against the shared `taskQueueContract`.
|
||||
- `:version` — prints CLI + contract version + changelog URL with no host contact.
|
||||
- `:upgrade` — checks `https://registry.npmjs.org/@vidbee/cli/latest`, caches the result for 30 days, and prints install commands for npm / pnpm / bun / brew. Does not spawn `npm install` automatically.
|
||||
- Three transports: Desktop loopback (with descriptor handshake + 1h token + autostart), `--vidbee-api <url>` (HTTPS required outside loopback / RFC1918), and `--vidbee-local` (in-process TaskQueueAPI + YtDlpExecutor, with optional SQLite for crash-recovery).
|
||||
- `dist/` bundle distributed via npm (`pnpm publish`) under `@vidbee/cli`. Tarball contains only `dist/`, README, CHANGELOG, LICENSE.
|
||||
- Sensitive-argument redaction (`--password`, `--video-password`, `--ap-password`, `--twofactor`, `Authorization:` headers, URL `token=` / `access_token=` / `signature=` / `policy=` query strings).
|
||||
|
||||
### Distribution
|
||||
- Standalone npm publishing via [`cli-publish.yml`](../../.github/workflows/cli-publish.yml), triggered by `cli-v*` tags.
|
||||
- Shell installer at [`scripts/cli-install.sh`](../../scripts/cli-install.sh) — installs the npm tarball into `~/.vidbee/cli` and writes a `vidbee` shim into `~/.local/bin`.
|
||||
- Homebrew tap (`vidbee/homebrew-tap`) tracked as a follow-up; the formula will wrap the same npm tarball.
|
||||
|
||||
### Removed
|
||||
- Bundling with VidBee Desktop. The Desktop installer no longer ships a CLI shim or rewrites your `PATH`. Install the CLI explicitly via npm / brew / shell installer. (NEX-148)
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 VidBee
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,201 @@
|
||||
# `@vidbee/cli`
|
||||
|
||||
Standalone command-line interface to [VidBee](https://github.com/nexmoe/vidbee), built on the shared `@vidbee/task-queue` kernel. CLI invocations land in the same queue that the Desktop UI and Web/API host use; nothing bypasses the application.
|
||||
|
||||
The CLI is **distributed independently** of VidBee Desktop: you do not need to install or run the desktop app to use it. Common targets are CI runners, Docker images, agents, and headless Linux servers — places where shipping a 100 MB Electron bundle to invoke `yt-dlp` would be wrong.
|
||||
|
||||
> Reference designs: `docs/vidbee-desktop-first-cli-ytdlp-rss-design.md` (CLI surface), `docs/vidbee-task-queue-state-machine-design.md` (kernel).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
Pick one. All three install the same `@vidbee/cli` Node package — there is no native binary today; we rely on Node ≥ 20 being available.
|
||||
|
||||
### npm / pnpm / bun
|
||||
|
||||
```sh
|
||||
# npm (most common)
|
||||
npm install -g @vidbee/cli
|
||||
|
||||
# pnpm
|
||||
pnpm add -g @vidbee/cli
|
||||
|
||||
# bun
|
||||
bun install -g @vidbee/cli
|
||||
```
|
||||
|
||||
`npx @vidbee/cli ...` works without a global install.
|
||||
|
||||
### Homebrew (macOS / Linux)
|
||||
|
||||
```sh
|
||||
brew install vidbee/tap/vidbee
|
||||
```
|
||||
|
||||
> The tap (`vidbee/homebrew-tap`) is a separate repository tracked under NEX-148 follow-ups. The formula wraps the same npm tarball.
|
||||
|
||||
### Shell installer
|
||||
|
||||
```sh
|
||||
curl -fsSL https://vidbee.dev/cli/install.sh | sh
|
||||
```
|
||||
|
||||
The installer downloads the npm tarball into `~/.vidbee/cli` and writes a `vidbee` shim into `~/.local/bin`, picking up your shell from `$SHELL`. Source: [`scripts/cli-install.sh`](https://github.com/nexmoe/vidbee/blob/main/scripts/cli-install.sh).
|
||||
|
||||
### Windows
|
||||
|
||||
Use the npm channel (`npm install -g @vidbee/cli`) for now. The shell installer requires bash + tar; on Windows we recommend WSL or an `npm i -g` install from PowerShell. If your PowerShell execution policy blocks scripts, run `Set-ExecutionPolicy -Scope Process Bypass` once for the current shell.
|
||||
|
||||
### Need crash-recovery for `--vidbee-local`?
|
||||
|
||||
The in-process transport persists task state in SQLite when `better-sqlite3` is available. It is declared as an `optionalDependency`; install it explicitly if you want crash-recovery:
|
||||
|
||||
```sh
|
||||
npm install -g @vidbee/cli better-sqlite3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Three transports
|
||||
|
||||
The CLI talks to a `taskQueueContract` host. There are three transports, all wire-compatible:
|
||||
|
||||
- **Desktop loopback** (default when a Desktop install is detected): reads the per-user automation descriptor (`~/Library/Application Support/VidBee/automation.json` on macOS, `${XDG_CONFIG_HOME:-~/.config}/VidBee/automation.json` on Linux, `%APPDATA%\VidBee\automation.json` on Windows), handshakes for a 1h bearer token, and POSTs JSON to `http://127.0.0.1:<port>/automation/v1/*`.
|
||||
- **Remote API** (`--vidbee-api <url>`): same wire format against any VidBee Web/API host. Plain HTTP is rejected unless the host is loopback or RFC1918 private. HTTPS is required everywhere else.
|
||||
- **In-process** (`--vidbee-local`): instantiates `TaskQueueAPI` + `YtDlpExecutor` directly. No Desktop, no API server, no descriptor. Used for CI, Docker images, and the three-host equivalence test.
|
||||
|
||||
If the Desktop descriptor is missing or its PID is stale, the CLI tries to launch Desktop in background mode. Pass `--vidbee-no-autostart` to opt out and exit `3` instead. On a host with no Desktop installed, use `--vidbee-api` or `--vidbee-local`.
|
||||
|
||||
---
|
||||
|
||||
## Argv contract
|
||||
|
||||
A single rule splits argv:
|
||||
|
||||
- Tokens starting with `--vidbee-` are reserved (full list below). Unknown `--vidbee-*` tokens are treated as typos and rejected with exit code `2` — they are **not** silently passed through to yt-dlp.
|
||||
- Tokens starting with `:` are VidBee subcommands (`:status`, `:download list`, `:version`, …).
|
||||
- Everything else is order-preserved and forwarded to yt-dlp.
|
||||
|
||||
### `--vidbee-*` flags
|
||||
|
||||
| flag | meaning |
|
||||
| --- | --- |
|
||||
| `--vidbee-api <url>` | connect to a remote VidBee API instead of the local Desktop |
|
||||
| `--vidbee-local` | run TaskQueueAPI in-process (CI / Docker) |
|
||||
| `--vidbee-target <desktop\|api\|local>` | explicit transport selection |
|
||||
| `--vidbee-pretty` | indent JSON output |
|
||||
| `--vidbee-wait` | block until terminal status |
|
||||
| `--vidbee-detach` | return immediately after enqueue (default) |
|
||||
| `--vidbee-priority <user\|subscription\|background>` | set task priority (default: user) |
|
||||
| `--vidbee-max-attempts <n>` | override outer retry cap (`0` disables) |
|
||||
| `--vidbee-no-retry` | shortcut for `--vidbee-max-attempts 0` |
|
||||
| `--vidbee-group-key <key>` | override per-group concurrency key |
|
||||
| `--vidbee-timeout <ms>` | autostart wait budget (default `10000`) |
|
||||
| `--vidbee-no-autostart` | do not launch Desktop in background mode |
|
||||
| `--vidbee-token <token>` | skip handshake (for CI / smoke tests) |
|
||||
|
||||
### Probe vs download
|
||||
|
||||
The CLI inspects argv exactly once: if any of `-j / --dump-json / -J / --dump-single-json / -F / --list-formats / --list-formats-as-table / --list-formats-old / --print / --get-* / -s / --simulate / --skip-download / --list-subs / --list-extractors / --list-extractor-descriptions / --update / --version` is present, the run is a **probe**: yt-dlp is spawned directly, output is captured into the §4.4 envelope, and no task is enqueued. Otherwise the run is a **download** and goes through `taskQueueContract.add({ kind: 'yt-dlp-forward', … })`.
|
||||
|
||||
`-o -` (write yt-dlp output to stdout) is allowed in probe mode only.
|
||||
|
||||
---
|
||||
|
||||
## Subcommands
|
||||
|
||||
```sh
|
||||
vidbee :status
|
||||
vidbee :version
|
||||
vidbee :upgrade [--force] [--cache <path>]
|
||||
vidbee :download list [--status queued|running|...] [--limit N] [--cursor C]
|
||||
vidbee :download status <id>
|
||||
vidbee :download logs <id>
|
||||
vidbee :download cancel <id>
|
||||
vidbee :download pause <id> [--reason text]
|
||||
vidbee :download resume <id>
|
||||
vidbee :download retry <id>
|
||||
vidbee :history list
|
||||
vidbee :history remove <id...>
|
||||
```
|
||||
|
||||
`:rss …` is owned by NEX-132 and shares the same dispatch.
|
||||
|
||||
### `:version`
|
||||
|
||||
Prints the installed CLI version + contract version + a link to the changelog. Does not contact any host — safe to run on a machine with no Desktop and no network.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"mode": "subcommand",
|
||||
"subcommand": "version",
|
||||
"result": {
|
||||
"cli": "0.1.0",
|
||||
"contract": "0.1.0",
|
||||
"changelog": "https://github.com/nexmoe/vidbee/blob/main/apps/cli/CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `:upgrade`
|
||||
|
||||
Checks `https://registry.npmjs.org/@vidbee/cli/latest` and prints whether a newer version exists, along with the install command for each supported package manager. Does **not** auto-spawn `npm install`: global installs touch sudo / system PATH and are best left to the user.
|
||||
|
||||
The result is cached for 30 days under `~/Library/Caches/VidBee/cli-upgrade-check.json` (macOS), `${XDG_CACHE_HOME:-~/.cache}/vidbee/cli-upgrade-check.json` (Linux), or `%LOCALAPPDATA%\VidBee\cli-upgrade-check.json` (Windows). Pass `--force` to bypass the cache, or `--cache <path>` to redirect it.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"mode": "subcommand",
|
||||
"subcommand": "upgrade",
|
||||
"result": {
|
||||
"current": "0.1.0",
|
||||
"latest": "0.2.0",
|
||||
"upToDate": false,
|
||||
"cached": false,
|
||||
"cachedAt": "2026-05-02T00:00:00.000Z",
|
||||
"registryUrl": "https://registry.npmjs.org/@vidbee/cli/latest",
|
||||
"installCommands": {
|
||||
"npm": "npm install -g @vidbee/cli",
|
||||
"pnpm": "pnpm add -g @vidbee/cli",
|
||||
"bun": "bun install -g @vidbee/cli",
|
||||
"brew": "brew upgrade vidbee/tap/vidbee"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
Every invocation prints exactly one JSON document on stdout. Exit codes follow design §4.4:
|
||||
|
||||
| code | meaning |
|
||||
| --- | --- |
|
||||
| `0` | success (probe / detached enqueue / wait-mode terminal success) |
|
||||
| `1` | wait-mode reached non-success (`failed`, `retry-scheduled`, `paused`, timeout) |
|
||||
| `2` | argv parse error |
|
||||
| `3` | Desktop / API unreachable |
|
||||
| `4` | authentication failure |
|
||||
| `5` | contract / version mismatch |
|
||||
|
||||
Sensitive arguments (`--password`, `--video-password`, `--ap-password`, `--twofactor`, `--add-headers Authorization:…`, URL query strings with `token=` / `access_token=` / `signature=` / `policy=`) are redacted to `<redacted>` before any envelope, persisted task row, or projection sees them.
|
||||
|
||||
---
|
||||
|
||||
## Versioning & releasing
|
||||
|
||||
CLI semver is **independent of VidBee Desktop**. The CLI starts at `0.1.0`; Desktop continues on `1.x`. We bump the CLI on its own cadence so a `yt-dlp` probe-flag fix doesn't have to wait for a Desktop release.
|
||||
|
||||
To cut a release:
|
||||
|
||||
1. Update `apps/cli/package.json` `version` and add a `CHANGELOG.md` entry.
|
||||
2. Push a `cli-vX.Y.Z` tag. The [`cli-publish.yml`](https://github.com/nexmoe/vidbee/blob/main/.github/workflows/cli-publish.yml) workflow runs the build, runs the tests, and publishes to npm with `pnpm publish` using the `NPM_TOKEN` secret.
|
||||
3. Verify on a clean machine: `npx @vidbee/cli@latest :version` should report the new version.
|
||||
|
||||
`pnpm --filter @vidbee/cli build` produces `dist/index.mjs` (single bundled ESM file) and `dist/bin/vidbee.mjs` (the npm `bin`). The published tarball includes only `dist/`, the README, the CHANGELOG, and the LICENSE — no source, no tests.
|
||||
|
||||
CI runs `pnpm --filter @vidbee/cli test` on every PR; the suite ships with 149+ unit tests covering parser, probe, envelope, transports, autostart, descriptor handshake, and the new local-info commands.
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Three-host equivalence: CLI `--vidbee-local` slot.
|
||||
*
|
||||
* The kernel-level test in `packages/task-queue/__integration__/three-host-equivalence.test.ts`
|
||||
* stands in for the CLI by spinning up a third TaskQueueAPI in-process.
|
||||
* This file proves that `createLocalClient` (the production code path the
|
||||
* CLI uses for `--vidbee-local`) wraps that same kernel without changing
|
||||
* the resulting Task / projection state.
|
||||
*
|
||||
* Together they fulfil the design doc's three-host-equivalence acceptance
|
||||
* for the CLI side (§10).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { TaskQueueAPI } from '@vidbee/task-queue'
|
||||
import type {
|
||||
Executor,
|
||||
ExecutorEvents,
|
||||
ExecutorRun,
|
||||
Task
|
||||
} from '@vidbee/task-queue'
|
||||
import { MemoryPersistAdapter, projectTaskToLegacy } from '@vidbee/task-queue'
|
||||
|
||||
import { createLocalClient } from '../src/transport/local-client'
|
||||
|
||||
interface ScriptedExecutor extends Executor {}
|
||||
|
||||
const SUCCESS_SCRIPT = (filePath: string) => (events: ExecutorEvents, ctx: { taskId: string; attemptId: string }) => {
|
||||
events.onSpawn({
|
||||
taskId: ctx.taskId,
|
||||
attemptId: ctx.attemptId,
|
||||
pid: 4242,
|
||||
pidStartedAt: 1000,
|
||||
kind: 'yt-dlp',
|
||||
spawnedAt: 1
|
||||
})
|
||||
events.onProgress({
|
||||
taskId: ctx.taskId,
|
||||
attemptId: ctx.attemptId,
|
||||
progress: {
|
||||
percent: 0.5,
|
||||
bytesDownloaded: 50,
|
||||
bytesTotal: 100,
|
||||
speedBps: 100,
|
||||
etaMs: 1000,
|
||||
ticks: 1
|
||||
},
|
||||
enteredProcessing: false
|
||||
})
|
||||
events.onProgress({
|
||||
taskId: ctx.taskId,
|
||||
attemptId: ctx.attemptId,
|
||||
progress: {
|
||||
percent: 0.95,
|
||||
bytesDownloaded: 95,
|
||||
bytesTotal: 100,
|
||||
speedBps: 100,
|
||||
etaMs: 100,
|
||||
ticks: 2
|
||||
},
|
||||
enteredProcessing: true
|
||||
})
|
||||
events.onFinish({
|
||||
taskId: ctx.taskId,
|
||||
attemptId: ctx.attemptId,
|
||||
result: {
|
||||
type: 'success',
|
||||
output: { filePath, size: 1024, durationMs: null, sha256: null }
|
||||
},
|
||||
closedAt: 2,
|
||||
stdoutTail: '',
|
||||
stderrTail: ''
|
||||
})
|
||||
}
|
||||
|
||||
const makeScripted = (filePath: string): ScriptedExecutor => ({
|
||||
run(ctx, events) {
|
||||
queueMicrotask(() => SUCCESS_SCRIPT(filePath)(events, ctx))
|
||||
return { cancel: async () => {}, pause: async () => {} } as ExecutorRun
|
||||
}
|
||||
})
|
||||
|
||||
const stripVolatile = (task: Readonly<Task>): unknown => {
|
||||
const c = JSON.parse(JSON.stringify(task)) as Record<string, unknown>
|
||||
delete c.id
|
||||
delete c.createdAt
|
||||
delete c.updatedAt
|
||||
delete c.enteredStatusAt
|
||||
delete c.pid
|
||||
delete c.pidStartedAt
|
||||
delete c.nextRetryAt
|
||||
return c
|
||||
}
|
||||
|
||||
const stripVolatileProjection = (task: Readonly<Task>): unknown => {
|
||||
const p = projectTaskToLegacy(task) as unknown as Record<string, unknown>
|
||||
delete p.id
|
||||
delete p.createdAt
|
||||
delete p.startedAt
|
||||
delete p.completedAt
|
||||
delete p.nextRetryAt
|
||||
return p
|
||||
}
|
||||
|
||||
const wait = async (predicate: () => boolean, timeoutMs = 2_000): Promise<void> => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
}
|
||||
throw new Error('wait: predicate did not hold within timeout')
|
||||
}
|
||||
|
||||
describe('createLocalClient — three-host equivalence (CLI slot)', () => {
|
||||
it('produces the same Task/projection as a bare TaskQueueAPI', async () => {
|
||||
const filePath = '/tmp/sample.mp4'
|
||||
|
||||
// Reference host: a TaskQueueAPI configured exactly like the kernel's
|
||||
// own three-host test does it.
|
||||
const refExec = makeScripted(filePath)
|
||||
const ref = new TaskQueueAPI({
|
||||
persist: new MemoryPersistAdapter(),
|
||||
executor: refExec,
|
||||
maxConcurrency: 4,
|
||||
rng: () => 0.5,
|
||||
filePresent: () => true
|
||||
})
|
||||
await ref.start()
|
||||
|
||||
// CLI host: production createLocalClient with the same fake executor.
|
||||
const cli = await createLocalClient({
|
||||
persist: 'memory',
|
||||
executor: makeScripted(filePath),
|
||||
filePresent: () => true,
|
||||
rng: () => 0.5,
|
||||
maxConcurrency: 4
|
||||
})
|
||||
|
||||
try {
|
||||
const input = { url: 'https://example.com/v', kind: 'video' as const, title: 'sample' }
|
||||
const refAdd = await ref.add({ input })
|
||||
const cliAdd = await cli.add!({ input })
|
||||
|
||||
await wait(() => ref.get(refAdd.id)?.status === 'completed')
|
||||
await wait(() => cli.api.get(cliAdd.id)?.status === 'completed')
|
||||
|
||||
const refTask = ref.get(refAdd.id)!
|
||||
const cliTask = cli.api.get(cliAdd.id)!
|
||||
|
||||
expect(stripVolatile(cliTask)).toStrictEqual(stripVolatile(refTask))
|
||||
expect(stripVolatileProjection(cliTask)).toStrictEqual(stripVolatileProjection(refTask))
|
||||
} finally {
|
||||
await ref.stop()
|
||||
await cli.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import('../index.mjs').catch((e) => {
|
||||
process.stderr.write(JSON.stringify({ ok: false, code: 'UNKNOWN_ERROR', message: String(e?.message ?? e) }) + '\n')
|
||||
process.exit(2)
|
||||
})
|
||||
Vendored
+27548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@vidbee/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "VidBee CLI — standalone yt-dlp wrapper that talks to the shared VidBee TaskQueue. Works against a local Desktop, a remote API, or fully in-process for CI / Docker / agents.",
|
||||
"homepage": "https://github.com/nexmoe/vidbee/tree/main/apps/cli#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/nexmoe/vidbee/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/nexmoe/vidbee.git",
|
||||
"directory": "apps/cli"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "VidBee Authors",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"vidbee": "./dist/bin/vidbee.mjs"
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./parser": "./src/parser/index.ts",
|
||||
"./envelope": "./src/envelope/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": "./dist/index.mjs"
|
||||
},
|
||||
"main": "./dist/index.mjs",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"keywords": [
|
||||
"vidbee",
|
||||
"yt-dlp",
|
||||
"video",
|
||||
"video-downloader",
|
||||
"downloader",
|
||||
"cli",
|
||||
"agent",
|
||||
"automation"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"build": "node scripts/build.mjs",
|
||||
"verify-dist": "node -e \"const fs=require('node:fs');for(const f of ['dist/index.mjs','dist/bin/vidbee.mjs']){if(!fs.existsSync(f)){console.error('missing build artifact: '+f);process.exit(1)}}\"",
|
||||
"prepublishOnly": "pnpm run test && pnpm run build && pnpm run verify-dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"yt-dlp-wrap-plus": "^2.3.20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.18.6",
|
||||
"@vidbee/db": "workspace:*",
|
||||
"@vidbee/downloader-core": "workspace:*",
|
||||
"@vidbee/task-queue": "workspace:*",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bundles the CLI as a single ESM file (`dist/index.mjs`) plus the
|
||||
* npm-bin shim (`dist/bin/vidbee.mjs`).
|
||||
*
|
||||
* The published artifact is the standalone npm tarball — Desktop no
|
||||
* longer bundles the CLI (NEX-148). `--vidbee-local` requires the bundled
|
||||
* `@vidbee/task-queue` and `@vidbee/downloader-core`; the bundler inlines
|
||||
* them. `better-sqlite3` is left external so the npm consumer installs it
|
||||
* as an `optionalDependency` only when crash-recovery is wanted.
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { mkdirSync, chmodSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(__dirname, '..')
|
||||
const dist = join(root, 'dist')
|
||||
|
||||
// Wipe `dist/` on every build so a previous build's artifacts (e.g. the
|
||||
// pre-NEX-148 `dist/shim/` directory) can never sneak into the published
|
||||
// tarball.
|
||||
rmSync(dist, { recursive: true, force: true })
|
||||
mkdirSync(dist, { recursive: true })
|
||||
mkdirSync(join(dist, 'bin'), { recursive: true })
|
||||
|
||||
await build({
|
||||
entryPoints: [join(root, 'src/bin.ts')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: join(dist, 'index.mjs'),
|
||||
external: ['better-sqlite3', 'electron', 'yt-dlp-wrap-plus', '@opentelemetry/api'],
|
||||
legalComments: 'inline',
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node\n// @vidbee/cli — standalone build (NEX-148). Built by scripts/build.mjs.'
|
||||
}
|
||||
})
|
||||
|
||||
// npm bin shim: thin wrapper that runs the bundled index.mjs.
|
||||
const npmBin = `#!/usr/bin/env node
|
||||
import('../index.mjs').catch((e) => {
|
||||
process.stderr.write(JSON.stringify({ ok: false, code: 'UNKNOWN_ERROR', message: String(e?.message ?? e) }) + '\\n')
|
||||
process.exit(2)
|
||||
})
|
||||
`
|
||||
const npmBinPath = join(dist, 'bin', 'vidbee.mjs')
|
||||
mkdirSync(dirname(npmBinPath), { recursive: true })
|
||||
const fs = await import('node:fs/promises')
|
||||
await fs.writeFile(npmBinPath, npmBin, 'utf-8')
|
||||
chmodSync(npmBinPath, 0o755)
|
||||
|
||||
console.log('built', dist)
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* CLI entrypoint. Wires `process.argv` into the testable `run()` function.
|
||||
* The shebang for the bundled output is added by `scripts/build.mjs`'s
|
||||
* esbuild banner; we don't need one in source.
|
||||
*
|
||||
* Phase A: argv slice [2:], stdout / stderr passthrough, exit with run()'s
|
||||
* computed code. The yt-dlp spawn path and the `--vidbee-local`
|
||||
* production wiring (constructing TaskQueueAPI + YtDlpExecutor) land in
|
||||
* Phase B once NEX-131 has merged A1 or A2.
|
||||
*/
|
||||
|
||||
import { run } from './runtime'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2)
|
||||
const { exitCode } = await run(argv, {
|
||||
stdout: (line) => process.stdout.write(`${line}\n`),
|
||||
stderr: (line) => process.stderr.write(`${line}\n`)
|
||||
})
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({ ok: false, code: 'UNKNOWN_ERROR', message: err instanceof Error ? err.message : String(err) })}\n`
|
||||
)
|
||||
process.exit(2)
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Build a TaskInput for the `yt-dlp-forward` kind from raw argv. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §6.2 (ForwardedYtDlpTaskInput)
|
||||
*
|
||||
* Hosts run `YtDlpExecutor` whose `buildArgsFor` first checks
|
||||
* `input.rawArgs` and uses it verbatim when present. We pack:
|
||||
*
|
||||
* - `rawArgs`: the unredacted argv (held in memory only by host adapter
|
||||
* for the spawn; do NOT read this back from CLI side).
|
||||
* - `options.sanitizedArgs`: the redacted argv used in envelopes / logs.
|
||||
* - `options.commandPreview`: human-readable `yt-dlp <sanitizedArgs>`.
|
||||
* - `options.outputHints`: best-effort parse of -o / --paths / `-o -`.
|
||||
* - `options.source: 'cli'`.
|
||||
*
|
||||
* URL extraction is best-effort: yt-dlp accepts a URL anywhere in argv;
|
||||
* we use the last bare positional that looks like a URL so the Task row
|
||||
* has a meaningful `url`. The executor itself doesn't depend on this
|
||||
* field when `rawArgs` is set, so a wrong guess is non-fatal.
|
||||
*/
|
||||
|
||||
import type { TaskInput, TaskKind } from '@vidbee/task-queue'
|
||||
|
||||
import type { Flags } from '../parser'
|
||||
import { redactArgs } from '../parser/redact'
|
||||
|
||||
export interface BuildForwardedInputOptions {
|
||||
argv: readonly string[]
|
||||
flags: Flags
|
||||
}
|
||||
|
||||
export interface BuildForwardedInputResult {
|
||||
input: TaskInput
|
||||
commandPreview: string
|
||||
redacted: boolean
|
||||
}
|
||||
|
||||
const URL_REGEX = /^https?:\/\//i
|
||||
|
||||
export function buildForwardedInput(
|
||||
opts: BuildForwardedInputOptions
|
||||
): BuildForwardedInputResult {
|
||||
const { args: sanitizedArgs, summary } = redactArgs(opts.argv)
|
||||
const url = guessUrl(opts.argv) ?? ''
|
||||
const outputHints = parseOutputHints(opts.argv)
|
||||
const commandPreview = formatCommand(sanitizedArgs)
|
||||
|
||||
const kind: TaskKind = 'yt-dlp-forward'
|
||||
const input: TaskInput = {
|
||||
url,
|
||||
kind,
|
||||
rawArgs: [...opts.argv],
|
||||
options: {
|
||||
source: 'cli',
|
||||
sanitizedArgs,
|
||||
commandPreview,
|
||||
outputHints,
|
||||
vidbee: {
|
||||
wait: opts.flags.wait,
|
||||
...(opts.flags.maxAttempts !== undefined && {
|
||||
maxAttempts: opts.flags.maxAttempts
|
||||
}),
|
||||
...(opts.flags.priority && { priority: opts.flags.priority }),
|
||||
...(opts.flags.groupKey !== undefined && { groupKey: opts.flags.groupKey })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { input, commandPreview, redacted: summary.redacted }
|
||||
}
|
||||
|
||||
function formatCommand(argv: readonly string[]): string {
|
||||
return ['yt-dlp', ...argv]
|
||||
.map((tok) => (needsShellQuote(tok) ? quote(tok) : tok))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function needsShellQuote(tok: string): boolean {
|
||||
return /[\s'"\\$`]/.test(tok)
|
||||
}
|
||||
|
||||
function quote(tok: string): string {
|
||||
return `'${tok.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
function guessUrl(argv: readonly string[]): string | null {
|
||||
// yt-dlp lets URLs appear at any position — we walk argv and pick the
|
||||
// last bare positional that looks like an HTTP(S) URL. Bare = not the
|
||||
// value of a flag like `--cookies <path>`.
|
||||
let last: string | null = null
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i]
|
||||
if (!tok || tok.startsWith('-')) continue
|
||||
const prev = argv[i - 1]
|
||||
if (prev !== undefined && prev.startsWith('-') && consumesValue(prev)) continue
|
||||
if (URL_REGEX.test(tok)) last = tok
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of yt-dlp flags whose immediately-following positional is a
|
||||
* value, not a URL. We don't need to be exhaustive — false negatives only
|
||||
* mean a URL might be guessed wrong, which is recoverable (executor uses
|
||||
* rawArgs verbatim regardless).
|
||||
*/
|
||||
const VALUE_CONSUMING_FLAGS = new Set<string>([
|
||||
'-f',
|
||||
'--format',
|
||||
'-o',
|
||||
'--output',
|
||||
'--cookies',
|
||||
'--cookies-from-browser',
|
||||
'--proxy',
|
||||
'--user-agent',
|
||||
'--referer',
|
||||
'--sleep-interval',
|
||||
'-r',
|
||||
'--limit-rate',
|
||||
'--retries',
|
||||
'--fragment-retries',
|
||||
'--retry-sleep',
|
||||
'-N',
|
||||
'--concurrent-fragments',
|
||||
'--throttled-rate',
|
||||
'--add-headers',
|
||||
'--add-header',
|
||||
'--config-location',
|
||||
'--paths',
|
||||
'-P',
|
||||
'--ffmpeg-location',
|
||||
'--postprocessor-args',
|
||||
'--user-agent',
|
||||
'--print',
|
||||
'--username',
|
||||
'--password',
|
||||
'--video-password',
|
||||
'--ap-password',
|
||||
'--twofactor',
|
||||
'--audio-format',
|
||||
'--audio-quality',
|
||||
'--merge-output-format',
|
||||
'--remux-video',
|
||||
'--recode-video',
|
||||
'--container'
|
||||
])
|
||||
|
||||
function consumesValue(flag: string): boolean {
|
||||
// Strip `=value` since `--foo=value` is one token and never consumes.
|
||||
if (flag.includes('=')) return false
|
||||
return VALUE_CONSUMING_FLAGS.has(flag)
|
||||
}
|
||||
|
||||
function parseOutputHints(argv: readonly string[]): {
|
||||
outputTemplate?: string
|
||||
paths?: string[]
|
||||
stdoutMode?: boolean
|
||||
} {
|
||||
let outputTemplate: string | undefined
|
||||
const paths: string[] = []
|
||||
let stdoutMode = false
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i]
|
||||
if (tok === undefined) continue
|
||||
if (tok === '-o' || tok === '--output') {
|
||||
const v = argv[i + 1]
|
||||
if (v === '-') stdoutMode = true
|
||||
else if (v) outputTemplate = v
|
||||
continue
|
||||
}
|
||||
if (tok === '-o-' || tok === '-o=-' || tok === '--output=-') {
|
||||
stdoutMode = true
|
||||
continue
|
||||
}
|
||||
if (tok.startsWith('-o=')) {
|
||||
outputTemplate = tok.slice(3)
|
||||
continue
|
||||
}
|
||||
if (tok.startsWith('--output=')) {
|
||||
outputTemplate = tok.slice('--output='.length)
|
||||
continue
|
||||
}
|
||||
if (tok === '-P' || tok === '--paths') {
|
||||
const v = argv[i + 1]
|
||||
if (v) paths.push(v)
|
||||
}
|
||||
}
|
||||
const out: { outputTemplate?: string; paths?: string[]; stdoutMode?: boolean } = {}
|
||||
if (outputTemplate !== undefined) out.outputTemplate = outputTemplate
|
||||
if (paths.length > 0) out.paths = paths
|
||||
if (stdoutMode) out.stdoutMode = true
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Download enqueue + optional wait-for-terminal-state. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.4, §6.1
|
||||
*
|
||||
* Behavior:
|
||||
* - `--vidbee-detach` (default) returns immediately after `add()` with the
|
||||
* queued task, exit 0.
|
||||
* - `--vidbee-wait` polls `get(id)` until the task reaches a terminal
|
||||
* state. Successful → exit 0; failed/retry-scheduled/cancelled → exit 1.
|
||||
*
|
||||
* Polling cadence: 200ms while running, 1s while paused/retry-scheduled.
|
||||
* The contract doesn't yet expose a per-task event subscription that we
|
||||
* could await directly, so we poll. Phase B+ work could swap this for the
|
||||
* SSE stream available at `/automation/v1/events`.
|
||||
*/
|
||||
|
||||
import type { Task, AddTaskRequest } from '@vidbee/task-queue'
|
||||
import { TERMINAL_STATUSES } from '@vidbee/task-queue'
|
||||
|
||||
import type { ContractClient } from '../subcommands'
|
||||
|
||||
export interface EnqueueOptions {
|
||||
client: ContractClient
|
||||
request: AddTaskRequest
|
||||
wait: boolean
|
||||
/** Total wait budget; null = unlimited (until terminal). */
|
||||
waitTimeoutMs?: number | null
|
||||
/** Test seam. */
|
||||
clock?: () => number
|
||||
delay?: (ms: number) => Promise<void>
|
||||
pollFastMs?: number
|
||||
pollSlowMs?: number
|
||||
}
|
||||
|
||||
export type EnqueueResult =
|
||||
| { kind: 'detached'; task: Task }
|
||||
| { kind: 'wait-success'; task: Task }
|
||||
| { kind: 'wait-non-success'; task: Task; reason: 'failed' | 'cancelled' | 'retry-scheduled' | 'paused' | 'timeout' }
|
||||
|
||||
const defaultDelay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||
|
||||
export async function enqueueDownload(opts: EnqueueOptions): Promise<EnqueueResult> {
|
||||
if (!opts.client.add) {
|
||||
throw new Error('transport does not support add()')
|
||||
}
|
||||
const created = await opts.client.add(opts.request)
|
||||
let task: Task = created.task
|
||||
if (!opts.wait) return { kind: 'detached', task }
|
||||
|
||||
const clock = opts.clock ?? Date.now
|
||||
const delay = opts.delay ?? defaultDelay
|
||||
const fast = opts.pollFastMs ?? 200
|
||||
const slow = opts.pollSlowMs ?? 1_000
|
||||
const start = clock()
|
||||
const budget = opts.waitTimeoutMs ?? null
|
||||
|
||||
while (true) {
|
||||
if (TERMINAL_STATUSES.has(task.status)) {
|
||||
return task.status === 'completed'
|
||||
? { kind: 'wait-success', task }
|
||||
: { kind: 'wait-non-success', task, reason: task.status as 'failed' | 'cancelled' }
|
||||
}
|
||||
// §4.4 example: --vidbee-wait surfaces retry-scheduled as exit 1
|
||||
// immediately rather than waiting for the next attempt to fire.
|
||||
if (task.status === 'retry-scheduled' || task.status === 'paused') {
|
||||
return { kind: 'wait-non-success', task, reason: task.status }
|
||||
}
|
||||
if (budget !== null && clock() - start >= budget) {
|
||||
return { kind: 'wait-non-success', task, reason: 'timeout' }
|
||||
}
|
||||
const interval = task.status === 'queued' ? slow : fast
|
||||
await delay(interval)
|
||||
task = await opts.client.get(task.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Probe-mode execution. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.3, §4.5, §8.3
|
||||
*
|
||||
* Probes do NOT enter the queue. They spawn yt-dlp directly with the raw
|
||||
* argv, capture stdout/stderr, and surface the result wrapped in the §4.4
|
||||
* envelope. The 32MB stdout cap is enforced; stderr is best-effort capped
|
||||
* so a runaway log can't blow our memory budget either.
|
||||
*
|
||||
* The CLI bundles a yt-dlp binary path discovery — Desktop's
|
||||
* `node_modules/yt-dlp-wrap-plus` ships one, but for the standalone
|
||||
* `--vidbee-local` / `npx @vidbee/cli` flows we accept `YTDLP_PATH` from
|
||||
* env or fall back to `yt-dlp` on PATH.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
|
||||
|
||||
export interface ProbeOptions {
|
||||
argv: readonly string[]
|
||||
ytDlpPath?: string
|
||||
/** Test seam — overrides spawn. */
|
||||
spawner?: ProbeSpawner
|
||||
/** Max stdout bytes; design = 32MB. */
|
||||
stdoutMaxBytes?: number
|
||||
/** stderr tail (default 64KB; same as attempts.stderr_tail). */
|
||||
stderrTailBytes?: number
|
||||
}
|
||||
|
||||
export interface ProbeSpawnHandle {
|
||||
stdout: NodeJS.ReadableStream
|
||||
stderr: NodeJS.ReadableStream
|
||||
on(event: 'close', listener: (code: number | null) => void): void
|
||||
on(event: 'error', listener: (err: Error) => void): void
|
||||
kill: (signal?: NodeJS.Signals) => void
|
||||
}
|
||||
|
||||
export type ProbeSpawner = (binary: string, args: readonly string[]) => ProbeSpawnHandle
|
||||
|
||||
export type ProbeResult =
|
||||
| { kind: 'success'; stdout: string; stderr: string; exitCode: number; binary: string }
|
||||
| { kind: 'error'; envelope: ErrorEnvelope }
|
||||
|
||||
const DEFAULT_STDOUT_MAX = 32 * 1024 * 1024
|
||||
const DEFAULT_STDERR_TAIL = 64 * 1024
|
||||
|
||||
export async function runProbe(opts: ProbeOptions): Promise<ProbeResult> {
|
||||
const binary = opts.ytDlpPath ?? resolveYtDlpBinary()
|
||||
const stdoutMax = opts.stdoutMaxBytes ?? DEFAULT_STDOUT_MAX
|
||||
const stderrTail = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL
|
||||
const spawner = opts.spawner ?? defaultSpawner
|
||||
|
||||
let handle: ProbeSpawnHandle
|
||||
try {
|
||||
handle = spawner(binary, opts.argv)
|
||||
} catch (err) {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'NOT_IMPLEMENTED',
|
||||
`failed to spawn yt-dlp at ${binary}: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let stdoutBytes = 0
|
||||
let stdoutOverflow = false
|
||||
const stdoutChunks: Buffer[] = []
|
||||
const stderrTailBuf = createTailBuffer(stderrTail)
|
||||
|
||||
handle.stdout.on('data', (chunk: Buffer) => {
|
||||
if (stdoutOverflow) return
|
||||
if (stdoutBytes + chunk.byteLength > stdoutMax) {
|
||||
stdoutOverflow = true
|
||||
handle.kill('SIGTERM')
|
||||
return
|
||||
}
|
||||
stdoutBytes += chunk.byteLength
|
||||
stdoutChunks.push(chunk)
|
||||
})
|
||||
handle.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrTailBuf.push(chunk)
|
||||
})
|
||||
|
||||
return new Promise<ProbeResult>((resolve) => {
|
||||
let settled = false
|
||||
handle.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve({
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'NOT_IMPLEMENTED',
|
||||
`yt-dlp spawn error: ${err.message}`
|
||||
)
|
||||
})
|
||||
})
|
||||
handle.on('close', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (stdoutOverflow) {
|
||||
resolve({
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'PROBE_OUTPUT_TOO_LARGE',
|
||||
`probe stdout exceeded ${stdoutMax} bytes`
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf-8')
|
||||
resolve({
|
||||
kind: 'success',
|
||||
stdout,
|
||||
stderr: stderrTailBuf.read().toString('utf-8'),
|
||||
exitCode: code ?? -1,
|
||||
binary
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function resolveYtDlpBinary(): string {
|
||||
const env = process.env.YTDLP_PATH?.trim()
|
||||
return env && env.length > 0 ? env : 'yt-dlp'
|
||||
}
|
||||
|
||||
function defaultSpawner(binary: string, args: readonly string[]): ProbeSpawnHandle {
|
||||
const child = spawn(binary, [...args], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
return {
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
on: (ev, l) => {
|
||||
child.on(ev as 'close' | 'error', l as never)
|
||||
},
|
||||
kill: (signal) => {
|
||||
try {
|
||||
child.kill(signal)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TailBuffer {
|
||||
push: (chunk: Buffer) => void
|
||||
read: () => Buffer
|
||||
}
|
||||
|
||||
function createTailBuffer(maxBytes: number): TailBuffer {
|
||||
let buf = Buffer.alloc(0)
|
||||
return {
|
||||
push(chunk: Buffer) {
|
||||
const merged = Buffer.concat([buf, chunk])
|
||||
buf = merged.byteLength > maxBytes ? merged.subarray(merged.byteLength - maxBytes) : merged
|
||||
},
|
||||
read: () => buf
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* JSON envelope shapes the CLI prints. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.4
|
||||
*
|
||||
* Every CLI invocation produces exactly one JSON document on stdout.
|
||||
* Exit code is determined by ExitCode below; envelope and exit code
|
||||
* MUST be consistent.
|
||||
*/
|
||||
|
||||
import type { Task } from '@vidbee/task-queue'
|
||||
|
||||
export const ExitCode = {
|
||||
/** probe success | detached download enqueue success | wait-mode success */
|
||||
OK: 0,
|
||||
/** wait-mode terminated non-success (failed | retry-scheduled) */
|
||||
WAIT_NON_SUCCESS: 1,
|
||||
/** argv parse error (incl. unknown --vidbee-*) */
|
||||
ARG_ERROR: 2,
|
||||
/** desktop / api unreachable: descriptor missing, conn refused, handshake failed */
|
||||
HOST_UNREACHABLE: 3,
|
||||
/** auth failure */
|
||||
AUTH_FAILED: 4,
|
||||
/** internal contract error (schema mismatch, version mismatch) */
|
||||
CONTRACT_ERROR: 5
|
||||
} as const
|
||||
|
||||
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]
|
||||
|
||||
export interface ProbeSuccessEnvelope {
|
||||
ok: true
|
||||
mode: 'probe'
|
||||
command: string
|
||||
ytDlp: { exitCode: number; stdout: string; stderr: string }
|
||||
}
|
||||
|
||||
export interface DownloadDetachedEnvelope {
|
||||
ok: true
|
||||
mode: 'download'
|
||||
task: Pick<Task, 'id' | 'status' | 'attempt' | 'maxAttempts'> & {
|
||||
command: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadWaitSuccessEnvelope {
|
||||
ok: true
|
||||
mode: 'download'
|
||||
task: Task
|
||||
ytDlp: { exitCode: number; stdoutTail: string; stderrTail: string }
|
||||
}
|
||||
|
||||
export interface DownloadWaitFailureEnvelope {
|
||||
ok: false
|
||||
mode: 'download'
|
||||
task: Task
|
||||
ytDlp: { exitCode: number; stdoutTail: string; stderrTail: string }
|
||||
}
|
||||
|
||||
export type ErrorCode =
|
||||
| 'PARSE_ERROR'
|
||||
| 'UNKNOWN_VIDBEE_FLAG'
|
||||
| 'INVALID_TARGET'
|
||||
| 'INVALID_PRIORITY'
|
||||
| 'INVALID_MAX_ATTEMPTS'
|
||||
| 'INVALID_TIMEOUT'
|
||||
| 'MISSING_VALUE'
|
||||
| 'UNEXPECTED_VALUE'
|
||||
| 'STDOUT_OUTPUT_DISALLOWED'
|
||||
| 'PROBE_OUTPUT_TOO_LARGE'
|
||||
| 'DESKTOP_NOT_READY'
|
||||
| 'API_UNREACHABLE'
|
||||
| 'HANDSHAKE_FAILED'
|
||||
| 'TOKEN_EXPIRED'
|
||||
| 'AUTH_FAILED'
|
||||
| 'CONTRACT_VERSION_MISMATCH'
|
||||
| 'CONTRACT_SCHEMA_MISMATCH'
|
||||
| 'NOT_IMPLEMENTED'
|
||||
| 'UNKNOWN_ERROR'
|
||||
|
||||
export interface ErrorEnvelope {
|
||||
ok: false
|
||||
code: ErrorCode
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Envelope =
|
||||
| ProbeSuccessEnvelope
|
||||
| DownloadDetachedEnvelope
|
||||
| DownloadWaitSuccessEnvelope
|
||||
| DownloadWaitFailureEnvelope
|
||||
| ErrorEnvelope
|
||||
| { ok: true; mode: 'subcommand'; subcommand: string; result: unknown }
|
||||
|
||||
export interface RenderOptions {
|
||||
pretty?: boolean
|
||||
}
|
||||
|
||||
export function renderEnvelope(env: Envelope, opts: RenderOptions = {}): string {
|
||||
return opts.pretty ? JSON.stringify(env, null, 2) : JSON.stringify(env)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ErrorCode to its corresponding exit code per §4.4. Anything not
|
||||
* in the explicit map falls through to `ARG_ERROR` (2) so a bug here can
|
||||
* never silently produce exit 0.
|
||||
*/
|
||||
export function exitCodeForError(code: ErrorCode): ExitCode {
|
||||
switch (code) {
|
||||
case 'DESKTOP_NOT_READY':
|
||||
case 'API_UNREACHABLE':
|
||||
case 'HANDSHAKE_FAILED':
|
||||
return ExitCode.HOST_UNREACHABLE
|
||||
case 'AUTH_FAILED':
|
||||
case 'TOKEN_EXPIRED':
|
||||
return ExitCode.AUTH_FAILED
|
||||
case 'CONTRACT_VERSION_MISMATCH':
|
||||
case 'CONTRACT_SCHEMA_MISMATCH':
|
||||
return ExitCode.CONTRACT_ERROR
|
||||
case 'PROBE_OUTPUT_TOO_LARGE':
|
||||
return ExitCode.WAIT_NON_SUCCESS
|
||||
case 'NOT_IMPLEMENTED':
|
||||
return ExitCode.CONTRACT_ERROR
|
||||
default:
|
||||
return ExitCode.ARG_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
export function errorEnvelope(
|
||||
code: ErrorCode,
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): ErrorEnvelope {
|
||||
return details === undefined
|
||||
? { ok: false, code, message }
|
||||
: { ok: false, code, message, details }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Public exports for in-process consumers (tests, host adapters, the
|
||||
// three-host equivalence test). The CLI binary lives in ./bin.ts.
|
||||
|
||||
export * from './parser'
|
||||
export * from './envelope'
|
||||
export * from './subcommands'
|
||||
export * from './transport'
|
||||
export { connect } from './transport/connect'
|
||||
export type { ConnectOptions, ConnectResult } from './transport/connect'
|
||||
export { AutomationClient, AutomationHttpError } from './transport/automation-client'
|
||||
export type { AutomationClientOptions, HandshakeResponse } from './transport/automation-client'
|
||||
export { readDescriptor, resolveDescriptorPath, isPidAlive } from './transport/descriptor'
|
||||
export type { DescriptorPayload, ReadDescriptorResult } from './transport/descriptor'
|
||||
export { ensureDesktopReady } from './transport/autostart'
|
||||
export type { AutostartOptions, AutostartResult } from './transport/autostart'
|
||||
export { createLocalClient } from './transport/local-client'
|
||||
export type { LocalClientOptions, LocalClientHandle } from './transport/local-client'
|
||||
export { redactArgs, redactText, REDACTED_PLACEHOLDER } from './parser/redact'
|
||||
export { buildForwardedInput } from './download/build-input'
|
||||
export { enqueueDownload } from './download/enqueue'
|
||||
export type { EnqueueOptions, EnqueueResult } from './download/enqueue'
|
||||
export { runProbe } from './download/probe'
|
||||
export type { ProbeOptions, ProbeResult, ProbeSpawner, ProbeSpawnHandle } from './download/probe'
|
||||
export { run } from './runtime'
|
||||
export type { RunIO, RunResult } from './runtime'
|
||||
export {
|
||||
readCliVersion,
|
||||
checkUpgrade,
|
||||
compareSemver,
|
||||
defaultCachePath
|
||||
} from './local-info'
|
||||
export type {
|
||||
CliVersionInfo,
|
||||
UpgradeCheckInput,
|
||||
UpgradeCheckResult
|
||||
} from './local-info'
|
||||
@@ -0,0 +1,8 @@
|
||||
export { readCliVersion } from './version'
|
||||
export type { CliVersionInfo } from './version'
|
||||
export {
|
||||
checkUpgrade,
|
||||
compareSemver,
|
||||
defaultCachePath
|
||||
} from './upgrade'
|
||||
export type { UpgradeCheckInput, UpgradeCheckResult } from './upgrade'
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* `vidbee :upgrade` — fetch the npm registry "latest" tag for
|
||||
* `@vidbee/cli`, compare with the installed version, and tell the caller
|
||||
* which package-manager command to run if newer.
|
||||
*
|
||||
* Design constraints (NEX-148 §3):
|
||||
* - Do NOT auto-spawn `npm i -g` — sudo / global path / brew vs npm
|
||||
* ownership differs by host; we hand the decision to the user.
|
||||
* - Cache the registry response for ~30 days so we don't hammer npm on
|
||||
* every CLI invocation. Cache file is platform-specific.
|
||||
* - Output is JSON-friendly (Agent envelope).
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, platform } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
const REGISTRY_URL = 'https://registry.npmjs.org/@vidbee/cli/latest'
|
||||
const FETCH_TIMEOUT_MS = 5_000
|
||||
|
||||
export interface UpgradeCheckInput {
|
||||
current: string
|
||||
/** Override for tests. */
|
||||
fetchLatest?: () => Promise<{ version: string; fetchedAt: number }>
|
||||
/** Override for tests; defaults to platform XDG cache. */
|
||||
cachePath?: string
|
||||
/** Force a fresh registry fetch even if cache is fresh. */
|
||||
force?: boolean
|
||||
/** Inject `Date.now()` for tests. */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export interface UpgradeCheckResult {
|
||||
current: string
|
||||
latest: string
|
||||
upToDate: boolean
|
||||
cached: boolean
|
||||
cachedAt: string | null
|
||||
registryUrl: string
|
||||
installCommands: {
|
||||
npm: string
|
||||
pnpm: string
|
||||
bun: string
|
||||
brew: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpgrade(
|
||||
input: UpgradeCheckInput
|
||||
): Promise<UpgradeCheckResult> {
|
||||
const now = input.now ?? Date.now
|
||||
const cachePath = input.cachePath ?? defaultCachePath()
|
||||
const fetcher = input.fetchLatest ?? defaultFetchLatest
|
||||
|
||||
let cachedAt: number | null = null
|
||||
let latest: string | null = null
|
||||
if (!input.force) {
|
||||
const cached = readCache(cachePath)
|
||||
if (cached && now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
latest = cached.version
|
||||
cachedAt = cached.fetchedAt
|
||||
}
|
||||
}
|
||||
|
||||
let cachedFlag = latest !== null
|
||||
if (latest === null) {
|
||||
const fetched = await fetcher()
|
||||
latest = fetched.version
|
||||
cachedAt = fetched.fetchedAt
|
||||
cachedFlag = false
|
||||
writeCache(cachePath, { version: latest, fetchedAt: cachedAt })
|
||||
}
|
||||
|
||||
const upToDate = compareSemver(input.current, latest) >= 0
|
||||
return {
|
||||
current: input.current,
|
||||
latest,
|
||||
upToDate,
|
||||
cached: cachedFlag,
|
||||
cachedAt: cachedAt === null ? null : new Date(cachedAt).toISOString(),
|
||||
registryUrl: REGISTRY_URL,
|
||||
installCommands: {
|
||||
npm: 'npm install -g @vidbee/cli',
|
||||
pnpm: 'pnpm add -g @vidbee/cli',
|
||||
bun: 'bun install -g @vidbee/cli',
|
||||
brew: 'brew upgrade vidbee/tap/vidbee'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultFetchLatest(): Promise<{
|
||||
version: string
|
||||
fetchedAt: number
|
||||
}> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(REGISTRY_URL, {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: ctrl.signal
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`registry responded ${res.status}`)
|
||||
}
|
||||
const body = (await res.json()) as { version?: string }
|
||||
if (typeof body.version !== 'string') {
|
||||
throw new Error('registry response missing `version`')
|
||||
}
|
||||
return { version: body.version, fetchedAt: Date.now() }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function readCache(
|
||||
path: string
|
||||
): { version: string; fetchedAt: number } | null {
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as {
|
||||
version?: string
|
||||
fetchedAt?: number
|
||||
}
|
||||
if (
|
||||
typeof parsed.version === 'string' &&
|
||||
typeof parsed.fetchedAt === 'number'
|
||||
) {
|
||||
return { version: parsed.version, fetchedAt: parsed.fetchedAt }
|
||||
}
|
||||
} catch {
|
||||
// ignore: cache miss
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeCache(
|
||||
path: string,
|
||||
payload: { version: string; fetchedAt: number }
|
||||
): void {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, JSON.stringify(payload), 'utf-8')
|
||||
} catch {
|
||||
// best-effort; the user can still rerun :upgrade.
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultCachePath(): string {
|
||||
const home = homedir()
|
||||
if (platform() === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local')
|
||||
return join(localAppData, 'VidBee', 'cli-upgrade-check.json')
|
||||
}
|
||||
if (platform() === 'darwin') {
|
||||
return join(home, 'Library', 'Caches', 'VidBee', 'cli-upgrade-check.json')
|
||||
}
|
||||
const xdg = process.env.XDG_CACHE_HOME ?? join(home, '.cache')
|
||||
return join(xdg, 'vidbee', 'cli-upgrade-check.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal semver-ish compare so we don't pull in a runtime dep. Treats
|
||||
* pre-release tags (`-rc.1`) as lower than the corresponding release.
|
||||
* Returns negative if a<b, positive if a>b, 0 if equal.
|
||||
*/
|
||||
export function compareSemver(a: string, b: string): number {
|
||||
const [aMain = '', aPre = ''] = a.split('-', 2)
|
||||
const [bMain = '', bPre = ''] = b.split('-', 2)
|
||||
const aParts = aMain.split('.').map((p) => Number.parseInt(p, 10) || 0)
|
||||
const bParts = bMain.split('.').map((p) => Number.parseInt(p, 10) || 0)
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const av = aParts[i] ?? 0
|
||||
const bv = bParts[i] ?? 0
|
||||
if (av !== bv) return av - bv
|
||||
}
|
||||
if (aPre === bPre) return 0
|
||||
if (aPre === '') return 1 // 0.1.0 > 0.1.0-rc.1
|
||||
if (bPre === '') return -1
|
||||
return aPre < bPre ? -1 : 1
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Resolve the installed CLI version by reading the package.json that ships
|
||||
* alongside the bundle (or the source tree, in dev). We avoid hard-coding
|
||||
* the version into a generated TS file so that a `pnpm version` bump in
|
||||
* `package.json` is picked up without an extra build step.
|
||||
*
|
||||
* Reference: NEX-148 §3.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const FALLBACK_VERSION = '0.0.0-dev'
|
||||
|
||||
export interface CliVersionInfo {
|
||||
cli: string
|
||||
contract: string
|
||||
changelog: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI and the shared `taskQueueContract` ship from the same monorepo;
|
||||
* we surface the contract version as the same string until the package
|
||||
* grows a separate semver. Hosts that pin a specific contract major use
|
||||
* this string for compatibility checks.
|
||||
*/
|
||||
export function readCliVersion(
|
||||
candidatePaths: readonly string[] = defaultCandidatePaths()
|
||||
): CliVersionInfo {
|
||||
for (const p of candidatePaths) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(p, 'utf-8')) as {
|
||||
name?: string
|
||||
version?: string
|
||||
}
|
||||
if (pkg.name === '@vidbee/cli' && typeof pkg.version === 'string') {
|
||||
return {
|
||||
cli: pkg.version,
|
||||
contract: pkg.version,
|
||||
changelog:
|
||||
'https://github.com/nexmoe/vidbee/blob/main/apps/cli/CHANGELOG.md'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return {
|
||||
cli: FALLBACK_VERSION,
|
||||
contract: FALLBACK_VERSION,
|
||||
changelog:
|
||||
'https://github.com/nexmoe/vidbee/blob/main/apps/cli/CHANGELOG.md'
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCandidatePaths(): string[] {
|
||||
let here: string
|
||||
try {
|
||||
here = fileURLToPath(import.meta.url)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const dir = dirname(here)
|
||||
// dist/index.mjs → ../package.json (npm tarball + Desktop bundle)
|
||||
// src/local-info/version.ts → ../../package.json (dev / vitest)
|
||||
return [
|
||||
resolve(dir, '..', 'package.json'),
|
||||
resolve(dir, '..', '..', 'package.json'),
|
||||
resolve(dir, '..', '..', '..', 'package.json')
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* The CLI's only argv splitter. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.1, §4.2, §4.5
|
||||
*
|
||||
* Rules:
|
||||
* - Tokens matching a known `--vidbee-*` flag are consumed (along with
|
||||
* their value when `kind === 'value'`).
|
||||
* - Tokens that look like `--vidbee-*` / `--vidbee:*` but are NOT in the
|
||||
* reserved table cause an error with exit code 2 — never silently
|
||||
* passed through to yt-dlp.
|
||||
* - The first positional token starting with `:` selects a VidBee
|
||||
* subcommand; subsequent positional tokens after a subcommand are the
|
||||
* subcommand's own args (NOT yt-dlp args).
|
||||
* - Everything else is order-preserved and handed to yt-dlp.
|
||||
* - `--` is honored: every token after `--` is yt-dlp passthrough,
|
||||
* including ones that would otherwise look like VidBee flags.
|
||||
*/
|
||||
|
||||
import { findProbeFlag } from './probe-flags'
|
||||
import {
|
||||
findReservedFlag,
|
||||
isVidbeePrefixed,
|
||||
type ReservedFlag
|
||||
} from './reserved-flags'
|
||||
|
||||
export interface VidbeeFlags {
|
||||
api?: string
|
||||
local: boolean
|
||||
target?: 'desktop' | 'api' | 'local'
|
||||
json: boolean
|
||||
pretty: boolean
|
||||
wait: boolean
|
||||
detach: boolean
|
||||
priority?: 'user' | 'subscription' | 'background'
|
||||
maxAttempts?: number
|
||||
noRetry: boolean
|
||||
groupKey?: string
|
||||
timeoutMs?: number
|
||||
noAutostart: boolean
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type ParsedArgv =
|
||||
| { kind: 'subcommand'; flags: VidbeeFlags; subcommand: string; subArgs: readonly string[] }
|
||||
| {
|
||||
kind: 'ytdlp'
|
||||
flags: VidbeeFlags
|
||||
mode: 'probe' | 'download'
|
||||
ytArgs: readonly string[]
|
||||
probeFlag: string | null
|
||||
}
|
||||
|
||||
export class ParseError extends Error {
|
||||
readonly exitCode: 2
|
||||
readonly code: string
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.exitCode = 2
|
||||
}
|
||||
}
|
||||
|
||||
const PRIORITY_VALUES = new Set<VidbeeFlags['priority']>([
|
||||
'user',
|
||||
'subscription',
|
||||
'background'
|
||||
])
|
||||
const TARGET_VALUES = new Set<VidbeeFlags['target']>(['desktop', 'api', 'local'])
|
||||
|
||||
export function parseArgv(argv: readonly string[]): ParsedArgv {
|
||||
const flags = defaultFlags()
|
||||
const yt: string[] = []
|
||||
let subcommand: string | null = null
|
||||
let passthroughOnly = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i]
|
||||
if (tok === undefined) continue
|
||||
|
||||
if (passthroughOnly) {
|
||||
yt.push(tok)
|
||||
continue
|
||||
}
|
||||
|
||||
if (tok === '--') {
|
||||
passthroughOnly = true
|
||||
yt.push(tok)
|
||||
continue
|
||||
}
|
||||
|
||||
// Subcommand: a `:` positional collects all remaining argv as its args.
|
||||
// We do not try to mix subcommand args with yt-dlp args.
|
||||
if (subcommand === null && tok.startsWith(':') && tok.length > 1) {
|
||||
subcommand = tok.slice(1)
|
||||
const subArgs = argv.slice(i + 1).filter((s): s is string => typeof s === 'string')
|
||||
// We still need to honor --vidbee-* in the subcommand tail (e.g.
|
||||
// `vidbee --vidbee-pretty :download list`); those have already been
|
||||
// consumed by earlier iterations. Anything inside subArgs is owned by
|
||||
// the subcommand handler — no further parsing here.
|
||||
return { kind: 'subcommand', flags, subcommand, subArgs }
|
||||
}
|
||||
|
||||
// VidBee-prefixed flags: must match the reserved table or we fail loud.
|
||||
if (isVidbeePrefixed(tok)) {
|
||||
const eq = tok.indexOf('=')
|
||||
const name = eq === -1 ? tok : tok.slice(0, eq)
|
||||
const inlineValue = eq === -1 ? null : tok.slice(eq + 1)
|
||||
const def = findReservedFlag(name)
|
||||
if (!def) {
|
||||
throw new ParseError(
|
||||
'UNKNOWN_VIDBEE_FLAG',
|
||||
`unknown VidBee flag: ${name}`
|
||||
)
|
||||
}
|
||||
const consumed = consumeReserved(flags, def, argv, i, inlineValue)
|
||||
i += consumed
|
||||
continue
|
||||
}
|
||||
|
||||
// Anything else is yt-dlp territory.
|
||||
yt.push(tok)
|
||||
}
|
||||
|
||||
if (subcommand !== null) {
|
||||
return { kind: 'subcommand', flags, subcommand, subArgs: [] }
|
||||
}
|
||||
|
||||
// §4.5: -o - in non-probe modes is rejected up front. We don't pretend to
|
||||
// understand more of -o than that single edge case.
|
||||
const probeFlag = findProbeFlag(yt)
|
||||
const mode: 'probe' | 'download' = probeFlag === null ? 'download' : 'probe'
|
||||
if (mode === 'download' && hasStdoutOutput(yt)) {
|
||||
throw new ParseError(
|
||||
'STDOUT_OUTPUT_DISALLOWED',
|
||||
'-o - is only allowed in probe mode'
|
||||
)
|
||||
}
|
||||
|
||||
return { kind: 'ytdlp', flags, mode, ytArgs: yt, probeFlag }
|
||||
}
|
||||
|
||||
function defaultFlags(): VidbeeFlags {
|
||||
return {
|
||||
local: false,
|
||||
json: true,
|
||||
pretty: false,
|
||||
wait: false,
|
||||
detach: false,
|
||||
noRetry: false,
|
||||
noAutostart: false
|
||||
}
|
||||
}
|
||||
|
||||
function consumeReserved(
|
||||
flags: VidbeeFlags,
|
||||
def: ReservedFlag,
|
||||
argv: readonly string[],
|
||||
i: number,
|
||||
inlineValue: string | null
|
||||
): number {
|
||||
if (def.kind === 'switch') {
|
||||
if (inlineValue !== null) {
|
||||
throw new ParseError(
|
||||
'UNEXPECTED_VALUE',
|
||||
`${def.name} does not take a value`
|
||||
)
|
||||
}
|
||||
applySwitch(flags, def.name)
|
||||
return 0
|
||||
}
|
||||
// value flag
|
||||
let value: string
|
||||
let consumed = 0
|
||||
if (inlineValue !== null) {
|
||||
value = inlineValue
|
||||
} else {
|
||||
const next = argv[i + 1]
|
||||
if (next === undefined) {
|
||||
throw new ParseError('MISSING_VALUE', `${def.name} requires a value`)
|
||||
}
|
||||
value = next
|
||||
consumed = 1
|
||||
}
|
||||
applyValue(flags, def.name, value)
|
||||
return consumed
|
||||
}
|
||||
|
||||
function applySwitch(flags: VidbeeFlags, name: string): void {
|
||||
switch (name) {
|
||||
case '--vidbee-local':
|
||||
flags.local = true
|
||||
break
|
||||
case '--vidbee-json':
|
||||
flags.json = true
|
||||
break
|
||||
case '--vidbee-pretty':
|
||||
flags.pretty = true
|
||||
break
|
||||
case '--vidbee-wait':
|
||||
flags.wait = true
|
||||
break
|
||||
case '--vidbee-detach':
|
||||
flags.detach = true
|
||||
break
|
||||
case '--vidbee-no-retry':
|
||||
flags.noRetry = true
|
||||
flags.maxAttempts = 0
|
||||
break
|
||||
case '--vidbee-no-autostart':
|
||||
flags.noAutostart = true
|
||||
break
|
||||
default:
|
||||
throw new ParseError(
|
||||
'UNKNOWN_VIDBEE_FLAG',
|
||||
`unknown VidBee switch: ${name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function applyValue(flags: VidbeeFlags, name: string, value: string): void {
|
||||
switch (name) {
|
||||
case '--vidbee-api':
|
||||
flags.api = value
|
||||
break
|
||||
case '--vidbee-target':
|
||||
if (!(TARGET_VALUES as Set<string>).has(value)) {
|
||||
throw new ParseError(
|
||||
'INVALID_TARGET',
|
||||
`--vidbee-target must be one of desktop|api|local; got ${value}`
|
||||
)
|
||||
}
|
||||
flags.target = value as VidbeeFlags['target']
|
||||
break
|
||||
case '--vidbee-priority':
|
||||
if (!(PRIORITY_VALUES as Set<string>).has(value)) {
|
||||
throw new ParseError(
|
||||
'INVALID_PRIORITY',
|
||||
`--vidbee-priority must be user|subscription|background; got ${value}`
|
||||
)
|
||||
}
|
||||
flags.priority = value as VidbeeFlags['priority']
|
||||
break
|
||||
case '--vidbee-max-attempts': {
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(n) || n < 0 || `${n}` !== value) {
|
||||
throw new ParseError(
|
||||
'INVALID_MAX_ATTEMPTS',
|
||||
`--vidbee-max-attempts must be a non-negative integer; got ${value}`
|
||||
)
|
||||
}
|
||||
flags.maxAttempts = n
|
||||
if (n === 0) flags.noRetry = true
|
||||
break
|
||||
}
|
||||
case '--vidbee-group-key':
|
||||
flags.groupKey = value
|
||||
break
|
||||
case '--vidbee-timeout': {
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
throw new ParseError(
|
||||
'INVALID_TIMEOUT',
|
||||
`--vidbee-timeout must be a positive integer (ms); got ${value}`
|
||||
)
|
||||
}
|
||||
flags.timeoutMs = n
|
||||
break
|
||||
}
|
||||
case '--vidbee-token':
|
||||
flags.token = value
|
||||
break
|
||||
default:
|
||||
throw new ParseError(
|
||||
'UNKNOWN_VIDBEE_FLAG',
|
||||
`unknown VidBee value flag: ${name}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §4.5: writing yt-dlp output to stdout (`-o -`) is only legal in probe
|
||||
* mode. We detect it by `-o`-followed-by-`-` or `-o-`/`--output -`.
|
||||
*/
|
||||
function hasStdoutOutput(argv: readonly string[]): boolean {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i]
|
||||
if (tok === '-o' || tok === '--output') {
|
||||
if (argv[i + 1] === '-') return true
|
||||
} else if (tok === '-o-' || tok === '--output=-' || tok === '-o=-') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export type { VidbeeFlags as Flags }
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Authoritative yt-dlp probe-flag set. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.3
|
||||
*
|
||||
* If argv contains ANY of these (with no positional dependence), the CLI
|
||||
* routes the run through ProcessRegistry as a probe — no task is enqueued
|
||||
* and no history row is written. This is the ONLY place where the CLI
|
||||
* inspects yt-dlp argv semantically; every other arg is order-preserved
|
||||
* passthrough.
|
||||
*/
|
||||
|
||||
const EXACT_FLAGS = new Set<string>([
|
||||
'-j',
|
||||
'--dump-json',
|
||||
'-J',
|
||||
'--dump-single-json',
|
||||
'-F',
|
||||
'--list-formats',
|
||||
'--list-formats-as-table',
|
||||
'--list-formats-old',
|
||||
'-s',
|
||||
'--simulate',
|
||||
'--skip-download',
|
||||
'--list-subs',
|
||||
'--list-extractors',
|
||||
'--list-extractor-descriptions'
|
||||
])
|
||||
|
||||
const EXACT_GETTERS = new Set<string>([
|
||||
'--get-id',
|
||||
'--get-title',
|
||||
'--get-thumbnail',
|
||||
'--get-description',
|
||||
'--get-duration',
|
||||
'--get-filename',
|
||||
'--get-format',
|
||||
'--get-url'
|
||||
])
|
||||
|
||||
/**
|
||||
* yt-dlp meta commands that have no URL but should still go through the
|
||||
* managed-forward path as probe — wrapped with envelope, exit code from
|
||||
* yt-dlp.
|
||||
*/
|
||||
const META_FLAGS = new Set<string>(['--update', '--version'])
|
||||
|
||||
/**
|
||||
* Returns true if the given argv contains a probe-class flag, including:
|
||||
* - all exact aliases listed in §4.3
|
||||
* - any --print (regardless of where / how many times)
|
||||
* - any --get-* getter
|
||||
* - meta commands (--update, --version)
|
||||
*
|
||||
* Both `--flag value` and `--flag=value` shapes are covered: the leading
|
||||
* token (the part up to and including `=` or end-of-token) is what gets
|
||||
* matched.
|
||||
*/
|
||||
export function isProbeArgv(argv: readonly string[]): boolean {
|
||||
return findProbeFlag(argv) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first probe-class flag found in argv, or null if none. Used
|
||||
* by tests and diagnostics; the runtime only needs the boolean form.
|
||||
*/
|
||||
export function findProbeFlag(argv: readonly string[]): string | null {
|
||||
for (const tok of argv) {
|
||||
const head = headOf(tok)
|
||||
if (EXACT_FLAGS.has(head)) return head
|
||||
if (EXACT_GETTERS.has(head)) return head
|
||||
if (META_FLAGS.has(head)) return head
|
||||
if (head === '--print') return head
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function headOf(tok: string): string {
|
||||
const eq = tok.indexOf('=')
|
||||
return eq === -1 ? tok : tok.slice(0, eq)
|
||||
}
|
||||
|
||||
export const PROBE_FLAG_REGISTRY = {
|
||||
exact: EXACT_FLAGS,
|
||||
getters: EXACT_GETTERS,
|
||||
meta: META_FLAGS,
|
||||
prefixed: ['--print'] as const
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Sensitive-argument redaction. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §8.2
|
||||
*
|
||||
* `rawArgs` is held in memory only; before any envelope, persisted task
|
||||
* row, attempt log tail, or projection sees the argv it MUST go through
|
||||
* `redactArgs`. Same rules apply to URL query strings encountered in the
|
||||
* argv (query tokens are scrubbed in place).
|
||||
*
|
||||
* The function is intentionally conservative — when in doubt, redact.
|
||||
*/
|
||||
|
||||
const REDACTED = '<redacted>'
|
||||
|
||||
/** Flags whose immediately-following value is sensitive in full. */
|
||||
const VALUE_FLAGS_FULL = new Set<string>([
|
||||
'--username',
|
||||
'--password',
|
||||
'--video-password',
|
||||
'--ap-password',
|
||||
'--twofactor',
|
||||
'--ap-username'
|
||||
])
|
||||
|
||||
/**
|
||||
* Header-shaped flags. Value form is `name:value`; if the header name is
|
||||
* one of the sensitive ones, only the value half is replaced.
|
||||
*/
|
||||
const HEADER_FLAGS = new Set<string>(['--add-headers', '--add-header'])
|
||||
const SENSITIVE_HEADER_NAMES = new Set<string>([
|
||||
'authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'token',
|
||||
'x-token',
|
||||
'bearer',
|
||||
'x-auth',
|
||||
'x-auth-token',
|
||||
'proxy-authorization'
|
||||
])
|
||||
|
||||
/** URL query parameters whose value should be scrubbed. */
|
||||
const SENSITIVE_QUERY_KEYS = new Set<string>([
|
||||
'token',
|
||||
'access_token',
|
||||
'auth_token',
|
||||
'id_token',
|
||||
'signature',
|
||||
'sig',
|
||||
'policy',
|
||||
'key',
|
||||
'secret',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'password',
|
||||
'pass',
|
||||
'pwd'
|
||||
])
|
||||
|
||||
export interface RedactSummary {
|
||||
/** True when at least one substitution was performed. */
|
||||
redacted: boolean
|
||||
}
|
||||
|
||||
export function redactArgs(
|
||||
args: readonly string[]
|
||||
): { args: string[]; summary: RedactSummary } {
|
||||
const out: string[] = []
|
||||
let redacted = false
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const tok = args[i]
|
||||
if (tok === undefined) continue
|
||||
|
||||
const eq = tok.indexOf('=')
|
||||
const head = eq === -1 ? tok : tok.slice(0, eq)
|
||||
const inline = eq === -1 ? null : tok.slice(eq + 1)
|
||||
|
||||
if (VALUE_FLAGS_FULL.has(head)) {
|
||||
if (inline !== null) {
|
||||
out.push(`${head}=${REDACTED}`)
|
||||
redacted = true
|
||||
} else {
|
||||
out.push(head)
|
||||
if (i + 1 < args.length) {
|
||||
out.push(REDACTED)
|
||||
redacted = true
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (HEADER_FLAGS.has(head)) {
|
||||
const consumed = handleHeader(head, inline, args, i, out)
|
||||
if (consumed.redacted) redacted = true
|
||||
i += consumed.skip
|
||||
continue
|
||||
}
|
||||
|
||||
// URL-shaped tokens — scrub query string
|
||||
if (looksLikeUrl(tok)) {
|
||||
const scrubbed = scrubUrl(tok)
|
||||
if (scrubbed.changed) redacted = true
|
||||
out.push(scrubbed.value)
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(tok)
|
||||
}
|
||||
|
||||
return { args: out, summary: { redacted } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort scrub of free-form text (stdout / stderr tail). We only
|
||||
* scrub things that have a structural shape — URL query strings and
|
||||
* `Authorization:` style header lines. A fancier regex pass risks false
|
||||
* positives in download progress output, so we keep it tight.
|
||||
*/
|
||||
export function redactText(text: string): string {
|
||||
let out = text
|
||||
out = out.replace(/(authorization|cookie|x-auth-token|bearer)\s*:\s*[^\n\r]+/gi, (m, name) => {
|
||||
return `${name}: ${REDACTED}`
|
||||
})
|
||||
out = out.replace(/\bhttps?:\/\/[^\s'"]+/g, (url) => scrubUrl(url).value)
|
||||
return out
|
||||
}
|
||||
|
||||
function handleHeader(
|
||||
head: string,
|
||||
inline: string | null,
|
||||
args: readonly string[],
|
||||
i: number,
|
||||
out: string[]
|
||||
): { skip: number; redacted: boolean } {
|
||||
let value: string | null
|
||||
let skip = 0
|
||||
if (inline !== null) {
|
||||
value = inline
|
||||
} else if (i + 1 < args.length) {
|
||||
value = args[i + 1] ?? null
|
||||
skip = 1
|
||||
} else {
|
||||
out.push(head)
|
||||
return { skip: 0, redacted: false }
|
||||
}
|
||||
if (value === null) {
|
||||
out.push(head)
|
||||
return { skip: 0, redacted: false }
|
||||
}
|
||||
const colon = value.indexOf(':')
|
||||
if (colon === -1) {
|
||||
if (inline !== null) out.push(`${head}=${value}`)
|
||||
else {
|
||||
out.push(head)
|
||||
out.push(value)
|
||||
}
|
||||
return { skip, redacted: false }
|
||||
}
|
||||
const name = value.slice(0, colon).trim().toLowerCase()
|
||||
const rendered = SENSITIVE_HEADER_NAMES.has(name)
|
||||
? `${value.slice(0, colon)}: ${REDACTED}`
|
||||
: value
|
||||
const changed = rendered !== value
|
||||
if (inline !== null) out.push(`${head}=${rendered}`)
|
||||
else {
|
||||
out.push(head)
|
||||
out.push(rendered)
|
||||
}
|
||||
return { skip, redacted: changed }
|
||||
}
|
||||
|
||||
function looksLikeUrl(tok: string): boolean {
|
||||
return /^https?:\/\//i.test(tok)
|
||||
}
|
||||
|
||||
function scrubUrl(raw: string): { value: string; changed: boolean } {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
return { value: raw, changed: false }
|
||||
}
|
||||
let changed = false
|
||||
for (const key of Array.from(url.searchParams.keys())) {
|
||||
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
|
||||
url.searchParams.set(key, REDACTED)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// Preserve the original token byte-for-byte when nothing was redacted —
|
||||
// `new URL().toString()` normalizes the form (e.g. adds a trailing /,
|
||||
// re-encodes punycode), and that normalization is undesirable for argv
|
||||
// we're going to log or echo back to the user.
|
||||
return { value: changed ? url.toString() : raw, changed }
|
||||
}
|
||||
|
||||
export const REDACTED_PLACEHOLDER = REDACTED
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The full set of `--vidbee-*` flags the CLI knows about. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.2
|
||||
*
|
||||
* `kind` controls how the parser splits argv:
|
||||
* - 'switch' — boolean; presence sets value true
|
||||
* - 'value' — consumes the next argv token, OR accepts `--flag=value`
|
||||
*
|
||||
* Any unknown `--vidbee-*` token causes the parser to reject argv with
|
||||
* exit 2; we never silently forward unrecognized vidbee flags to yt-dlp.
|
||||
*/
|
||||
|
||||
export type ReservedFlagKind = 'switch' | 'value'
|
||||
|
||||
export interface ReservedFlag {
|
||||
name: string
|
||||
kind: ReservedFlagKind
|
||||
}
|
||||
|
||||
export const RESERVED_FLAGS: readonly ReservedFlag[] = [
|
||||
{ name: '--vidbee-api', kind: 'value' },
|
||||
{ name: '--vidbee-local', kind: 'switch' },
|
||||
{ name: '--vidbee-target', kind: 'value' },
|
||||
{ name: '--vidbee-json', kind: 'switch' },
|
||||
{ name: '--vidbee-pretty', kind: 'switch' },
|
||||
{ name: '--vidbee-wait', kind: 'switch' },
|
||||
{ name: '--vidbee-detach', kind: 'switch' },
|
||||
{ name: '--vidbee-priority', kind: 'value' },
|
||||
{ name: '--vidbee-max-attempts', kind: 'value' },
|
||||
{ name: '--vidbee-no-retry', kind: 'switch' },
|
||||
{ name: '--vidbee-group-key', kind: 'value' },
|
||||
{ name: '--vidbee-timeout', kind: 'value' },
|
||||
{ name: '--vidbee-no-autostart', kind: 'switch' },
|
||||
{ name: '--vidbee-token', kind: 'value' }
|
||||
]
|
||||
|
||||
const RESERVED_BY_NAME = new Map<string, ReservedFlag>(
|
||||
RESERVED_FLAGS.map((f) => [f.name, f])
|
||||
)
|
||||
|
||||
export function findReservedFlag(name: string): ReservedFlag | undefined {
|
||||
return RESERVED_BY_NAME.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `tok` looks like a VidBee flag. We deliberately match the loose
|
||||
* prefix `--vidb` (which has no overlap with any yt-dlp option as of
|
||||
* yt-dlp 2024.x — `--video-*` does not start with `--vidb`) so that typos
|
||||
* like `--vidbe-wait` are caught and reported instead of being silently
|
||||
* forwarded to yt-dlp. Reference: design doc §4.5 edge-case table.
|
||||
*
|
||||
* The strict membership check (against RESERVED_FLAGS) happens on the
|
||||
* canonical `--vidbee-*` / `--vidbee:*` form; anything else with the
|
||||
* `--vidb` prefix is rejected as an unknown VidBee flag.
|
||||
*/
|
||||
export function isVidbeePrefixed(tok: string): boolean {
|
||||
return tok.startsWith('--vidb')
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* The CLI's `main(argv)`. Exported so unit tests can drive it without
|
||||
* spawning a subprocess. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4
|
||||
*
|
||||
* The runtime is split into the cold path (parse argv, choose transport,
|
||||
* connect) and the hot paths (subcommand dispatch, yt-dlp probe, yt-dlp
|
||||
* download enqueue). Each hot path is a separate function so tests can
|
||||
* drive them in isolation.
|
||||
*/
|
||||
|
||||
import { ParseError, parseArgv } from './parser'
|
||||
import {
|
||||
ExitCode,
|
||||
errorEnvelope,
|
||||
exitCodeForError,
|
||||
renderEnvelope,
|
||||
type Envelope,
|
||||
type ErrorEnvelope
|
||||
} from './envelope'
|
||||
import {
|
||||
dispatchSubcommand,
|
||||
type ContractClient,
|
||||
type SubcommandContext
|
||||
} from './subcommands'
|
||||
import { selectTransport, validateApiUrl } from './transport'
|
||||
import { connect, type ConnectOptions, type ConnectResult } from './transport/connect'
|
||||
import { buildForwardedInput } from './download/build-input'
|
||||
import { enqueueDownload } from './download/enqueue'
|
||||
import { runProbe, type ProbeOptions } from './download/probe'
|
||||
import { redactArgs, redactText } from './parser/redact'
|
||||
import {
|
||||
checkUpgrade,
|
||||
readCliVersion,
|
||||
type CliVersionInfo,
|
||||
type UpgradeCheckInput,
|
||||
type UpgradeCheckResult
|
||||
} from './local-info'
|
||||
|
||||
export interface RunIO {
|
||||
stdout: (line: string) => void
|
||||
stderr: (line: string) => void
|
||||
/**
|
||||
* Test seam — overrides the entire transport-selection / connect step.
|
||||
* Production wiring uses `connect()` from ./transport/connect.
|
||||
*/
|
||||
connect?: (opts: ConnectOptions) => Promise<ConnectResult>
|
||||
/** Test seam — overrides yt-dlp probe spawn. */
|
||||
probe?: (opts: ProbeOptions) => ReturnType<typeof runProbe>
|
||||
/** Test seam — overrides `:version` resolution. */
|
||||
readVersion?: () => CliVersionInfo
|
||||
/**
|
||||
* Test seam — overrides `:upgrade` registry / cache I/O. Receives the
|
||||
* resolved CLI version and any subcommand args (`--force`, `--no-cache`,
|
||||
* `--cache <path>`).
|
||||
*/
|
||||
checkUpgrade?: (input: UpgradeCheckInput) => Promise<UpgradeCheckResult>
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
exitCode: number
|
||||
envelope: Envelope
|
||||
}
|
||||
|
||||
export async function run(argv: readonly string[], io: RunIO): Promise<RunResult> {
|
||||
let parsed: ReturnType<typeof parseArgv>
|
||||
try {
|
||||
parsed = parseArgv(argv)
|
||||
} catch (err) {
|
||||
if (err instanceof ParseError) {
|
||||
const env = errorEnvelope(coerceErrorCode(err.code), err.message)
|
||||
return emit(io, env, false, ExitCode.ARG_ERROR)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const pretty = parsed.flags.pretty
|
||||
|
||||
if (parsed.flags.api !== undefined) {
|
||||
const bad = validateApiUrl(parsed.flags.api)
|
||||
if (bad) return emit(io, bad, pretty, ExitCode.HOST_UNREACHABLE)
|
||||
}
|
||||
|
||||
const connectImpl = io.connect ?? connect
|
||||
|
||||
if (parsed.kind === 'ytdlp') {
|
||||
if (parsed.mode === 'probe') {
|
||||
return await runProbeMode(parsed, io, pretty)
|
||||
}
|
||||
return await runDownloadMode(parsed, io, pretty, connectImpl)
|
||||
}
|
||||
|
||||
// Local-only subcommands run without contacting Desktop / API. They are
|
||||
// dispatched here so a missing automation descriptor doesn't make
|
||||
// `vidbee :version` fail with HOST_UNREACHABLE.
|
||||
if (parsed.subcommand === 'version' || parsed.subcommand === 'upgrade') {
|
||||
return await runLocalSubcommand(parsed.subcommand, parsed.subArgs, io, pretty)
|
||||
}
|
||||
|
||||
// Subcommand path
|
||||
const conn = await connectImpl({ flags: parsed.flags })
|
||||
if (conn.kind === 'error') {
|
||||
return emit(io, conn.envelope, pretty, exitCodeForError(conn.envelope.code))
|
||||
}
|
||||
|
||||
const ctx: SubcommandContext = { client: conn.client }
|
||||
try {
|
||||
const result = await dispatchSubcommand(parsed.subcommand, parsed.subArgs, ctx)
|
||||
if (result.kind === 'error') {
|
||||
return emit(io, result.envelope, pretty, exitCodeForError(result.envelope.code))
|
||||
}
|
||||
const env: Envelope = {
|
||||
ok: true,
|
||||
mode: 'subcommand',
|
||||
subcommand: parsed.subcommand,
|
||||
result: result.value
|
||||
}
|
||||
return emit(io, env, pretty, ExitCode.OK)
|
||||
} catch (err) {
|
||||
const env = errorEnvelope(
|
||||
'UNKNOWN_ERROR',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
)
|
||||
return emit(io, env, pretty, ExitCode.ARG_ERROR)
|
||||
} finally {
|
||||
await safeTeardown(conn)
|
||||
}
|
||||
}
|
||||
|
||||
type YtdlpParsed = Extract<ReturnType<typeof parseArgv>, { kind: 'ytdlp' }>
|
||||
|
||||
async function runLocalSubcommand(
|
||||
subcommand: 'version' | 'upgrade',
|
||||
subArgs: readonly string[],
|
||||
io: RunIO,
|
||||
pretty: boolean
|
||||
): Promise<RunResult> {
|
||||
const versionInfo = (io.readVersion ?? readCliVersion)()
|
||||
if (subcommand === 'version') {
|
||||
const env: Envelope = {
|
||||
ok: true,
|
||||
mode: 'subcommand',
|
||||
subcommand: 'version',
|
||||
result: versionInfo
|
||||
}
|
||||
return emit(io, env, pretty, ExitCode.OK)
|
||||
}
|
||||
// :upgrade
|
||||
const upgradeImpl = io.checkUpgrade ?? checkUpgrade
|
||||
const opts = parseUpgradeArgs(subArgs)
|
||||
if (opts.kind === 'error') return emit(io, opts.envelope, pretty, ExitCode.ARG_ERROR)
|
||||
try {
|
||||
const result = await upgradeImpl({
|
||||
current: versionInfo.cli,
|
||||
...(opts.force ? { force: true } : {}),
|
||||
...(opts.cachePath !== undefined ? { cachePath: opts.cachePath } : {})
|
||||
})
|
||||
const env: Envelope = {
|
||||
ok: true,
|
||||
mode: 'subcommand',
|
||||
subcommand: 'upgrade',
|
||||
result
|
||||
}
|
||||
return emit(io, env, pretty, ExitCode.OK)
|
||||
} catch (err) {
|
||||
const env = errorEnvelope(
|
||||
'API_UNREACHABLE',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
{ registry: 'https://registry.npmjs.org/@vidbee/cli/latest' }
|
||||
)
|
||||
return emit(io, env, pretty, ExitCode.HOST_UNREACHABLE)
|
||||
}
|
||||
}
|
||||
|
||||
interface UpgradeArgs {
|
||||
kind: 'ok'
|
||||
force: boolean
|
||||
cachePath?: string
|
||||
}
|
||||
function parseUpgradeArgs(
|
||||
subArgs: readonly string[]
|
||||
): UpgradeArgs | { kind: 'error'; envelope: ErrorEnvelope } {
|
||||
const out: UpgradeArgs = { kind: 'ok', force: false }
|
||||
for (let i = 0; i < subArgs.length; i++) {
|
||||
const tok = subArgs[i]
|
||||
if (tok === undefined) continue
|
||||
if (tok === '--force') {
|
||||
out.force = true
|
||||
continue
|
||||
}
|
||||
if (tok === '--cache') {
|
||||
const next = subArgs[i + 1]
|
||||
if (next === undefined) {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope('MISSING_VALUE', '--cache requires a path')
|
||||
}
|
||||
}
|
||||
out.cachePath = next
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (tok.startsWith('--cache=')) {
|
||||
out.cachePath = tok.slice('--cache='.length)
|
||||
continue
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope('PARSE_ERROR', `unknown :upgrade flag: ${tok}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runProbeMode(
|
||||
parsed: YtdlpParsed,
|
||||
io: RunIO,
|
||||
pretty: boolean
|
||||
): Promise<RunResult> {
|
||||
const probeImpl = io.probe ?? runProbe
|
||||
const result = await probeImpl({ argv: parsed.ytArgs })
|
||||
if (result.kind === 'error') {
|
||||
return emit(
|
||||
io,
|
||||
result.envelope,
|
||||
pretty,
|
||||
exitCodeForError(result.envelope.code)
|
||||
)
|
||||
}
|
||||
const { args: sanitized } = redactArgs(parsed.ytArgs)
|
||||
const env: Envelope = {
|
||||
ok: true,
|
||||
mode: 'probe',
|
||||
command: ['yt-dlp', ...sanitized].join(' '),
|
||||
ytDlp: {
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: redactText(result.stderr)
|
||||
}
|
||||
}
|
||||
return emit(io, env, pretty, result.exitCode === 0 ? ExitCode.OK : ExitCode.WAIT_NON_SUCCESS)
|
||||
}
|
||||
|
||||
async function runDownloadMode(
|
||||
parsed: YtdlpParsed,
|
||||
io: RunIO,
|
||||
pretty: boolean,
|
||||
connectImpl: NonNullable<RunIO['connect']>
|
||||
): Promise<RunResult> {
|
||||
const conn = await connectImpl({ flags: parsed.flags })
|
||||
if (conn.kind === 'error') {
|
||||
return emit(io, conn.envelope, pretty, exitCodeForError(conn.envelope.code))
|
||||
}
|
||||
try {
|
||||
const built = buildForwardedInput({ argv: parsed.ytArgs, flags: parsed.flags })
|
||||
const request: Parameters<NonNullable<ContractClient['add']>>[0] = {
|
||||
input: built.input
|
||||
}
|
||||
if (parsed.flags.priority) {
|
||||
request.priority = priorityToCode(parsed.flags.priority)
|
||||
}
|
||||
if (parsed.flags.groupKey !== undefined) request.groupKey = parsed.flags.groupKey
|
||||
if (parsed.flags.maxAttempts !== undefined) request.maxAttempts = parsed.flags.maxAttempts
|
||||
|
||||
const result = await enqueueDownload({
|
||||
client: conn.client,
|
||||
request,
|
||||
wait: parsed.flags.wait
|
||||
})
|
||||
|
||||
if (result.kind === 'detached') {
|
||||
const env: Envelope = {
|
||||
ok: true,
|
||||
mode: 'download',
|
||||
task: {
|
||||
id: result.task.id,
|
||||
status: result.task.status,
|
||||
attempt: result.task.attempt,
|
||||
maxAttempts: result.task.maxAttempts,
|
||||
command: built.commandPreview
|
||||
}
|
||||
}
|
||||
return emit(io, env, pretty, ExitCode.OK)
|
||||
}
|
||||
if (result.kind === 'wait-success') {
|
||||
const env: Envelope = {
|
||||
ok: true,
|
||||
mode: 'download',
|
||||
task: result.task,
|
||||
ytDlp: {
|
||||
exitCode: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: ''
|
||||
}
|
||||
}
|
||||
return emit(io, env, pretty, ExitCode.OK)
|
||||
}
|
||||
// wait-non-success
|
||||
const env: Envelope = {
|
||||
ok: false,
|
||||
mode: 'download',
|
||||
task: result.task,
|
||||
ytDlp: {
|
||||
exitCode: 1,
|
||||
stdoutTail: '',
|
||||
stderrTail: redactText(
|
||||
(result.task.lastError as { stderrTail?: string } | null)?.stderrTail ?? ''
|
||||
)
|
||||
}
|
||||
}
|
||||
return emit(io, env, pretty, ExitCode.WAIT_NON_SUCCESS)
|
||||
} catch (err) {
|
||||
const env = errorEnvelope(
|
||||
'UNKNOWN_ERROR',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
)
|
||||
return emit(io, env, pretty, ExitCode.ARG_ERROR)
|
||||
} finally {
|
||||
await safeTeardown(conn)
|
||||
}
|
||||
}
|
||||
|
||||
function priorityToCode(p: 'user' | 'subscription' | 'background'): 0 | 10 | 20 {
|
||||
if (p === 'user') return 0
|
||||
if (p === 'subscription') return 10
|
||||
return 20
|
||||
}
|
||||
|
||||
async function safeTeardown(conn: ConnectResult): Promise<void> {
|
||||
if (conn.kind !== 'connected') return
|
||||
if (!conn.teardown) return
|
||||
try {
|
||||
await conn.teardown()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function emit(
|
||||
io: RunIO,
|
||||
env: Envelope,
|
||||
pretty: boolean,
|
||||
exitCode: number
|
||||
): RunResult {
|
||||
io.stdout(renderEnvelope(env, { pretty }))
|
||||
return { exitCode, envelope: env }
|
||||
}
|
||||
|
||||
function coerceErrorCode(code: string): ErrorEnvelope['code'] {
|
||||
switch (code) {
|
||||
case 'UNKNOWN_VIDBEE_FLAG':
|
||||
case 'INVALID_TARGET':
|
||||
case 'INVALID_PRIORITY':
|
||||
case 'INVALID_MAX_ATTEMPTS':
|
||||
case 'INVALID_TIMEOUT':
|
||||
case 'MISSING_VALUE':
|
||||
case 'UNEXPECTED_VALUE':
|
||||
case 'STDOUT_OUTPUT_DISALLOWED':
|
||||
return code
|
||||
default:
|
||||
return 'PARSE_ERROR'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Subcommand dispatch for `vidbee :…` invocations. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.1
|
||||
*
|
||||
* Phase A delivered the read-only commands; Phase B fills in the write
|
||||
* verbs under `:download` (cancel/pause/resume/retry/logs).
|
||||
*/
|
||||
|
||||
import type { AddTaskRequest, Task, TaskQueueAPI, TaskStatus } from '@vidbee/task-queue'
|
||||
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
|
||||
|
||||
export interface ContractClient {
|
||||
/**
|
||||
* Minimal surface of `taskQueueContract` the CLI needs. Phase A only
|
||||
* required readers; Phase B added writers (add/cancel/pause/resume/retry).
|
||||
* The transport layer (loopback HTTP / remote HTTPS / `--vidbee-local`)
|
||||
* supplies an implementation.
|
||||
*/
|
||||
list: (input: ListInput) => Promise<{ items: Task[]; nextCursor: string | null }>
|
||||
get: (id: string) => Promise<Task>
|
||||
stats: () => Promise<unknown>
|
||||
removeFromHistory: (id: string) => Promise<void>
|
||||
add?: (req: AddTaskRequest) => Promise<{ id: string; task: Task }>
|
||||
cancel?: (id: string) => Promise<void>
|
||||
pause?: (id: string, reason?: string) => Promise<void>
|
||||
resume?: (id: string) => Promise<void>
|
||||
retry?: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface ListInput {
|
||||
status?: TaskStatus
|
||||
groupKey?: string
|
||||
parentId?: string
|
||||
limit?: number
|
||||
cursor?: string | null
|
||||
}
|
||||
|
||||
export interface SubcommandContext {
|
||||
client: ContractClient
|
||||
/**
|
||||
* `--vidbee-local` instantiates this directly. Read-only commands don't
|
||||
* need it but `:download logs <id>` reaches into attempt rows that the
|
||||
* orchestrator owns; future work will add a route to the contract.
|
||||
*/
|
||||
api?: TaskQueueAPI
|
||||
}
|
||||
|
||||
export type SubcommandResult =
|
||||
| { kind: 'value'; value: unknown }
|
||||
| { kind: 'error'; envelope: ErrorEnvelope }
|
||||
|
||||
export async function dispatchSubcommand(
|
||||
subcommand: string,
|
||||
args: readonly string[],
|
||||
ctx: SubcommandContext
|
||||
): Promise<SubcommandResult> {
|
||||
const path = subcommand.split('/').filter(Boolean)
|
||||
switch (path[0]) {
|
||||
case 'status':
|
||||
return ok(await ctx.client.stats())
|
||||
case 'download':
|
||||
return await handleDownload(path.slice(1), args, ctx)
|
||||
case 'history':
|
||||
return await handleHistory(path.slice(1), args, ctx)
|
||||
case 'rss':
|
||||
return notImplemented('rss', 'NEX-132 owns :rss subcommands')
|
||||
default:
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'PARSE_ERROR',
|
||||
`unknown subcommand: :${subcommand}`,
|
||||
{ subcommand }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(
|
||||
rest: readonly string[],
|
||||
args: readonly string[],
|
||||
ctx: SubcommandContext
|
||||
): Promise<SubcommandResult> {
|
||||
const verb = rest[0] ?? args[0]
|
||||
const tail = (rest.length > 0 ? args : args.slice(1)).filter(
|
||||
(s): s is string => typeof s === 'string'
|
||||
)
|
||||
switch (verb) {
|
||||
case 'list': {
|
||||
const input = parseListArgs(tail)
|
||||
return ok(await ctx.client.list(input))
|
||||
}
|
||||
case 'status': {
|
||||
const id = tail[0]
|
||||
if (!id) return missingArg(':download status <id>', 'id')
|
||||
return ok(await ctx.client.get(id))
|
||||
}
|
||||
case 'logs': {
|
||||
const id = tail[0]
|
||||
if (!id) return missingArg(':download logs <id>', 'id')
|
||||
// The contract doesn't expose a per-attempt logs route. We surface
|
||||
// the task object (which includes `lastError.stderrTail`) so callers
|
||||
// can inspect failures without a separate round-trip. A dedicated
|
||||
// logs route is tracked as a follow-up that needs new contract
|
||||
// schema work in @vidbee/task-queue.
|
||||
const task = await ctx.client.get(id)
|
||||
const stderrTail =
|
||||
(task.lastError as { stderrTail?: string } | null)?.stderrTail ?? null
|
||||
return ok({ task, logs: { stderrTail } })
|
||||
}
|
||||
case 'cancel': {
|
||||
const id = tail[0]
|
||||
if (!id) return missingArg(':download cancel <id>', 'id')
|
||||
if (!ctx.client.cancel) return capabilityError('cancel')
|
||||
await ctx.client.cancel(id)
|
||||
return ok({ id, status: 'cancel-requested' })
|
||||
}
|
||||
case 'pause': {
|
||||
const id = tail[0]
|
||||
if (!id) return missingArg(':download pause <id> [--reason text]', 'id')
|
||||
const reason = readNamedArg(tail, '--reason')
|
||||
if (!ctx.client.pause) return capabilityError('pause')
|
||||
await ctx.client.pause(id, reason)
|
||||
return ok({ id, status: 'pause-requested' })
|
||||
}
|
||||
case 'resume': {
|
||||
const id = tail[0]
|
||||
if (!id) return missingArg(':download resume <id>', 'id')
|
||||
if (!ctx.client.resume) return capabilityError('resume')
|
||||
await ctx.client.resume(id)
|
||||
return ok({ id, status: 'resume-requested' })
|
||||
}
|
||||
case 'retry': {
|
||||
const id = tail[0]
|
||||
if (!id) return missingArg(':download retry <id>', 'id')
|
||||
if (!ctx.client.retry) return capabilityError('retry')
|
||||
await ctx.client.retry(id)
|
||||
return ok({ id, status: 'retry-requested' })
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'PARSE_ERROR',
|
||||
`unknown :download verb: ${verb ?? '(missing)'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHistory(
|
||||
rest: readonly string[],
|
||||
args: readonly string[],
|
||||
ctx: SubcommandContext
|
||||
): Promise<SubcommandResult> {
|
||||
const verb = rest[0] ?? args[0]
|
||||
const tail = (rest.length > 0 ? args : args.slice(1)).filter(
|
||||
(s): s is string => typeof s === 'string'
|
||||
)
|
||||
switch (verb) {
|
||||
case 'list': {
|
||||
const input = parseListArgs(tail)
|
||||
// History view is a list filtered to terminal statuses; the contract
|
||||
// doesn't expose a separate history route — the projection happens
|
||||
// host-side. Here we just forward.
|
||||
return ok(await ctx.client.list(input))
|
||||
}
|
||||
case 'remove': {
|
||||
if (tail.length === 0) return missingArg(':history remove <id...>', 'id')
|
||||
const removed: string[] = []
|
||||
for (const id of tail) {
|
||||
await ctx.client.removeFromHistory(id)
|
||||
removed.push(id)
|
||||
}
|
||||
return ok({ removed })
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'PARSE_ERROR',
|
||||
`unknown :history verb: ${verb ?? '(missing)'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
|
||||
'queued',
|
||||
'running',
|
||||
'processing',
|
||||
'paused',
|
||||
'retry-scheduled',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled'
|
||||
])
|
||||
|
||||
export function parseListArgs(args: readonly string[]): ListInput {
|
||||
const input: ListInput = {}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const tok = args[i]
|
||||
if (tok === undefined) continue
|
||||
const eq = tok.indexOf('=')
|
||||
const name = eq === -1 ? tok : tok.slice(0, eq)
|
||||
const inline = eq === -1 ? null : tok.slice(eq + 1)
|
||||
const consume = (): string => {
|
||||
if (inline !== null) return inline
|
||||
const next = args[i + 1]
|
||||
if (next === undefined) {
|
||||
throw Object.assign(new Error(`${name} requires a value`), {
|
||||
code: 'MISSING_VALUE'
|
||||
})
|
||||
}
|
||||
i += 1
|
||||
return next
|
||||
}
|
||||
switch (name) {
|
||||
case '--status': {
|
||||
const v = consume()
|
||||
if (!(KNOWN_STATUSES as Set<string>).has(v)) {
|
||||
throw Object.assign(new Error(`unknown status: ${v}`), {
|
||||
code: 'INVALID_STATUS'
|
||||
})
|
||||
}
|
||||
input.status = v as TaskStatus
|
||||
break
|
||||
}
|
||||
case '--group':
|
||||
case '--group-key':
|
||||
input.groupKey = consume()
|
||||
break
|
||||
case '--parent':
|
||||
input.parentId = consume()
|
||||
break
|
||||
case '--limit': {
|
||||
const v = consume()
|
||||
const n = Number.parseInt(v, 10)
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
throw Object.assign(new Error(`invalid limit: ${v}`), {
|
||||
code: 'INVALID_LIMIT'
|
||||
})
|
||||
}
|
||||
input.limit = n
|
||||
break
|
||||
}
|
||||
case '--cursor':
|
||||
input.cursor = consume()
|
||||
break
|
||||
default:
|
||||
throw Object.assign(new Error(`unknown flag: ${name}`), {
|
||||
code: 'UNKNOWN_FLAG'
|
||||
})
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function ok(value: unknown): SubcommandResult {
|
||||
return { kind: 'value', value }
|
||||
}
|
||||
|
||||
function notImplemented(what: string, why: string): SubcommandResult {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope('NOT_IMPLEMENTED', `${what} not implemented`, {
|
||||
reason: why
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function missingArg(usage: string, argName: string): SubcommandResult {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope('PARSE_ERROR', `missing ${argName}; usage: ${usage}`)
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityError(op: string): SubcommandResult {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'CONTRACT_VERSION_MISMATCH',
|
||||
`transport does not support ${op}; upgrade Desktop or API host`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function readNamedArg(args: readonly string[], name: string): string | undefined {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const tok = args[i]
|
||||
if (tok === undefined) continue
|
||||
if (tok === name) return args[i + 1]
|
||||
if (tok.startsWith(`${name}=`)) return tok.slice(name.length + 1)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Talks to the `/automation/v1/*` HTTP surface used by both Desktop
|
||||
* loopback (NEX-131 A1) and the remote Web/API host. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §5.1, §5.3, §8.1
|
||||
*
|
||||
* Wire format (POST JSON, GET for stats / health / events):
|
||||
* POST /automation/v1/handshake -> { token, expiresAt, ttlMs }
|
||||
* POST /automation/v1/add -> AddOutput
|
||||
* POST /automation/v1/get -> { task, projection } | null
|
||||
* POST /automation/v1/list -> { tasks, nextCursor }
|
||||
* POST /automation/v1/cancel|pause|resume -> { ok: true }
|
||||
* POST /automation/v1/retry -> { ok: true }
|
||||
* POST /automation/v1/setMaxConcurrency -> { ok: true }
|
||||
* POST /automation/v1/setMaxPerGroup -> { ok: true }
|
||||
* POST /automation/v1/removeFromHistory -> { ok: true }
|
||||
* GET /automation/v1/stats -> stats
|
||||
* GET /automation/v1/health -> health (no auth)
|
||||
* GET /automation/v1/events -> SSE
|
||||
*
|
||||
* The client owns a short-lived bearer token. It re-handshakes on:
|
||||
* - first call (no token cached)
|
||||
* - explicit 401 from the server
|
||||
* - token within `expiresAtSlackMs` of expiry
|
||||
*/
|
||||
|
||||
import type { Task, AddTaskRequest } from '@vidbee/task-queue'
|
||||
|
||||
import type { ContractClient, ListInput } from '../subcommands'
|
||||
|
||||
export interface AutomationClientOptions {
|
||||
/** Base URL such as `http://127.0.0.1:27100` (no trailing slash, no path). */
|
||||
baseUrl: string
|
||||
/** Pre-supplied bearer token (e.g. from `--vidbee-token` or env). */
|
||||
token?: string
|
||||
/** Skip handshake; only legal when `token` is provided. */
|
||||
skipHandshake?: boolean
|
||||
/** Request timeout per call (ms). Default 30s. */
|
||||
requestTimeoutMs?: number
|
||||
/** Renew when fewer than this many ms remain on the token. Default 60_000. */
|
||||
expiresAtSlackMs?: number
|
||||
/**
|
||||
* fetch override for tests. Defaults to global fetch.
|
||||
*/
|
||||
fetch?: typeof fetch
|
||||
/** Test seam — defaults to Date.now. */
|
||||
clock?: () => number
|
||||
}
|
||||
|
||||
export interface HandshakeResponse {
|
||||
token: string
|
||||
expiresAt: number
|
||||
ttlMs: number
|
||||
schemaVersion: string
|
||||
}
|
||||
|
||||
export class AutomationHttpError extends Error {
|
||||
readonly status: number
|
||||
readonly body: string
|
||||
readonly code: 'AUTH_FAILED' | 'API_UNREACHABLE' | 'CONTRACT_ERROR' | 'UNKNOWN'
|
||||
|
||||
constructor(
|
||||
code: AutomationHttpError['code'],
|
||||
status: number,
|
||||
message: string,
|
||||
body = ''
|
||||
) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
export class AutomationClient implements ContractClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly fetchImpl: typeof fetch
|
||||
private readonly clock: () => number
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly expiresAtSlackMs: number
|
||||
private readonly skipHandshake: boolean
|
||||
private token: string | null
|
||||
private tokenExpiresAt: number | null
|
||||
|
||||
constructor(opts: AutomationClientOptions) {
|
||||
this.baseUrl = stripTrailingSlash(opts.baseUrl)
|
||||
this.fetchImpl = opts.fetch ?? globalThis.fetch
|
||||
this.clock = opts.clock ?? Date.now
|
||||
this.requestTimeoutMs = opts.requestTimeoutMs ?? 30_000
|
||||
this.expiresAtSlackMs = opts.expiresAtSlackMs ?? 60_000
|
||||
this.skipHandshake = opts.skipHandshake ?? false
|
||||
this.token = opts.token ?? null
|
||||
this.tokenExpiresAt = opts.token ? Number.POSITIVE_INFINITY : null
|
||||
}
|
||||
|
||||
// ───────────── Handshake ─────────────
|
||||
|
||||
async handshake(): Promise<HandshakeResponse> {
|
||||
const res = await this.rawFetch('POST', '/automation/v1/handshake', {})
|
||||
if (!res.ok) {
|
||||
throw new AutomationHttpError(
|
||||
'API_UNREACHABLE',
|
||||
res.status,
|
||||
`handshake failed: ${res.status} ${res.statusText}`,
|
||||
res.bodyText
|
||||
)
|
||||
}
|
||||
const body = res.json as HandshakeResponse
|
||||
if (!body || typeof body.token !== 'string' || typeof body.expiresAt !== 'number') {
|
||||
throw new AutomationHttpError(
|
||||
'CONTRACT_ERROR',
|
||||
res.status,
|
||||
'handshake response missing token/expiresAt',
|
||||
res.bodyText
|
||||
)
|
||||
}
|
||||
this.token = body.token
|
||||
this.tokenExpiresAt = body.expiresAt
|
||||
return body
|
||||
}
|
||||
|
||||
async ensureToken(): Promise<string> {
|
||||
if (this.skipHandshake) {
|
||||
if (!this.token) {
|
||||
throw new AutomationHttpError(
|
||||
'AUTH_FAILED',
|
||||
0,
|
||||
'skipHandshake set but no token provided'
|
||||
)
|
||||
}
|
||||
return this.token
|
||||
}
|
||||
if (this.token && this.tokenExpiresAt !== null) {
|
||||
if (this.tokenExpiresAt - this.clock() > this.expiresAtSlackMs) {
|
||||
return this.token
|
||||
}
|
||||
}
|
||||
const res = await this.handshake()
|
||||
return res.token
|
||||
}
|
||||
|
||||
// ───────────── ContractClient ─────────────
|
||||
|
||||
async list(input: ListInput): Promise<{ items: Task[]; nextCursor: string | null }> {
|
||||
const body = await this.callPost<{
|
||||
tasks: { task: Task; projection: unknown }[] | Task[]
|
||||
nextCursor: string | null
|
||||
}>('list', input)
|
||||
return {
|
||||
items: Array.isArray(body.tasks)
|
||||
? body.tasks.map((t) => ('task' in (t as Record<string, unknown>) ? (t as { task: Task }).task : (t as Task)))
|
||||
: [],
|
||||
nextCursor: body.nextCursor ?? null
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Task> {
|
||||
const body = await this.callPost<{ task: Task; projection: unknown } | Task | null>(
|
||||
'get',
|
||||
{ id }
|
||||
)
|
||||
if (!body) {
|
||||
throw new AutomationHttpError(
|
||||
'CONTRACT_ERROR',
|
||||
404,
|
||||
`task ${id} not found`
|
||||
)
|
||||
}
|
||||
if ('task' in (body as Record<string, unknown>)) return (body as { task: Task }).task
|
||||
return body as Task
|
||||
}
|
||||
|
||||
async stats(): Promise<unknown> {
|
||||
await this.ensureToken()
|
||||
const res = await this.rawFetch('GET', '/automation/v1/stats')
|
||||
if (res.status === 401) {
|
||||
this.token = null
|
||||
await this.ensureToken()
|
||||
const retry = await this.rawFetch('GET', '/automation/v1/stats')
|
||||
if (!retry.ok) {
|
||||
throw new AutomationHttpError('UNKNOWN', retry.status, 'stats failed', retry.bodyText)
|
||||
}
|
||||
return retry.json
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new AutomationHttpError('UNKNOWN', res.status, 'stats failed', res.bodyText)
|
||||
}
|
||||
return res.json
|
||||
}
|
||||
|
||||
async removeFromHistory(id: string): Promise<void> {
|
||||
await this.callPost<{ ok: true }>('removeFromHistory', { id })
|
||||
}
|
||||
|
||||
// Phase B writers
|
||||
async add(req: AddTaskRequest): Promise<{ id: string; task: Task }> {
|
||||
const { id } = await this.callPost<{ id: string }>('add', req)
|
||||
const task = await this.get(id)
|
||||
return { id, task }
|
||||
}
|
||||
async cancel(id: string): Promise<void> {
|
||||
await this.callPost<{ ok: true }>('cancel', { id })
|
||||
}
|
||||
async pause(id: string, reason?: string): Promise<void> {
|
||||
await this.callPost<{ ok: true }>('pause', { id, reason })
|
||||
}
|
||||
async resume(id: string): Promise<void> {
|
||||
await this.callPost<{ ok: true }>('resume', { id })
|
||||
}
|
||||
async retry(id: string): Promise<void> {
|
||||
await this.callPost<{ ok: true }>('retry', { id })
|
||||
}
|
||||
|
||||
// ───────────── Internals ─────────────
|
||||
|
||||
private async callPost<T>(op: string, body: unknown): Promise<T> {
|
||||
await this.ensureToken()
|
||||
const res = await this.rawFetch('POST', `/automation/v1/${op}`, body)
|
||||
if (res.status === 401) {
|
||||
this.token = null
|
||||
await this.ensureToken()
|
||||
const retry = await this.rawFetch('POST', `/automation/v1/${op}`, body)
|
||||
if (!retry.ok) {
|
||||
throw httpErrorOf(retry, op)
|
||||
}
|
||||
return retry.json as T
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw httpErrorOf(res, op)
|
||||
}
|
||||
return res.json as T
|
||||
}
|
||||
|
||||
private async rawFetch(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<{ ok: boolean; status: number; statusText: string; json: unknown; bodyText: string }> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), this.requestTimeoutMs)
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
if (this.token) headers.Authorization = `Bearer ${this.token}`
|
||||
if (method === 'POST') headers['Content-Type'] = 'application/json'
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: ctrl.signal
|
||||
}
|
||||
if (method === 'POST') init.body = JSON.stringify(body ?? {})
|
||||
const res = await this.fetchImpl(`${this.baseUrl}${path}`, init)
|
||||
const text = await res.text()
|
||||
let json: unknown = null
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
/* leave as null; bodyText preserved for diagnostics */
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
json,
|
||||
bodyText: text
|
||||
}
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err)
|
||||
throw new AutomationHttpError(
|
||||
'API_UNREACHABLE',
|
||||
0,
|
||||
`automation request failed: ${cause}`
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.endsWith('/') ? url.slice(0, -1) : url
|
||||
}
|
||||
|
||||
function httpErrorOf(
|
||||
res: { status: number; statusText: string; bodyText: string },
|
||||
op: string
|
||||
): AutomationHttpError {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return new AutomationHttpError(
|
||||
'AUTH_FAILED',
|
||||
res.status,
|
||||
`automation ${op} unauthorized`,
|
||||
res.bodyText
|
||||
)
|
||||
}
|
||||
if (res.status === 0) {
|
||||
return new AutomationHttpError(
|
||||
'API_UNREACHABLE',
|
||||
res.status,
|
||||
`automation ${op} unreachable`,
|
||||
res.bodyText
|
||||
)
|
||||
}
|
||||
return new AutomationHttpError(
|
||||
'UNKNOWN',
|
||||
res.status,
|
||||
`automation ${op} failed: ${res.status} ${res.statusText}`,
|
||||
res.bodyText
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Auto-launch VidBee Desktop in background mode when the descriptor is
|
||||
* missing or stale. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §5.4
|
||||
*
|
||||
* The Desktop side adds `--background` / `--from-cli` flags (NEX-131
|
||||
* commit 7de4e20) which keep the app tray-only, no main window. We spawn
|
||||
* the platform-appropriate launcher and then poll for the descriptor to
|
||||
* appear.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
import { isPidAlive, readDescriptor, type ResolveDescriptorOptions } from './descriptor'
|
||||
|
||||
export interface AutostartOptions {
|
||||
/** Total wall-clock budget; defaults to 10s per design. */
|
||||
timeoutMs?: number
|
||||
/** Polling interval. */
|
||||
pollIntervalMs?: number
|
||||
/** Test seam — overrides spawn. */
|
||||
spawnLauncher?: (cmd: string, args: readonly string[]) => void
|
||||
/** Test seam — overrides Date.now. */
|
||||
clock?: () => number
|
||||
/** Test seam — overrides setTimeout-based wait. */
|
||||
delay?: (ms: number) => Promise<void>
|
||||
/** Test seam for descriptor lookup. */
|
||||
descriptorOptions?: ResolveDescriptorOptions
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type AutostartResult =
|
||||
| { kind: 'ready' }
|
||||
| { kind: 'autostart-disabled' }
|
||||
| { kind: 'unsupported-platform'; platform: NodeJS.Platform }
|
||||
| { kind: 'timeout'; waitedMs: number }
|
||||
| { kind: 'launch-failed'; reason: string }
|
||||
|
||||
const defaultDelay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Returns immediately when the descriptor already points at a live PID;
|
||||
* otherwise spawns Desktop in background mode and polls until the
|
||||
* descriptor reappears or the timeout expires.
|
||||
*/
|
||||
export async function ensureDesktopReady(
|
||||
enabled: boolean,
|
||||
opts: AutostartOptions = {}
|
||||
): Promise<AutostartResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000
|
||||
const poll = opts.pollIntervalMs ?? 200
|
||||
const clock = opts.clock ?? Date.now
|
||||
const delay = opts.delay ?? defaultDelay
|
||||
const platform = opts.platform ?? process.platform
|
||||
|
||||
// First check: if we already have a fresh descriptor pointing at a live
|
||||
// pid, no autostart needed.
|
||||
if (descriptorIsReady(opts.descriptorOptions)) return { kind: 'ready' }
|
||||
|
||||
if (!enabled) return { kind: 'autostart-disabled' }
|
||||
|
||||
const launchSpec = launcherForPlatform(platform)
|
||||
if (!launchSpec) return { kind: 'unsupported-platform', platform }
|
||||
|
||||
try {
|
||||
if (opts.spawnLauncher) {
|
||||
opts.spawnLauncher(launchSpec.cmd, launchSpec.args)
|
||||
} else {
|
||||
const child = spawn(launchSpec.cmd, [...launchSpec.args], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
windowsHide: true
|
||||
})
|
||||
child.unref()
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
kind: 'launch-failed',
|
||||
reason: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
const start = clock()
|
||||
while (clock() - start < timeoutMs) {
|
||||
if (descriptorIsReady(opts.descriptorOptions)) return { kind: 'ready' }
|
||||
await delay(poll)
|
||||
}
|
||||
return { kind: 'timeout', waitedMs: clock() - start }
|
||||
}
|
||||
|
||||
function descriptorIsReady(descriptorOpts: ResolveDescriptorOptions | undefined): boolean {
|
||||
const r = readDescriptor(descriptorOpts ?? {})
|
||||
if (!r.ok) return false
|
||||
return isPidAlive(r.descriptor.pid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-platform launcher. The Desktop end accepts `--background` /
|
||||
* `--from-cli`; both forms are forwarded for forward-compat.
|
||||
*
|
||||
* Some flow notes:
|
||||
* - macOS: `open -ga VidBee --args --background --from-cli`
|
||||
* - Linux: relies on `vidbee-desktop` desktop entry on PATH; the AppImage
|
||||
* install instructions create a symlink to the AppImage, which we then
|
||||
* invoke directly with `--background`.
|
||||
* - Windows: `start "" VidBee --background` via cmd /c so it goes
|
||||
* through the shell start verb (handles the .lnk in Start Menu).
|
||||
*
|
||||
* For Phase B we ship the macOS path as the well-tested reference; Linux
|
||||
* and Windows commands are best-effort and can be tightened by user QA on
|
||||
* those platforms.
|
||||
*/
|
||||
function launcherForPlatform(
|
||||
platform: NodeJS.Platform
|
||||
): { cmd: string; args: readonly string[] } | null {
|
||||
if (platform === 'darwin') {
|
||||
return { cmd: 'open', args: ['-ga', 'VidBee', '--args', '--background', '--from-cli'] }
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
cmd: 'cmd',
|
||||
args: ['/c', 'start', '""', 'VidBee', '--background', '--from-cli']
|
||||
}
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
return { cmd: 'vidbee-desktop', args: ['--background', '--from-cli'] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Resolves a `ContractClient` according to CLI flags. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §3, §4.2, §5
|
||||
*
|
||||
* Three transports:
|
||||
* - local: `--vidbee-local` → in-process TaskQueueAPI (createLocalClient)
|
||||
* - api: `--vidbee-api <url>` → AutomationClient against that base
|
||||
* - desktop: default → read descriptor → autostart if needed → handshake
|
||||
*
|
||||
* Each path can fail gracefully and returns an `ErrorEnvelope` instead of
|
||||
* throwing, so the runtime can map to the right exit code.
|
||||
*/
|
||||
|
||||
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
|
||||
import type { Flags } from '../parser'
|
||||
import type { ContractClient } from '../subcommands'
|
||||
import { AutomationClient } from './automation-client'
|
||||
import { ensureDesktopReady, type AutostartResult } from './autostart'
|
||||
import { isPidAlive, readDescriptor } from './descriptor'
|
||||
import { createLocalClient, type LocalClientHandle } from './local-client'
|
||||
|
||||
export interface ConnectOptions {
|
||||
flags: Flags
|
||||
/** Test seam — overrides AutomationClient instantiation. */
|
||||
buildAutomationClient?: (
|
||||
baseUrl: string,
|
||||
token: string | null
|
||||
) => ContractClient
|
||||
/** Test seam — overrides descriptor read. */
|
||||
readDescriptorImpl?: typeof readDescriptor
|
||||
/** Test seam — overrides autostart. */
|
||||
ensureDesktopReadyImpl?: typeof ensureDesktopReady
|
||||
/** Test seam — overrides createLocalClient. */
|
||||
createLocalClientImpl?: typeof createLocalClient
|
||||
}
|
||||
|
||||
export type ConnectResult =
|
||||
| { kind: 'connected'; client: ContractClient; teardown?: () => Promise<void> }
|
||||
| { kind: 'error'; envelope: ErrorEnvelope }
|
||||
|
||||
export async function connect(opts: ConnectOptions): Promise<ConnectResult> {
|
||||
const { flags } = opts
|
||||
|
||||
if (flags.local || flags.target === 'local') {
|
||||
const factory = opts.createLocalClientImpl ?? createLocalClient
|
||||
try {
|
||||
const handle: LocalClientHandle = await factory({})
|
||||
return { kind: 'connected', client: handle, teardown: handle.shutdown }
|
||||
} catch (err) {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'NOT_IMPLEMENTED',
|
||||
`--vidbee-local failed to start: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.api !== undefined || flags.target === 'api') {
|
||||
const url = flags.api
|
||||
if (!url) {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'API_UNREACHABLE',
|
||||
'--vidbee-target api requires --vidbee-api <url>'
|
||||
)
|
||||
}
|
||||
}
|
||||
const builder = opts.buildAutomationClient ?? defaultAutomationBuilder
|
||||
const client = builder(url, flags.token ?? null)
|
||||
return { kind: 'connected', client }
|
||||
}
|
||||
|
||||
// Desktop default path
|
||||
return await connectDesktop(opts)
|
||||
}
|
||||
|
||||
async function connectDesktop(opts: ConnectOptions): Promise<ConnectResult> {
|
||||
const { flags } = opts
|
||||
const readImpl = opts.readDescriptorImpl ?? readDescriptor
|
||||
const ensureImpl = opts.ensureDesktopReadyImpl ?? ensureDesktopReady
|
||||
|
||||
let descriptor = readImpl({})
|
||||
let needsAutostart = false
|
||||
if (!descriptor.ok) {
|
||||
needsAutostart = true
|
||||
} else if (!isPidAlive(descriptor.descriptor.pid)) {
|
||||
needsAutostart = true
|
||||
}
|
||||
|
||||
if (needsAutostart) {
|
||||
const result: AutostartResult = await ensureImpl(!flags.noAutostart, {
|
||||
...(flags.timeoutMs !== undefined ? { timeoutMs: flags.timeoutMs } : {})
|
||||
})
|
||||
if (result.kind === 'autostart-disabled') {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
'Desktop is not running and --vidbee-no-autostart was set'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.kind === 'unsupported-platform') {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
`autostart not supported on platform ${result.platform}; pass --vidbee-api or run Desktop manually`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.kind === 'launch-failed') {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
`failed to launch Desktop: ${result.reason}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.kind === 'timeout') {
|
||||
return {
|
||||
kind: 'error',
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
`Desktop did not become ready within ${result.waitedMs}ms`
|
||||
)
|
||||
}
|
||||
}
|
||||
descriptor = readImpl({})
|
||||
if (!descriptor.ok) return { kind: 'error', envelope: descriptor.envelope }
|
||||
}
|
||||
|
||||
if (!descriptor.ok) return { kind: 'error', envelope: descriptor.envelope }
|
||||
const baseUrl = `http://${descriptor.descriptor.host}:${descriptor.descriptor.port}`
|
||||
const builder = opts.buildAutomationClient ?? defaultAutomationBuilder
|
||||
const client = builder(baseUrl, flags.token ?? null)
|
||||
return { kind: 'connected', client }
|
||||
}
|
||||
|
||||
function defaultAutomationBuilder(
|
||||
baseUrl: string,
|
||||
token: string | null
|
||||
): ContractClient {
|
||||
if (token) {
|
||||
return new AutomationClient({ baseUrl, token, skipHandshake: true })
|
||||
}
|
||||
return new AutomationClient({ baseUrl })
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Reads (and validates) the Desktop automation descriptor. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §5.2 / §5.3
|
||||
*
|
||||
* The descriptor lives at a per-platform path and is overrideable via
|
||||
* `VIDBEE_AUTOMATION_DESCRIPTOR=/path`. It carries `tokenHash` only, never
|
||||
* the plaintext token; the CLI obtains the plaintext via `handshake`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
|
||||
|
||||
export interface DescriptorPayload {
|
||||
version: 1
|
||||
schemaVersion: string
|
||||
kind: 'desktop'
|
||||
host: string
|
||||
port: number
|
||||
tokenHash: string | null
|
||||
tokenIssuedAt: number | null
|
||||
tokenExpiresAt: number | null
|
||||
pid: number
|
||||
pidStartedAt: number
|
||||
updatedAt: number
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export interface ResolveDescriptorOptions {
|
||||
/** Test seam — overrides VIDBEE_AUTOMATION_DESCRIPTOR resolution. */
|
||||
pathOverride?: string
|
||||
envOverride?: string | null
|
||||
platform?: NodeJS.Platform
|
||||
homedir?: () => string
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Test seam — defaults to fs.existsSync. */
|
||||
exists?: (p: string) => boolean
|
||||
/** Test seam — defaults to fs.readFileSync(utf-8). */
|
||||
readFile?: (p: string) => string
|
||||
}
|
||||
|
||||
const DIRNAME = 'VidBee'
|
||||
const DESCRIPTOR_FILE = 'automation.json'
|
||||
|
||||
export function resolveDescriptorPath(
|
||||
opts: ResolveDescriptorOptions = {}
|
||||
): string {
|
||||
if (opts.pathOverride) return opts.pathOverride
|
||||
const env = opts.env ?? process.env
|
||||
const envOverride =
|
||||
opts.envOverride !== undefined
|
||||
? opts.envOverride
|
||||
: env.VIDBEE_AUTOMATION_DESCRIPTOR?.trim()
|
||||
if (envOverride && envOverride.length > 0) return envOverride
|
||||
|
||||
const platform = opts.platform ?? process.platform
|
||||
const home = (opts.homedir ?? homedir)()
|
||||
if (platform === 'darwin') {
|
||||
return join(home, 'Library', 'Application Support', DIRNAME, DESCRIPTOR_FILE)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const appdata = env.APPDATA?.trim()
|
||||
const base = appdata && appdata.length > 0 ? appdata : join(home, 'AppData', 'Roaming')
|
||||
return join(base, DIRNAME, DESCRIPTOR_FILE)
|
||||
}
|
||||
// Linux + others
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim()
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(home, '.config')
|
||||
return join(base, DIRNAME, DESCRIPTOR_FILE)
|
||||
}
|
||||
|
||||
export type ReadDescriptorResult =
|
||||
| { ok: true; descriptor: DescriptorPayload; path: string }
|
||||
| { ok: false; envelope: ErrorEnvelope; path: string }
|
||||
|
||||
export function readDescriptor(
|
||||
opts: ResolveDescriptorOptions = {}
|
||||
): ReadDescriptorResult {
|
||||
const path = resolveDescriptorPath(opts)
|
||||
const exists = opts.exists ?? existsSync
|
||||
const read = opts.readFile ?? ((p: string) => readFileSync(p, 'utf-8'))
|
||||
if (!exists(path)) {
|
||||
return {
|
||||
ok: false,
|
||||
path,
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
`automation descriptor not found at ${path}`,
|
||||
{ path }
|
||||
)
|
||||
}
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(read(path))
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
path,
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
`automation descriptor is malformed: ${err instanceof Error ? err.message : err}`,
|
||||
{ path }
|
||||
)
|
||||
}
|
||||
}
|
||||
const v = validateDescriptor(parsed)
|
||||
if (!v.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
path,
|
||||
envelope: errorEnvelope('DESKTOP_NOT_READY', v.reason, { path })
|
||||
}
|
||||
}
|
||||
return { ok: true, descriptor: v.value, path }
|
||||
}
|
||||
|
||||
function validateDescriptor(
|
||||
value: unknown
|
||||
):
|
||||
| { ok: true; value: DescriptorPayload }
|
||||
| { ok: false; reason: string } {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return { ok: false, reason: 'descriptor is not a JSON object' }
|
||||
}
|
||||
const v = value as Record<string, unknown>
|
||||
if (v.version !== 1) {
|
||||
return { ok: false, reason: `unsupported descriptor version: ${String(v.version)}` }
|
||||
}
|
||||
if (typeof v.host !== 'string' || typeof v.port !== 'number') {
|
||||
return { ok: false, reason: 'descriptor missing host/port' }
|
||||
}
|
||||
if (typeof v.pid !== 'number' || typeof v.pidStartedAt !== 'number') {
|
||||
return { ok: false, reason: 'descriptor missing pid/pidStartedAt' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
version: 1,
|
||||
schemaVersion: typeof v.schemaVersion === 'string' ? v.schemaVersion : '1.0.0',
|
||||
kind: 'desktop',
|
||||
host: v.host,
|
||||
port: v.port,
|
||||
tokenHash: typeof v.tokenHash === 'string' ? v.tokenHash : null,
|
||||
tokenIssuedAt: typeof v.tokenIssuedAt === 'number' ? v.tokenIssuedAt : null,
|
||||
tokenExpiresAt: typeof v.tokenExpiresAt === 'number' ? v.tokenExpiresAt : null,
|
||||
pid: v.pid,
|
||||
pidStartedAt: v.pidStartedAt,
|
||||
updatedAt: typeof v.updatedAt === 'number' ? v.updatedAt : 0,
|
||||
appVersion: typeof v.appVersion === 'string' ? v.appVersion : '0.0.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §5.2: detect a stale descriptor by checking whether the recorded pid is
|
||||
* still alive. We use `process.kill(pid, 0)` (no signal sent — only checks
|
||||
* the OS-level existence). Errors map to "stale", which is the correct
|
||||
* conservative answer.
|
||||
*/
|
||||
export function isPidAlive(
|
||||
pid: number,
|
||||
killProbe: (pid: number, signal: 0) => void = (p, s) => process.kill(p, s)
|
||||
): boolean {
|
||||
try {
|
||||
killProbe(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Transport selection for the CLI. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §3, §5, §8
|
||||
*
|
||||
* Three transports exist:
|
||||
* - desktop loopback (default) — descriptor + handshake; Phase B
|
||||
* - remote api (--vidbee-api) — HTTPS unless host is loopback / private
|
||||
* - local (--vidbee-local) — in-process TaskQueueAPI for CI / headless
|
||||
*
|
||||
* Phase A delivers `local` only; the other two surface a clear
|
||||
* `DESKTOP_NOT_READY` / `API_UNREACHABLE` error pointing at NEX-131.
|
||||
*/
|
||||
|
||||
import type { Flags } from '../parser'
|
||||
import type { ContractClient } from '../subcommands'
|
||||
import { ExitCode, errorEnvelope, type ErrorEnvelope } from '../envelope'
|
||||
|
||||
export interface TransportSelection {
|
||||
kind: 'local' | 'desktop' | 'api'
|
||||
/** Filled by the caller when actually connected. */
|
||||
client?: ContractClient
|
||||
}
|
||||
|
||||
export function selectTransport(flags: Flags): TransportSelection {
|
||||
if (flags.local || flags.target === 'local') return { kind: 'local' }
|
||||
if (flags.api !== undefined || flags.target === 'api') return { kind: 'api' }
|
||||
return { kind: 'desktop' }
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.1: plaintext HTTP only allowed when host is loopback or private.
|
||||
* Returns null when ok, an ErrorEnvelope when the URL fails the policy.
|
||||
*/
|
||||
export function validateApiUrl(url: string): ErrorEnvelope | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return errorEnvelope('API_UNREACHABLE', `invalid --vidbee-api URL: ${url}`)
|
||||
}
|
||||
if (parsed.protocol === 'https:') return null
|
||||
if (parsed.protocol !== 'http:') {
|
||||
return errorEnvelope(
|
||||
'API_UNREACHABLE',
|
||||
`unsupported scheme for --vidbee-api: ${parsed.protocol}`
|
||||
)
|
||||
}
|
||||
if (isLoopbackOrPrivateHost(parsed.hostname)) return null
|
||||
return errorEnvelope(
|
||||
'API_UNREACHABLE',
|
||||
`plaintext HTTP --vidbee-api is only allowed for loopback / private hosts; got ${parsed.hostname}`,
|
||||
{ url }
|
||||
)
|
||||
}
|
||||
|
||||
export function isLoopbackOrPrivateHost(host: string): boolean {
|
||||
const lower = host.toLowerCase()
|
||||
if (lower === 'localhost') return true
|
||||
if (lower === '::1' || lower === '[::1]') return true
|
||||
// ipv4 octet form
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(lower)
|
||||
if (m) {
|
||||
const a = Number(m[1])
|
||||
const b = Number(m[2])
|
||||
if (a === 127) return true
|
||||
if (a === 10) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
return false
|
||||
}
|
||||
// *.local (mDNS) is treated as private
|
||||
if (lower.endsWith('.local')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the right error envelope when a Phase-B-only transport is asked
|
||||
* for. The CLI shouldn't crash — it should print a coherent JSON envelope
|
||||
* and exit 3.
|
||||
*/
|
||||
export function transportNotReady(
|
||||
selection: TransportSelection
|
||||
): { envelope: ErrorEnvelope; exitCode: typeof ExitCode.HOST_UNREACHABLE } {
|
||||
if (selection.kind === 'desktop') {
|
||||
return {
|
||||
envelope: errorEnvelope(
|
||||
'DESKTOP_NOT_READY',
|
||||
'desktop loopback transport is not yet wired; tracked in NEX-131 A1.'
|
||||
),
|
||||
exitCode: ExitCode.HOST_UNREACHABLE
|
||||
}
|
||||
}
|
||||
return {
|
||||
envelope: errorEnvelope(
|
||||
'API_UNREACHABLE',
|
||||
'remote --vidbee-api transport is not yet wired; tracked in NEX-131 A2.'
|
||||
),
|
||||
exitCode: ExitCode.HOST_UNREACHABLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* `--vidbee-local` in-process transport. Reference:
|
||||
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §3, §4.1, §10
|
||||
*
|
||||
* Instantiates a TaskQueueAPI with a YtDlpExecutor in the calling process
|
||||
* — no Desktop, no API server. Used for CI / Docker / `npx @vidbee/cli`
|
||||
* and for the three-host equivalence test (`packages/task-queue/__integration__/three-host-equivalence.test.ts`).
|
||||
*
|
||||
* The persistence layer can be swapped between Memory and Sqlite via
|
||||
* options. The default uses an in-memory adapter so the CLI exits
|
||||
* cleanly without leftover SQLite files; pass `persist: 'sqlite'` to opt
|
||||
* into crash-recovery.
|
||||
*
|
||||
* Imports of `@vidbee/task-queue` and `@vidbee/downloader-core` are kept
|
||||
* as dynamic imports so the parser/probe path can run without those
|
||||
* runtime dependencies resolved (e.g. an `npx @vidbee/cli` install that
|
||||
* skipped optional yt-dlp deps).
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir, homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import type {
|
||||
AddTaskRequest,
|
||||
Executor,
|
||||
Task,
|
||||
TaskQueueAPI,
|
||||
TaskStatus
|
||||
} from '@vidbee/task-queue'
|
||||
|
||||
import type { ContractClient, ListInput } from '../subcommands'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export interface LocalClientOptions {
|
||||
/** Default download directory. Defaults to `~/Downloads/VidBee`. */
|
||||
defaultDownloadDir?: string
|
||||
/** Persistence: in-memory (default) or sqlite (for crash-recovery tests). */
|
||||
persist?: 'memory' | 'sqlite'
|
||||
/** Sqlite path; default is a per-process temp file under `os.tmpdir()`. */
|
||||
sqlitePath?: string
|
||||
/** yt-dlp binary path (forwarded to YtDlpExecutor). */
|
||||
ytDlpPath?: string
|
||||
/** ffmpeg directory. */
|
||||
ffmpegLocation?: string
|
||||
/** Concurrency knob. */
|
||||
maxConcurrency?: number
|
||||
/**
|
||||
* Test seam — supply a pre-built Executor (e.g. a scripted fake from the
|
||||
* three-host equivalence harness). When set, the YtDlpExecutor is not
|
||||
* instantiated and `ytDlpPath` / `ffmpegLocation` are ignored.
|
||||
*/
|
||||
executor?: Executor
|
||||
/** Test seam — disable kernel `processing → completed` fs guard. */
|
||||
filePresent?: (path: string) => boolean
|
||||
/** Test seam — deterministic backoff jitter for tests. */
|
||||
rng?: () => number
|
||||
}
|
||||
|
||||
export interface LocalClientHandle extends ContractClient {
|
||||
api: TaskQueueAPI
|
||||
/** Tear down (stop API, close persist, remove temp files). */
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the in-process client. Returns an object usable as both a
|
||||
* ContractClient and a teardown handle.
|
||||
*/
|
||||
export async function createLocalClient(
|
||||
opts: LocalClientOptions = {}
|
||||
): Promise<LocalClientHandle> {
|
||||
const taskQueue = await import('@vidbee/task-queue')
|
||||
const downloaderCore = await import('@vidbee/downloader-core')
|
||||
const tqDb = await import('@vidbee/db/task-queue')
|
||||
|
||||
const defaultDir =
|
||||
opts.defaultDownloadDir ?? join(homedir(), 'Downloads', 'VidBee')
|
||||
|
||||
let tempDir: string | null = null
|
||||
let sqliteDb: { close: () => void } | null = null
|
||||
let persistAdapter: InstanceType<typeof taskQueue.MemoryPersistAdapter> | InstanceType<typeof taskQueue.SqlitePersistAdapter>
|
||||
if ((opts.persist ?? 'memory') === 'sqlite') {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'vidbee-cli-'))
|
||||
const dbPath = opts.sqlitePath ?? join(tempDir, 'task-queue.db')
|
||||
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
|
||||
const db = new Database(dbPath, { timeout: 5000 }) as unknown as {
|
||||
exec: (sql: string) => void
|
||||
prepare: (sql: string) => unknown
|
||||
pragma: (sql: string, opts?: { simple?: boolean }) => unknown
|
||||
transaction: (fn: (...args: unknown[]) => unknown) => unknown
|
||||
close: () => void
|
||||
}
|
||||
db.exec(tqDb.TASK_QUEUE_DDL_V1)
|
||||
sqliteDb = db
|
||||
persistAdapter = new taskQueue.SqlitePersistAdapter({
|
||||
db: db as unknown as ConstructorParameters<typeof taskQueue.SqlitePersistAdapter>[0]['db']
|
||||
})
|
||||
} else {
|
||||
persistAdapter = new taskQueue.MemoryPersistAdapter()
|
||||
}
|
||||
|
||||
const executor =
|
||||
opts.executor ??
|
||||
new downloaderCore.YtDlpExecutor({
|
||||
resolveYtDlpPath: () => opts.ytDlpPath ?? process.env.YTDLP_PATH ?? 'yt-dlp',
|
||||
resolveFfmpegLocation: () => opts.ffmpegLocation ?? process.env.FFMPEG_PATH,
|
||||
defaultDownloadDir: defaultDir
|
||||
})
|
||||
|
||||
const api = new taskQueue.TaskQueueAPI({
|
||||
persist: persistAdapter,
|
||||
executor,
|
||||
maxConcurrency: opts.maxConcurrency ?? 4,
|
||||
...(opts.filePresent !== undefined ? { filePresent: opts.filePresent } : {}),
|
||||
...(opts.rng !== undefined ? { rng: opts.rng } : {})
|
||||
})
|
||||
await api.start()
|
||||
|
||||
let shuttingDown = false
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
try {
|
||||
await api.stop()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
if (sqliteDb) {
|
||||
try {
|
||||
sqliteDb.close()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (tempDir) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const list = async (input: ListInput) => {
|
||||
const opts: { status?: TaskStatus; groupKey?: string; parentId?: string; limit?: number; cursor: string | null } = {
|
||||
cursor: input.cursor ?? null
|
||||
}
|
||||
if (input.status !== undefined) opts.status = input.status
|
||||
if (input.groupKey !== undefined) opts.groupKey = input.groupKey
|
||||
if (input.parentId !== undefined) opts.parentId = input.parentId
|
||||
if (input.limit !== undefined) opts.limit = input.limit
|
||||
const page = api.list(opts)
|
||||
return { items: [...page.tasks] as Task[], nextCursor: page.nextCursor ?? null }
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
shutdown,
|
||||
list,
|
||||
get: async (id) => {
|
||||
const task = api.get(id)
|
||||
if (!task) throw new Error(`task ${id} not found`)
|
||||
return task
|
||||
},
|
||||
stats: async () => api.stats(),
|
||||
removeFromHistory: async (id) => {
|
||||
await api.removeFromHistory(id)
|
||||
},
|
||||
add: async (req: AddTaskRequest) => {
|
||||
const { id } = await api.add(req)
|
||||
const task = api.get(id)
|
||||
if (!task) throw new Error(`add() did not produce a task with id ${id}`)
|
||||
return { id, task }
|
||||
},
|
||||
cancel: async (id) => {
|
||||
await api.cancel(id)
|
||||
},
|
||||
pause: async (id, reason) => {
|
||||
await api.pause(id, reason)
|
||||
},
|
||||
resume: async (id) => {
|
||||
await api.resume(id)
|
||||
},
|
||||
retry: async (id) => {
|
||||
await api.retryManual(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AutomationClient } from '../src/transport/automation-client'
|
||||
|
||||
interface FakeResponse {
|
||||
status: number
|
||||
body: unknown
|
||||
}
|
||||
|
||||
function fakeFetch(routes: Record<string, FakeResponse | ((init: RequestInit) => FakeResponse)>): typeof fetch {
|
||||
const calls: { url: string; init: RequestInit }[] = []
|
||||
const f = (async (url: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const u = String(url)
|
||||
calls.push({ url: u, init })
|
||||
const path = new URL(u).pathname
|
||||
const route = routes[path]
|
||||
const r = typeof route === 'function' ? route(init) : route
|
||||
if (!r) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
const body = typeof r.body === 'string' ? r.body : JSON.stringify(r.body)
|
||||
return new Response(body, {
|
||||
status: r.status,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
}) as typeof fetch
|
||||
;(f as unknown as { calls: typeof calls }).calls = calls
|
||||
return f
|
||||
}
|
||||
|
||||
describe('AutomationClient handshake', () => {
|
||||
it('handshakes once then reuses token', async () => {
|
||||
let handshakes = 0
|
||||
const fetchImpl = fakeFetch({
|
||||
'/automation/v1/handshake': () => {
|
||||
handshakes += 1
|
||||
return {
|
||||
status: 200,
|
||||
body: { token: 'tok', expiresAt: Date.now() + 600_000, ttlMs: 600_000, schemaVersion: '1.0.0' }
|
||||
}
|
||||
},
|
||||
'/automation/v1/stats': { status: 200, body: { running: 0 } }
|
||||
})
|
||||
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
|
||||
await c.stats()
|
||||
await c.stats()
|
||||
expect(handshakes).toBe(1)
|
||||
})
|
||||
|
||||
it('re-handshakes on 401', async () => {
|
||||
let handshakes = 0
|
||||
let nextStats: 401 | 200 = 401
|
||||
const fetchImpl = fakeFetch({
|
||||
'/automation/v1/handshake': () => {
|
||||
handshakes += 1
|
||||
return { status: 200, body: { token: 't', expiresAt: Date.now() + 600_000, ttlMs: 600_000 } }
|
||||
},
|
||||
'/automation/v1/stats': () => {
|
||||
if (nextStats === 401) {
|
||||
nextStats = 200
|
||||
return { status: 401, body: { error: 'unauthorized' } }
|
||||
}
|
||||
return { status: 200, body: { ok: true } }
|
||||
}
|
||||
})
|
||||
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
|
||||
const result = await c.stats()
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(handshakes).toBe(2) // initial + re-handshake on 401
|
||||
})
|
||||
|
||||
it('skipHandshake uses provided token', async () => {
|
||||
const fetchImpl = fakeFetch({
|
||||
'/automation/v1/stats': (init) => {
|
||||
const auth = (init.headers as Record<string, string>).Authorization
|
||||
return { status: auth === 'Bearer t' ? 200 : 401, body: { auth } }
|
||||
}
|
||||
})
|
||||
const c = new AutomationClient({
|
||||
baseUrl: 'http://127.0.0.1:27100',
|
||||
fetch: fetchImpl,
|
||||
token: 't',
|
||||
skipHandshake: true
|
||||
})
|
||||
const r = await c.stats()
|
||||
expect(r).toEqual({ auth: 'Bearer t' })
|
||||
})
|
||||
|
||||
it('list maps {tasks} → {items}', async () => {
|
||||
const fetchImpl = fakeFetch({
|
||||
'/automation/v1/handshake': { status: 200, body: { token: 't', expiresAt: Date.now() + 600_000, ttlMs: 600_000 } },
|
||||
'/automation/v1/list': {
|
||||
status: 200,
|
||||
body: {
|
||||
tasks: [{ task: { id: 'a', status: 'queued' }, projection: {} }],
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
})
|
||||
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
|
||||
const r = await c.list({})
|
||||
expect(r.items).toHaveLength(1)
|
||||
expect((r.items[0] as { id: string }).id).toBe('a')
|
||||
})
|
||||
|
||||
it('add() chases get() to assemble {id, task}', async () => {
|
||||
const fetchImpl = fakeFetch({
|
||||
'/automation/v1/handshake': { status: 200, body: { token: 't', expiresAt: Date.now() + 600_000, ttlMs: 600_000 } },
|
||||
'/automation/v1/add': { status: 200, body: { id: 'id1' } },
|
||||
'/automation/v1/get': { status: 200, body: { task: { id: 'id1', status: 'queued' }, projection: {} } }
|
||||
})
|
||||
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
|
||||
const r = await c.add({ input: { url: 'https://x', kind: 'video' } })
|
||||
expect(r.id).toBe('id1')
|
||||
expect((r.task as { status: string }).status).toBe('queued')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ensureDesktopReady } from '../src/transport/autostart'
|
||||
|
||||
describe('ensureDesktopReady (§5.4)', () => {
|
||||
it('returns ready when descriptor already points at live pid', async () => {
|
||||
let attempts = 0
|
||||
const r = await ensureDesktopReady(true, {
|
||||
descriptorOptions: {
|
||||
pathOverride: '/x',
|
||||
exists: () => true,
|
||||
readFile: () => {
|
||||
attempts += 1
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
host: '127.0.0.1',
|
||||
port: 27_100,
|
||||
pid: process.pid, // live pid for this process
|
||||
pidStartedAt: 0,
|
||||
schemaVersion: '1.0.0',
|
||||
kind: 'desktop',
|
||||
tokenHash: null,
|
||||
tokenIssuedAt: null,
|
||||
tokenExpiresAt: null,
|
||||
updatedAt: 0,
|
||||
appVersion: '1.0.0'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(r.kind).toBe('ready')
|
||||
expect(attempts).toBe(1) // no autostart polling needed
|
||||
})
|
||||
|
||||
it('returns autostart-disabled when enabled=false and no descriptor', async () => {
|
||||
const r = await ensureDesktopReady(false, {
|
||||
descriptorOptions: { pathOverride: '/x', exists: () => false }
|
||||
})
|
||||
expect(r.kind).toBe('autostart-disabled')
|
||||
})
|
||||
|
||||
it('spawns launcher and returns ready when descriptor appears', async () => {
|
||||
let calls = 0
|
||||
let descriptorPresent = false
|
||||
let spawned = 0
|
||||
const r = await ensureDesktopReady(true, {
|
||||
timeoutMs: 1_000,
|
||||
pollIntervalMs: 5,
|
||||
delay: async () => {},
|
||||
spawnLauncher: () => {
|
||||
spawned += 1
|
||||
descriptorPresent = true
|
||||
},
|
||||
descriptorOptions: {
|
||||
pathOverride: '/x',
|
||||
exists: () => descriptorPresent,
|
||||
readFile: () => {
|
||||
calls += 1
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
host: '127.0.0.1',
|
||||
port: 27_100,
|
||||
pid: process.pid,
|
||||
pidStartedAt: 0,
|
||||
schemaVersion: '1.0.0',
|
||||
kind: 'desktop',
|
||||
tokenHash: null,
|
||||
tokenIssuedAt: null,
|
||||
tokenExpiresAt: null,
|
||||
updatedAt: 0,
|
||||
appVersion: '1.0.0'
|
||||
})
|
||||
}
|
||||
},
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(r.kind).toBe('ready')
|
||||
expect(spawned).toBe(1)
|
||||
expect(calls).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('returns timeout when descriptor never appears', async () => {
|
||||
let now = 0
|
||||
const r = await ensureDesktopReady(true, {
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 10,
|
||||
clock: () => now,
|
||||
delay: async (ms) => {
|
||||
now += ms
|
||||
},
|
||||
spawnLauncher: () => {},
|
||||
descriptorOptions: { pathOverride: '/x', exists: () => false },
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(r.kind).toBe('timeout')
|
||||
})
|
||||
|
||||
it('returns unsupported-platform on freebsd', async () => {
|
||||
const r = await ensureDesktopReady(true, {
|
||||
descriptorOptions: { pathOverride: '/x', exists: () => false },
|
||||
platform: 'freebsd'
|
||||
})
|
||||
expect(r.kind).toBe('unsupported-platform')
|
||||
})
|
||||
|
||||
it('returns launch-failed when spawnLauncher throws', async () => {
|
||||
const r = await ensureDesktopReady(true, {
|
||||
descriptorOptions: { pathOverride: '/x', exists: () => false },
|
||||
platform: 'darwin',
|
||||
spawnLauncher: () => {
|
||||
throw new Error('ENOENT VidBee')
|
||||
}
|
||||
})
|
||||
if (r.kind !== 'launch-failed') throw new Error('expected launch-failed')
|
||||
expect(r.reason).toMatch(/VidBee/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildForwardedInput } from '../src/download/build-input'
|
||||
import type { Flags } from '../src/parser'
|
||||
|
||||
const baseFlags: Flags = {
|
||||
local: false,
|
||||
json: true,
|
||||
pretty: false,
|
||||
wait: false,
|
||||
detach: false,
|
||||
noRetry: false,
|
||||
noAutostart: false
|
||||
}
|
||||
|
||||
describe('buildForwardedInput (§6.2)', () => {
|
||||
it('preserves rawArgs and emits sanitizedArgs in options', () => {
|
||||
const r = buildForwardedInput({
|
||||
argv: ['--password', 'shh', '-f', 'best', 'https://x'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect(r.input.kind).toBe('yt-dlp-forward')
|
||||
expect(r.input.rawArgs).toEqual(['--password', 'shh', '-f', 'best', 'https://x'])
|
||||
const opts = r.input.options as { sanitizedArgs: string[] }
|
||||
expect(opts.sanitizedArgs[1]).toBe('<redacted>')
|
||||
expect(r.redacted).toBe(true)
|
||||
})
|
||||
|
||||
it('extracts URL from argv (last bare URL positional)', () => {
|
||||
const r = buildForwardedInput({
|
||||
argv: ['-f', 'best', 'https://a.com', 'https://b.com'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect(r.input.url).toBe('https://b.com')
|
||||
})
|
||||
|
||||
it('does not pick up URL-shaped value of a value-consuming flag', () => {
|
||||
const r = buildForwardedInput({
|
||||
argv: ['--cookies', '/tmp/c.txt', 'https://x'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect(r.input.url).toBe('https://x')
|
||||
})
|
||||
|
||||
it('parses output hints from -o / --paths / -o -', () => {
|
||||
const r1 = buildForwardedInput({
|
||||
argv: ['-j', '-o', '-', 'https://x'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect((r1.input.options as { outputHints: { stdoutMode: boolean } }).outputHints.stdoutMode).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const r2 = buildForwardedInput({
|
||||
argv: ['-o', 'video.mp4', 'https://x'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect(
|
||||
(r2.input.options as { outputHints: { outputTemplate: string } }).outputHints.outputTemplate
|
||||
).toBe('video.mp4')
|
||||
|
||||
const r3 = buildForwardedInput({
|
||||
argv: ['-P', '/tmp', 'https://x'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect((r3.input.options as { outputHints: { paths: string[] } }).outputHints.paths).toEqual([
|
||||
'/tmp'
|
||||
])
|
||||
})
|
||||
|
||||
it('packs vidbee.* metadata when flags are set', () => {
|
||||
const r = buildForwardedInput({
|
||||
argv: ['https://x'],
|
||||
flags: { ...baseFlags, wait: true, priority: 'subscription', maxAttempts: 2 }
|
||||
})
|
||||
expect((r.input.options as { vidbee: { wait: boolean; priority: string; maxAttempts: number } }).vidbee).toEqual({
|
||||
wait: true,
|
||||
priority: 'subscription',
|
||||
maxAttempts: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('quotes whitespace tokens in command preview', () => {
|
||||
const r = buildForwardedInput({
|
||||
argv: ['-o', 'has space.mp4', 'https://x'],
|
||||
flags: baseFlags
|
||||
})
|
||||
expect(r.commandPreview).toContain("'has space.mp4'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { connect } from '../src/transport/connect'
|
||||
|
||||
describe('connect (transport selection)', () => {
|
||||
const baseFlags = {
|
||||
local: false,
|
||||
json: true,
|
||||
pretty: false,
|
||||
wait: false,
|
||||
detach: false,
|
||||
noRetry: false,
|
||||
noAutostart: false
|
||||
}
|
||||
|
||||
it('returns local client when --vidbee-local', async () => {
|
||||
const fakeClient = {
|
||||
list: async () => ({ items: [], nextCursor: null }),
|
||||
get: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
stats: async () => ({}),
|
||||
removeFromHistory: async () => {},
|
||||
shutdown: async () => {}
|
||||
}
|
||||
const r = await connect({
|
||||
flags: { ...baseFlags, local: true },
|
||||
createLocalClientImpl: async () => fakeClient as never
|
||||
})
|
||||
if (r.kind !== 'connected') throw new Error('expected connected')
|
||||
expect(r.client.stats).toBeDefined()
|
||||
expect(r.teardown).toBeDefined()
|
||||
})
|
||||
|
||||
it('reports DESKTOP_NOT_READY when descriptor missing and autostart disabled', async () => {
|
||||
const r = await connect({
|
||||
flags: { ...baseFlags, noAutostart: true },
|
||||
readDescriptorImpl: () => ({ ok: false, path: '/x', envelope: { ok: false, code: 'DESKTOP_NOT_READY', message: 'missing' } }),
|
||||
ensureDesktopReadyImpl: async () => ({ kind: 'autostart-disabled' })
|
||||
})
|
||||
if (r.kind !== 'error') throw new Error('expected error')
|
||||
expect(r.envelope.code).toBe('DESKTOP_NOT_READY')
|
||||
})
|
||||
|
||||
it('uses descriptor + automation client when ready', async () => {
|
||||
const r = await connect({
|
||||
flags: { ...baseFlags },
|
||||
readDescriptorImpl: () => ({
|
||||
ok: true,
|
||||
path: '/x',
|
||||
descriptor: {
|
||||
version: 1,
|
||||
schemaVersion: '1.0.0',
|
||||
kind: 'desktop',
|
||||
host: '127.0.0.1',
|
||||
port: 27_100,
|
||||
tokenHash: null,
|
||||
tokenIssuedAt: null,
|
||||
tokenExpiresAt: null,
|
||||
pid: process.pid,
|
||||
pidStartedAt: 0,
|
||||
updatedAt: 0,
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
}),
|
||||
buildAutomationClient: (baseUrl) => {
|
||||
return {
|
||||
list: async () => ({ items: [], nextCursor: null }),
|
||||
get: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
stats: async () => ({ baseUrl }),
|
||||
removeFromHistory: async () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (r.kind !== 'connected') throw new Error('expected connected')
|
||||
expect(await r.client.stats()).toEqual({ baseUrl: 'http://127.0.0.1:27100' })
|
||||
})
|
||||
|
||||
it('reports API_UNREACHABLE when --vidbee-target api with no url', async () => {
|
||||
const r = await connect({ flags: { ...baseFlags, target: 'api' } })
|
||||
if (r.kind !== 'error') throw new Error('expected error')
|
||||
expect(r.envelope.code).toBe('API_UNREACHABLE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isPidAlive, readDescriptor, resolveDescriptorPath } from '../src/transport/descriptor'
|
||||
|
||||
describe('resolveDescriptorPath (§5.2)', () => {
|
||||
it('honors VIDBEE_AUTOMATION_DESCRIPTOR override', () => {
|
||||
const p = resolveDescriptorPath({ envOverride: '/tmp/custom.json' })
|
||||
expect(p).toBe('/tmp/custom.json')
|
||||
})
|
||||
|
||||
it('falls back to per-platform paths', () => {
|
||||
expect(
|
||||
resolveDescriptorPath({
|
||||
platform: 'darwin',
|
||||
homedir: () => '/Users/u',
|
||||
envOverride: null,
|
||||
env: {}
|
||||
})
|
||||
).toBe('/Users/u/Library/Application Support/VidBee/automation.json')
|
||||
|
||||
expect(
|
||||
resolveDescriptorPath({
|
||||
platform: 'linux',
|
||||
homedir: () => '/home/u',
|
||||
envOverride: null,
|
||||
env: {}
|
||||
})
|
||||
).toBe('/home/u/.config/VidBee/automation.json')
|
||||
|
||||
expect(
|
||||
resolveDescriptorPath({
|
||||
platform: 'win32',
|
||||
homedir: () => 'C:\\Users\\u',
|
||||
envOverride: null,
|
||||
env: { APPDATA: 'C:\\Users\\u\\AppData\\Roaming' }
|
||||
})
|
||||
).toContain('VidBee')
|
||||
})
|
||||
|
||||
it('XDG_CONFIG_HOME overrides ~/.config on linux', () => {
|
||||
expect(
|
||||
resolveDescriptorPath({
|
||||
platform: 'linux',
|
||||
homedir: () => '/home/u',
|
||||
envOverride: null,
|
||||
env: { XDG_CONFIG_HOME: '/cfg' }
|
||||
})
|
||||
).toBe('/cfg/VidBee/automation.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDescriptor', () => {
|
||||
it('returns DESKTOP_NOT_READY when missing', () => {
|
||||
const r = readDescriptor({ pathOverride: '/missing/path', exists: () => false })
|
||||
if (r.ok) throw new Error('expected error')
|
||||
expect(r.envelope.code).toBe('DESKTOP_NOT_READY')
|
||||
})
|
||||
|
||||
it('returns DESKTOP_NOT_READY when malformed', () => {
|
||||
const r = readDescriptor({
|
||||
pathOverride: '/x',
|
||||
exists: () => true,
|
||||
readFile: () => 'not json'
|
||||
})
|
||||
if (r.ok) throw new Error('expected error')
|
||||
expect(r.envelope.message).toMatch(/malformed/)
|
||||
})
|
||||
|
||||
it('parses a valid descriptor', () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
schemaVersion: '1.0.0',
|
||||
kind: 'desktop',
|
||||
host: '127.0.0.1',
|
||||
port: 27_100,
|
||||
tokenHash: 'sha256:abc',
|
||||
tokenIssuedAt: 1,
|
||||
tokenExpiresAt: 2,
|
||||
pid: 12,
|
||||
pidStartedAt: 100,
|
||||
updatedAt: 100,
|
||||
appVersion: '1.0.0'
|
||||
}
|
||||
const r = readDescriptor({
|
||||
pathOverride: '/x',
|
||||
exists: () => true,
|
||||
readFile: () => JSON.stringify(payload)
|
||||
})
|
||||
if (!r.ok) throw new Error('expected ok')
|
||||
expect(r.descriptor.host).toBe('127.0.0.1')
|
||||
expect(r.descriptor.port).toBe(27_100)
|
||||
expect(r.descriptor.tokenHash).toBe('sha256:abc')
|
||||
})
|
||||
|
||||
it('rejects future schema versions', () => {
|
||||
const r = readDescriptor({
|
||||
pathOverride: '/x',
|
||||
exists: () => true,
|
||||
readFile: () => JSON.stringify({ version: 99 })
|
||||
})
|
||||
if (r.ok) throw new Error('expected error')
|
||||
expect(r.envelope.message).toMatch(/version/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPidAlive', () => {
|
||||
it('returns true when probe succeeds', () => {
|
||||
expect(isPidAlive(123, () => undefined)).toBe(true)
|
||||
})
|
||||
it('returns false when probe throws', () => {
|
||||
expect(isPidAlive(123, () => {
|
||||
throw new Error('ESRCH')
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { enqueueDownload } from '../src/download/enqueue'
|
||||
import type { ContractClient } from '../src/subcommands'
|
||||
import type { Task } from '@vidbee/task-queue'
|
||||
import { EMPTY_PROGRESS } from '@vidbee/task-queue'
|
||||
|
||||
function task(id: string, status: Task['status']): Task {
|
||||
return {
|
||||
id,
|
||||
kind: 'yt-dlp-forward',
|
||||
parentId: null,
|
||||
input: { url: 'https://x', kind: 'yt-dlp-forward' },
|
||||
priority: 0,
|
||||
groupKey: 'x',
|
||||
status,
|
||||
prevStatus: null,
|
||||
statusReason: null,
|
||||
enteredStatusAt: 0,
|
||||
attempt: 0,
|
||||
maxAttempts: 5,
|
||||
nextRetryAt: null,
|
||||
progress: { ...EMPTY_PROGRESS },
|
||||
output: null,
|
||||
lastError: null,
|
||||
pid: null,
|
||||
pidStartedAt: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function client(initial: Task, transitions: Task[] = []): ContractClient {
|
||||
let i = 0
|
||||
return {
|
||||
list: async () => ({ items: [], nextCursor: null }),
|
||||
get: async () => transitions[Math.min(i++, transitions.length - 1)] ?? initial,
|
||||
stats: async () => ({}),
|
||||
removeFromHistory: async () => {},
|
||||
add: async () => ({ id: initial.id, task: initial })
|
||||
}
|
||||
}
|
||||
|
||||
describe('enqueueDownload', () => {
|
||||
it('detached returns immediately', async () => {
|
||||
const t = task('a', 'queued')
|
||||
const r = await enqueueDownload({
|
||||
client: client(t),
|
||||
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
|
||||
wait: false
|
||||
})
|
||||
expect(r.kind).toBe('detached')
|
||||
if (r.kind === 'detached') expect(r.task.id).toBe('a')
|
||||
})
|
||||
|
||||
it('wait resolves to wait-success on completed', async () => {
|
||||
const t0 = task('a', 'queued')
|
||||
const t1 = task('a', 'running')
|
||||
const t2 = task('a', 'completed')
|
||||
const r = await enqueueDownload({
|
||||
client: client(t0, [t1, t2]),
|
||||
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
|
||||
wait: true,
|
||||
delay: async () => {}
|
||||
})
|
||||
expect(r.kind).toBe('wait-success')
|
||||
})
|
||||
|
||||
it('wait reports retry-scheduled as wait-non-success', async () => {
|
||||
const t0 = task('a', 'queued')
|
||||
const t1 = task('a', 'retry-scheduled')
|
||||
const r = await enqueueDownload({
|
||||
client: client(t0, [t1]),
|
||||
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
|
||||
wait: true,
|
||||
delay: async () => {}
|
||||
})
|
||||
if (r.kind !== 'wait-non-success') throw new Error('expected non-success')
|
||||
expect(r.reason).toBe('retry-scheduled')
|
||||
})
|
||||
|
||||
it('wait surfaces failed terminal status', async () => {
|
||||
const t0 = task('a', 'queued')
|
||||
const t1 = task('a', 'failed')
|
||||
const r = await enqueueDownload({
|
||||
client: client(t0, [t1]),
|
||||
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
|
||||
wait: true,
|
||||
delay: async () => {}
|
||||
})
|
||||
if (r.kind !== 'wait-non-success') throw new Error('expected non-success')
|
||||
expect(r.reason).toBe('failed')
|
||||
})
|
||||
|
||||
it('throws when transport lacks add()', async () => {
|
||||
const c: ContractClient = {
|
||||
list: async () => ({ items: [], nextCursor: null }),
|
||||
get: async () => task('a', 'queued'),
|
||||
stats: async () => ({}),
|
||||
removeFromHistory: async () => {}
|
||||
}
|
||||
await expect(
|
||||
enqueueDownload({
|
||||
client: c,
|
||||
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
|
||||
wait: false
|
||||
})
|
||||
).rejects.toThrow(/transport.*add/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ExitCode,
|
||||
errorEnvelope,
|
||||
exitCodeForError,
|
||||
renderEnvelope
|
||||
} from '../src/envelope'
|
||||
|
||||
describe('exitCodeForError (§4.4 table)', () => {
|
||||
it('maps host unreachability to 3', () => {
|
||||
expect(exitCodeForError('DESKTOP_NOT_READY')).toBe(ExitCode.HOST_UNREACHABLE)
|
||||
expect(exitCodeForError('API_UNREACHABLE')).toBe(ExitCode.HOST_UNREACHABLE)
|
||||
expect(exitCodeForError('HANDSHAKE_FAILED')).toBe(ExitCode.HOST_UNREACHABLE)
|
||||
})
|
||||
it('maps auth to 4', () => {
|
||||
expect(exitCodeForError('AUTH_FAILED')).toBe(ExitCode.AUTH_FAILED)
|
||||
expect(exitCodeForError('TOKEN_EXPIRED')).toBe(ExitCode.AUTH_FAILED)
|
||||
})
|
||||
it('maps contract / not-implemented to 5', () => {
|
||||
expect(exitCodeForError('CONTRACT_VERSION_MISMATCH')).toBe(ExitCode.CONTRACT_ERROR)
|
||||
expect(exitCodeForError('CONTRACT_SCHEMA_MISMATCH')).toBe(ExitCode.CONTRACT_ERROR)
|
||||
expect(exitCodeForError('NOT_IMPLEMENTED')).toBe(ExitCode.CONTRACT_ERROR)
|
||||
})
|
||||
it('maps probe overflow to 1', () => {
|
||||
expect(exitCodeForError('PROBE_OUTPUT_TOO_LARGE')).toBe(ExitCode.WAIT_NON_SUCCESS)
|
||||
})
|
||||
it('falls back to 2 for parse-time codes', () => {
|
||||
expect(exitCodeForError('UNKNOWN_VIDBEE_FLAG')).toBe(ExitCode.ARG_ERROR)
|
||||
expect(exitCodeForError('PARSE_ERROR')).toBe(ExitCode.ARG_ERROR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderEnvelope', () => {
|
||||
it('emits compact JSON by default', () => {
|
||||
const env = errorEnvelope('PARSE_ERROR', 'oops')
|
||||
expect(renderEnvelope(env)).toBe('{"ok":false,"code":"PARSE_ERROR","message":"oops"}')
|
||||
})
|
||||
it('pretty-prints when asked', () => {
|
||||
const env = errorEnvelope('PARSE_ERROR', 'oops')
|
||||
expect(renderEnvelope(env, { pretty: true })).toContain('\n')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
checkUpgrade,
|
||||
compareSemver,
|
||||
defaultCachePath,
|
||||
readCliVersion
|
||||
} from '../src/local-info'
|
||||
|
||||
describe('readCliVersion', () => {
|
||||
it('returns the @vidbee/cli package.json version when found', () => {
|
||||
const info = readCliVersion()
|
||||
// The version comes from apps/cli/package.json
|
||||
expect(info.cli).toMatch(/^\d+\.\d+\.\d+/)
|
||||
expect(info.contract).toEqual(info.cli)
|
||||
expect(info.changelog).toContain('CHANGELOG')
|
||||
})
|
||||
|
||||
it('falls back to a marker when no candidate matches', () => {
|
||||
const info = readCliVersion(['/path/that/definitely/does/not/exist.json'])
|
||||
expect(info.cli).toBe('0.0.0-dev')
|
||||
expect(info.contract).toBe('0.0.0-dev')
|
||||
})
|
||||
|
||||
it('skips package.json files with the wrong name', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'vidbee-cli-version-'))
|
||||
const wrong = join(dir, 'wrong.json')
|
||||
const right = join(dir, 'right.json')
|
||||
writeFileSync(wrong, JSON.stringify({ name: 'something-else', version: '9.9.9' }))
|
||||
writeFileSync(
|
||||
right,
|
||||
JSON.stringify({ name: '@vidbee/cli', version: '1.2.3' })
|
||||
)
|
||||
const info = readCliVersion([wrong, right])
|
||||
expect(info.cli).toBe('1.2.3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareSemver', () => {
|
||||
it('orders by major/minor/patch', () => {
|
||||
expect(compareSemver('0.1.0', '0.2.0')).toBeLessThan(0)
|
||||
expect(compareSemver('1.0.0', '0.9.9')).toBeGreaterThan(0)
|
||||
expect(compareSemver('1.2.3', '1.2.3')).toBe(0)
|
||||
})
|
||||
it('treats prerelease as smaller than release', () => {
|
||||
expect(compareSemver('0.1.0-rc.1', '0.1.0')).toBeLessThan(0)
|
||||
expect(compareSemver('0.1.0', '0.1.0-rc.1')).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkUpgrade', () => {
|
||||
let cachePath: string
|
||||
beforeEach(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'vidbee-cli-upgrade-'))
|
||||
cachePath = join(dir, 'cli-upgrade-check.json')
|
||||
})
|
||||
afterEach(() => {
|
||||
// tmpdir gets cleaned by the OS; nothing else to do.
|
||||
})
|
||||
|
||||
it('reports out-of-date when registry returns a newer version', async () => {
|
||||
const result = await checkUpgrade({
|
||||
current: '0.1.0',
|
||||
cachePath,
|
||||
fetchLatest: async () => ({ version: '0.2.0', fetchedAt: 1000 })
|
||||
})
|
||||
expect(result.upToDate).toBe(false)
|
||||
expect(result.latest).toBe('0.2.0')
|
||||
expect(result.cached).toBe(false)
|
||||
expect(result.installCommands.npm).toContain('@vidbee/cli')
|
||||
// Cache should be written
|
||||
const cached = JSON.parse(readFileSync(cachePath, 'utf-8'))
|
||||
expect(cached.version).toBe('0.2.0')
|
||||
})
|
||||
|
||||
it('reports up-to-date when installed >= registry', async () => {
|
||||
const result = await checkUpgrade({
|
||||
current: '0.2.0',
|
||||
cachePath,
|
||||
fetchLatest: async () => ({ version: '0.2.0', fetchedAt: 1000 })
|
||||
})
|
||||
expect(result.upToDate).toBe(true)
|
||||
})
|
||||
|
||||
it('uses a fresh cache and skips fetch', async () => {
|
||||
writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({ version: '0.5.0', fetchedAt: 1_000_000 })
|
||||
)
|
||||
let fetched = false
|
||||
const result = await checkUpgrade({
|
||||
current: '0.4.0',
|
||||
cachePath,
|
||||
now: () => 1_000_000 + 24 * 60 * 60 * 1000, // 1 day later
|
||||
fetchLatest: async () => {
|
||||
fetched = true
|
||||
return { version: '0.6.0', fetchedAt: 2_000_000 }
|
||||
}
|
||||
})
|
||||
expect(fetched).toBe(false)
|
||||
expect(result.cached).toBe(true)
|
||||
expect(result.latest).toBe('0.5.0')
|
||||
expect(result.upToDate).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes the cache after the TTL expires', async () => {
|
||||
writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({ version: '0.5.0', fetchedAt: 0 })
|
||||
)
|
||||
let fetched = false
|
||||
const result = await checkUpgrade({
|
||||
current: '0.4.0',
|
||||
cachePath,
|
||||
now: () => 31 * 24 * 60 * 60 * 1000, // 31 days later
|
||||
fetchLatest: async () => {
|
||||
fetched = true
|
||||
return { version: '0.6.0', fetchedAt: 99 }
|
||||
}
|
||||
})
|
||||
expect(fetched).toBe(true)
|
||||
expect(result.cached).toBe(false)
|
||||
expect(result.latest).toBe('0.6.0')
|
||||
})
|
||||
|
||||
it('--force bypasses the cache', async () => {
|
||||
writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({ version: '0.5.0', fetchedAt: Date.now() })
|
||||
)
|
||||
let fetched = false
|
||||
const result = await checkUpgrade({
|
||||
current: '0.4.0',
|
||||
cachePath,
|
||||
force: true,
|
||||
fetchLatest: async () => {
|
||||
fetched = true
|
||||
return { version: '0.7.0', fetchedAt: Date.now() }
|
||||
}
|
||||
})
|
||||
expect(fetched).toBe(true)
|
||||
expect(result.cached).toBe(false)
|
||||
expect(result.latest).toBe('0.7.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultCachePath', () => {
|
||||
it('returns a non-empty platform-specific path', () => {
|
||||
const p = defaultCachePath()
|
||||
expect(p).toContain('cli-upgrade-check.json')
|
||||
expect(p.length).toBeGreaterThan('cli-upgrade-check.json'.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ParseError, parseArgv } from '../src/parser'
|
||||
|
||||
describe('parseArgv — VidBee flag handling (§4.1, §4.2)', () => {
|
||||
it('consumes --vidbee-wait switch and removes it from yt-dlp argv', () => {
|
||||
const r = parseArgv(['--vidbee-wait', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.wait).toBe(true)
|
||||
expect(r.ytArgs).toEqual(['https://x'])
|
||||
})
|
||||
|
||||
it('accepts --vidbee-api with separate value', () => {
|
||||
const r = parseArgv(['--vidbee-api', 'http://10.0.0.5:3100', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.api).toBe('http://10.0.0.5:3100')
|
||||
expect(r.ytArgs).toEqual(['https://x'])
|
||||
})
|
||||
|
||||
it('accepts --vidbee-api=value form', () => {
|
||||
const r = parseArgv(['--vidbee-api=http://10.0.0.5:3100', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.api).toBe('http://10.0.0.5:3100')
|
||||
})
|
||||
|
||||
it('rejects unknown --vidbee-* with exit 2 (§4.2 typo guard)', () => {
|
||||
expect(() => parseArgv(['--vidbe-wait', 'https://x'])).toThrow(ParseError)
|
||||
try {
|
||||
parseArgv(['--vidbe-wait', 'https://x'])
|
||||
} catch (e) {
|
||||
expect((e as ParseError).exitCode).toBe(2)
|
||||
expect((e as ParseError).code).toBe('UNKNOWN_VIDBEE_FLAG')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects --vidbee-wait=anything (switch with value)', () => {
|
||||
expect(() => parseArgv(['--vidbee-wait=true', 'https://x'])).toThrow(
|
||||
ParseError
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects --vidbee-api with no value', () => {
|
||||
expect(() => parseArgv(['--vidbee-api'])).toThrow(ParseError)
|
||||
})
|
||||
|
||||
it('validates --vidbee-target enum', () => {
|
||||
expect(() => parseArgv(['--vidbee-target', 'lol', 'https://x'])).toThrow(
|
||||
ParseError
|
||||
)
|
||||
const r = parseArgv(['--vidbee-target', 'local', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.target).toBe('local')
|
||||
})
|
||||
|
||||
it('validates --vidbee-priority enum', () => {
|
||||
expect(() => parseArgv(['--vidbee-priority', 'critical', 'https://x'])).toThrow(
|
||||
ParseError
|
||||
)
|
||||
const r = parseArgv(['--vidbee-priority', 'subscription', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.priority).toBe('subscription')
|
||||
})
|
||||
|
||||
it('validates --vidbee-max-attempts is non-negative integer', () => {
|
||||
expect(() => parseArgv(['--vidbee-max-attempts', '-1', 'https://x'])).toThrow(
|
||||
ParseError
|
||||
)
|
||||
expect(() => parseArgv(['--vidbee-max-attempts', '1.5', 'https://x'])).toThrow(
|
||||
ParseError
|
||||
)
|
||||
const r = parseArgv(['--vidbee-max-attempts', '0', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.maxAttempts).toBe(0)
|
||||
expect(r.flags.noRetry).toBe(true)
|
||||
})
|
||||
|
||||
it('validates --vidbee-timeout is positive integer', () => {
|
||||
expect(() => parseArgv(['--vidbee-timeout', '0', 'https://x'])).toThrow(ParseError)
|
||||
const r = parseArgv(['--vidbee-timeout', '5000', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.timeoutMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('--vidbee-no-retry is equivalent to --vidbee-max-attempts 0 (§6.3)', () => {
|
||||
const r = parseArgv(['--vidbee-no-retry', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.flags.noRetry).toBe(true)
|
||||
expect(r.flags.maxAttempts).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseArgv — yt-dlp passthrough (§4.1)', () => {
|
||||
it('preserves order of yt-dlp argv', () => {
|
||||
const argv = [
|
||||
'-f',
|
||||
'bestvideo+bestaudio/best',
|
||||
'-o',
|
||||
'%(title)s.%(ext)s',
|
||||
'https://x'
|
||||
]
|
||||
const r = parseArgv(argv)
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.ytArgs).toEqual(argv)
|
||||
})
|
||||
|
||||
it('preserves order across interleaved --vidbee-* flags', () => {
|
||||
const r = parseArgv([
|
||||
'-f',
|
||||
'best',
|
||||
'--vidbee-wait',
|
||||
'-o',
|
||||
'./out',
|
||||
'https://x',
|
||||
'--vidbee-priority',
|
||||
'background'
|
||||
])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.ytArgs).toEqual(['-f', 'best', '-o', './out', 'https://x'])
|
||||
expect(r.flags.wait).toBe(true)
|
||||
expect(r.flags.priority).toBe('background')
|
||||
})
|
||||
|
||||
it('passes through after --', () => {
|
||||
const r = parseArgv(['--', '--vidbee-wait', '--anything-goes'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.ytArgs).toEqual(['--', '--vidbee-wait', '--anything-goes'])
|
||||
})
|
||||
|
||||
it('treats unknown yt-dlp flag as passthrough (§4.5)', () => {
|
||||
const r = parseArgv(['--future-yt-dlp-flag', 'value', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.ytArgs).toEqual(['--future-yt-dlp-flag', 'value', 'https://x'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseArgv — mode detection (§4.3, §4.5)', () => {
|
||||
it('detects probe mode from any probe-class flag', () => {
|
||||
const r = parseArgv(['-j', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.mode).toBe('probe')
|
||||
expect(r.probeFlag).toBe('-j')
|
||||
})
|
||||
|
||||
it('defaults to download mode when no probe flag is present', () => {
|
||||
const r = parseArgv(['https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.mode).toBe('download')
|
||||
expect(r.probeFlag).toBe(null)
|
||||
})
|
||||
|
||||
it('rejects -o - in download mode (§4.5)', () => {
|
||||
expect(() => parseArgv(['-o', '-', 'https://x'])).toThrow(ParseError)
|
||||
expect(() => parseArgv(['--output', '-', 'https://x'])).toThrow(ParseError)
|
||||
expect(() => parseArgv(['-o-', 'https://x'])).toThrow(ParseError)
|
||||
})
|
||||
|
||||
it('allows -o - in probe mode (§4.5)', () => {
|
||||
const r = parseArgv(['-j', '-o', '-', 'https://x'])
|
||||
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
|
||||
expect(r.mode).toBe('probe')
|
||||
expect(r.ytArgs).toEqual(['-j', '-o', '-', 'https://x'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseArgv — subcommands (§4.1)', () => {
|
||||
it('routes :status as subcommand', () => {
|
||||
const r = parseArgv([':status'])
|
||||
if (r.kind !== 'subcommand') throw new Error('expected subcommand')
|
||||
expect(r.subcommand).toBe('status')
|
||||
expect(r.subArgs).toEqual([])
|
||||
})
|
||||
|
||||
it('captures all tokens after :subcommand as its args', () => {
|
||||
const r = parseArgv([':download', 'list', '--status', 'queued'])
|
||||
if (r.kind !== 'subcommand') throw new Error('expected subcommand')
|
||||
expect(r.subcommand).toBe('download')
|
||||
expect(r.subArgs).toEqual(['list', '--status', 'queued'])
|
||||
})
|
||||
|
||||
it('still consumes --vidbee-* before subcommand', () => {
|
||||
const r = parseArgv(['--vidbee-pretty', ':download', 'list'])
|
||||
if (r.kind !== 'subcommand') throw new Error('expected subcommand')
|
||||
expect(r.flags.pretty).toBe(true)
|
||||
expect(r.subcommand).toBe('download')
|
||||
expect(r.subArgs).toEqual(['list'])
|
||||
})
|
||||
|
||||
it('a bare ":" (length 1) is treated as yt-dlp argv passthrough', () => {
|
||||
const r = parseArgv([':', 'https://x'])
|
||||
expect(r.kind).toBe('ytdlp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseArgv — fuzz / property tests (§4.1)', () => {
|
||||
it('preserves yt-dlp argv order across 1000 random argvs', () => {
|
||||
const rng = mulberry32(0xdead_beef)
|
||||
const ytTokens = [
|
||||
'https://example.com/v',
|
||||
'-f',
|
||||
'best',
|
||||
'-o',
|
||||
'%(title)s.%(ext)s',
|
||||
'--continue',
|
||||
'--no-overwrites',
|
||||
'--newline',
|
||||
'--cookies-from-browser',
|
||||
'firefox',
|
||||
'-N',
|
||||
'4',
|
||||
'--retries',
|
||||
'10'
|
||||
]
|
||||
// Only single-token vidbee flags (switches and `--flag=value`) so a
|
||||
// value flag can never be split from its value by another insertion.
|
||||
const vidbeeTokens: string[][] = [
|
||||
['--vidbee-wait'],
|
||||
['--vidbee-detach'],
|
||||
['--vidbee-priority=subscription'],
|
||||
['--vidbee-priority=background'],
|
||||
['--vidbee-max-attempts=3'],
|
||||
['--vidbee-no-retry'],
|
||||
['--vidbee-pretty'],
|
||||
['--vidbee-group-key=host:example.com']
|
||||
]
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const yt: string[] = []
|
||||
const argv: string[] = []
|
||||
const ytLen = Math.floor(rng() * ytTokens.length)
|
||||
for (let j = 0; j < ytLen; j++) {
|
||||
const idx = Math.floor(rng() * ytTokens.length)
|
||||
const tok = ytTokens[idx] as string
|
||||
yt.push(tok)
|
||||
argv.push(tok)
|
||||
}
|
||||
// Sprinkle 0..3 vidbee flag groups at random positions.
|
||||
const vidbeeCount = Math.floor(rng() * 4)
|
||||
for (let j = 0; j < vidbeeCount; j++) {
|
||||
const groupIdx = Math.floor(rng() * vidbeeTokens.length)
|
||||
const group = vidbeeTokens[groupIdx] as string[]
|
||||
const insertPos = Math.floor(rng() * (argv.length + 1))
|
||||
argv.splice(insertPos, 0, ...group)
|
||||
}
|
||||
// -o - is only legal in probe; if argv contains "-o" "-" pair, ensure
|
||||
// the test doesn't trip the §4.5 guard incorrectly.
|
||||
if (containsStdoutOutput(argv) && !containsProbe(argv)) continue
|
||||
const r = parseArgv(argv)
|
||||
if (r.kind !== 'ytdlp') {
|
||||
throw new Error(
|
||||
`unexpected subcommand result for argv: ${JSON.stringify(argv)}`
|
||||
)
|
||||
}
|
||||
expect(r.ytArgs).toEqual(yt)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function containsStdoutOutput(argv: readonly string[]): boolean {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '-o' || argv[i] === '--output') {
|
||||
if (argv[i + 1] === '-') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function containsProbe(argv: readonly string[]): boolean {
|
||||
return argv.some(
|
||||
(t) =>
|
||||
t === '-j' ||
|
||||
t === '-J' ||
|
||||
t === '-F' ||
|
||||
t === '-s' ||
|
||||
t.startsWith('--dump-') ||
|
||||
t.startsWith('--list-') ||
|
||||
t.startsWith('--get-') ||
|
||||
t === '--simulate' ||
|
||||
t === '--skip-download' ||
|
||||
t === '--print' ||
|
||||
t.startsWith('--print=') ||
|
||||
t === '--update' ||
|
||||
t === '--version'
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic PRNG so failures are reproducible.
|
||||
function mulberry32(seed: number): () => number {
|
||||
let s = seed >>> 0
|
||||
return () => {
|
||||
s = (s + 0x6d_2b_79_f5) >>> 0
|
||||
let t = s
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
PROBE_FLAG_REGISTRY,
|
||||
findProbeFlag,
|
||||
isProbeArgv
|
||||
} from '../src/parser/probe-flags'
|
||||
|
||||
describe('probe-flag detection (§4.3)', () => {
|
||||
it('flags every documented exact alias', () => {
|
||||
const aliases = [
|
||||
'-j',
|
||||
'--dump-json',
|
||||
'-J',
|
||||
'--dump-single-json',
|
||||
'-F',
|
||||
'--list-formats',
|
||||
'--list-formats-as-table',
|
||||
'--list-formats-old',
|
||||
'-s',
|
||||
'--simulate',
|
||||
'--skip-download',
|
||||
'--list-subs',
|
||||
'--list-extractors',
|
||||
'--list-extractor-descriptions'
|
||||
]
|
||||
for (const a of aliases) {
|
||||
expect(isProbeArgv([a, 'https://x'])).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('flags every documented --get-* alias', () => {
|
||||
const getters = [
|
||||
'--get-id',
|
||||
'--get-title',
|
||||
'--get-thumbnail',
|
||||
'--get-description',
|
||||
'--get-duration',
|
||||
'--get-filename',
|
||||
'--get-format',
|
||||
'--get-url'
|
||||
]
|
||||
for (const g of getters) {
|
||||
expect(isProbeArgv([g, 'https://x'])).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('flags --print regardless of position', () => {
|
||||
expect(isProbeArgv(['--print', 'title', 'https://x'])).toBe(true)
|
||||
expect(isProbeArgv(['https://x', '--print', 'title'])).toBe(true)
|
||||
expect(isProbeArgv(['--print', 'title', '--print', 'duration'])).toBe(true)
|
||||
expect(isProbeArgv(['--print=title', 'https://x'])).toBe(true)
|
||||
})
|
||||
|
||||
it('flags meta commands without URL', () => {
|
||||
expect(isProbeArgv(['--update'])).toBe(true)
|
||||
expect(isProbeArgv(['--version'])).toBe(true)
|
||||
})
|
||||
|
||||
it('mixed argv with probe-class flag is still probe (§4.5)', () => {
|
||||
expect(isProbeArgv(['-j', '-f', 'bestaudio', 'https://x'])).toBe(true)
|
||||
expect(isProbeArgv(['--simulate', '-o', './out.%(ext)s'])).toBe(true)
|
||||
// -o file present but probe flag wins
|
||||
expect(isProbeArgv(['-F', '-o', 'video.mp4', 'https://x'])).toBe(true)
|
||||
})
|
||||
|
||||
it('does not false-positive on download argv', () => {
|
||||
expect(isProbeArgv(['https://x'])).toBe(false)
|
||||
expect(
|
||||
isProbeArgv(['-f', 'bestvideo+bestaudio/best', '-o', '%(title)s.%(ext)s', 'https://x'])
|
||||
).toBe(false)
|
||||
expect(isProbeArgv(['--continue', '--no-overwrites', 'https://x'])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the matched flag for diagnostics', () => {
|
||||
expect(findProbeFlag(['--dump-json', 'https://x'])).toBe('--dump-json')
|
||||
expect(findProbeFlag(['https://x'])).toBe(null)
|
||||
})
|
||||
|
||||
it('exposes the registry constants for cross-checking', () => {
|
||||
expect(PROBE_FLAG_REGISTRY.exact.size).toBeGreaterThan(0)
|
||||
expect(PROBE_FLAG_REGISTRY.getters.size).toBeGreaterThan(0)
|
||||
expect(PROBE_FLAG_REGISTRY.prefixed).toContain('--print')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { runProbe, type ProbeSpawnHandle } from '../src/download/probe'
|
||||
|
||||
function fakeChild(spec: {
|
||||
stdoutChunks?: Buffer[]
|
||||
stderrChunks?: Buffer[]
|
||||
exitCode?: number
|
||||
}): ProbeSpawnHandle {
|
||||
const stdout = Readable.from(spec.stdoutChunks ?? [])
|
||||
const stderr = Readable.from(spec.stderrChunks ?? [])
|
||||
let onClose: ((code: number | null) => void) | null = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let onError: ((err: Error) => void) | null = null
|
||||
// when both streams end, fire close
|
||||
let pending = 2
|
||||
const tryClose = () => {
|
||||
pending -= 1
|
||||
if (pending === 0) onClose?.(spec.exitCode ?? 0)
|
||||
}
|
||||
stdout.on('end', tryClose)
|
||||
stderr.on('end', tryClose)
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
on(ev, listener) {
|
||||
if (ev === 'close') onClose = listener as (code: number | null) => void
|
||||
else if (ev === 'error') onError = listener as (err: Error) => void
|
||||
},
|
||||
kill: () => {
|
||||
stdout.destroy()
|
||||
stderr.destroy()
|
||||
onClose?.(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('runProbe', () => {
|
||||
it('captures stdout/stderr and exit code on success', async () => {
|
||||
const r = await runProbe({
|
||||
argv: ['-j', 'https://x'],
|
||||
spawner: () =>
|
||||
fakeChild({
|
||||
stdoutChunks: [Buffer.from('{"a":1}\n')],
|
||||
stderrChunks: [Buffer.from('warn\n')],
|
||||
exitCode: 0
|
||||
})
|
||||
})
|
||||
if (r.kind !== 'success') throw new Error(`expected success, got ${JSON.stringify(r)}`)
|
||||
expect(r.stdout).toContain('"a":1')
|
||||
expect(r.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('returns PROBE_OUTPUT_TOO_LARGE when stdout exceeds cap', async () => {
|
||||
const r = await runProbe({
|
||||
argv: ['-j', 'https://x'],
|
||||
stdoutMaxBytes: 4,
|
||||
spawner: () =>
|
||||
fakeChild({
|
||||
stdoutChunks: [Buffer.from('xxxxxxxxxx')]
|
||||
})
|
||||
})
|
||||
if (r.kind !== 'error') throw new Error('expected error')
|
||||
expect(r.envelope.code).toBe('PROBE_OUTPUT_TOO_LARGE')
|
||||
})
|
||||
|
||||
it('returns NOT_IMPLEMENTED when spawner throws', async () => {
|
||||
const r = await runProbe({
|
||||
argv: ['-j', 'https://x'],
|
||||
spawner: () => {
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
})
|
||||
if (r.kind !== 'error') throw new Error('expected error')
|
||||
expect(r.envelope.message).toMatch(/ENOENT|spawn/i)
|
||||
})
|
||||
|
||||
it('truncates stderr to tail bytes', async () => {
|
||||
const big = Buffer.from('x'.repeat(200))
|
||||
const r = await runProbe({
|
||||
argv: ['-j', 'https://x'],
|
||||
stderrTailBytes: 50,
|
||||
spawner: () =>
|
||||
fakeChild({
|
||||
stdoutChunks: [Buffer.from('ok')],
|
||||
stderrChunks: [big]
|
||||
})
|
||||
})
|
||||
if (r.kind !== 'success') throw new Error('expected success')
|
||||
expect(r.stderr.length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { redactArgs, redactText } from '../src/parser/redact'
|
||||
|
||||
describe('redactArgs (§8.2)', () => {
|
||||
it('replaces --password value', () => {
|
||||
const r = redactArgs(['--password', 'shh', 'https://x'])
|
||||
expect(r.args).toEqual(['--password', '<redacted>', 'https://x'])
|
||||
expect(r.summary.redacted).toBe(true)
|
||||
})
|
||||
|
||||
it('replaces --password=value form', () => {
|
||||
const r = redactArgs(['--password=shh', 'https://x'])
|
||||
expect(r.args).toEqual(['--password=<redacted>', 'https://x'])
|
||||
})
|
||||
|
||||
it('handles all sensitive value flags', () => {
|
||||
for (const flag of [
|
||||
'--username',
|
||||
'--password',
|
||||
'--video-password',
|
||||
'--ap-password',
|
||||
'--twofactor'
|
||||
]) {
|
||||
const r = redactArgs([flag, 'secret', 'https://x'])
|
||||
expect(r.args[1]).toBe('<redacted>')
|
||||
}
|
||||
})
|
||||
|
||||
it('redacts Authorization in --add-headers', () => {
|
||||
const r = redactArgs(['--add-headers', 'Authorization:Bearer abc'])
|
||||
expect(r.args[1]).toMatch(/Authorization: <redacted>/i)
|
||||
})
|
||||
|
||||
it('keeps non-sensitive headers verbatim', () => {
|
||||
const r = redactArgs(['--add-headers', 'X-Foo:bar'])
|
||||
expect(r.args[1]).toBe('X-Foo:bar')
|
||||
expect(r.summary.redacted).toBe(false)
|
||||
})
|
||||
|
||||
it('scrubs URL query token=', () => {
|
||||
const r = redactArgs(['https://x.com/v?token=abc&other=keep'])
|
||||
const url = new URL(r.args[0] as string)
|
||||
expect(url.searchParams.get('token')).toBe('<redacted>')
|
||||
expect(url.searchParams.get('other')).toBe('keep')
|
||||
})
|
||||
|
||||
it('scrubs URL query access_token, signature, policy', () => {
|
||||
const r = redactArgs(['https://x.com/v?access_token=a&signature=b&policy=c'])
|
||||
const url = new URL(r.args[0] as string)
|
||||
expect(url.searchParams.get('access_token')).toBe('<redacted>')
|
||||
expect(url.searchParams.get('signature')).toBe('<redacted>')
|
||||
expect(url.searchParams.get('policy')).toBe('<redacted>')
|
||||
})
|
||||
|
||||
it('leaves unrelated argv untouched', () => {
|
||||
const argv = ['-f', 'best', 'https://x.com/v', '-o', 'video.mp4']
|
||||
const r = redactArgs(argv)
|
||||
expect(r.args).toEqual(argv)
|
||||
expect(r.summary.redacted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactText (§8.2 stdout/stderr)', () => {
|
||||
it('replaces Authorization: header line', () => {
|
||||
const out = redactText('Sending Authorization: Bearer abc\nDone')
|
||||
expect(out).toContain('<redacted>')
|
||||
expect(out).not.toContain('Bearer abc')
|
||||
})
|
||||
|
||||
it('scrubs URLs in free text', () => {
|
||||
const out = redactText('Got https://x.com/v?token=foo')
|
||||
// URL-encoded form is acceptable; the important thing is that the
|
||||
// original token value is gone.
|
||||
expect(out).not.toContain('token=foo')
|
||||
expect(out).toMatch(/token=(%3C|<)redacted/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,480 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { run } from '../src/runtime'
|
||||
import type { ContractClient } from '../src/subcommands'
|
||||
import type { ConnectOptions, ConnectResult } from '../src/transport/connect'
|
||||
import type { Task } from '@vidbee/task-queue'
|
||||
import { EMPTY_PROGRESS } from '@vidbee/task-queue'
|
||||
|
||||
function fakeTask(id: string, status: Task['status'] = 'queued'): Task {
|
||||
return {
|
||||
id,
|
||||
kind: 'video',
|
||||
parentId: null,
|
||||
input: { url: 'https://e.com', kind: 'video' },
|
||||
priority: 0,
|
||||
groupKey: 'e.com',
|
||||
status,
|
||||
prevStatus: null,
|
||||
statusReason: null,
|
||||
enteredStatusAt: 0,
|
||||
attempt: 0,
|
||||
maxAttempts: 5,
|
||||
nextRetryAt: null,
|
||||
progress: { ...EMPTY_PROGRESS },
|
||||
output: null,
|
||||
lastError: null,
|
||||
pid: null,
|
||||
pidStartedAt: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(seeded: Task[] = [fakeTask('t1', 'queued'), fakeTask('t2', 'completed')]): ContractClient {
|
||||
return {
|
||||
list: async () => ({ items: seeded, nextCursor: null }),
|
||||
get: async (id) => seeded.find((t) => t.id === id) ?? fakeTask(id),
|
||||
stats: async () => ({ pending: 0, running: 0, completed: 1 }),
|
||||
removeFromHistory: async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeConnect(client: ContractClient) {
|
||||
return async (_opts: ConnectOptions): Promise<ConnectResult> => ({
|
||||
kind: 'connected',
|
||||
client
|
||||
})
|
||||
}
|
||||
|
||||
function makeIO(client = fakeClient()) {
|
||||
const out: string[] = []
|
||||
const err: string[] = []
|
||||
return {
|
||||
out,
|
||||
err,
|
||||
io: {
|
||||
stdout: (l: string) => out.push(l),
|
||||
stderr: (l: string) => err.push(l),
|
||||
connect: fakeConnect(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('run() — argv error path', () => {
|
||||
it('exits 2 with error envelope on unknown --vidbee-*', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['--vidbe-wait', 'https://x'], io)
|
||||
expect(r.exitCode).toBe(2)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.ok).toBe(false)
|
||||
expect(env.code).toBe('UNKNOWN_VIDBEE_FLAG')
|
||||
})
|
||||
it('exits 2 with STDOUT_OUTPUT_DISALLOWED on -o - in download mode', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['-o', '-', 'https://x'], io)
|
||||
expect(r.exitCode).toBe(2)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('STDOUT_OUTPUT_DISALLOWED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — read-only subcommands via fake transport', () => {
|
||||
it(':status returns stats from the contract', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['--vidbee-local', ':status'], io)
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.ok).toBe(true)
|
||||
expect(env.mode).toBe('subcommand')
|
||||
expect(env.subcommand).toBe('status')
|
||||
expect(env.result).toEqual({ pending: 0, running: 0, completed: 1 })
|
||||
})
|
||||
|
||||
it(':download list passes filters through to the contract', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(
|
||||
['--vidbee-local', ':download', 'list', '--status', 'queued', '--limit', '10'],
|
||||
io
|
||||
)
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.result.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it(':download status <id> returns the task', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['--vidbee-local', ':download', 'status', 't1'], io)
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.result.id).toBe('t1')
|
||||
})
|
||||
|
||||
it(':download status without id reports parse error', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['--vidbee-local', ':download', 'status'], io)
|
||||
expect(r.exitCode).toBe(2)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('PARSE_ERROR')
|
||||
})
|
||||
|
||||
it(':history list forwards to the contract', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['--vidbee-local', ':history', 'list'], io)
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.ok).toBe(true)
|
||||
})
|
||||
|
||||
it(':history remove <id...> calls the contract per id', async () => {
|
||||
const removed: string[] = []
|
||||
const client: ContractClient = {
|
||||
list: async () => ({ items: [], nextCursor: null }),
|
||||
get: async (id) => fakeTask(id),
|
||||
stats: async () => ({}),
|
||||
removeFromHistory: async (id) => {
|
||||
removed.push(id)
|
||||
}
|
||||
}
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', ':history', 'remove', 'a', 'b'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(removed).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('unknown subcommand exits 2', async () => {
|
||||
const { io, out } = makeIO()
|
||||
const r = await run(['--vidbee-local', ':bogus'], io)
|
||||
expect(r.exitCode).toBe(2)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('PARSE_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — :download write verbs', () => {
|
||||
it(':download cancel calls client.cancel', async () => {
|
||||
const cancelled: string[] = []
|
||||
const client: ContractClient = {
|
||||
...fakeClient(),
|
||||
cancel: async (id) => {
|
||||
cancelled.push(id)
|
||||
}
|
||||
}
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', ':download', 'cancel', 't1'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(cancelled).toEqual(['t1'])
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.result.status).toBe('cancel-requested')
|
||||
})
|
||||
|
||||
it(':download pause forwards --reason', async () => {
|
||||
const paused: { id: string; reason?: string }[] = []
|
||||
const client: ContractClient = {
|
||||
...fakeClient(),
|
||||
pause: async (id, reason) => {
|
||||
paused.push({ id, reason })
|
||||
}
|
||||
}
|
||||
const out: string[] = []
|
||||
await run(['--vidbee-local', ':download', 'pause', 't1', '--reason', 'manual'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(paused).toEqual([{ id: 't1', reason: 'manual' }])
|
||||
})
|
||||
|
||||
it(':download retry calls client.retry', async () => {
|
||||
const retried: string[] = []
|
||||
const client: ContractClient = {
|
||||
...fakeClient(),
|
||||
retry: async (id) => {
|
||||
retried.push(id)
|
||||
}
|
||||
}
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', ':download', 'retry', 't1'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(retried).toEqual(['t1'])
|
||||
})
|
||||
|
||||
it(':download cancel surfaces CONTRACT_VERSION_MISMATCH when transport lacks support', async () => {
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', ':download', 'cancel', 't1'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(fakeClient())
|
||||
})
|
||||
expect(r.exitCode).toBe(5)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('CONTRACT_VERSION_MISMATCH')
|
||||
})
|
||||
|
||||
it(':download logs returns task + stderrTail', async () => {
|
||||
const t = fakeTask('t1', 'failed')
|
||||
;(t as { lastError: unknown }).lastError = { stderrTail: 'boom' }
|
||||
const client: ContractClient = {
|
||||
...fakeClient([t]),
|
||||
get: async () => t
|
||||
}
|
||||
const out: string[] = []
|
||||
await run(['--vidbee-local', ':download', 'logs', 't1'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.result.logs.stderrTail).toBe('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — yt-dlp probe (Phase B)', () => {
|
||||
it('emits §4.4 probe envelope with sanitized command', async () => {
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', '-j', '--password', 'shh', 'https://x'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(fakeClient()),
|
||||
probe: async () => ({ kind: 'success', stdout: '{"title":"x"}', stderr: '', exitCode: 0, binary: 'yt-dlp' })
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.ok).toBe(true)
|
||||
expect(env.mode).toBe('probe')
|
||||
expect(env.command).toContain('<redacted>')
|
||||
expect(env.ytDlp.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('reports PROBE_OUTPUT_TOO_LARGE when stdout exceeds 32MB', async () => {
|
||||
const out: string[] = []
|
||||
const r = await run(['-j', 'https://x'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(fakeClient()),
|
||||
probe: async () => ({
|
||||
kind: 'error',
|
||||
envelope: {
|
||||
ok: false,
|
||||
code: 'PROBE_OUTPUT_TOO_LARGE',
|
||||
message: 'too big'
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(r.exitCode).toBe(1)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('PROBE_OUTPUT_TOO_LARGE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — yt-dlp download enqueue (Phase B)', () => {
|
||||
it('detached mode returns queued task and exit 0', async () => {
|
||||
const queued = fakeTask('q1', 'queued')
|
||||
const client: ContractClient = {
|
||||
...fakeClient(),
|
||||
add: async () => ({ id: 'q1', task: queued }),
|
||||
get: async () => queued
|
||||
}
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', 'https://x'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.mode).toBe('download')
|
||||
expect(env.task.id).toBe('q1')
|
||||
expect(env.task.status).toBe('queued')
|
||||
expect(env.task.command).toContain('yt-dlp')
|
||||
})
|
||||
|
||||
it('--vidbee-wait blocks then exits 0 on completed', async () => {
|
||||
const completed = fakeTask('q1', 'completed')
|
||||
const client: ContractClient = {
|
||||
...fakeClient(),
|
||||
add: async () => ({ id: 'q1', task: completed }),
|
||||
get: async () => completed
|
||||
}
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', '--vidbee-wait', 'https://x'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.task.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('--vidbee-wait exits 1 on retry-scheduled', async () => {
|
||||
const retry = fakeTask('q1', 'retry-scheduled')
|
||||
const client: ContractClient = {
|
||||
...fakeClient(),
|
||||
add: async () => ({ id: 'q1', task: retry }),
|
||||
get: async () => retry
|
||||
}
|
||||
const out: string[] = []
|
||||
const r = await run(['--vidbee-local', '--vidbee-wait', 'https://x'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: fakeConnect(client)
|
||||
})
|
||||
expect(r.exitCode).toBe(1)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — output formatting', () => {
|
||||
it('--vidbee-pretty produces pretty-printed JSON', async () => {
|
||||
const { io, out } = makeIO()
|
||||
await run(['--vidbee-local', '--vidbee-pretty', ':status'], io)
|
||||
expect(out[0]).toContain('\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — :version', () => {
|
||||
it('emits CLI/contract version + changelog without contacting any host', async () => {
|
||||
const out: string[] = []
|
||||
let connectCalled = false
|
||||
const r = await run([':version'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
connect: async () => {
|
||||
connectCalled = true
|
||||
return { kind: 'connected', client: fakeClient() }
|
||||
},
|
||||
readVersion: () => ({
|
||||
cli: '1.2.3',
|
||||
contract: '1.2.3',
|
||||
changelog: 'https://example.test/changelog'
|
||||
})
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(connectCalled).toBe(false)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.ok).toBe(true)
|
||||
expect(env.mode).toBe('subcommand')
|
||||
expect(env.subcommand).toBe('version')
|
||||
expect(env.result.cli).toBe('1.2.3')
|
||||
expect(env.result.contract).toBe('1.2.3')
|
||||
expect(env.result.changelog).toContain('changelog')
|
||||
})
|
||||
})
|
||||
|
||||
describe('run() — :upgrade', () => {
|
||||
it('reports up-to-date when current >= latest', async () => {
|
||||
const out: string[] = []
|
||||
const r = await run([':upgrade'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
readVersion: () => ({
|
||||
cli: '0.2.0',
|
||||
contract: '0.2.0',
|
||||
changelog: 'https://example.test/changelog'
|
||||
}),
|
||||
checkUpgrade: async (input) => ({
|
||||
current: input.current,
|
||||
latest: '0.2.0',
|
||||
upToDate: true,
|
||||
cached: false,
|
||||
cachedAt: '2026-01-01T00:00:00.000Z',
|
||||
registryUrl: 'https://registry.npmjs.org/@vidbee/cli/latest',
|
||||
installCommands: {
|
||||
npm: 'npm install -g @vidbee/cli',
|
||||
pnpm: 'pnpm add -g @vidbee/cli',
|
||||
bun: 'bun install -g @vidbee/cli',
|
||||
brew: 'brew upgrade vidbee/tap/vidbee'
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(r.exitCode).toBe(0)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.subcommand).toBe('upgrade')
|
||||
expect(env.result.upToDate).toBe(true)
|
||||
})
|
||||
|
||||
it('passes --force through to the upgrade checker', async () => {
|
||||
let receivedForce = false
|
||||
await run([':upgrade', '--force'], {
|
||||
stdout: () => {},
|
||||
stderr: () => {},
|
||||
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' }),
|
||||
checkUpgrade: async (input) => {
|
||||
receivedForce = input.force === true
|
||||
return {
|
||||
current: input.current,
|
||||
latest: '0.2.0',
|
||||
upToDate: false,
|
||||
cached: false,
|
||||
cachedAt: null,
|
||||
registryUrl: 'https://registry.npmjs.org/@vidbee/cli/latest',
|
||||
installCommands: { npm: '', pnpm: '', bun: '', brew: '' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(receivedForce).toBe(true)
|
||||
})
|
||||
|
||||
it('passes --cache <path> through to the upgrade checker', async () => {
|
||||
let receivedCache: string | undefined
|
||||
await run([':upgrade', '--cache', '/tmp/x'], {
|
||||
stdout: () => {},
|
||||
stderr: () => {},
|
||||
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' }),
|
||||
checkUpgrade: async (input) => {
|
||||
receivedCache = input.cachePath
|
||||
return {
|
||||
current: input.current,
|
||||
latest: '0.1.0',
|
||||
upToDate: true,
|
||||
cached: false,
|
||||
cachedAt: null,
|
||||
registryUrl: 'https://registry.npmjs.org/@vidbee/cli/latest',
|
||||
installCommands: { npm: '', pnpm: '', bun: '', brew: '' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(receivedCache).toBe('/tmp/x')
|
||||
})
|
||||
|
||||
it('rejects unknown :upgrade flags with exit 2', async () => {
|
||||
const out: string[] = []
|
||||
const r = await run([':upgrade', '--bogus'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' })
|
||||
})
|
||||
expect(r.exitCode).toBe(2)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('PARSE_ERROR')
|
||||
})
|
||||
|
||||
it('reports HOST_UNREACHABLE when the registry fetch fails', async () => {
|
||||
const out: string[] = []
|
||||
const r = await run([':upgrade'], {
|
||||
stdout: (l) => out.push(l),
|
||||
stderr: () => {},
|
||||
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' }),
|
||||
checkUpgrade: async () => {
|
||||
throw new Error('econnrefused')
|
||||
}
|
||||
})
|
||||
expect(r.exitCode).toBe(3)
|
||||
const env = JSON.parse(out[0] as string)
|
||||
expect(env.code).toBe('API_UNREACHABLE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { dispatchSubcommand, parseListArgs, type ContractClient } from '../src/subcommands'
|
||||
|
||||
const stub: ContractClient = {
|
||||
list: async () => ({ items: [], nextCursor: null }),
|
||||
get: async () => {
|
||||
throw new Error('not used')
|
||||
},
|
||||
stats: async () => ({ ok: true }),
|
||||
removeFromHistory: async () => {}
|
||||
}
|
||||
|
||||
describe('parseListArgs', () => {
|
||||
it('parses --status / --limit / --cursor', () => {
|
||||
expect(
|
||||
parseListArgs(['--status', 'queued', '--limit', '20', '--cursor', 'abc'])
|
||||
).toEqual({ status: 'queued', limit: 20, cursor: 'abc' })
|
||||
})
|
||||
it('accepts --flag=value form', () => {
|
||||
expect(parseListArgs(['--limit=50'])).toEqual({ limit: 50 })
|
||||
})
|
||||
it('rejects unknown statuses', () => {
|
||||
expect(() => parseListArgs(['--status', 'lol'])).toThrow()
|
||||
})
|
||||
it('rejects non-positive limit', () => {
|
||||
expect(() => parseListArgs(['--limit', '0'])).toThrow()
|
||||
})
|
||||
it('rejects unknown flags', () => {
|
||||
expect(() => parseListArgs(['--mystery'])).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchSubcommand', () => {
|
||||
it(':status -> stats', async () => {
|
||||
const r = await dispatchSubcommand('status', [], { client: stub })
|
||||
expect(r.kind).toBe('value')
|
||||
if (r.kind === 'value') expect(r.value).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('unknown subcommand surfaces PARSE_ERROR', async () => {
|
||||
const r = await dispatchSubcommand('teleport', [], { client: stub })
|
||||
expect(r.kind).toBe('error')
|
||||
if (r.kind === 'error') expect(r.envelope.code).toBe('PARSE_ERROR')
|
||||
})
|
||||
|
||||
it(':rss surfaces NOT_IMPLEMENTED (NEX-132 owns it)', async () => {
|
||||
const r = await dispatchSubcommand('rss', ['list'], { client: stub })
|
||||
expect(r.kind).toBe('error')
|
||||
if (r.kind === 'error') expect(r.envelope.code).toBe('NOT_IMPLEMENTED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isLoopbackOrPrivateHost,
|
||||
selectTransport,
|
||||
transportNotReady,
|
||||
validateApiUrl
|
||||
} from '../src/transport'
|
||||
|
||||
describe('isLoopbackOrPrivateHost (§8.1)', () => {
|
||||
it('accepts loopback', () => {
|
||||
expect(isLoopbackOrPrivateHost('localhost')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('127.5.5.5')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('::1')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts RFC1918 private ranges', () => {
|
||||
expect(isLoopbackOrPrivateHost('10.0.0.5')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('192.168.1.1')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('172.16.0.1')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('172.31.255.255')).toBe(true)
|
||||
expect(isLoopbackOrPrivateHost('169.254.1.1')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects public ranges', () => {
|
||||
expect(isLoopbackOrPrivateHost('8.8.8.8')).toBe(false)
|
||||
expect(isLoopbackOrPrivateHost('172.32.0.1')).toBe(false)
|
||||
expect(isLoopbackOrPrivateHost('172.15.255.255')).toBe(false)
|
||||
expect(isLoopbackOrPrivateHost('example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats *.local as private (mDNS)', () => {
|
||||
expect(isLoopbackOrPrivateHost('foo.local')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateApiUrl (§8.1)', () => {
|
||||
it('accepts https on any host', () => {
|
||||
expect(validateApiUrl('https://example.com')).toBe(null)
|
||||
})
|
||||
it('accepts http on loopback / private', () => {
|
||||
expect(validateApiUrl('http://127.0.0.1:3100')).toBe(null)
|
||||
expect(validateApiUrl('http://10.0.0.5:3100')).toBe(null)
|
||||
})
|
||||
it('rejects http on public host', () => {
|
||||
const env = validateApiUrl('http://example.com')
|
||||
expect(env?.code).toBe('API_UNREACHABLE')
|
||||
})
|
||||
it('rejects unparseable URLs', () => {
|
||||
expect(validateApiUrl('not a url')?.code).toBe('API_UNREACHABLE')
|
||||
})
|
||||
it('rejects unsupported schemes', () => {
|
||||
expect(validateApiUrl('ftp://example.com')?.code).toBe('API_UNREACHABLE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectTransport', () => {
|
||||
it('selects local when --vidbee-local is set', () => {
|
||||
expect(selectTransport({ local: true } as never).kind).toBe('local')
|
||||
})
|
||||
it('selects api when --vidbee-api is given', () => {
|
||||
expect(
|
||||
selectTransport({ local: false, api: 'https://x' } as never).kind
|
||||
).toBe('api')
|
||||
})
|
||||
it('defaults to desktop', () => {
|
||||
expect(selectTransport({ local: false } as never).kind).toBe('desktop')
|
||||
})
|
||||
it('honors --vidbee-target overrides', () => {
|
||||
expect(selectTransport({ local: false, target: 'local' } as never).kind).toBe('local')
|
||||
expect(selectTransport({ local: false, target: 'api' } as never).kind).toBe('api')
|
||||
})
|
||||
})
|
||||
|
||||
describe('transportNotReady (Phase B gate)', () => {
|
||||
it('says DESKTOP_NOT_READY for desktop', () => {
|
||||
const r = transportNotReady({ kind: 'desktop' })
|
||||
expect(r.envelope.code).toBe('DESKTOP_NOT_READY')
|
||||
expect(r.exitCode).toBe(3)
|
||||
})
|
||||
it('says API_UNREACHABLE for api', () => {
|
||||
const r = transportNotReady({ kind: 'api' })
|
||||
expect(r.envelope.code).toBe('API_UNREACHABLE')
|
||||
expect(r.exitCode).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*", "__integration__/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts', '__integration__/**/*.test.ts'],
|
||||
testTimeout: 10_000
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
VITE_GLITCHTIP_DSN=
|
||||
VITE_GLITCHTIP_ENVIRONMENT=production
|
||||
SENTRY_URL=https://glitchtip.example.com
|
||||
SENTRY_ORG=your-org-slug
|
||||
SENTRY_PROJECT=vidbee-desktop
|
||||
SENTRY_AUTH_TOKEN=
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# GitHub issue #378: Electron's SUID sandbox helper must be owned by root and
|
||||
# carry the setuid bit (mode 4755), otherwise the app aborts on launch with
|
||||
# "The SUID sandbox helper binary was found, but is not configured correctly."
|
||||
# electron-builder does not reliably set this for .deb installs, so fix it here
|
||||
# in the Debian postinst maintainer script. productName is "VidBee", so the
|
||||
# install prefix is /opt/VidBee.
|
||||
SANDBOX="/opt/VidBee/chrome-sandbox"
|
||||
if [ -f "$SANDBOX" ]; then
|
||||
chown root:root "$SANDBOX"
|
||||
chmod 4755 "$SANDBOX"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,75 @@
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const BINARIES = [
|
||||
'yt-dlp_macos',
|
||||
path.join('ffmpeg', 'ffmpeg'),
|
||||
path.join('ffmpeg', 'ffprobe'),
|
||||
'deno'
|
||||
]
|
||||
|
||||
const findAppBundle = (appOutDir) => {
|
||||
const entries = fs.readdirSync(appOutDir)
|
||||
const app = entries.find((entry) => entry.endsWith('.app'))
|
||||
return app ? path.join(appOutDir, app) : null
|
||||
}
|
||||
|
||||
const resolveSigningIdentity = () =>
|
||||
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
|
||||
|
||||
const signBinary = (targetPath, entitlementsPath) => {
|
||||
const identity = resolveSigningIdentity()
|
||||
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
|
||||
|
||||
if (identity !== '-') {
|
||||
args.push('--options', 'runtime', '--timestamp')
|
||||
}
|
||||
|
||||
args.push(targetPath)
|
||||
execFileSync('codesign', args, { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the packaged runtime binary directory for the app bundle.
|
||||
*/
|
||||
const resolveBinaryResourcesPath = (appBundle) => {
|
||||
const contentsResourcesPath = path.join(appBundle, 'Contents', 'Resources')
|
||||
const candidates = [
|
||||
path.join(contentsResourcesPath, 'resources'),
|
||||
path.join(contentsResourcesPath, 'app.asar.unpacked', 'resources')
|
||||
]
|
||||
|
||||
return (
|
||||
candidates.find((candidate) =>
|
||||
BINARIES.some((binary) => fs.existsSync(path.join(candidate, binary)))
|
||||
) || candidates[0]
|
||||
)
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'darwin') {
|
||||
return
|
||||
}
|
||||
|
||||
const appBundle = findAppBundle(context.appOutDir)
|
||||
if (!appBundle) {
|
||||
console.warn('afterPack: No .app bundle found, skipping tool signing.')
|
||||
return
|
||||
}
|
||||
|
||||
const resourcesPath = resolveBinaryResourcesPath(appBundle)
|
||||
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
|
||||
|
||||
for (const binary of BINARIES) {
|
||||
const targetPath = path.join(resourcesPath, binary)
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
console.warn(`afterPack: Missing ${binary}, skipping.`)
|
||||
continue
|
||||
}
|
||||
console.log(`afterPack: Signing ${binary} with entitlements.`)
|
||||
signBinary(targetPath, entitlementsPath)
|
||||
}
|
||||
}
|
||||
|
||||
exports.resolveBinaryResourcesPath = resolveBinaryResourcesPath
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,303 @@
|
||||
# Journal des modifications de VidBee
|
||||
|
||||
Cette page ne présente que les évolutions visibles par les utilisateurs, sans détails techniques.
|
||||
Pour les notes de version complètes, consultez [GitHub Releases](https://github.com/nexmoe/VidBee/releases).
|
||||
|
||||
## [v1.3.10](https://github.com/nexmoe/VidBee/releases/tag/v1.3.10) - 2026-04-11
|
||||
### Ameliorations
|
||||
- Telechargement en un clic active par defaut pour demarrer plus vite des la premiere utilisation.
|
||||
- Titres de la liste de telechargement alignes a gauche pour une lecture plus claire.
|
||||
|
||||
## [v1.3.9](https://github.com/nexmoe/VidBee/releases/tag/v1.3.9) - 2026-04-11
|
||||
### Ameliorations
|
||||
- Emplacement FFmpeg personnalise plus simple : chemin vers l'executable ou vers le dossier qui le contient.
|
||||
- Messages plus clairs lors des verifications d'abonnement quand un flux est indisponible ou en erreur.
|
||||
- Statut et conseils de telechargement plus lisibles pour corriger un reglage ou reessayer.
|
||||
- Noms de fichiers tres longs raccourcis automatiquement sur macOS et Linux, comme sous Windows.
|
||||
- La ou c'est pris en charge, raccourci clavier pour ajouter un lien aux telechargements plus vite.
|
||||
|
||||
## [v1.3.8](https://github.com/nexmoe/VidBee/releases/tag/v1.3.8) - 2026-04-06
|
||||
### Corrections de bugs
|
||||
- Migrations de base de donnees plus robustes pour les anciennes installations.
|
||||
- Des outils optionnels comme FFmpeg ne bloquent plus le demarrage lorsqu'ils sont absents ou echouent a l'initialisation.
|
||||
- Les verifications d'abonnement reduisent les rapports d'erreur bruyants pour les problemes reseau ou de format de flux courants.
|
||||
|
||||
### Ameliorations
|
||||
- Affinage des retours GlitchTip et des details de rapport d'erreurs.
|
||||
|
||||
## [v1.3.7](https://github.com/nexmoe/VidBee/releases/tag/v1.3.7) - 2026-03-29
|
||||
### Corrections de bugs
|
||||
- Amelioration de la sanitisation des noms de fichiers pour gerer les caracteres Unicode speciaux et les codes de controle.
|
||||
- Les telechargements Windows tronquent desormais automatiquement les noms de fichiers trop longs.
|
||||
- La modification de la limite de telechargements simultanes prend effet immediatement sans redemarrage.
|
||||
|
||||
## [v1.3.6](https://github.com/nexmoe/VidBee/releases/tag/v1.3.6) - 2026-03-29
|
||||
|
||||
### Fonctionnalités
|
||||
- Intégration de GlitchTip pour les rapports d'erreurs et les retours utilisateurs
|
||||
- Ajout d'une notification de mise à jour lorsque la version est obsolète
|
||||
- Intégration de l'analytique Rybbit
|
||||
- Amélioration des détails de l'interface de téléchargement
|
||||
|
||||
## [v1.3.4](https://github.com/nexmoe/VidBee/releases/tag/v1.3.4) - 2026-03-14
|
||||
### Corrections de bugs
|
||||
- Utilisation de la source Electron par defaut pendant le packaging afin d'ameliorer la fiabilite des builds macOS de release.
|
||||
|
||||
## [v1.3.3](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3) - 2026-03-14
|
||||
### Mises a jour de fonctionnalites
|
||||
- Amelioration du flux de publication afin de diffuser les builds preview separement des notifications de mise a jour en production.
|
||||
|
||||
### Corrections de bugs
|
||||
- Reactivation de npm rebuild pendant le packaging Electron afin de preparer plus fiablement les dependances natives dans les builds de release.
|
||||
- Amelioration du bundling desktop pour inclure plus regulierement les dependances partagees du workspace dans les versions publiees.
|
||||
|
||||
## [v1.3.3-preview.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.1) - 2026-03-14
|
||||
### Corrections de bugs
|
||||
- Reactivation de npm rebuild pendant le packaging Electron pour preparer plus fiablement les dependances natives dans les builds de release.
|
||||
|
||||
## [v1.3.3-preview.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.0) - 2026-03-14
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout d'un canal de publication preview pour diffuser les builds de test sans declencher les mises a jour du site de production.
|
||||
|
||||
### Corrections de bugs
|
||||
- Amelioration du bundling desktop afin d'inclure plus regulierement les dependances partagees du workspace dans les builds publies.
|
||||
|
||||
## [v1.3.2](https://github.com/nexmoe/VidBee/releases/tag/v1.3.2) - 2026-03-14
|
||||
### Corrections de bugs
|
||||
- Amelioration de la fiabilite du packaging desktop afin d'inclure plus regulierement les composants de telechargement partages.
|
||||
|
||||
## [v1.3.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.1) - 2026-03-14
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout des editions Web et API, avec des capacites de telechargement partagees et un comportement des reglages harmonise.
|
||||
- Ajout de la prise en charge de l'envoi de fichiers Cookie et de configuration depuis les reglages.
|
||||
- Migration de l'historique des telechargements vers SQLite pour une meilleure fiabilite et une meilleure coherence multi-plateforme.
|
||||
- Ajout d'un flux partage pour l'ajout d'URL dans les boites de dialogue de telechargement et amelioration de la visibilite du curseur en theme sombre.
|
||||
|
||||
### Corrections de bugs
|
||||
- Amelioration de la robustesse et des diagnostics du processus d'initialisation des binaires embarques sur desktop.
|
||||
- Correction du saut de curseur dans le champ de profil des reglages.
|
||||
- Correction de la validation du dossier de telechargement sous Linux lors de la selection d'un dossier existant non vide.
|
||||
- Amelioration de la coherence des localisations, y compris des corrections de traduction chinoise.
|
||||
|
||||
## [v1.3.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.0) - 2026-02-15
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout de nouvelles actions en un clic pour coller un lien et lancer le téléchargement plus vite.
|
||||
- Ajout de la prise en charge des langues française, russe et turque dans l'application.
|
||||
- Le format de conteneur sélectionné est désormais respecté de manière plus cohérente.
|
||||
|
||||
### Corrections de bugs
|
||||
- Amélioration de la compatibilité des téléchargements pour YouTube et les scénarios de repli de format.
|
||||
- Les réglages et la documentation ont été améliorés, avec des indications plus claires pour les rapports de bug et le RSS.
|
||||
|
||||
## [v1.2.4](https://github.com/nexmoe/VidBee/releases/tag/v1.2.4) - 2026-01-24
|
||||
### Mises a jour de fonctionnalites
|
||||
- Le flux de téléchargement en un clic est plus direct et demande moins d'étapes.
|
||||
- Un onglet Cookie dédié a été ajouté dans les réglages pour simplifier les actions liées au compte.
|
||||
- Les points d'entrée FAQ sont plus clairs et les messages d'erreur sont plus faciles à comprendre.
|
||||
|
||||
### Corrections de bugs
|
||||
- Les indications RSS sont plus claires, surtout pour les nouveaux utilisateurs.
|
||||
|
||||
## [v1.2.3](https://github.com/nexmoe/VidBee/releases/tag/v1.2.3) - 2026-01-23
|
||||
### Mises a jour de fonctionnalites
|
||||
- Le chargement des playlists est plus stable et ne compresse plus l'interface.
|
||||
- Le guide d'utilisation des cookies inclut désormais des exemples plus clairs.
|
||||
|
||||
## [v1.2.2](https://github.com/nexmoe/VidBee/releases/tag/v1.2.2) - 2026-01-21
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les actions liées au téléchargement sont plus faciles à trouver.
|
||||
- Ajout d'une option pour inclure ou retirer le filigrane lors du partage.
|
||||
- Les interactions de téléchargement sont plus cohérentes dans l'ensemble.
|
||||
|
||||
## [v1.2.1](https://github.com/nexmoe/VidBee/releases/tag/v1.2.1) - 2026-01-20
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les éléments avec le même titre dans les playlists sont plus faciles à distinguer.
|
||||
- Il est plus simple de trouver les journaux et les fichiers liés lors du dépannage.
|
||||
|
||||
### Corrections de bugs
|
||||
- Les notifications de téléchargement sont moins intrusives.
|
||||
- Les liens et indications des abonnements sont plus fiables.
|
||||
|
||||
## [v1.2.0](https://github.com/nexmoe/VidBee/releases/tag/v1.2.0) - 2026-01-17
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout d'actions rapides pour tout sélectionner et vider l'historique des téléchargements.
|
||||
- Le comportement lors de la réduction et de la réouverture est plus fluide.
|
||||
- Les doublons dans les abonnements sont réduits.
|
||||
- Les pages Playlist et Réglages sont plus simples à utiliser.
|
||||
|
||||
### Corrections de bugs
|
||||
- La reprise après une interruption de téléchargement est plus fiable.
|
||||
|
||||
## [v1.1.12](https://github.com/nexmoe/VidBee/releases/tag/v1.1.12) - 2026-01-15
|
||||
### Mises a jour de fonctionnalites
|
||||
- Le comportement du dossier de téléchargement dans les réglages est plus prévisible.
|
||||
|
||||
### Corrections de bugs
|
||||
- Les rapports de retour contiennent désormais des informations d'appui plus claires.
|
||||
|
||||
## [v1.1.11](https://github.com/nexmoe/VidBee/releases/tag/v1.1.11) - 2026-01-14
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les flux de téléchargement et la mise en page sont plus clairs.
|
||||
- La navigation des abonnements est plus fluide.
|
||||
- Les réglages par défaut sont plus adaptés à un usage quotidien.
|
||||
|
||||
### Corrections de bugs
|
||||
- Les messages d'erreur proposent des étapes suivantes plus claires.
|
||||
|
||||
## [v1.1.10](https://github.com/nexmoe/VidBee/releases/tag/v1.1.10) - 2026-01-12
|
||||
### Mises a jour de fonctionnalites
|
||||
- L'installation et la mise à jour sur macOS sont plus stables.
|
||||
|
||||
## [v1.1.8](https://github.com/nexmoe/VidBee/releases/tag/v1.1.8) - 2026-01-12
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les détails de progression des téléchargements sont plus lisibles.
|
||||
|
||||
### Corrections de bugs
|
||||
- Les notifications de mise à jour localisées sont plus claires.
|
||||
|
||||
## [v1.1.7](https://github.com/nexmoe/VidBee/releases/tag/v1.1.7) - 2026-01-11
|
||||
### Mises a jour de fonctionnalites
|
||||
- Davantage d'options de préférences de sortie média ont été ajoutées.
|
||||
- La configuration initiale et l'utilisation quotidienne sont plus fluides.
|
||||
|
||||
## [v1.1.6](https://github.com/nexmoe/VidBee/releases/tag/v1.1.6) - 2026-01-11
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les flux liés aux informations vidéo locales sont plus faciles à utiliser.
|
||||
- La gestion des profils de cookies est plus stable et plus prévisible.
|
||||
|
||||
## [v1.1.5](https://github.com/nexmoe/VidBee/releases/tag/v1.1.5) - 2026-01-10
|
||||
### Mises a jour de fonctionnalites
|
||||
- Correction de problèmes connus dans les réglages avancés.
|
||||
|
||||
### Corrections de bugs
|
||||
- Amélioration de la stabilité du chargement des couvertures distantes.
|
||||
- La sélection des couvertures d'abonnement est plus fiable.
|
||||
|
||||
## [v1.1.4](https://github.com/nexmoe/VidBee/releases/tag/v1.1.4) - 2026-01-09
|
||||
### Mises a jour de fonctionnalites
|
||||
- Le comportement de la fenêtre au démarrage est plus naturel.
|
||||
- Le comportement global des réglages est plus cohérent.
|
||||
|
||||
## [v1.1.3](https://github.com/nexmoe/VidBee/releases/tag/v1.1.3) - 2026-01-02
|
||||
### Mises a jour de fonctionnalites
|
||||
- Le statut de mise à jour est plus visible sur la page About.
|
||||
|
||||
### Corrections de bugs
|
||||
- La sélection de format est plus fiable selon les scénarios.
|
||||
|
||||
## [v1.1.2](https://github.com/nexmoe/VidBee/releases/tag/v1.1.2) - 2025-12-26
|
||||
### Mises a jour de fonctionnalites
|
||||
- La disponibilité du téléchargement a été rétablie pour davantage de sites.
|
||||
- Le flux de signalement des problèmes est plus simple.
|
||||
|
||||
## [v1.1.1](https://github.com/nexmoe/VidBee/releases/tag/v1.1.1) - 2025-12-26
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les notifications de mise à jour sont moins perturbantes.
|
||||
- Les textes et liens de la page About sont plus clairs.
|
||||
- Les interactions du panneau de téléchargement sont plus fluides.
|
||||
|
||||
## [v1.1.0](https://github.com/nexmoe/VidBee/releases/tag/v1.1.0) - 2025-12-20
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout d'actions groupées pour nettoyer l'historique des téléchargements.
|
||||
- L'ouverture des liens de tâche de téléchargement est plus prévisible.
|
||||
- Ajout de la prise en charge des dossiers de téléchargement personnalisés.
|
||||
- La boîte de dialogue de configuration RSS est plus simple à comprendre et à remplir.
|
||||
|
||||
## [v1.0.2](https://github.com/nexmoe/VidBee/releases/tag/v1.0.2) - 2025-12-06
|
||||
### Mises a jour de fonctionnalites
|
||||
- La saisie des chemins est plus tolérante au quotidien.
|
||||
|
||||
### Corrections de bugs
|
||||
- Ajout de plus d'options de compatibilité pour davantage de scénarios d'usage.
|
||||
|
||||
## [v1.0.1](https://github.com/nexmoe/VidBee/releases/tag/v1.0.1) - 2025-11-16
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout de la prise en charge du lancement automatique.
|
||||
- La prise en charge des langues a été encore élargie.
|
||||
|
||||
## [v1.0.0](https://github.com/nexmoe/VidBee/releases/tag/v1.0.0) - 2025-11-15
|
||||
### Mises a jour de fonctionnalites
|
||||
- Première version majeure stable de VidBee.
|
||||
- Ajout des téléchargements via abonnements RSS.
|
||||
- La navigation et le flux général de l'interface sont plus clairs.
|
||||
- L'historique et l'aperçu des médias ont été améliorés.
|
||||
|
||||
## [v0.3.5](https://github.com/nexmoe/VidBee/releases/tag/v0.3.5) - 2025-11-08
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les textes et messages du téléchargement en un clic sont plus faciles à comprendre.
|
||||
- Le style visuel est plus cohérent.
|
||||
|
||||
## [v0.3.4](https://github.com/nexmoe/VidBee/releases/tag/v0.3.4) - 2025-11-03
|
||||
### Mises a jour de fonctionnalites
|
||||
- Les messages de mise à jour et l'affichage des options de téléchargement sont plus clairs.
|
||||
|
||||
## [v0.3.3](https://github.com/nexmoe/VidBee/releases/tag/v0.3.3) - 2025-11-02
|
||||
### Corrections de bugs
|
||||
- La stabilité du traitement des téléchargements a été améliorée dans davantage de scénarios.
|
||||
|
||||
## [v0.3.2](https://github.com/nexmoe/VidBee/releases/tag/v0.3.2) - 2025-10-31
|
||||
### Mises a jour de fonctionnalites
|
||||
- L'expérience de distribution multi-appareils a été améliorée.
|
||||
|
||||
## [v0.3.1](https://github.com/nexmoe/VidBee/releases/tag/v0.3.1) - 2025-10-30
|
||||
### Mises a jour de fonctionnalites
|
||||
- L'expérience Linux est plus conviviale.
|
||||
- Ajout de notifications de nouvelles versions pour des mises à niveau plus rapides.
|
||||
|
||||
## [v0.3.0](https://github.com/nexmoe/VidBee/releases/tag/v0.3.0) - 2025-10-29
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout de la prise en charge du téléchargement de playlists.
|
||||
- Ajout de contrôles pour réduire les perturbations sur le bureau.
|
||||
|
||||
## [v0.2.2](https://github.com/nexmoe/VidBee/releases/tag/v0.2.2) - 2025-10-27
|
||||
### Mises a jour de fonctionnalites
|
||||
- Poursuite du peaufinage UX pendant la phase de préversion.
|
||||
|
||||
## [v0.2.1](https://github.com/nexmoe/VidBee/releases/tag/v0.2.1) - 2025-10-26
|
||||
### Mises a jour de fonctionnalites
|
||||
- Poursuite du peaufinage UX pendant la phase de préversion.
|
||||
|
||||
## [v0.2.0](https://github.com/nexmoe/VidBee/releases/tag/v0.2.0) - 2025-10-25
|
||||
### Mises a jour de fonctionnalites
|
||||
- Poursuite du peaufinage UX pendant la phase de préversion.
|
||||
|
||||
## [v0.1.8](https://github.com/nexmoe/VidBee/releases/tag/v0.1.8) - 2025-10-24
|
||||
### Mises a jour de fonctionnalites
|
||||
- Le programme de préversion publique a démarré.
|
||||
|
||||
## [v0.1.7](https://github.com/nexmoe/VidBee/releases/tag/v0.1.7) - 2025-10-24
|
||||
### Mises a jour de fonctionnalites
|
||||
- Ajout de la prise en charge de la mise a jour automatique et amélioration des consignes de publication dans la documentation.
|
||||
- Amélioration de la documentation du projet, y compris les captures d'écran et le guide de contribution.
|
||||
|
||||
### Corrections de bugs
|
||||
- Simplification de la gestion des chemins de téléchargement et suppression de la logique de chemin de sortie inutilisée.
|
||||
|
||||
## [v0.1.6](https://github.com/nexmoe/VidBee/releases/tag/v0.1.6) - 2025-10-23
|
||||
### Corrections de bugs
|
||||
- Suppression d'une étape inutile de création de dossier dans le workflow de publication.
|
||||
|
||||
## [v0.1.5](https://github.com/nexmoe/VidBee/releases/tag/v0.1.5) - 2025-10-23
|
||||
### Corrections de bugs
|
||||
- Amélioration du workflow de publication pour télécharger les binaires yt-dlp lors du packaging multiplateforme.
|
||||
|
||||
## [v0.1.4](https://github.com/nexmoe/VidBee/releases/tag/v0.1.4) - 2025-10-23
|
||||
### Corrections de bugs
|
||||
- Mise à jour du workflow de publication pour cibler uniquement les builds Windows.
|
||||
|
||||
## [v0.1.3](https://github.com/nexmoe/VidBee/releases/tag/v0.1.3) - 2025-10-23
|
||||
### Corrections de bugs
|
||||
- Simplification des étapes de build de publication et de la gestion des artefacts dans CI.
|
||||
- Ajustement des déclencheurs CI: l'automatisation des pull requests ne s'exécute que pour `main`.
|
||||
|
||||
## [v0.1.2](https://github.com/nexmoe/VidBee/releases/tag/v0.1.2) - 2025-10-23
|
||||
### Corrections de bugs
|
||||
- Définition explicite du shell pour l'étape de build dans le workflow de publication.
|
||||
|
||||
## [v0.1.1](https://github.com/nexmoe/VidBee/releases/tag/v0.1.1) - 2025-10-23
|
||||
### Mises a jour de fonctionnalites
|
||||
- Itération de release précoce sans changement utilisateur supplémentaire documenté.
|
||||
|
||||
## [v0.1.0](https://github.com/nexmoe/VidBee/releases/tag/v0.1.0) - 2025-10-23
|
||||
### Mises a jour de fonctionnalites
|
||||
- Point de départ de la première release publique.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user